@@ -26,7 +26,7 @@ posthog.init(process.env.POSTHOG_KEY!, {
|
||||
});
|
||||
|
||||
const OrganizationSelectionPage = lazy(
|
||||
() => import("./pages/OrganizationSelectionPage")
|
||||
() => import("./pages/OrganizationSelectionPage"),
|
||||
);
|
||||
|
||||
function App() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OrganizationSwitcherSkeleton } from "./OrganizationSwitcherSkeleton";
|
||||
import { Sidebar } from "./ui/sidebar";
|
||||
|
||||
export function AppSidebarSkeleton(
|
||||
props: React.ComponentProps<typeof Sidebar>
|
||||
props: React.ComponentProps<typeof Sidebar>,
|
||||
) {
|
||||
return (
|
||||
<AppSidebarShell
|
||||
|
||||
@@ -44,7 +44,7 @@ export function EditableField({
|
||||
}
|
||||
className={cn(
|
||||
"w-full resize-none",
|
||||
required && !value && "border-red-500"
|
||||
required && !value && "border-red-500",
|
||||
)}
|
||||
placeholder={`Enter ${label.toLowerCase()}`}
|
||||
rows={4}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function NavMain() {
|
||||
|
||||
if (item.items?.length) {
|
||||
return item.items.some((subItem) =>
|
||||
location.pathname.startsWith(subItem.url)
|
||||
location.pathname.startsWith(subItem.url),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function NavMain() {
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => {
|
||||
const subItemActive = location.pathname.startsWith(
|
||||
subItem.url
|
||||
subItem.url,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,7 @@ const navItems = [
|
||||
];
|
||||
|
||||
export function NavSecondary(
|
||||
props: React.ComponentPropsWithoutRef<typeof SidebarGroup>
|
||||
props: React.ComponentPropsWithoutRef<typeof SidebarGroup>,
|
||||
) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function OrganizationSwitcher({
|
||||
useEffect(() => {
|
||||
if (hasOrganizations) {
|
||||
const org = data.organizations.edges.find(
|
||||
(edge) => edge.node.id === organizationId
|
||||
(edge) => edge.node.id === organizationId,
|
||||
);
|
||||
if (org) {
|
||||
setCurrentOrganization(org.node);
|
||||
@@ -104,7 +104,7 @@ export function OrganizationSwitcher({
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"gap-2.5",
|
||||
!currentOrganization && "border border-dashed"
|
||||
!currentOrganization && "border border-dashed",
|
||||
)}
|
||||
>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-sm bg-highlight-bg">
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function PeopleSelector({
|
||||
<SelectItem key={edge.node.id} value={edge.node.id}>
|
||||
{edge.node.fullName} ({edge.node.primaryEmailAddress})
|
||||
</SelectItem>
|
||||
)
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -11,7 +11,7 @@ const Avatar = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -38,7 +38,7 @@ const AvatarFallback = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-subtle-bg",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,7 @@ const badgeVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
|
||||
@@ -20,7 +20,7 @@ const BreadcrumbList = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-3 break-words text-sm text-secondary sm:gap-2.5",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -31,7 +31,7 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
@@ -50,7 +50,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const Checkbox = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-level-0 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-active-b focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-invert",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ const Command = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-level-0 text-primary",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -48,7 +48,7 @@ const CommandInput = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-tertiary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -91,7 +91,7 @@ const CommandGroup = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-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-tertiary",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -119,7 +119,7 @@ const CommandItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent-bg aria-selected:text-accent data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-secondary-bg transition-colors",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 backdrop-blur-xs data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-mid-b bg-white p-6 shadow-lg duration-200 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%] sm:rounded-lg",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -57,7 +57,7 @@ const DialogHeader = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -71,7 +71,7 @@ const DialogFooter = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -86,7 +86,7 @@ const DialogTitle = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent-bg data-[state=open]:bg-accent-bg [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -48,7 +48,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-level-1 p-1 shadow-lg 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -67,7 +67,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-level-1 p-1 shadow-md",
|
||||
"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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -86,7 +86,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors focus:bg-active-bg focus:text-active data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -101,7 +101,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent-bg focus:text-accent data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -125,7 +125,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden transition-colors focus:bg-accent-bg focus:text-accent data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -150,7 +150,7 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -9,13 +9,13 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-solid-b bg-invert-bg px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-primary placeholder:text-tertiary focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-active-b disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const PopoverContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-level-0 p-4 text-primary shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@ const RadioGroupItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-level-0 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-active-b focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-solid-b bg-invert-bg px-3 py-2 text-sm ring-offset-level-0 placeholder:text-tertiary focus:outline-hidden focus:ring focus:ring-active-b disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -39,7 +39,7 @@ const SelectContent = React.forwardRef<
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-invert-bg text-primary shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
@@ -48,7 +48,7 @@ const SelectContent = React.forwardRef<
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -66,7 +66,7 @@ const SelectItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-hidden focus:bg-active-bg focus:text-active data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -9,7 +9,7 @@ const Separator = React.forwardRef<
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
@@ -18,11 +18,11 @@ const Separator = React.forwardRef<
|
||||
className={cn(
|
||||
"shrink-0 bg-solid-b",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const SheetOverlay = React.forwardRef<
|
||||
<SheetPrimitive.Overlay
|
||||
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
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
@@ -46,7 +46,7 @@ const sheetVariants = cva(
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
@@ -81,7 +81,7 @@ const SheetHeader = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -95,7 +95,7 @@ const SheetFooter = ({
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -63,7 +63,7 @@ const SidebarProvider = React.forwardRef<
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
@@ -84,7 +84,7 @@ const SidebarProvider = React.forwardRef<
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
@@ -124,7 +124,15 @@ const SidebarProvider = React.forwardRef<
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
[
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -140,7 +148,7 @@ const SidebarProvider = React.forwardRef<
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-level-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
@@ -150,7 +158,7 @@ const SidebarProvider = React.forwardRef<
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
SidebarProvider.displayName = "SidebarProvider";
|
||||
|
||||
@@ -171,7 +179,7 @@ const Sidebar = React.forwardRef<
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
@@ -180,7 +188,7 @@ const Sidebar = React.forwardRef<
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-level-0 text-secondary",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
@@ -227,7 +235,7 @@ const Sidebar = React.forwardRef<
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
@@ -240,7 +248,7 @@ const Sidebar = React.forwardRef<
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-4 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -253,7 +261,7 @@ const Sidebar = React.forwardRef<
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
@@ -304,7 +312,7 @@ const SidebarRail = React.forwardRef<
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-level-0",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -322,7 +330,7 @@ const SidebarInset = React.forwardRef<
|
||||
className={cn(
|
||||
"relative flex min-h-svh flex-1 flex-col bg-level-0",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-(--spacing(4)))] md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -340,7 +348,7 @@ const SidebarInput = React.forwardRef<
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-level-0 shadow-none focus-visible:ring-2 focus-visible:ring-active-b",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -403,7 +411,7 @@ const SidebarContent = React.forwardRef<
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -439,7 +447,7 @@ const SidebarGroupLabel = React.forwardRef<
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-secondary/70 outline-hidden ring-active-b transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -462,7 +470,7 @@ const SidebarGroupAction = React.forwardRef<
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -531,7 +539,7 @@ const sidebarMenuButtonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
@@ -552,7 +560,7 @@ const SidebarMenuButton = React.forwardRef<
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
@@ -589,7 +597,7 @@ const SidebarMenuButton = React.forwardRef<
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
@@ -616,7 +624,7 @@ const SidebarMenuAction = React.forwardRef<
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-accent md:opacity-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -638,7 +646,7 @@ const SidebarMenuBadge = React.forwardRef<
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -693,7 +701,7 @@ const SidebarMenuSub = React.forwardRef<
|
||||
className={cn(
|
||||
"ml-5 flex min-w-0 flex-col gap-1 border-l border-solid-b px-2.5 py-1",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -728,7 +736,7 @@ const SidebarMenuSubButton = React.forwardRef<
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
@@ -12,18 +12,18 @@ function Switch({
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
export { Switch };
|
||||
|
||||
@@ -56,7 +56,7 @@ const TableRow = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-h-subtle-bg data-[state=selected]:bg-subtle-bg",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -71,7 +71,7 @@ const TableHead = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-tertiary [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center rounded-md p-1 text-tertiary",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b-2 border-transparent cursor-pointer inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm font-medium ring-offset-level-0 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-active-b focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-active data-[state=active]:border-primary",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -42,7 +42,7 @@ const TabsContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-level-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-active-b focus-visible:ring-offset-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ const Textarea = React.forwardRef<
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-solid-b bg-invert-bg px-3 py-2 text-sm ring-offset-level-0 placeholder:text-tertiary focus-visible:outline-hidden focus-visible:ring focus-visible:ring-active-b disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
@@ -15,7 +15,7 @@ const ToastViewport = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -35,7 +35,7 @@ const toastVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
@@ -61,7 +61,7 @@ const ToastAction = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-invert focus:outline-hidden focus:ring-1 focus:ring-active-b disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-solid-b/40 hover:group-[.destructive]:border-danger-b/30 hover:group-[.destructive]:bg-danger-bg hover:group-[.destructive]:text-danger focus:group-[.destructive]:ring-danger-b",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -76,7 +76,7 @@ const ToastClose = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-primary/50 opacity-0 transition-opacity hover:text-primary focus:opacity-100 focus:outline-hidden focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
|
||||
@@ -19,7 +19,7 @@ const TooltipContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-invert animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -33,11 +33,11 @@ export default function OrganizationSelectionPage() {
|
||||
const navigate = useNavigate();
|
||||
const data = useLazyLoadQuery<OrganizationSelectionPageQuery>(
|
||||
organizationSelectionQuery,
|
||||
{}
|
||||
{},
|
||||
);
|
||||
|
||||
const organizations = data.viewer.organizations.edges.map(
|
||||
(edge) => edge.node
|
||||
(edge) => edge.node,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,7 +3,14 @@ import { useSearchParams } from "react-router";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { buildEndpoint } from "../utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -24,12 +31,12 @@ type SigningResponse = {
|
||||
export default function SigningRequestsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [signingData, setSigningData] = useState<SigningResponse | null>(null);
|
||||
const [currentDocIndex, setCurrentDocIndex] = useState(0);
|
||||
|
||||
|
||||
// Fetch documents to sign using the token
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -40,13 +47,16 @@ export default function SigningRequestsPage() {
|
||||
|
||||
async function fetchDocuments() {
|
||||
try {
|
||||
const response = await fetch(buildEndpoint("/api/console/v1/policies/signing-requests"), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
const response = await fetch(
|
||||
buildEndpoint("/api/console/v1/policies/signing-requests"),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch signing documents");
|
||||
@@ -54,18 +64,20 @@ export default function SigningRequestsPage() {
|
||||
|
||||
const documents: Document[] = await response.json();
|
||||
// Transform the API response to match our internal structure
|
||||
const enhancedDocuments = documents.map(doc => ({
|
||||
const enhancedDocuments = documents.map((doc) => ({
|
||||
...doc,
|
||||
signed: false
|
||||
signed: false,
|
||||
}));
|
||||
|
||||
|
||||
setSigningData({
|
||||
documents: enhancedDocuments,
|
||||
requesterName: "Requester", // Default values as they're not in the API response
|
||||
requesterOrganization: "Organization"
|
||||
requesterOrganization: "Organization",
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An unknown error occurred");
|
||||
setError(
|
||||
err instanceof Error ? err.message : "An unknown error occurred",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -77,17 +89,22 @@ export default function SigningRequestsPage() {
|
||||
// Handle document signing
|
||||
const handleSignDocument = async () => {
|
||||
if (!signingData || !token) return;
|
||||
|
||||
|
||||
const docToSign = signingData.documents[currentDocIndex];
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(buildEndpoint(`/api/console/v1/policies/signing-requests/${docToSign.policy_version_id}/sign`), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
const response = await fetch(
|
||||
buildEndpoint(
|
||||
`/api/console/v1/policies/signing-requests/${docToSign.policy_version_id}/sign`,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to sign document");
|
||||
@@ -124,7 +141,7 @@ export default function SigningRequestsPage() {
|
||||
// Calculate progress
|
||||
const getSignedCount = () => {
|
||||
if (!signingData) return 0;
|
||||
return signingData.documents.filter(doc => doc.signed).length;
|
||||
return signingData.documents.filter((doc) => doc.signed).length;
|
||||
};
|
||||
|
||||
const getProgressPercentage = () => {
|
||||
@@ -139,7 +156,9 @@ export default function SigningRequestsPage() {
|
||||
<Card className="w-full max-w-3xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Loading Signing Requests</CardTitle>
|
||||
<CardDescription>Please wait while we fetch your documents...</CardDescription>
|
||||
<CardDescription>
|
||||
Please wait while we fetch your documents...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -170,7 +189,9 @@ export default function SigningRequestsPage() {
|
||||
<Card className="w-full max-w-3xl">
|
||||
<CardHeader>
|
||||
<CardTitle>No Documents to Sign</CardTitle>
|
||||
<CardDescription>There are no documents requiring your signature at this time.</CardDescription>
|
||||
<CardDescription>
|
||||
There are no documents requiring your signature at this time.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -187,24 +208,28 @@ export default function SigningRequestsPage() {
|
||||
<Helmet>
|
||||
<title>Document Signing - Probo</title>
|
||||
</Helmet>
|
||||
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Document Signing Request</h1>
|
||||
<p className="text-muted-foreground">
|
||||
From {signingData.requesterName} at {signingData.requesterOrganization}
|
||||
From {signingData.requesterName} at{" "}
|
||||
{signingData.requesterOrganization}
|
||||
</p>
|
||||
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{getSignedCount()} of {signingData.documents.length} documents signed
|
||||
{getSignedCount()} of {signingData.documents.length} documents
|
||||
signed
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{Math.round(getProgressPercentage())}%
|
||||
</span>
|
||||
<span className="text-sm font-medium">{Math.round(getProgressPercentage())}%</span>
|
||||
</div>
|
||||
<Progress value={getProgressPercentage()} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{currentDoc.title}</CardTitle>
|
||||
@@ -212,31 +237,29 @@ export default function SigningRequestsPage() {
|
||||
Document {currentDocIndex + 1} of {signingData.documents.length}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
<CardContent>
|
||||
<div className="border rounded-md p-4 min-h-[400px] bg-muted/20">
|
||||
<div className="prose prose-olive max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{currentDoc.content}</ReactMarkdown>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{currentDoc.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
{currentDoc.signed ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-green-600 font-medium">✓ Signed</div>
|
||||
{!isLastDocument && (
|
||||
<Button onClick={handleNextDocument}>
|
||||
Next Document
|
||||
</Button>
|
||||
<Button onClick={handleNextDocument}>Next Document</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={handleSignDocument}>
|
||||
Sign Document
|
||||
</Button>
|
||||
<Button onClick={handleSignDocument}>Sign Document</Button>
|
||||
)}
|
||||
|
||||
|
||||
{allSigned && (
|
||||
<div className="text-green-600 font-medium">
|
||||
All documents have been signed
|
||||
@@ -246,4 +269,4 @@ export default function SigningRequestsPage() {
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function ConfirmEmailPage() {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to confirm email. Please try again."
|
||||
: "Failed to confirm email. Please try again.",
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function ConfirmInvitationPage() {
|
||||
token: token.trim(),
|
||||
password: password,
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
@@ -97,7 +97,7 @@ export default function ConfirmInvitationPage() {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to confirm invitation. Please try again."
|
||||
: "Failed to confirm invitation. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function ForgotPasswordPage() {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function LoginPage() {
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password }),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function RegisterPage() {
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password, fullName }),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function ResetPasswordPage() {
|
||||
token: token.trim(),
|
||||
password: password,
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
@@ -96,7 +96,7 @@ export default function ResetPasswordPage() {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to reset password. Please try again."
|
||||
: "Failed to reset password. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -86,11 +86,11 @@ export default function CreateOrganizationView() {
|
||||
const { toast } = useToast();
|
||||
const data = useLazyLoadQuery<CreateOrganizationViewViewerQuery>(
|
||||
viewerQuery,
|
||||
{}
|
||||
{},
|
||||
);
|
||||
const [createOrganization] =
|
||||
useMutation<CreateOrganizationViewCreateOrganizationMutation>(
|
||||
createOrganizationMutation
|
||||
createOrganizationMutation,
|
||||
);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
@@ -114,7 +114,7 @@ export default function CreateOrganizationView() {
|
||||
connections: [
|
||||
ConnectionHandler.getConnectionID(
|
||||
data.viewer.id,
|
||||
"OrganizationSwitcher_organizations"
|
||||
"OrganizationSwitcher_organizations",
|
||||
),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ const BreadCrumbNavLink = ({
|
||||
className={cn(
|
||||
"text-quaternary",
|
||||
isActive && "text-primary",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -60,7 +60,7 @@ const BreadcrumbNavLink = ({
|
||||
className={cn(
|
||||
"text-quaternary",
|
||||
isActive && "text-primary",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -98,7 +98,7 @@ function BreadCrumbOrganization() {
|
||||
}
|
||||
`,
|
||||
{ organizationId: organizationId! },
|
||||
{ fetchPolicy: "store-or-network" }
|
||||
{ fetchPolicy: "store-or-network" },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -146,7 +146,7 @@ function BreadcrumbFrameworkOverview() {
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ frameworkId: frameworkId! }
|
||||
{ frameworkId: frameworkId! },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -196,7 +196,7 @@ function BreadcrumbVendorOverview() {
|
||||
}
|
||||
`,
|
||||
{ vendorId: vendorId! },
|
||||
{ fetchPolicy: "store-or-network" }
|
||||
{ fetchPolicy: "store-or-network" },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -246,7 +246,7 @@ function BreadcrumbPeopleOverview() {
|
||||
}
|
||||
`,
|
||||
{ peopleId: peopleId! },
|
||||
{ fetchPolicy: "store-or-network" }
|
||||
{ fetchPolicy: "store-or-network" },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -296,7 +296,7 @@ function BreadcrumbPolicyOverview() {
|
||||
}
|
||||
`,
|
||||
{ policyId: policyId! },
|
||||
{ fetchPolicy: "store-or-network" }
|
||||
{ fetchPolicy: "store-or-network" },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -335,7 +335,9 @@ function BreadcrumbMeasureView() {
|
||||
const data =
|
||||
useLazyLoadQuery<OrganizationBreadcrumbBreadcrumbMeasureViewQuery>(
|
||||
graphql`
|
||||
query OrganizationBreadcrumbBreadcrumbMeasureViewQuery($measureId: ID!) {
|
||||
query OrganizationBreadcrumbBreadcrumbMeasureViewQuery(
|
||||
$measureId: ID!
|
||||
) {
|
||||
measure: node(id: $measureId) {
|
||||
id
|
||||
... on Measure {
|
||||
@@ -345,7 +347,7 @@ function BreadcrumbMeasureView() {
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ measureId: measureId! }
|
||||
{ measureId: measureId! },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -385,7 +387,7 @@ function BreadcrumbControl() {
|
||||
}
|
||||
`,
|
||||
{ controlId: controlId! },
|
||||
{ fetchPolicy: "store-or-network" }
|
||||
{ fetchPolicy: "store-or-network" },
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -431,7 +433,7 @@ function BreadcrumbRiskShow() {
|
||||
}
|
||||
`,
|
||||
{ riskId: riskId! },
|
||||
{ fetchPolicy: "store-or-network" }
|
||||
{ fetchPolicy: "store-or-network" },
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,10 +9,10 @@ import ErrorBoundary from "@/components/ErrorBoundary";
|
||||
|
||||
export default function OrganizationLayout() {
|
||||
const controlMatch = useMatch(
|
||||
"organizations/:organizationId/frameworks/:frameworkId/controls/*"
|
||||
"organizations/:organizationId/frameworks/:frameworkId/controls/*",
|
||||
);
|
||||
const frameworkMatch = useMatch(
|
||||
"organizations/:organizationId/frameworks/:frameworkId"
|
||||
"organizations/:organizationId/frameworks/:frameworkId",
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -28,7 +28,7 @@ export default function OrganizationLayout() {
|
||||
"p-8 h-screen overflow-auto",
|
||||
controlMatch || frameworkMatch
|
||||
? "w-full"
|
||||
: "mx-auto w-2xl md:w-3xl lg:w-5xl xl:w-6xl 2xl:w-7xl"
|
||||
: "mx-auto w-2xl md:w-3xl lg:w-5xl xl:w-6xl 2xl:w-7xl",
|
||||
)}
|
||||
>
|
||||
<header className="flex shrink-0 items-center gap-2 mb-7">
|
||||
|
||||
@@ -54,7 +54,10 @@ export function OrganizationsRoutes() {
|
||||
<Route path="vendors/:vendorId" element={<VendorPage />} />
|
||||
<Route path="policies" element={<PolicyListPage />} />
|
||||
<Route path="policies/:policyId" element={<ShowPolicyPage />} />
|
||||
<Route path="policies/:policyId/versions/:versionId/edit" element={<EditPolicyPage />} />
|
||||
<Route
|
||||
path="policies/:policyId/versions/:versionId/edit"
|
||||
element={<EditPolicyPage />}
|
||||
/>
|
||||
<Route path="risks" element={<ListRiskPage />} />
|
||||
<Route path="risks/new" element={<NewRiskPage />} />
|
||||
<Route path="risks/:riskId" element={<ShowRiskView />} />
|
||||
|
||||
@@ -42,7 +42,6 @@ import type { SettingsViewRemoveUserMutation as SettingsViewRemoveUserMutationTy
|
||||
import { PageTemplate } from "@/components/PageTemplate";
|
||||
import { SettingsViewSkeleton } from "./SettingsPage";
|
||||
|
||||
|
||||
interface AvailableConnector {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -120,7 +119,8 @@ function SettingsViewContent({
|
||||
const data = usePreloadedQuery(settingsViewQuery, queryRef);
|
||||
const organization = data.organization;
|
||||
const users = organization.users?.edges.map((edge) => edge.node) || [];
|
||||
const connectors = organization.connectors?.edges.map((edge) => edge.node) || [];
|
||||
const connectors =
|
||||
organization.connectors?.edges.map((edge) => edge.node) || [];
|
||||
const { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -130,26 +130,36 @@ function SettingsViewContent({
|
||||
const [inviteFullName, setInviteFullName] = useState("");
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [organizationName, setOrganizationName] = useState(
|
||||
organization.name || ""
|
||||
organization.name || "",
|
||||
);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
// Available connectors
|
||||
// Available connectors
|
||||
const availableConnectors: AvailableConnector[] = [
|
||||
{ id: "github", name: "GitHub", type: "oauth2", description: "Connect to GitHub repositories and issues" },
|
||||
{ id: "slack", name: "Slack", type: "oauth2", description: "Connect to Slack workspace and channels" },
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
type: "oauth2",
|
||||
description: "Connect to GitHub repositories and issues",
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
type: "oauth2",
|
||||
description: "Connect to Slack workspace and channels",
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out connectors that are already connected
|
||||
const connectedConnectorIds = connectors.map((connector) => connector.name);
|
||||
const notConnectedConnectors = availableConnectors.filter(
|
||||
(connector) => !connectedConnectorIds.includes(connector.id)
|
||||
(connector) => !connectedConnectorIds.includes(connector.id),
|
||||
);
|
||||
|
||||
const [updateOrganization] =
|
||||
useMutation<SettingsViewUpdateOrganizationMutationType>(
|
||||
updateOrganizationMutation
|
||||
updateOrganizationMutation,
|
||||
);
|
||||
|
||||
const [inviteUser] =
|
||||
@@ -161,7 +171,7 @@ function SettingsViewContent({
|
||||
const { organizationId } = useParams();
|
||||
const [, loadQuery] =
|
||||
useQueryLoader<SettingsViewQueryType>(settingsViewQuery);
|
||||
|
||||
|
||||
const handleUpdateName = () => {
|
||||
updateOrganization({
|
||||
variables: {
|
||||
@@ -490,7 +500,9 @@ function SettingsViewContent({
|
||||
<div className="space-y-6">
|
||||
{connectors.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-medium">Connected services</h3>
|
||||
<h3 className="mb-4 text-sm font-medium">
|
||||
Connected services
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{connectors.map((connector) => (
|
||||
<div
|
||||
@@ -506,7 +518,10 @@ function SettingsViewContent({
|
||||
{connector.name}
|
||||
</span>
|
||||
<span className="text-sm text-tertiary">
|
||||
{connector.type} · Connected on {new Date(connector.createdAt).toLocaleDateString()}
|
||||
{connector.type} · Connected on{" "}
|
||||
{new Date(
|
||||
connector.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -518,7 +533,9 @@ function SettingsViewContent({
|
||||
|
||||
{notConnectedConnectors.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-medium">Available services</h3>
|
||||
<h3 className="mb-4 text-sm font-medium">
|
||||
Available services
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{notConnectedConnectors.map((connector) => (
|
||||
<div
|
||||
@@ -541,11 +558,25 @@ function SettingsViewContent({
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={(() => {
|
||||
const baseUrl = process.env.API_SERVER_HOST || window.location.origin;
|
||||
const url = new URL('/api/console/v1/connectors/initiate', baseUrl);
|
||||
url.searchParams.append('organization_id', organization.id);
|
||||
url.searchParams.append('connector_id', connector.id);
|
||||
url.searchParams.append('continue', window.location.href);
|
||||
const baseUrl =
|
||||
process.env.API_SERVER_HOST ||
|
||||
window.location.origin;
|
||||
const url = new URL(
|
||||
"/api/console/v1/connectors/initiate",
|
||||
baseUrl,
|
||||
);
|
||||
url.searchParams.append(
|
||||
"organization_id",
|
||||
organization.id,
|
||||
);
|
||||
url.searchParams.append(
|
||||
"connector_id",
|
||||
connector.id,
|
||||
);
|
||||
url.searchParams.append(
|
||||
"continue",
|
||||
window.location.href,
|
||||
);
|
||||
return url.toString();
|
||||
})()}
|
||||
className="inline-flex items-center justify-center h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground"
|
||||
@@ -559,17 +590,21 @@ function SettingsViewContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectors.length === 0 && notConnectedConnectors.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="rounded-full bg-subtle-bg p-3">
|
||||
<Building2 className="h-6 w-6 text-tertiary" />
|
||||
{connectors.length === 0 &&
|
||||
notConnectedConnectors.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="rounded-full bg-subtle-bg p-3">
|
||||
<Building2 className="h-6 w-6 text-tertiary" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium">
|
||||
No integrations available
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-tertiary">
|
||||
There are currently no integrations available for your
|
||||
workspace.
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium">No integrations available</h3>
|
||||
<p className="mt-2 text-sm text-tertiary">
|
||||
There are currently no integrations available for your workspace.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -86,7 +86,7 @@ function EditableField({
|
||||
}
|
||||
className={cn(
|
||||
"w-full resize-none",
|
||||
required && !value && "border-red-500"
|
||||
required && !value && "border-red-500",
|
||||
)}
|
||||
placeholder={`Enter ${label.toLowerCase()}`}
|
||||
rows={4}
|
||||
@@ -133,7 +133,7 @@ function EditFrameworkViewContent({
|
||||
|
||||
const [commit, isInFlight] =
|
||||
useMutation<EditFrameworkViewUpdateFrameworkMutation>(
|
||||
updateFrameworkMutation
|
||||
updateFrameworkMutation,
|
||||
);
|
||||
|
||||
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
|
||||
|
||||
@@ -37,7 +37,7 @@ export function FrameworkLayoutViewSkeleton() {
|
||||
|
||||
export function FrameworkLayout() {
|
||||
const match = useMatch(
|
||||
"organizations/:organizationId/frameworks/:frameworkId/*"
|
||||
"organizations/:organizationId/frameworks/:frameworkId/*",
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -75,7 +75,7 @@ function FrameworkLayoutViewContent({
|
||||
|
||||
// Setup delete mutation
|
||||
const [commitDeleteMutation] = useMutation<FrameworkLayoutViewDeleteMutation>(
|
||||
DeleteFrameworkMutation
|
||||
DeleteFrameworkMutation,
|
||||
);
|
||||
|
||||
const handleDeleteFramework = useCallback(() => {
|
||||
@@ -83,7 +83,7 @@ function FrameworkLayoutViewContent({
|
||||
|
||||
const connectionId = ConnectionHandler.getConnectionID(
|
||||
organizationId!,
|
||||
"FrameworkListView_frameworks"
|
||||
"FrameworkListView_frameworks",
|
||||
);
|
||||
|
||||
commitDeleteMutation({
|
||||
@@ -189,7 +189,7 @@ function FrameworkLayoutViewContent({
|
||||
export default function FrameworkLayoutView() {
|
||||
const { frameworkId } = useParams();
|
||||
const [queryRef, loadQuery] = useQueryLoader<FrameworkLayoutViewQueryType>(
|
||||
FrameworkLayoutViewQuery
|
||||
FrameworkLayoutViewQuery,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ControlList(props: ControlListProps) {
|
||||
const { className, fragmentKey } = props;
|
||||
const { controls } = useFragment<ControlList_List$key>(
|
||||
controlListFragment,
|
||||
fragmentKey
|
||||
fragmentKey,
|
||||
);
|
||||
|
||||
if (controls.edges.length === 0) {
|
||||
@@ -45,7 +45,7 @@ export function ControlList(props: ControlListProps) {
|
||||
<aside
|
||||
className={cn(
|
||||
"shrink-0 w-70 border-r h-full overflow-x-hidden overflow-y-auto [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-level-0 [&::-webkit-scrollbar-thumb]:bg-highlight-bg [&::-webkit-scrollbar-thumb]:rounded-xs",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{controls.edges.map(({ node: control }, i) => (
|
||||
@@ -55,7 +55,7 @@ export function ControlList(props: ControlListProps) {
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"block pl-8 pr-4 py-4 border-b hover:bg-h-subtle-bg",
|
||||
(isActive || (i === 0 && !controlId)) && "bg-subtle-bg"
|
||||
(isActive || (i === 0 && !controlId)) && "bg-subtle-bg",
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -65,7 +65,7 @@ export function ControlList(props: ControlListProps) {
|
||||
<div
|
||||
className={cn(
|
||||
"inline-block font-mono text-sm px-1 py-0.25 rounded-sm bg-highlight-bg border font-semibold",
|
||||
isActive && "font-bold bg-active-bg border-mid-b"
|
||||
isActive && "font-bold bg-active-bg border-mid-b",
|
||||
)}
|
||||
>
|
||||
{control.referenceId}
|
||||
@@ -73,7 +73,7 @@ export function ControlList(props: ControlListProps) {
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-none break-words mt-2",
|
||||
isActive && "font-medium"
|
||||
isActive && "font-medium",
|
||||
)}
|
||||
>
|
||||
{control.name.length > maxControlNameLength
|
||||
|
||||
@@ -89,7 +89,7 @@ function FrameworkListViewContent({
|
||||
export default function FrameworkListView() {
|
||||
const { organizationId } = useParams();
|
||||
const [queryRef, loadQuery] = useQueryLoader<FrameworkListViewQueryType>(
|
||||
FrameworkListViewQuery
|
||||
FrameworkListViewQuery,
|
||||
);
|
||||
|
||||
if (!queryRef) {
|
||||
|
||||
@@ -39,7 +39,7 @@ function FrameworkViewContent({
|
||||
}) {
|
||||
const data = usePreloadedQuery<FrameworkViewQuery>(
|
||||
frameworkViewQuery,
|
||||
queryRef
|
||||
queryRef,
|
||||
);
|
||||
|
||||
if (!data?.node) {
|
||||
|
||||
@@ -40,7 +40,7 @@ export function FrameworkImportDropdown() {
|
||||
const [loadingFramework, setLoadingFramework] = useState<string | null>(null);
|
||||
const [commit, isInFlight] =
|
||||
useMutation<ImportFrameworkDialogImportFrameworkMutation>(
|
||||
importFrameworkMutation
|
||||
importFrameworkMutation,
|
||||
);
|
||||
|
||||
const handleImport = async (frameworkId: string) => {
|
||||
@@ -56,12 +56,12 @@ export function FrameworkImportDropdown() {
|
||||
`${frameworkId}.json`,
|
||||
{
|
||||
type: "application/json",
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const connectionId = ConnectionHandler.getConnectionID(
|
||||
organizationId!,
|
||||
"FrameworkListView_frameworks"
|
||||
"FrameworkListView_frameworks",
|
||||
);
|
||||
|
||||
commit({
|
||||
|
||||
@@ -70,7 +70,7 @@ function EditableField({
|
||||
}
|
||||
className={cn(
|
||||
"w-full resize-none",
|
||||
required && !value && "border-red-500"
|
||||
required && !value && "border-red-500",
|
||||
)}
|
||||
placeholder={`Enter ${label.toLowerCase()}`}
|
||||
rows={4}
|
||||
@@ -102,7 +102,7 @@ function NewFrameworkViewContent() {
|
||||
|
||||
const [commit, isInFlight] =
|
||||
useMutation<NewFrameworkViewCreateFrameworkMutation>(
|
||||
createFrameworkMutation
|
||||
createFrameworkMutation,
|
||||
);
|
||||
|
||||
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
|
||||
@@ -126,7 +126,7 @@ function NewFrameworkViewContent() {
|
||||
|
||||
const connectionId = ConnectionHandler.getConnectionID(
|
||||
organizationId!,
|
||||
"FrameworkListPage_frameworks"
|
||||
"FrameworkListPage_frameworks",
|
||||
);
|
||||
|
||||
commit({
|
||||
@@ -154,7 +154,7 @@ function NewFrameworkViewContent() {
|
||||
});
|
||||
|
||||
navigate(
|
||||
`/organizations/${organizationId}/frameworks/${data.createFramework.frameworkEdge.node.id}`
|
||||
`/organizations/${organizationId}/frameworks/${data.createFramework.frameworkEdge.node.id}`,
|
||||
);
|
||||
},
|
||||
onError(error) {
|
||||
|
||||
@@ -233,8 +233,12 @@ export function Control({
|
||||
const [isUnlinkingPolicy, setIsUnlinkingPolicy] = useState(false);
|
||||
|
||||
// Create mutation hooks
|
||||
const [commitCreateMeasureMapping] = useMutation(createMeasureMappingMutation);
|
||||
const [commitDeleteMeasureMapping] = useMutation(deleteMeasureMappingMutation);
|
||||
const [commitCreateMeasureMapping] = useMutation(
|
||||
createMeasureMappingMutation,
|
||||
);
|
||||
const [commitDeleteMeasureMapping] = useMutation(
|
||||
deleteMeasureMappingMutation,
|
||||
);
|
||||
const [commitCreatePolicyMapping] = useMutation(createPolicyMappingMutation);
|
||||
const [commitDeletePolicyMapping] = useMutation(deletePolicyMappingMutation);
|
||||
|
||||
@@ -269,16 +273,20 @@ export function Control({
|
||||
organizationMeasuresQuery,
|
||||
{
|
||||
organizationId,
|
||||
}
|
||||
},
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setOrganizationMeasuresData(data);
|
||||
},
|
||||
complete: () => {
|
||||
// Fetch linked measures for this control
|
||||
fetchQuery<ControlLinkedMeasuresQuery>(environment, linkedMeasuresQuery, {
|
||||
controlId: control.id,
|
||||
}).subscribe({
|
||||
fetchQuery<ControlLinkedMeasuresQuery>(
|
||||
environment,
|
||||
linkedMeasuresQuery,
|
||||
{
|
||||
controlId: control.id,
|
||||
},
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setLinkedMeasuresData(data);
|
||||
setIsLoadingMeasures(false);
|
||||
@@ -310,7 +318,7 @@ export function Control({
|
||||
const getMeasures = useCallback(() => {
|
||||
if (!organizationMeasuresData?.organization?.measures?.edges) return [];
|
||||
return organizationMeasuresData.organization.measures.edges.map(
|
||||
(edge) => edge.node
|
||||
(edge) => edge.node,
|
||||
);
|
||||
}, [organizationMeasuresData]);
|
||||
|
||||
@@ -324,7 +332,7 @@ export function Control({
|
||||
const linkedMeasures = getLinkedMeasures();
|
||||
return linkedMeasures.some((measure) => measure.id === measureId);
|
||||
},
|
||||
[getLinkedMeasures]
|
||||
[getLinkedMeasures],
|
||||
);
|
||||
|
||||
const getMeasureCategories = useCallback(() => {
|
||||
@@ -397,7 +405,7 @@ export function Control({
|
||||
linkedMeasuresQuery,
|
||||
{
|
||||
controlId: control.id,
|
||||
}
|
||||
},
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setLinkedMeasuresData(data);
|
||||
@@ -423,7 +431,7 @@ export function Control({
|
||||
},
|
||||
});
|
||||
},
|
||||
[commitCreateMeasureMapping, control.id, environment, toast]
|
||||
[commitCreateMeasureMapping, control.id, environment, toast],
|
||||
);
|
||||
|
||||
const handleUnlinkMeasure = useCallback(
|
||||
@@ -458,7 +466,7 @@ export function Control({
|
||||
linkedMeasuresQuery,
|
||||
{
|
||||
controlId: control.id,
|
||||
}
|
||||
},
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setLinkedMeasuresData(data);
|
||||
@@ -484,7 +492,7 @@ export function Control({
|
||||
},
|
||||
});
|
||||
},
|
||||
[commitDeleteMeasureMapping, control.id, environment, toast]
|
||||
[commitDeleteMeasureMapping, control.id, environment, toast],
|
||||
);
|
||||
|
||||
const handleOpenMeasureMappingDialog = useCallback(() => {
|
||||
@@ -523,7 +531,7 @@ export function Control({
|
||||
organizationPoliciesQuery,
|
||||
{
|
||||
organizationId,
|
||||
}
|
||||
},
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setOrganizationPoliciesData(data);
|
||||
@@ -535,7 +543,7 @@ export function Control({
|
||||
linkedPoliciesQuery,
|
||||
{
|
||||
controlId: control.id,
|
||||
}
|
||||
},
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setLinkedPoliciesData(data);
|
||||
@@ -568,7 +576,7 @@ export function Control({
|
||||
const getPolicies = useCallback(() => {
|
||||
if (!organizationPoliciesData?.organization?.policies?.edges) return [];
|
||||
return organizationPoliciesData.organization.policies.edges.map(
|
||||
(edge) => edge.node
|
||||
(edge) => edge.node,
|
||||
);
|
||||
}, [organizationPoliciesData]);
|
||||
|
||||
@@ -582,7 +590,7 @@ export function Control({
|
||||
const linkedPolicies = getLinkedPolicies();
|
||||
return linkedPolicies.some((policy) => policy.id === policyId);
|
||||
},
|
||||
[getLinkedPolicies]
|
||||
[getLinkedPolicies],
|
||||
);
|
||||
|
||||
const filteredPolicies = useCallback(() => {
|
||||
@@ -634,7 +642,7 @@ export function Control({
|
||||
linkedPoliciesQuery,
|
||||
{
|
||||
controlId: control.id,
|
||||
}
|
||||
},
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setLinkedPoliciesData(data);
|
||||
@@ -660,7 +668,7 @@ export function Control({
|
||||
},
|
||||
});
|
||||
},
|
||||
[commitCreatePolicyMapping, control.id, environment, toast]
|
||||
[commitCreatePolicyMapping, control.id, environment, toast],
|
||||
);
|
||||
|
||||
const handleUnlinkPolicy = useCallback(
|
||||
@@ -695,7 +703,7 @@ export function Control({
|
||||
linkedPoliciesQuery,
|
||||
{
|
||||
controlId: control.id,
|
||||
}
|
||||
},
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setLinkedPoliciesData(data);
|
||||
@@ -721,7 +729,7 @@ export function Control({
|
||||
},
|
||||
});
|
||||
},
|
||||
[commitDeletePolicyMapping, control.id, environment, toast]
|
||||
[commitDeletePolicyMapping, control.id, environment, toast],
|
||||
);
|
||||
|
||||
const handleOpenPolicyMappingDialog = useCallback(() => {
|
||||
@@ -871,7 +879,7 @@ export function Control({
|
||||
<td className="py-3 px-4">
|
||||
<div
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${getStateColor(
|
||||
measure.state
|
||||
measure.state,
|
||||
)} inline-block`}
|
||||
>
|
||||
{formatState(measure.state)}
|
||||
@@ -984,7 +992,7 @@ export function Control({
|
||||
<td className="py-3 px-4">
|
||||
<div
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${getStateColor(
|
||||
measure.state
|
||||
measure.state,
|
||||
)} inline-block`}
|
||||
>
|
||||
{formatState(measure.state)}
|
||||
@@ -1076,7 +1084,9 @@ export function Control({
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b text-left text-sm text-secondary bg-invert-bg">
|
||||
<th className="py-3 px-4 font-medium">Name</th>
|
||||
<th className="py-3 px-4 font-medium">Review Date</th>
|
||||
<th className="py-3 px-4 font-medium">
|
||||
Review Date
|
||||
</th>
|
||||
<th className="py-3 px-4 font-medium text-right">
|
||||
Actions
|
||||
</th>
|
||||
@@ -1103,7 +1113,7 @@ export function Control({
|
||||
<td className="py-3 px-4">
|
||||
{policy.updatedAt
|
||||
? new Date(
|
||||
policy.updatedAt
|
||||
policy.updatedAt,
|
||||
).toLocaleDateString()
|
||||
: "Not set"}
|
||||
</td>
|
||||
|
||||
@@ -26,7 +26,7 @@ function ControlViewContent({
|
||||
}) {
|
||||
const { node } = usePreloadedQuery<ControlViewQuery>(
|
||||
controlViewQuery,
|
||||
queryRef
|
||||
queryRef,
|
||||
);
|
||||
|
||||
return <Control controlKey={node} />;
|
||||
|
||||
@@ -58,7 +58,7 @@ function EditMeasureViewContent({
|
||||
const [formData, setFormData] = useState({
|
||||
name: measure.name ?? "",
|
||||
description: measure.description ?? "",
|
||||
category: measure.category ?? ""
|
||||
category: measure.category ?? "",
|
||||
});
|
||||
|
||||
const [commit, isInFlight] =
|
||||
@@ -89,7 +89,7 @@ function EditMeasureViewContent({
|
||||
id: measureId!,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
category: formData.category
|
||||
category: formData.category,
|
||||
},
|
||||
},
|
||||
onCompleted(data, errors) {
|
||||
@@ -154,7 +154,9 @@ function EditMeasureViewContent({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(`/organizations/${organizationId}/measures/${measureId}`)
|
||||
navigate(
|
||||
`/organizations/${organizationId}/measures/${measureId}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -121,7 +121,7 @@ function MeasureListContent({
|
||||
}) {
|
||||
const data = usePreloadedQuery<MeasureListViewQueryType>(
|
||||
measureListViewQuery,
|
||||
queryRef
|
||||
queryRef,
|
||||
) as unknown as OrganizationData;
|
||||
|
||||
const { organizationId } = useParams();
|
||||
@@ -129,14 +129,15 @@ function MeasureListContent({
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [importMeasure] =
|
||||
useMutation<MeasureListViewImportMeasureMutationType>(importMeasureMutation);
|
||||
const [importMeasure] = useMutation<MeasureListViewImportMeasureMutationType>(
|
||||
importMeasureMutation,
|
||||
);
|
||||
|
||||
// Monitor URL hash for changes and update state accordingly
|
||||
const [initialHashCategory] = useState(() =>
|
||||
window.location.hash.substring(1)
|
||||
? decodeURIComponent(window.location.hash.substring(1))
|
||||
: ""
|
||||
: "",
|
||||
);
|
||||
|
||||
// Keep track of manually expanded categories
|
||||
@@ -202,10 +203,10 @@ function MeasureListContent({
|
||||
|
||||
// Calculate global progress
|
||||
const implementedCount = processedMeasures.filter(
|
||||
(measure: Measure) => measure.status === "complete"
|
||||
(measure: Measure) => measure.status === "complete",
|
||||
).length;
|
||||
const notApplicableCount = processedMeasures.filter(
|
||||
(measure: Measure) => measure.status === "not-applicable"
|
||||
(measure: Measure) => measure.status === "not-applicable",
|
||||
).length;
|
||||
const totalMeasures = processedMeasures.length;
|
||||
|
||||
@@ -223,7 +224,7 @@ function MeasureListContent({
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
// Group measures by category
|
||||
@@ -236,7 +237,7 @@ function MeasureListContent({
|
||||
acc[measure.category].push(measure);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Measure[]>
|
||||
{} as Record<string, Measure[]>,
|
||||
);
|
||||
|
||||
// Function to toggle a category's expanded state
|
||||
@@ -253,10 +254,10 @@ function MeasureListContent({
|
||||
const categories: Category[] = Object.entries(measuresByCategory)
|
||||
.map(([categoryName, categoryMeasures]: [string, Measure[]]) => {
|
||||
const catImplementedCount = categoryMeasures.filter(
|
||||
(measure) => measure.status === "complete"
|
||||
(measure) => measure.status === "complete",
|
||||
).length;
|
||||
const catNotApplicableCount = categoryMeasures.filter(
|
||||
(measure) => measure.status === "not-applicable"
|
||||
(measure) => measure.status === "not-applicable",
|
||||
).length;
|
||||
// Consider both "complete" and "not-applicable" as done for category progress
|
||||
const catDoneCount = catImplementedCount + catNotApplicableCount;
|
||||
@@ -271,7 +272,7 @@ function MeasureListContent({
|
||||
description: `Measures related to ${categoryName.toLowerCase()}`,
|
||||
progress: progress,
|
||||
measures: categoryMeasures.sort((a, b) =>
|
||||
(a.name || "").localeCompare(b.name || "")
|
||||
(a.name || "").localeCompare(b.name || ""),
|
||||
),
|
||||
doneCount: catDoneCount,
|
||||
totalCount: catTotalCount,
|
||||
@@ -308,7 +309,7 @@ function MeasureListContent({
|
||||
connections: [
|
||||
ConnectionHandler.getConnectionID(
|
||||
data.organization.id,
|
||||
"MeasureListView_measures"
|
||||
"MeasureListView_measures",
|
||||
),
|
||||
],
|
||||
input: {
|
||||
@@ -591,28 +592,28 @@ function MeasureListContent({
|
||||
</p>
|
||||
<p>
|
||||
{measure.description.startsWith(
|
||||
"##"
|
||||
"##",
|
||||
)
|
||||
? measure.description
|
||||
.split("\n")
|
||||
.find((line) =>
|
||||
line.startsWith(
|
||||
"## Why"
|
||||
)
|
||||
"## Why",
|
||||
),
|
||||
)
|
||||
?.replace("## Why?", "")
|
||||
?.replace("## Why", "")
|
||||
?.trim() ||
|
||||
measure.description.split(
|
||||
"\n"
|
||||
"\n",
|
||||
)[1] ||
|
||||
""
|
||||
: measure.description.substring(
|
||||
0,
|
||||
180
|
||||
180,
|
||||
) +
|
||||
(measure.description.length >
|
||||
180
|
||||
(measure.description
|
||||
.length > 180
|
||||
? "..."
|
||||
: "")}
|
||||
</p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,11 +36,12 @@ export default function NewMeasureView() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
category: ""
|
||||
category: "",
|
||||
});
|
||||
|
||||
const [commit, isInFlight] =
|
||||
useMutation<NewMeasureViewCreateMeasureMutation>(createMeasureMutation);
|
||||
const [commit, isInFlight] = useMutation<NewMeasureViewCreateMeasureMutation>(
|
||||
createMeasureMutation,
|
||||
);
|
||||
|
||||
const handleFieldChange = (field: keyof typeof formData, value: unknown) => {
|
||||
setFormData((prev) => ({
|
||||
@@ -63,7 +64,7 @@ export default function NewMeasureView() {
|
||||
|
||||
const connectionId = ConnectionHandler.getConnectionID(
|
||||
organizationId!,
|
||||
"MeasureListView_measures"
|
||||
"MeasureListView_measures",
|
||||
);
|
||||
|
||||
commit({
|
||||
@@ -72,7 +73,7 @@ export default function NewMeasureView() {
|
||||
organizationId: organizationId!,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
category: formData.category
|
||||
category: formData.category,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
@@ -92,7 +93,7 @@ export default function NewMeasureView() {
|
||||
});
|
||||
|
||||
navigate(
|
||||
`/organizations/${organizationId}/measures/${data.createMeasure.measureEdge.node.id}`
|
||||
`/organizations/${organizationId}/measures/${data.createMeasure.measureEdge.node.id}`,
|
||||
);
|
||||
},
|
||||
onError(error) {
|
||||
|
||||
@@ -100,7 +100,7 @@ function NewPeopleViewContent() {
|
||||
direction: "ASC",
|
||||
field: "FULL_NAME",
|
||||
},
|
||||
}
|
||||
},
|
||||
),
|
||||
ConnectionHandler.getConnectionID(
|
||||
organizationId!,
|
||||
@@ -110,7 +110,7 @@ function NewPeopleViewContent() {
|
||||
direction: "ASC",
|
||||
field: "FULL_NAME",
|
||||
},
|
||||
}
|
||||
},
|
||||
),
|
||||
ConnectionHandler.getConnectionID(
|
||||
organizationId!,
|
||||
@@ -120,7 +120,7 @@ function NewPeopleViewContent() {
|
||||
direction: "ASC",
|
||||
field: "FULL_NAME",
|
||||
},
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
input: {
|
||||
@@ -196,7 +196,7 @@ function NewPeopleViewContent() {
|
||||
onClick={() => {
|
||||
const newEmails =
|
||||
formData.additionalEmailAddresses.filter(
|
||||
(_, i) => i !== index
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
handleFieldChange("additionalEmailAddresses", newEmails);
|
||||
}}
|
||||
@@ -243,7 +243,7 @@ function NewPeopleViewContent() {
|
||||
"rounded-full px-4 py-1 text-sm transition-colors",
|
||||
formData.kind === "EMPLOYEE"
|
||||
? "bg-blue-100 text-blue-900 ring-2 ring-blue-600 ring-offset-2"
|
||||
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg"
|
||||
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg",
|
||||
)}
|
||||
>
|
||||
Employee
|
||||
@@ -255,7 +255,7 @@ function NewPeopleViewContent() {
|
||||
"rounded-full px-4 py-1 text-sm transition-colors",
|
||||
formData.kind === "CONTRACTOR"
|
||||
? "bg-purple-100 text-purple-900 ring-2 ring-purple-600 ring-offset-2"
|
||||
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg"
|
||||
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg",
|
||||
)}
|
||||
>
|
||||
Contractor
|
||||
@@ -269,7 +269,7 @@ function NewPeopleViewContent() {
|
||||
"rounded-full px-4 py-1 text-sm transition-colors",
|
||||
formData.kind === "SERVICE_ACCOUNT"
|
||||
? "bg-green-100 text-green-900 ring-2 ring-green-600 ring-offset-2"
|
||||
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg"
|
||||
: "bg-secondary-bg text-primary hover:bg-h-secondary-bg",
|
||||
)}
|
||||
>
|
||||
Service Account
|
||||
|
||||
@@ -148,7 +148,7 @@ function PeopleListContent({
|
||||
}) {
|
||||
const data = usePreloadedQuery<PeopleListViewQueryType>(
|
||||
peopleListViewQuery,
|
||||
queryRef
|
||||
queryRef,
|
||||
);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -217,10 +217,10 @@ function PeopleListContent({
|
||||
{person?.kind === "EMPLOYEE"
|
||||
? "Employee"
|
||||
: person?.kind === "CONTRACTOR"
|
||||
? "Contractor"
|
||||
: person?.kind === "SERVICE_ACCOUNT"
|
||||
? "Service Account"
|
||||
: "Vendor"}
|
||||
? "Contractor"
|
||||
: person?.kind === "SERVICE_ACCOUNT"
|
||||
? "Service Account"
|
||||
: "Vendor"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -230,7 +230,7 @@ function PeopleListContent({
|
||||
e.preventDefault();
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to delete this person?"
|
||||
"Are you sure you want to delete this person?",
|
||||
)
|
||||
) {
|
||||
deletePeople({
|
||||
|
||||
@@ -202,7 +202,7 @@ function PeopleViewContent({
|
||||
onClick={() => {
|
||||
const newEmails =
|
||||
formData.additionalEmailAddresses.filter(
|
||||
(_, i) => i !== index
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
handleFieldChange("additionalEmailAddresses", newEmails);
|
||||
}}
|
||||
|
||||
@@ -19,7 +19,11 @@ import { PageTemplate } from "@/components/PageTemplate";
|
||||
import { EditPolicyViewSkeleton } from "./EditPolicyPage";
|
||||
|
||||
const editPolicyViewQuery = graphql`
|
||||
query EditPolicyViewQuery($policyId: ID!, $organizationId: ID!, $policyVersionId: ID!) {
|
||||
query EditPolicyViewQuery(
|
||||
$policyId: ID!
|
||||
$organizationId: ID!
|
||||
$policyVersionId: ID!
|
||||
) {
|
||||
policyVersion: node(id: $policyVersionId) {
|
||||
id
|
||||
... on PolicyVersion {
|
||||
@@ -63,7 +67,7 @@ function EditPolicyViewContent({
|
||||
const { organizationId, policyId, versionId } = useParams();
|
||||
const data = usePreloadedQuery<EditPolicyViewQuery>(
|
||||
editPolicyViewQuery,
|
||||
queryRef
|
||||
queryRef,
|
||||
);
|
||||
|
||||
const [content, setContent] = useState(data.policyVersion.content || "");
|
||||
@@ -140,7 +144,7 @@ function EditPolicyViewContent({
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/organizations/${organizationId}/policies/${policyId}`
|
||||
`/organizations/${organizationId}/policies/${policyId}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -175,7 +179,7 @@ export default function EditPolicyView() {
|
||||
|
||||
return (
|
||||
<Suspense fallback={<EditPolicyViewSkeleton />}>
|
||||
<EditPolicyViewContent queryRef={queryRef}/>
|
||||
<EditPolicyViewContent queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { format } from "date-fns";
|
||||
import type { PolicyListViewQuery, PolicyListViewQuery$data } from "./__generated__/PolicyListViewQuery.graphql";
|
||||
import type {
|
||||
PolicyListViewQuery,
|
||||
PolicyListViewQuery$data,
|
||||
} from "./__generated__/PolicyListViewQuery.graphql";
|
||||
import type { PolicyListViewDeleteMutation } from "./__generated__/PolicyListViewDeleteMutation.graphql";
|
||||
import type { PolicyListViewCreateMutation } from "./__generated__/PolicyListViewCreateMutation.graphql";
|
||||
import type { PolicyListViewSendSigningNotificationsMutation } from "./__generated__/PolicyListViewSendSigningNotificationsMutation.graphql";
|
||||
@@ -54,7 +57,8 @@ const policyListViewQuery = graphql`
|
||||
organization: node(id: $organizationId) {
|
||||
... on Organization {
|
||||
...PeopleSelector_organization
|
||||
policies(first: 50, orderBy: {field: TITLE, direction: ASC}) @connection(key: "PolicyListView_policies") {
|
||||
policies(first: 50, orderBy: { field: TITLE, direction: ASC })
|
||||
@connection(key: "PolicyListView_policies") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -127,7 +131,9 @@ const createPolicyMutation = graphql`
|
||||
`;
|
||||
|
||||
const sendSigningNotificationsMutation = graphql`
|
||||
mutation PolicyListViewSendSigningNotificationsMutation($input: SendSigningNotificationsInput!) {
|
||||
mutation PolicyListViewSendSigningNotificationsMutation(
|
||||
$input: SendSigningNotificationsInput!
|
||||
) {
|
||||
sendSigningNotifications(input: $input) {
|
||||
success
|
||||
}
|
||||
@@ -137,26 +143,33 @@ function PolicyTableRow({
|
||||
policy,
|
||||
organizationId,
|
||||
}: {
|
||||
policy: NonNullable<PolicyListViewQuery$data["organization"]["policies"]>["edges"][0]["node"];
|
||||
policy: NonNullable<
|
||||
PolicyListViewQuery$data["organization"]["policies"]
|
||||
>["edges"][0]["node"];
|
||||
organizationId: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [deletePolicy, isDeleting] = useMutation<PolicyListViewDeleteMutation>(DeletePolicyMutation);
|
||||
const [deletePolicy, isDeleting] =
|
||||
useMutation<PolicyListViewDeleteMutation>(DeletePolicyMutation);
|
||||
|
||||
const latestVersion = policy.versions?.edges[0]?.node;
|
||||
const status = latestVersion?.status || "DRAFT";
|
||||
|
||||
|
||||
// Get signature counts
|
||||
const signatureEdges = latestVersion?.signatures?.edges || [];
|
||||
const totalSignatures = signatureEdges.length;
|
||||
const signedCount = signatureEdges.filter(
|
||||
edge => edge?.node?.state === "SIGNED"
|
||||
(edge) => edge?.node?.state === "SIGNED",
|
||||
).length;
|
||||
|
||||
const handleDeletePolicy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm("Are you sure you want to delete this policy? This action cannot be undone.")) {
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to delete this policy? This action cannot be undone.",
|
||||
)
|
||||
) {
|
||||
deletePolicy({
|
||||
variables: {
|
||||
input: {
|
||||
@@ -165,7 +178,7 @@ function PolicyTableRow({
|
||||
connections: [
|
||||
ConnectionHandler.getConnectionID(
|
||||
organizationId,
|
||||
"PolicyListView_policies"
|
||||
"PolicyListView_policies",
|
||||
),
|
||||
],
|
||||
},
|
||||
@@ -202,7 +215,7 @@ function PolicyTableRow({
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
<tr
|
||||
className="border-t border-solid-b hover:bg-subtle-bg cursor-pointer"
|
||||
onClick={() => {
|
||||
navigate(`/organizations/${organizationId}/policies/${policy.id}`);
|
||||
@@ -228,7 +241,9 @@ function PolicyTableRow({
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
{status === "PUBLISHED" ? (
|
||||
<span className="text-sm text-primary">{signedCount} / {totalSignatures}</span>
|
||||
<span className="text-sm text-primary">
|
||||
{signedCount} / {totalSignatures}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-tertiary">-</span>
|
||||
)}
|
||||
@@ -236,8 +251,8 @@ function PolicyTableRow({
|
||||
<td className="py-4 px-6">
|
||||
<Badge
|
||||
className={`font-medium border-0 py-0 px-[6px] h-5 text-xs rounded-md ${
|
||||
status === "PUBLISHED"
|
||||
? "bg-success-bg text-success"
|
||||
status === "PUBLISHED"
|
||||
? "bg-success-bg text-success"
|
||||
: "bg-secondary-bg text-tertiary"
|
||||
}`}
|
||||
>
|
||||
@@ -292,7 +307,8 @@ function CreatePolicyModal({
|
||||
const [content, setContent] = useState("");
|
||||
const [ownerId, setOwnerId] = useState<string | null>(null);
|
||||
|
||||
const [createPolicy, isCreating] = useMutation<PolicyListViewCreateMutation>(createPolicyMutation);
|
||||
const [createPolicy, isCreating] =
|
||||
useMutation<PolicyListViewCreateMutation>(createPolicyMutation);
|
||||
|
||||
// Reset form fields
|
||||
const resetForm = () => {
|
||||
@@ -319,7 +335,7 @@ function CreatePolicyModal({
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const input = {
|
||||
organizationId,
|
||||
title,
|
||||
@@ -334,7 +350,7 @@ function CreatePolicyModal({
|
||||
ConnectionHandler.getConnectionID(
|
||||
organizationId,
|
||||
"PolicyListView_policies",
|
||||
{orderBy: {field: "TITLE", direction: "ASC"}}
|
||||
{ orderBy: { field: "TITLE", direction: "ASC" } },
|
||||
),
|
||||
],
|
||||
},
|
||||
@@ -379,7 +395,10 @@ function CreatePolicyModal({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[1080px] h-[700px] max-h-[700px] p-0 gap-0 flex flex-col">
|
||||
<DialogTitle className="sr-only">Create new policy</DialogTitle>
|
||||
<DialogDescription className="sr-only">Form to create a new policy with title, content, and owner information.</DialogDescription>
|
||||
<DialogDescription className="sr-only">
|
||||
Form to create a new policy with title, content, and owner
|
||||
information.
|
||||
</DialogDescription>
|
||||
<div className="flex justify-between items-center py-2 px-4 border-b border-solid-b h-[40px]">
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<span className="text-tertiary">Policies</span>
|
||||
@@ -391,23 +410,23 @@ function CreatePolicyModal({
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 p-6 overflow-y-auto">
|
||||
<div className="mb-6">
|
||||
<h1
|
||||
className={`text-4xl leading-tight font-bold outline-none focus:outline-none ${!title ? 'text-gray-400' : 'text-black'}`}
|
||||
<h1
|
||||
className={`text-4xl leading-tight font-bold outline-none focus:outline-none ${!title ? "text-gray-400" : "text-black"}`}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={(e) => {
|
||||
const newText = e.currentTarget.textContent || "";
|
||||
setTitle(newText);
|
||||
}}
|
||||
style={{ WebkitTapHighlightColor: 'transparent' }}
|
||||
style={{ WebkitTapHighlightColor: "transparent" }}
|
||||
onClick={(e) => {
|
||||
if (!title) {
|
||||
e.currentTarget.textContent = '';
|
||||
e.currentTarget.textContent = "";
|
||||
}
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
if (e.currentTarget.textContent === "Enter policy title...") {
|
||||
e.currentTarget.textContent = '';
|
||||
e.currentTarget.textContent = "";
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
@@ -421,8 +440,7 @@ function CreatePolicyModal({
|
||||
el.textContent = title || "Enter policy title...";
|
||||
}
|
||||
}}
|
||||
>
|
||||
</h1>
|
||||
></h1>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="This Privacy Policy outlines how NovaSoft collects, uses, and protects personal information provided by users of its services. By accessing or using our platform, you agree to the collection and use of information in accordance with this policy..."
|
||||
@@ -433,15 +451,17 @@ function CreatePolicyModal({
|
||||
</div>
|
||||
<div className="w-[420px] bg-[rgba(5,77,5,0.03)] p-6 flex flex-col gap-4">
|
||||
<h3 className="font-medium text-base">Properties</h3>
|
||||
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-center py-2 border-t border-[rgba(2,42,2,0.08)]">
|
||||
<span className="text-sm font-medium text-tertiary">Status</span>
|
||||
<span className="text-sm font-medium text-tertiary">
|
||||
Status
|
||||
</span>
|
||||
<div className="bg-[rgba(0,39,0,0.05)] py-1.5 px-2 rounded-lg">
|
||||
<span className="text-sm font-medium">Draft</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-between items-center py-2 border-t border-[rgba(2,42,2,0.08)]">
|
||||
<span className="text-sm font-medium text-tertiary">Owner</span>
|
||||
<div className="relative">
|
||||
@@ -453,10 +473,16 @@ function CreatePolicyModal({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-between items-center py-2 border-t border-[rgba(2,42,2,0.08)]">
|
||||
<span className="text-sm font-medium text-tertiary">Review date</span>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 rounded-full bg-[rgba(0,39,0,0.05)] border-none">
|
||||
<span className="text-sm font-medium text-tertiary">
|
||||
Review date
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 rounded-full bg-[rgba(0,39,0,0.05)] border-none"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -492,16 +518,19 @@ function PolicyListViewContent({
|
||||
}) {
|
||||
const data = usePreloadedQuery<PolicyListViewQuery>(
|
||||
policyListViewQuery,
|
||||
queryRef
|
||||
queryRef,
|
||||
);
|
||||
const { organizationId } = useParams();
|
||||
const policies =
|
||||
data.organization?.policies?.edges.map((edge) => edge?.node) ?? [];
|
||||
|
||||
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [confirmSigningModalOpen, setConfirmSigningModalOpen] = useState(false);
|
||||
|
||||
const [sendSigningNotifications, isSendingNotifications] = useMutation<PolicyListViewSendSigningNotificationsMutation>(sendSigningNotificationsMutation);
|
||||
const [sendSigningNotifications, isSendingNotifications] =
|
||||
useMutation<PolicyListViewSendSigningNotificationsMutation>(
|
||||
sendSigningNotificationsMutation,
|
||||
);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleOpenModal = () => {
|
||||
@@ -524,7 +553,8 @@ function PolicyListViewContent({
|
||||
console.error("Error sending signing notifications:", errors);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to send signing notifications. Please try again.",
|
||||
description:
|
||||
"Failed to send signing notifications. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -540,7 +570,8 @@ function PolicyListViewContent({
|
||||
console.error("Error sending signing notifications:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to send signing notifications. Please try again.",
|
||||
description:
|
||||
"Failed to send signing notifications. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
@@ -635,13 +666,17 @@ function PolicyListViewContent({
|
||||
organizationId={organizationId!}
|
||||
organizationRef={data.organization}
|
||||
/>
|
||||
|
||||
|
||||
{/* Confirmation Modal for Sending Signing Notifications */}
|
||||
<Dialog open={confirmSigningModalOpen} onOpenChange={setConfirmSigningModalOpen}>
|
||||
<Dialog
|
||||
open={confirmSigningModalOpen}
|
||||
onOpenChange={setConfirmSigningModalOpen}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogTitle>Send Signing Notifications</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will send signing notifications to all users who have pending policies to sign. Are you sure you want to continue?
|
||||
This will send signing notifications to all users who have pending
|
||||
policies to sign. Are you sure you want to continue?
|
||||
</DialogDescription>
|
||||
<DialogFooter className="gap-2 mt-4">
|
||||
<Button
|
||||
|
||||
@@ -7,7 +7,15 @@ import {
|
||||
useQueryLoader,
|
||||
useMutation,
|
||||
} from "react-relay";
|
||||
import { Clock, Download, Edit, Trash2, MoreHorizontal, X, FileSignature } from "lucide-react";
|
||||
import {
|
||||
Clock,
|
||||
Download,
|
||||
Edit,
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
FileSignature,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { PageTemplate } from "@/components/PageTemplate";
|
||||
@@ -15,7 +23,7 @@ import type { ShowPolicyViewQuery } from "./__generated__/ShowPolicyViewQuery.gr
|
||||
import { ShowPolicyViewPublishMutation } from "./__generated__/ShowPolicyViewPublishMutation.graphql";
|
||||
import { ShowPolicyViewCreateDraftMutation } from "./__generated__/ShowPolicyViewCreateDraftMutation.graphql";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ShowPolicyViewSkeleton } from "./ShowPolicyPage";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
@@ -96,7 +104,9 @@ const publishPolicyVersionMutation = graphql`
|
||||
`;
|
||||
|
||||
const createDraftPolicyVersionMutation = graphql`
|
||||
mutation ShowPolicyViewCreateDraftMutation($input: CreateDraftPolicyVersionInput!) {
|
||||
mutation ShowPolicyViewCreateDraftMutation(
|
||||
$input: CreateDraftPolicyVersionInput!
|
||||
) {
|
||||
createDraftPolicyVersion(input: $input) {
|
||||
policyVersionEdge {
|
||||
node {
|
||||
@@ -114,29 +124,36 @@ function ShowPolicyContent({
|
||||
}: {
|
||||
queryRef: PreloadedQuery<ShowPolicyViewQuery>;
|
||||
}) {
|
||||
const data = usePreloadedQuery<ShowPolicyViewQuery>(policyViewQuery, queryRef);
|
||||
const data = usePreloadedQuery<ShowPolicyViewQuery>(
|
||||
policyViewQuery,
|
||||
queryRef,
|
||||
);
|
||||
const policy = data.node;
|
||||
const { organizationId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [queryRef2, loadQuery] = useQueryLoader<ShowPolicyViewQuery>(policyViewQuery);
|
||||
const [queryRef2, loadQuery] =
|
||||
useQueryLoader<ShowPolicyViewQuery>(policyViewQuery);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
|
||||
const [isSignaturesModalOpen, setIsSignaturesModalOpen] = useState(false);
|
||||
|
||||
const [publishDraft, isPublishInFlight] = useMutation<ShowPolicyViewPublishMutation>(publishPolicyVersionMutation);
|
||||
const [createDraft, isCreateDraftInFlight] = useMutation<ShowPolicyViewCreateDraftMutation>(createDraftPolicyVersionMutation);
|
||||
|
||||
const [publishDraft, isPublishInFlight] =
|
||||
useMutation<ShowPolicyViewPublishMutation>(publishPolicyVersionMutation);
|
||||
const [createDraft, isCreateDraftInFlight] =
|
||||
useMutation<ShowPolicyViewCreateDraftMutation>(
|
||||
createDraftPolicyVersionMutation,
|
||||
);
|
||||
|
||||
const latestVersionEdge = policy.latestVersion?.edges[0];
|
||||
const latestVersionNode = latestVersionEdge?.node;
|
||||
|
||||
|
||||
const isDraft = latestVersionNode?.status === "DRAFT";
|
||||
|
||||
useEffect(() => {
|
||||
// No need to update selectedVersion state since we're using the VersionHistoryModal component
|
||||
}, []);
|
||||
|
||||
|
||||
// Handle delete policy
|
||||
const handleDeletePolicy = useCallback(() => {
|
||||
@@ -157,32 +174,32 @@ function ShowPolicyContent({
|
||||
navigate(`/organizations/${organizationId}/policies`);
|
||||
}, 1000);
|
||||
}, [toast, navigate, organizationId]);
|
||||
|
||||
|
||||
// Navigate to publish flow
|
||||
const handlePublish = useCallback(() => {
|
||||
if (!policy.id) return;
|
||||
|
||||
|
||||
publishDraft({
|
||||
variables: {
|
||||
input: {
|
||||
policyId: policy.id
|
||||
}
|
||||
policyId: policy.id,
|
||||
},
|
||||
},
|
||||
onCompleted: (_, errors) => {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: "Error publishing policy",
|
||||
description: errors[0]?.message || "An unknown error occurred",
|
||||
variant: "destructive"
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
toast({
|
||||
title: "Policy published",
|
||||
description: `The policy has been published successfully`,
|
||||
});
|
||||
|
||||
|
||||
// Reload the query to refresh the data
|
||||
loadQuery({ policyId: policy.id });
|
||||
},
|
||||
@@ -190,9 +207,9 @@ function ShowPolicyContent({
|
||||
toast({
|
||||
title: "Error publishing policy",
|
||||
description: error.message || "An unknown error occurred",
|
||||
variant: "destructive"
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [policy.id, publishDraft, toast, loadQuery]);
|
||||
|
||||
@@ -202,18 +219,21 @@ function ShowPolicyContent({
|
||||
}, []);
|
||||
|
||||
// Restore version
|
||||
const handleRestoreVersion = useCallback((versionNumber: number) => {
|
||||
// Here you would implement the logic to restore a version
|
||||
toast({
|
||||
title: "Version restored",
|
||||
description: `Version ${versionNumber} has been restored`,
|
||||
});
|
||||
setIsVersionHistoryOpen(false);
|
||||
// Reload the query to refresh the data
|
||||
if (policy.id) {
|
||||
loadQuery({ policyId: policy.id });
|
||||
}
|
||||
}, [policy.id, loadQuery, toast]);
|
||||
const handleRestoreVersion = useCallback(
|
||||
(versionNumber: number) => {
|
||||
// Here you would implement the logic to restore a version
|
||||
toast({
|
||||
title: "Version restored",
|
||||
description: `Version ${versionNumber} has been restored`,
|
||||
});
|
||||
setIsVersionHistoryOpen(false);
|
||||
// Reload the query to refresh the data
|
||||
if (policy.id) {
|
||||
loadQuery({ policyId: policy.id });
|
||||
}
|
||||
},
|
||||
[policy.id, loadQuery, toast],
|
||||
);
|
||||
|
||||
// Handle edit policy
|
||||
const handleEditPolicy = useCallback(() => {
|
||||
@@ -224,35 +244,47 @@ function ShowPolicyContent({
|
||||
createDraft({
|
||||
variables: {
|
||||
input: {
|
||||
policyID: policy.id
|
||||
}
|
||||
policyID: policy.id,
|
||||
},
|
||||
},
|
||||
onCompleted: (response, errors) => {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: "Error creating draft",
|
||||
description: errors[0]?.message || "An unknown error occurred",
|
||||
variant: "destructive"
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newDraftId = response.createDraftPolicyVersion.policyVersionEdge.node.id;
|
||||
navigate(`/organizations/${organizationId}/policies/${policy.id}/versions/${newDraftId}/edit`);
|
||||
|
||||
const newDraftId =
|
||||
response.createDraftPolicyVersion.policyVersionEdge.node.id;
|
||||
navigate(
|
||||
`/organizations/${organizationId}/policies/${policy.id}/versions/${newDraftId}/edit`,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error creating draft",
|
||||
description: error.message || "An unknown error occurred",
|
||||
variant: "destructive"
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Navigate directly to edit if it's already a draft
|
||||
navigate(`/organizations/${organizationId}/policies/${policy.id}/versions/${latestVersionNode?.id}/edit`);
|
||||
navigate(
|
||||
`/organizations/${organizationId}/policies/${policy.id}/versions/${latestVersionNode?.id}/edit`,
|
||||
);
|
||||
}
|
||||
}, [policy.id, latestVersionNode, createDraft, navigate, organizationId, toast]);
|
||||
}, [
|
||||
policy.id,
|
||||
latestVersionNode,
|
||||
createDraft,
|
||||
navigate,
|
||||
organizationId,
|
||||
toast,
|
||||
]);
|
||||
|
||||
// Format date helper
|
||||
const formatDate = (dateString?: string) => {
|
||||
@@ -265,7 +297,7 @@ function ShowPolicyContent({
|
||||
<PageTemplate
|
||||
title={policy.title!}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -285,7 +317,7 @@ function ShowPolicyContent({
|
||||
<FileSignature className="h-4 w-4" />
|
||||
<span className="font-medium">Signature history</span>
|
||||
</Button>
|
||||
|
||||
|
||||
{isDraft && (
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -297,7 +329,7 @@ function ShowPolicyContent({
|
||||
<span className="font-medium">Publish version</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -312,10 +344,12 @@ function ShowPolicyContent({
|
||||
<DropdownMenuItem asChild>
|
||||
<div onClick={handleEditPolicy}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{latestVersionNode?.status === "PUBLISHED" ? "Create new draft" : "Edit draft policy"}
|
||||
{latestVersionNode?.status === "PUBLISHED"
|
||||
? "Create new draft"
|
||||
: "Edit draft policy"}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
<DropdownMenuItem
|
||||
onClick={handleDeletePolicy}
|
||||
className="text-danger focus:text-danger focus:bg-danger-bg"
|
||||
>
|
||||
@@ -326,42 +360,44 @@ function ShowPolicyContent({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{latestVersionNode ? (
|
||||
<div className="bg-white rounded-lg border border-solid-b shadow-sm p-6">
|
||||
{latestVersionNode.changelog && (
|
||||
<div className="mb-6 p-4 bg-level-1 rounded-md border border-solid-b">
|
||||
<div className="text-xs text-tertiary uppercase font-medium mb-1">Change summary</div>
|
||||
<div className="text-sm text-secondary">
|
||||
{latestVersionNode.changelog}
|
||||
</div>
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{latestVersionNode ? (
|
||||
<div className="bg-white rounded-lg border border-solid-b shadow-sm p-6">
|
||||
{latestVersionNode.changelog && (
|
||||
<div className="mb-6 p-4 bg-level-1 rounded-md border border-solid-b">
|
||||
<div className="text-xs text-tertiary uppercase font-medium mb-1">
|
||||
Change summary
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prose prose-olive max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{latestVersionNode.content || "No content available"}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-solid-b text-xs text-tertiary flex justify-between items-center">
|
||||
<div>
|
||||
{latestVersionNode.status === "PUBLISHED"
|
||||
? `Published on ${formatDate(latestVersionNode.publishedAt || "")}${latestVersionNode.publishedBy ? ` by ${latestVersionNode.publishedBy.fullName}` : ''}`
|
||||
: `Last modified on ${formatDate(latestVersionNode.updatedAt)} by ${policy.owner?.fullName || 'Unknown'}`}
|
||||
<div className="text-sm text-secondary">
|
||||
{latestVersionNode.changelog}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prose prose-olive max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{latestVersionNode.content || "No content available"}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-solid-b shadow-sm p-6 text-center text-tertiary">
|
||||
No content available
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-solid-b text-xs text-tertiary flex justify-between items-center">
|
||||
<div>
|
||||
{latestVersionNode.status === "PUBLISHED"
|
||||
? `Published on ${formatDate(latestVersionNode.publishedAt || "")}${latestVersionNode.publishedBy ? ` by ${latestVersionNode.publishedBy.fullName}` : ""}`
|
||||
: `Last modified on ${formatDate(latestVersionNode.updatedAt)} by ${policy.owner?.fullName || "Unknown"}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-solid-b shadow-sm p-6 text-center text-tertiary">
|
||||
No content available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signatures Modal */}
|
||||
<SignaturesModal
|
||||
<SignaturesModal
|
||||
isOpen={isSignaturesModalOpen}
|
||||
onClose={() => setIsSignaturesModalOpen(false)}
|
||||
policyRef={policy}
|
||||
@@ -382,7 +418,8 @@ function ShowPolicyContent({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Policy</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete the policy "{policy.title}"? This action cannot be undone.
|
||||
Are you sure you want to delete the policy "{policy.title}"? This
|
||||
action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -408,7 +445,8 @@ function ShowPolicyContent({
|
||||
}
|
||||
|
||||
export default function ShowPolicyView() {
|
||||
const [queryRef, loadQuery] = useQueryLoader<ShowPolicyViewQuery>(policyViewQuery);
|
||||
const [queryRef, loadQuery] =
|
||||
useQueryLoader<ShowPolicyViewQuery>(policyViewQuery);
|
||||
const { policyId } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,7 +27,10 @@ export function SignaturesList({ signatures }: SignaturesListProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{signatures.map((signature) => (
|
||||
<div key={signature.id} className="bg-white rounded-lg border border-solid-b shadow-sm p-6">
|
||||
<div
|
||||
key={signature.id}
|
||||
className="bg-white rounded-lg border border-solid-b shadow-sm p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src="" alt={signature.signedBy.fullName} />
|
||||
@@ -44,15 +47,17 @@ export function SignaturesList({ signatures }: SignaturesListProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Status:</span>{" "}
|
||||
<span className={`px-2 py-0.5 rounded-full ${
|
||||
signature.state === "SIGNED"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full ${
|
||||
signature.state === "SIGNED"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{signature.state}
|
||||
</span>
|
||||
</div>
|
||||
@@ -71,4 +76,4 @@ export function SignaturesList({ signatures }: SignaturesListProps) {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,18 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { format } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useEffect } from "react";
|
||||
import { graphql, useFragment, useMutation, useQueryLoader, usePreloadedQuery, ConnectionHandler } from "react-relay";
|
||||
import type { SignaturesModal_policyVersions$data, SignaturesModal_policyVersions$key } from "./__generated__/SignaturesModal_policyVersions.graphql";
|
||||
import {
|
||||
graphql,
|
||||
useFragment,
|
||||
useMutation,
|
||||
useQueryLoader,
|
||||
usePreloadedQuery,
|
||||
ConnectionHandler,
|
||||
} from "react-relay";
|
||||
import type {
|
||||
SignaturesModal_policyVersions$data,
|
||||
SignaturesModal_policyVersions$key,
|
||||
} from "./__generated__/SignaturesModal_policyVersions.graphql";
|
||||
import type { SignaturesModalRequestSignatureMutation } from "./__generated__/SignaturesModalRequestSignatureMutation.graphql";
|
||||
import type { SignaturesModalOrganizationQuery } from "./__generated__/SignaturesModalOrganizationQuery.graphql";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -26,7 +36,8 @@ export const policyVersionsFragment = graphql`
|
||||
publishedBy {
|
||||
fullName
|
||||
}
|
||||
signatures(first: 100) @connection(key: "SignaturesModal_policyVersions_signatures") {
|
||||
signatures(first: 100)
|
||||
@connection(key: "SignaturesModal_policyVersions_signatures") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -50,7 +61,10 @@ export const policyVersionsFragment = graphql`
|
||||
`;
|
||||
|
||||
const requestSignatureMutation = graphql`
|
||||
mutation SignaturesModalRequestSignatureMutation($input: RequestSignatureInput!, $connections: [ID!]!) {
|
||||
mutation SignaturesModalRequestSignatureMutation(
|
||||
$input: RequestSignatureInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
requestSignature(input: $input) {
|
||||
policyVersionSignatureEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
@@ -100,54 +114,66 @@ interface SignaturesModalProps {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function SignaturesModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
export function SignaturesModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
policyRef,
|
||||
owner,
|
||||
}: SignaturesModalProps) {
|
||||
const data = useFragment<SignaturesModal_policyVersions$key>(
|
||||
policyVersionsFragment,
|
||||
policyRef
|
||||
);
|
||||
|
||||
const versionNodes = (data?.policyVersions?.edges?.map(edge => edge.node) || []);
|
||||
const publishedVersions = versionNodes
|
||||
.filter(v => v.status === "PUBLISHED")
|
||||
.sort((a, b) => b.version - a.version);
|
||||
|
||||
const [selectedVersion, setSelectedVersion] = useState<number>(
|
||||
publishedVersions[0]?.version || 0
|
||||
policyRef,
|
||||
);
|
||||
|
||||
const versionNodes =
|
||||
data?.policyVersions?.edges?.map((edge) => edge.node) || [];
|
||||
const publishedVersions = versionNodes
|
||||
.filter((v) => v.status === "PUBLISHED")
|
||||
.sort((a, b) => b.version - a.version);
|
||||
|
||||
const [selectedVersion, setSelectedVersion] = useState<number>(
|
||||
publishedVersions[0]?.version || 0,
|
||||
);
|
||||
|
||||
const selectedVersionData = versionNodes.find(
|
||||
(v) => v.version === selectedVersion,
|
||||
);
|
||||
|
||||
const selectedVersionData = versionNodes.find(v => v.version === selectedVersion);
|
||||
|
||||
const { organizationId } = useParams();
|
||||
const [queryRef, loadQuery] = useQueryLoader<SignaturesModalOrganizationQuery>(organizationQuery);
|
||||
const [queryRef, loadQuery] =
|
||||
useQueryLoader<SignaturesModalOrganizationQuery>(organizationQuery);
|
||||
const { toast } = useToast();
|
||||
const [isRequesting, setIsRequesting] = useState<string | null>(null);
|
||||
|
||||
const [commitRequestSignature] = useMutation<SignaturesModalRequestSignatureMutation>(requestSignatureMutation);
|
||||
|
||||
|
||||
const [commitRequestSignature] =
|
||||
useMutation<SignaturesModalRequestSignatureMutation>(
|
||||
requestSignatureMutation,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && organizationId && selectedVersionData?.status === "PUBLISHED") {
|
||||
if (
|
||||
isOpen &&
|
||||
organizationId &&
|
||||
selectedVersionData?.status === "PUBLISHED"
|
||||
) {
|
||||
loadQuery({ organizationId });
|
||||
}
|
||||
}, [isOpen, organizationId, loadQuery, selectedVersionData]);
|
||||
|
||||
|
||||
const formatDateTime = (dateString?: string | null) => {
|
||||
if (!dateString) return "N/A";
|
||||
const date = new Date(dateString);
|
||||
return format(date, "h:mm a • MMM d, yyyy");
|
||||
};
|
||||
|
||||
const signatures = selectedVersionData?.signatures?.edges?.map(edge => edge.node) || [];
|
||||
|
||||
const signatures =
|
||||
selectedVersionData?.signatures?.edges?.map((edge) => edge.node) || [];
|
||||
|
||||
const handleRequestSignature = (personId: string) => {
|
||||
if (!selectedVersionData) return;
|
||||
|
||||
|
||||
setIsRequesting(personId);
|
||||
|
||||
|
||||
commitRequestSignature({
|
||||
variables: {
|
||||
input: {
|
||||
@@ -157,7 +183,7 @@ export function SignaturesModal({
|
||||
connections: [
|
||||
ConnectionHandler.getConnectionID(
|
||||
selectedVersionData.id,
|
||||
"SignaturesModal_policyVersions_signatures"
|
||||
"SignaturesModal_policyVersions_signatures",
|
||||
),
|
||||
],
|
||||
},
|
||||
@@ -188,27 +214,44 @@ export function SignaturesModal({
|
||||
<h2 className="text-lg font-semibold p-6 pb-4">Version history</h2>
|
||||
<div className="overflow-y-auto">
|
||||
{versionNodes.length > 0 ? (
|
||||
versionNodes.map(version => {
|
||||
versionNodes.map((version) => {
|
||||
const isPublished = version.status === "PUBLISHED";
|
||||
const isSelected = selectedVersion === version.version;
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
key={version.id}
|
||||
className={`flex items-center gap-3 p-6 py-4 ${
|
||||
isPublished ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
|
||||
} ${isSelected ? 'bg-slate-50' : ''}`}
|
||||
onClick={() => isPublished && setSelectedVersion(version.version)}
|
||||
isPublished
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
} ${isSelected ? "bg-slate-50" : ""}`}
|
||||
onClick={() =>
|
||||
isPublished && setSelectedVersion(version.version)
|
||||
}
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src="" alt={version.publishedBy?.fullName || owner?.fullName || ""} />
|
||||
<AvatarImage
|
||||
src=""
|
||||
alt={
|
||||
version.publishedBy?.fullName ||
|
||||
owner?.fullName ||
|
||||
""
|
||||
}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{(version.publishedBy?.fullName || owner?.fullName || "").charAt(0)}
|
||||
{(
|
||||
version.publishedBy?.fullName ||
|
||||
owner?.fullName ||
|
||||
""
|
||||
).charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Version {version.version}</span>
|
||||
<span className="font-medium">
|
||||
Version {version.version}
|
||||
</span>
|
||||
{isPublished ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-800">
|
||||
Published
|
||||
@@ -220,7 +263,13 @@ export function SignaturesModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-tertiary">
|
||||
{version.publishedBy?.fullName || owner?.fullName || "Unknown"} • {formatDateTime(version.publishedAt || version.updatedAt)}
|
||||
{version.publishedBy?.fullName ||
|
||||
owner?.fullName ||
|
||||
"Unknown"}{" "}
|
||||
•{" "}
|
||||
{formatDateTime(
|
||||
version.publishedAt || version.updatedAt,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,27 +282,30 @@ export function SignaturesModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Section header */}
|
||||
<div className="p-6 border-b border-solid-b">
|
||||
<h2 className="text-2xl font-semibold mb-2">
|
||||
{selectedVersionData?.status === "PUBLISHED" ? "Signatures & Requests" : "Signatures"}
|
||||
{selectedVersionData?.status === "PUBLISHED"
|
||||
? "Signatures & Requests"
|
||||
: "Signatures"}
|
||||
</h2>
|
||||
{selectedVersionData?.status === "PUBLISHED" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click request to ask for a signature from people in your organization
|
||||
Click request to ask for a signature from people in your
|
||||
organization
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Content area with unified people and signatures list */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{selectedVersionData ? (
|
||||
selectedVersionData.status === "PUBLISHED" && queryRef ? (
|
||||
<PeopleAndSignaturesList
|
||||
queryRef={queryRef}
|
||||
<PeopleAndSignaturesList
|
||||
queryRef={queryRef}
|
||||
onRequestSignature={handleRequestSignature}
|
||||
requestingId={isRequesting}
|
||||
existingSignatures={signatures}
|
||||
@@ -263,30 +315,40 @@ export function SignaturesModal({
|
||||
<div className="space-y-4">
|
||||
{signatures.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{signatures.map(signature => (
|
||||
{signatures.map((signature) => (
|
||||
<div key={signature.id} className="py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>
|
||||
{signature.signedBy?.fullName?.charAt(0) || signature.requestedBy?.fullName?.charAt(0)}
|
||||
{signature.signedBy?.fullName?.charAt(0) ||
|
||||
signature.requestedBy?.fullName?.charAt(
|
||||
0,
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{signature.signedBy?.fullName || signature.requestedBy?.fullName}</div>
|
||||
<div className="font-medium">
|
||||
{signature.signedBy?.fullName ||
|
||||
signature.requestedBy?.fullName}
|
||||
</div>
|
||||
<div className="text-sm text-tertiary">
|
||||
{signature.state === "SIGNED"
|
||||
? `Signed on ${formatDateTime(signature.signedAt)}`
|
||||
{signature.state === "SIGNED"
|
||||
? `Signed on ${formatDateTime(signature.signedAt)}`
|
||||
: `Requested on ${formatDateTime(signature.requestedAt)}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
signature.state === "SIGNED"
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{signature.state === "SIGNED" ? "Signed" : "Pending"}
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
signature.state === "SIGNED"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{signature.state === "SIGNED"
|
||||
? "Signed"
|
||||
: "Pending"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -294,9 +356,13 @@ export function SignaturesModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-tertiary">No signatures available for this version</p>
|
||||
<p className="text-tertiary">
|
||||
No signatures available for this version
|
||||
</p>
|
||||
{selectedVersionData.status !== "PUBLISHED" && (
|
||||
<p className="text-sm text-muted-foreground mt-2">Only published versions can have signatures</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Only published versions can have signatures
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -307,7 +373,7 @@ export function SignaturesModal({
|
||||
No version selected
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{selectedVersionData?.status === "PUBLISHED" && !queryRef && (
|
||||
<div className="py-8 text-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
|
||||
@@ -317,13 +383,9 @@ export function SignaturesModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end p-4 border-t border-solid-b mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="mr-2"
|
||||
>
|
||||
<Button variant="outline" onClick={onClose} className="mr-2">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
@@ -340,27 +402,25 @@ function PeopleAndSignaturesList({
|
||||
existingSignatures,
|
||||
formatDateTime,
|
||||
}: {
|
||||
queryRef: PreloadedQuery<SignaturesModalOrganizationQuery>,
|
||||
onRequestSignature: (personId: string) => void,
|
||||
requestingId: string | null,
|
||||
existingSignatures: Array<SignaturesModal_policyVersions$data["policyVersions"]["edges"][0]["node"]["signatures"]["edges"][0]["node"]>,
|
||||
formatDateTime: (date?: string | null) => string,
|
||||
queryRef: PreloadedQuery<SignaturesModalOrganizationQuery>;
|
||||
onRequestSignature: (personId: string) => void;
|
||||
requestingId: string | null;
|
||||
existingSignatures: Array<
|
||||
SignaturesModal_policyVersions$data["policyVersions"]["edges"][0]["node"]["signatures"]["edges"][0]["node"]
|
||||
>;
|
||||
formatDateTime: (date?: string | null) => string;
|
||||
}) {
|
||||
const data = usePreloadedQuery<SignaturesModalOrganizationQuery>(
|
||||
organizationQuery,
|
||||
queryRef
|
||||
queryRef,
|
||||
);
|
||||
|
||||
|
||||
const people = data?.organization?.peoples?.edges || [];
|
||||
|
||||
|
||||
if (!data?.organization) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
Organization not found
|
||||
</div>
|
||||
);
|
||||
return <div className="text-center py-8">Organization not found</div>;
|
||||
}
|
||||
|
||||
|
||||
if (people.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
@@ -368,30 +428,28 @@ function PeopleAndSignaturesList({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Create lookup for signatures by person ID
|
||||
const signaturesByPersonId = new Map();
|
||||
existingSignatures.forEach(sig => {
|
||||
existingSignatures.forEach((sig) => {
|
||||
if (sig.signedBy?.id) {
|
||||
signaturesByPersonId.set(sig.signedBy.id, sig);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{people.map((edge: any) => {
|
||||
const person = edge.node;
|
||||
const signature = signaturesByPersonId.get(person.id);
|
||||
const isRequesting = requestingId === person.id;
|
||||
|
||||
|
||||
return (
|
||||
<div key={person.id} className="py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>
|
||||
{person.fullName.charAt(0)}
|
||||
</AvatarFallback>
|
||||
<AvatarFallback>{person.fullName.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{person.fullName}</div>
|
||||
@@ -400,12 +458,16 @@ function PeopleAndSignaturesList({
|
||||
signature.state === "SIGNED" ? (
|
||||
<div className="flex items-center gap-1 text-green-700">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
<span>Signed on {formatDateTime(signature.signedAt)}</span>
|
||||
<span>
|
||||
Signed on {formatDateTime(signature.signedAt)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-amber-700">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>Requested on {formatDateTime(signature.requestedAt)}</span>
|
||||
<span>
|
||||
Requested on {formatDateTime(signature.requestedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -414,13 +476,15 @@ function PeopleAndSignaturesList({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{signature ? (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
signature.state === "SIGNED"
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
signature.state === "SIGNED"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{signature.state === "SIGNED" ? "Signed" : "Pending"}
|
||||
</span>
|
||||
) : (
|
||||
@@ -446,4 +510,4 @@ function PeopleAndSignaturesList({
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,26 +40,27 @@ interface VersionHistoryModalProps {
|
||||
onRestoreVersion?: (versionNumber: number) => void;
|
||||
}
|
||||
|
||||
export function VersionHistoryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
export function VersionHistoryModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
policyRef,
|
||||
onRestoreVersion
|
||||
onRestoreVersion,
|
||||
}: VersionHistoryModalProps) {
|
||||
const data = useFragment<VersionHistoryModal_policyVersions$key>(
|
||||
policyVersionsFragment,
|
||||
policyRef
|
||||
policyRef,
|
||||
);
|
||||
|
||||
|
||||
// Safely access and extract version nodes
|
||||
const versionNodes = data?.versionHistory?.edges?.map(edge => edge.node) || [];
|
||||
|
||||
const versionNodes =
|
||||
data?.versionHistory?.edges?.map((edge) => edge.node) || [];
|
||||
|
||||
// Sort versions by version number (descending)
|
||||
const versions = [...versionNodes].sort((a, b) => b.version - a.version);
|
||||
|
||||
|
||||
const latestVersion = versions.length > 0 ? versions[0].version : 0;
|
||||
const [selectedVersion, setSelectedVersion] = useState<number>(latestVersion);
|
||||
|
||||
|
||||
// Format time helper for version history
|
||||
const formatDateTime = (dateString?: string | null) => {
|
||||
if (!dateString) return "N/A";
|
||||
@@ -76,20 +77,33 @@ export function VersionHistoryModal({
|
||||
<h2 className="text-lg font-semibold p-6 pb-4">Version history</h2>
|
||||
<div className="overflow-y-auto">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
<div
|
||||
key={version.id}
|
||||
className={`flex items-center gap-3 p-6 py-4 cursor-pointer ${selectedVersion === version.version ? 'bg-slate-50' : ''}`}
|
||||
className={`flex items-center gap-3 p-6 py-4 cursor-pointer ${selectedVersion === version.version ? "bg-slate-50" : ""}`}
|
||||
onClick={() => setSelectedVersion(version.version)}
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src="" alt={version.publishedBy?.fullName || data.owner?.fullName || ""} />
|
||||
<AvatarImage
|
||||
src=""
|
||||
alt={
|
||||
version.publishedBy?.fullName ||
|
||||
data.owner?.fullName ||
|
||||
""
|
||||
}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{(version.publishedBy?.fullName || data.owner?.fullName || "").charAt(0)}
|
||||
{(
|
||||
version.publishedBy?.fullName ||
|
||||
data.owner?.fullName ||
|
||||
""
|
||||
).charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Version {version.version}</span>
|
||||
<span className="font-medium">
|
||||
Version {version.version}
|
||||
</span>
|
||||
{version.status === "PUBLISHED" ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-800">
|
||||
Published
|
||||
@@ -101,37 +115,38 @@ export function VersionHistoryModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-tertiary">
|
||||
{version.publishedBy?.fullName || data.owner?.fullName || "Unknown"} • {formatDateTime(version.publishedAt || version.updatedAt)}
|
||||
{version.publishedBy?.fullName ||
|
||||
data.owner?.fullName ||
|
||||
"Unknown"}{" "}
|
||||
•{" "}
|
||||
{formatDateTime(version.publishedAt || version.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 p-6 relative overflow-y-auto">
|
||||
<h2 className="text-2xl font-semibold mb-6">{data.title}</h2>
|
||||
|
||||
|
||||
<div className="prose prose-olive max-w-none">
|
||||
{selectedVersion && (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{versions.find(v => v.version === selectedVersion)?.content || "No content available"}
|
||||
{versions.find((v) => v.version === selectedVersion)
|
||||
?.content || "No content available"}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex justify-end p-4 border-t border-solid-b mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="mr-2"
|
||||
>
|
||||
<Button variant="outline" onClick={onClose} className="mr-2">
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
|
||||
{onRestoreVersion && (
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -145,4 +160,4 @@ export function VersionHistoryModal({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ const RISK_CATEGORIES = [
|
||||
"Reputational",
|
||||
"Strategic",
|
||||
"Technology",
|
||||
"Other"
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
const listRiskViewQuery = graphql`
|
||||
@@ -138,19 +138,21 @@ const deleteRiskMutation = graphql`
|
||||
// Helper function to convert risk score to risk level
|
||||
const calculateRiskLevel = (score: number): string => {
|
||||
if (score >= 15) return "High";
|
||||
if (score >= 8) return "Medium";
|
||||
if (score >= 8) return "Medium";
|
||||
return "Low";
|
||||
};
|
||||
|
||||
// Helper function to get color for risk level that matches the matrix colors
|
||||
const getRiskLevelColor = (level: string): {backgroundColor: string, color: string} => {
|
||||
switch(level) {
|
||||
const getRiskLevelColor = (
|
||||
level: string,
|
||||
): { backgroundColor: string; color: string } => {
|
||||
switch (level) {
|
||||
case "High":
|
||||
return {backgroundColor: "#ef4444", color: "#ffffff"}; // red-500
|
||||
return { backgroundColor: "#ef4444", color: "#ffffff" }; // red-500
|
||||
case "Medium":
|
||||
return {backgroundColor: "#fcd34d", color: "#000000"}; // yellow-300
|
||||
return { backgroundColor: "#fcd34d", color: "#000000" }; // yellow-300
|
||||
default:
|
||||
return {backgroundColor: "#22c55e", color: "#ffffff"}; // green-500
|
||||
return { backgroundColor: "#22c55e", color: "#ffffff" }; // green-500
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,7 +236,6 @@ const emptyRiskMatrixColors = {
|
||||
high: "bg-red-50 text-black",
|
||||
};
|
||||
|
||||
|
||||
// Risk Matrix Component
|
||||
function RiskMatrix({
|
||||
risks,
|
||||
@@ -278,10 +279,10 @@ function RiskMatrix({
|
||||
const getCellContent = (impactValue: number, likelihoodValue: number) => {
|
||||
return risks.filter((risk) => {
|
||||
const likelihood = isResidual
|
||||
? risk.residualLikelihood ?? risk.inherentLikelihood
|
||||
? (risk.residualLikelihood ?? risk.inherentLikelihood)
|
||||
: risk.inherentLikelihood;
|
||||
const impact = isResidual
|
||||
? risk.residualImpact ?? risk.inherentImpact
|
||||
? (risk.residualImpact ?? risk.inherentImpact)
|
||||
: risk.inherentImpact;
|
||||
|
||||
return likelihood === likelihoodValue && impact === impactValue;
|
||||
@@ -298,13 +299,37 @@ function RiskMatrix({
|
||||
// This follows the 5x5 grid with rows representing impact (5 to 1) and columns representing likelihood (1 to 5)
|
||||
const colorMatrix = [
|
||||
// Impact 5 (Catastrophic) - first row
|
||||
[colorSet.low, colorSet.medium, colorSet.high, colorSet.high, colorSet.high],
|
||||
[
|
||||
colorSet.low,
|
||||
colorSet.medium,
|
||||
colorSet.high,
|
||||
colorSet.high,
|
||||
colorSet.high,
|
||||
],
|
||||
// Impact 4 (Significant) - second row
|
||||
[colorSet.low, colorSet.medium, colorSet.medium, colorSet.high, colorSet.high],
|
||||
[
|
||||
colorSet.low,
|
||||
colorSet.medium,
|
||||
colorSet.medium,
|
||||
colorSet.high,
|
||||
colorSet.high,
|
||||
],
|
||||
// Impact 3 (Moderate) - third row
|
||||
[colorSet.low, colorSet.low, colorSet.medium, colorSet.medium, colorSet.high],
|
||||
[
|
||||
colorSet.low,
|
||||
colorSet.low,
|
||||
colorSet.medium,
|
||||
colorSet.medium,
|
||||
colorSet.high,
|
||||
],
|
||||
// Impact 2 (Low) - fourth row
|
||||
[colorSet.low, colorSet.low, colorSet.low, colorSet.medium, colorSet.medium],
|
||||
[
|
||||
colorSet.low,
|
||||
colorSet.low,
|
||||
colorSet.low,
|
||||
colorSet.medium,
|
||||
colorSet.medium,
|
||||
],
|
||||
// Impact 1 (Negligible) - fifth row
|
||||
[colorSet.low, colorSet.low, colorSet.low, colorSet.low, colorSet.low],
|
||||
];
|
||||
@@ -373,11 +398,14 @@ function RiskMatrix({
|
||||
<div className="text-sm pt-1 border-t border-gray-200 mt-1">
|
||||
<span className="text-muted-foreground">Risk Level:</span>
|
||||
<span className="font-bold ml-1">
|
||||
{calculateRiskLevel(impactValue * likelihoodValue)} ({impactValue * likelihoodValue})
|
||||
{calculateRiskLevel(impactValue * likelihoodValue)} (
|
||||
{impactValue * likelihoodValue})
|
||||
</span>
|
||||
<span
|
||||
className="ml-2 px-2 py-0.5 text-xs rounded-full font-medium inline-block"
|
||||
style={getRiskLevelColor(calculateRiskLevel(impactValue * likelihoodValue))}
|
||||
style={getRiskLevelColor(
|
||||
calculateRiskLevel(impactValue * likelihoodValue),
|
||||
)}
|
||||
>
|
||||
{calculateRiskLevel(impactValue * likelihoodValue)}
|
||||
</span>
|
||||
@@ -393,15 +421,15 @@ function RiskMatrix({
|
||||
key={risk.id}
|
||||
className="text-sm border-l-2 pl-2"
|
||||
style={{
|
||||
borderColor:
|
||||
getRiskLevelColor(
|
||||
calculateRiskLevel(
|
||||
isResidual
|
||||
? (risk.residualImpact || risk.inherentImpact) *
|
||||
(risk.residualLikelihood || risk.inherentLikelihood)
|
||||
: risk.inherentImpact * risk.inherentLikelihood
|
||||
)
|
||||
).backgroundColor
|
||||
borderColor: getRiskLevelColor(
|
||||
calculateRiskLevel(
|
||||
isResidual
|
||||
? (risk.residualImpact || risk.inherentImpact) *
|
||||
(risk.residualLikelihood ||
|
||||
risk.inherentLikelihood)
|
||||
: risk.inherentImpact * risk.inherentLikelihood,
|
||||
),
|
||||
).backgroundColor,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
@@ -516,7 +544,9 @@ function ListRiskViewContent({
|
||||
const [showResidualRisk, setShowResidualRisk] = useState(false);
|
||||
|
||||
// State for category filters
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(new Set());
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Setup delete mutation
|
||||
const [commitDeleteMutation] =
|
||||
@@ -540,16 +570,19 @@ function ListRiskViewContent({
|
||||
const connectionId = risksConnection?.risks?.__id;
|
||||
|
||||
// Get unique categories from existing risks
|
||||
const availableCategories = Array.from(new Set(risks.map(risk => risk.category))).sort();
|
||||
const availableCategories = Array.from(
|
||||
new Set(risks.map((risk) => risk.category)),
|
||||
).sort();
|
||||
|
||||
// Filter risks based on selected categories
|
||||
const filteredRisks = selectedCategories.size === 0
|
||||
? risks
|
||||
: risks.filter(risk => selectedCategories.has(risk.category));
|
||||
const filteredRisks =
|
||||
selectedCategories.size === 0
|
||||
? risks
|
||||
: risks.filter((risk) => selectedCategories.has(risk.category));
|
||||
|
||||
// Handle category toggle
|
||||
const toggleCategory = (category: string) => {
|
||||
setSelectedCategories(prev => {
|
||||
setSelectedCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(category)) {
|
||||
newSet.delete(category);
|
||||
@@ -761,9 +794,16 @@ function ListRiskViewContent({
|
||||
>
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs rounded-full font-medium inline-block"
|
||||
style={getRiskLevelColor(calculateRiskLevel(risk.inherentLikelihood * risk.inherentImpact))}
|
||||
style={getRiskLevelColor(
|
||||
calculateRiskLevel(
|
||||
risk.inherentLikelihood * risk.inherentImpact,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{calculateRiskLevel(risk.inherentLikelihood * risk.inherentImpact)} ({risk.inherentLikelihood * risk.inherentImpact})
|
||||
{calculateRiskLevel(
|
||||
risk.inherentLikelihood * risk.inherentImpact,
|
||||
)}{" "}
|
||||
({risk.inherentLikelihood * risk.inherentImpact})
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
@@ -783,9 +823,18 @@ function ListRiskViewContent({
|
||||
{risk.residualLikelihood && risk.residualImpact ? (
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs rounded-full font-medium inline-block"
|
||||
style={getRiskLevelColor(calculateRiskLevel(risk.residualLikelihood * risk.residualImpact))}
|
||||
style={getRiskLevelColor(
|
||||
calculateRiskLevel(
|
||||
risk.residualLikelihood *
|
||||
risk.residualImpact,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{calculateRiskLevel(risk.residualLikelihood * risk.residualImpact)} ({risk.residualLikelihood * risk.residualImpact})
|
||||
{calculateRiskLevel(
|
||||
risk.residualLikelihood * risk.residualImpact,
|
||||
)}{" "}
|
||||
({risk.residualLikelihood * risk.residualImpact}
|
||||
)
|
||||
</span>
|
||||
) : (
|
||||
"Not set"
|
||||
|
||||
@@ -107,7 +107,7 @@ function NewRiskForm({
|
||||
const [createRisk, isInFlight] = useMutation(createRiskMutation);
|
||||
|
||||
const categories = useMemo(
|
||||
() => Array.from(new Set(riskTemplates.map(t => t.category))),
|
||||
() => Array.from(new Set(riskTemplates.map((t) => t.category))),
|
||||
[riskTemplates],
|
||||
);
|
||||
|
||||
@@ -115,13 +115,13 @@ function NewRiskForm({
|
||||
() =>
|
||||
riskTemplates
|
||||
.map((t, idx) => ({ ...t, originalIndex: idx }))
|
||||
.filter(t => !selectedCategory || t.category === selectedCategory),
|
||||
.filter((t) => !selectedCategory || t.category === selectedCategory),
|
||||
[riskTemplates, selectedCategory],
|
||||
);
|
||||
|
||||
// Handle category selection
|
||||
const selectCategory = (category: string) => {
|
||||
setSelectedCategory(prev => {
|
||||
setSelectedCategory((prev) => {
|
||||
if (prev !== category) setSelectedTemplate("");
|
||||
return category;
|
||||
});
|
||||
@@ -226,7 +226,7 @@ function NewRiskForm({
|
||||
connections: [
|
||||
ConnectionHandler.getConnectionID(
|
||||
organizationId!,
|
||||
"ListRiskView_risks"
|
||||
"ListRiskView_risks",
|
||||
),
|
||||
],
|
||||
},
|
||||
@@ -289,7 +289,9 @@ function NewRiskForm({
|
||||
categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? "default" : "outline"}
|
||||
variant={
|
||||
selectedCategory === category ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => selectCategory(category)}
|
||||
className="rounded-full"
|
||||
@@ -309,8 +311,8 @@ function NewRiskForm({
|
||||
<SelectContent className="max-h-[300px] overflow-y-auto">
|
||||
<SelectItem value="none">Select a template</SelectItem>
|
||||
{filteredRisks.map((template) => (
|
||||
<SelectItem
|
||||
key={template.originalIndex}
|
||||
<SelectItem
|
||||
key={template.originalIndex}
|
||||
value={template.originalIndex.toString()}
|
||||
>
|
||||
{template.name}
|
||||
@@ -319,7 +321,8 @@ function NewRiskForm({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-tertiary">
|
||||
Select a risk template to prefill the form or create a custom risk.
|
||||
Select a risk template to prefill the form or create a custom
|
||||
risk.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -360,7 +363,9 @@ function NewRiskForm({
|
||||
required={true}
|
||||
/>
|
||||
{ownerError && (
|
||||
<p className="text-sm text-destructive mt-1">Please select a risk owner</p>
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
Please select a risk owner
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -211,19 +211,21 @@ const deleteRiskPolicyMappingMutation = graphql`
|
||||
// Helper function to convert risk score to risk level
|
||||
const calculateRiskLevel = (score: number): string => {
|
||||
if (score >= 15) return "High";
|
||||
if (score >= 8) return "Medium";
|
||||
if (score >= 8) return "Medium";
|
||||
return "Low";
|
||||
};
|
||||
|
||||
// Helper function to get color for risk level that matches the matrix colors
|
||||
const getRiskLevelColor = (level: string): {backgroundColor: string, color: string} => {
|
||||
switch(level) {
|
||||
const getRiskLevelColor = (
|
||||
level: string,
|
||||
): { backgroundColor: string; color: string } => {
|
||||
switch (level) {
|
||||
case "High":
|
||||
return {backgroundColor: "#ef4444", color: "#ffffff"}; // red-500
|
||||
return { backgroundColor: "#ef4444", color: "#ffffff" }; // red-500
|
||||
case "Medium":
|
||||
return {backgroundColor: "#fcd34d", color: "#000000"}; // yellow-300
|
||||
return { backgroundColor: "#fcd34d", color: "#000000" }; // yellow-300
|
||||
default:
|
||||
return {backgroundColor: "#22c55e", color: "#ffffff"}; // green-500
|
||||
return { backgroundColor: "#22c55e", color: "#ffffff" }; // green-500
|
||||
}
|
||||
};
|
||||
|
||||
@@ -245,26 +247,46 @@ function getLikelihoodLabel(likelihood: number): {
|
||||
if (likelihood === 5)
|
||||
return {
|
||||
label: "Frequent (5)",
|
||||
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
|
||||
style: {
|
||||
backgroundColor: "#fff",
|
||||
color: "black",
|
||||
border: "1px solid #e2e8f0",
|
||||
},
|
||||
};
|
||||
if (likelihood === 4)
|
||||
return {
|
||||
label: "Probable (4)",
|
||||
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
|
||||
style: {
|
||||
backgroundColor: "#fff",
|
||||
color: "black",
|
||||
border: "1px solid #e2e8f0",
|
||||
},
|
||||
};
|
||||
if (likelihood === 3)
|
||||
return {
|
||||
label: "Occasional (3)",
|
||||
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
|
||||
style: {
|
||||
backgroundColor: "#fff",
|
||||
color: "black",
|
||||
border: "1px solid #e2e8f0",
|
||||
},
|
||||
};
|
||||
if (likelihood === 2)
|
||||
return {
|
||||
label: "Remote (2)",
|
||||
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
|
||||
style: {
|
||||
backgroundColor: "#fff",
|
||||
color: "black",
|
||||
border: "1px solid #e2e8f0",
|
||||
},
|
||||
};
|
||||
return {
|
||||
label: "Improbable (1)",
|
||||
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
|
||||
style: {
|
||||
backgroundColor: "#fff",
|
||||
color: "black",
|
||||
border: "1px solid #e2e8f0",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -275,26 +297,46 @@ function getImpactLabel(impact: number): {
|
||||
if (impact === 5)
|
||||
return {
|
||||
label: "Catastrophic (5)",
|
||||
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
|
||||
style: {
|
||||
backgroundColor: "#fff",
|
||||
color: "black",
|
||||
border: "1px solid #e2e8f0",
|
||||
},
|
||||
};
|
||||
if (impact === 4)
|
||||
return {
|
||||
label: "Significant (4)",
|
||||
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
|
||||
style: {
|
||||
backgroundColor: "#fff",
|
||||
color: "black",
|
||||
border: "1px solid #e2e8f0",
|
||||
},
|
||||
};
|
||||
if (impact === 3)
|
||||
return {
|
||||
label: "Moderate (3)",
|
||||
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
|
||||
style: {
|
||||
backgroundColor: "#fff",
|
||||
color: "black",
|
||||
border: "1px solid #e2e8f0",
|
||||
},
|
||||
};
|
||||
if (impact === 2)
|
||||
return {
|
||||
label: "Low (2)",
|
||||
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
|
||||
style: {
|
||||
backgroundColor: "#fff",
|
||||
color: "black",
|
||||
border: "1px solid #e2e8f0",
|
||||
},
|
||||
};
|
||||
return {
|
||||
label: "Negligible (1)",
|
||||
style: { backgroundColor: "#fff", color: "black", border: "1px solid #e2e8f0" },
|
||||
style: {
|
||||
backgroundColor: "#fff",
|
||||
color: "black",
|
||||
border: "1px solid #e2e8f0",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,7 +349,7 @@ function ShowRiskViewContent({
|
||||
}) {
|
||||
const data = usePreloadedQuery<ShowRiskViewQuery>(
|
||||
showRiskViewQuery,
|
||||
queryRef
|
||||
queryRef,
|
||||
);
|
||||
const { toast } = useToast();
|
||||
const environment = useRelayEnvironment();
|
||||
@@ -317,14 +359,14 @@ function ShowRiskViewContent({
|
||||
const risk = data.node;
|
||||
const severity = getRiskSeverity(
|
||||
risk.inherentLikelihood!,
|
||||
risk.inherentImpact!
|
||||
risk.inherentImpact!,
|
||||
);
|
||||
|
||||
// Fix typing for measures
|
||||
const measures = risk.measures?.edges?.map((edge) => edge.node) || [];
|
||||
const residualSeverity = getRiskSeverity(
|
||||
risk.residualLikelihood!,
|
||||
risk.residualImpact!
|
||||
risk.residualImpact!,
|
||||
);
|
||||
|
||||
// Fix typing for policies
|
||||
@@ -340,9 +382,9 @@ function ShowRiskViewContent({
|
||||
const [measureSearchQuery, setMeasureSearchQuery] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
|
||||
const [isLoadingMeasures, setIsLoadingMeasures] = useState(false);
|
||||
const [linkingMeasures, setLinkingMeasures] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
const [linkingMeasures, setLinkingMeasures] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [unlinkingMeasures, setUnlinkingMeasures] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
@@ -363,21 +405,21 @@ function ShowRiskViewContent({
|
||||
// Setup mutation hooks
|
||||
const [createRiskMeasureMapping] =
|
||||
useMutation<ShowRiskViewCreateRiskMeasureMappingMutation>(
|
||||
createRiskMeasureMappingMutation
|
||||
createRiskMeasureMappingMutation,
|
||||
);
|
||||
const [deleteRiskMeasureMapping] =
|
||||
useMutation<ShowRiskViewDeleteRiskMeasureMappingMutation>(
|
||||
deleteRiskMeasureMappingMutation
|
||||
deleteRiskMeasureMappingMutation,
|
||||
);
|
||||
|
||||
// Setup policy mutation hooks
|
||||
const [createRiskPolicyMapping] =
|
||||
useMutation<ShowRiskViewCreateRiskPolicyMappingMutation>(
|
||||
createRiskPolicyMappingMutation
|
||||
createRiskPolicyMappingMutation,
|
||||
);
|
||||
const [deleteRiskPolicyMapping] =
|
||||
useMutation<ShowRiskViewDeleteRiskPolicyMappingMutation>(
|
||||
deleteRiskPolicyMappingMutation
|
||||
deleteRiskPolicyMappingMutation,
|
||||
);
|
||||
|
||||
// Clear filters when dialog closes
|
||||
@@ -411,7 +453,7 @@ function ShowRiskViewContent({
|
||||
organizationMeasuresQuery,
|
||||
{
|
||||
organizationId,
|
||||
}
|
||||
},
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setOrganizationMeasuresData(data);
|
||||
@@ -441,7 +483,7 @@ function ShowRiskViewContent({
|
||||
organizationPoliciesQuery,
|
||||
{
|
||||
organizationId,
|
||||
}
|
||||
},
|
||||
).subscribe({
|
||||
next: (data) => {
|
||||
setOrganizationPoliciesData(data);
|
||||
@@ -463,7 +505,7 @@ function ShowRiskViewContent({
|
||||
const getMeasures = useCallback(() => {
|
||||
if (!organizationMeasuresData?.organization?.measures?.edges) return [];
|
||||
return organizationMeasuresData.organization.measures.edges.map(
|
||||
(edge) => edge.node
|
||||
(edge) => edge.node,
|
||||
);
|
||||
}, [organizationMeasuresData]);
|
||||
|
||||
@@ -508,7 +550,7 @@ function ShowRiskViewContent({
|
||||
const getPolicies = useCallback(() => {
|
||||
if (!organizationPoliciesData?.organization?.policies?.edges) return [];
|
||||
return organizationPoliciesData.organization.policies.edges.map(
|
||||
(edge) => edge.node
|
||||
(edge) => edge.node,
|
||||
);
|
||||
}, [organizationPoliciesData]);
|
||||
|
||||
@@ -531,7 +573,7 @@ function ShowRiskViewContent({
|
||||
NonNullable<
|
||||
ShowRiskViewOrganizationMeasuresQuery$data["organization"]
|
||||
>["measures"]
|
||||
>["edges"][0]["node"]
|
||||
>["edges"][0]["node"],
|
||||
) => {
|
||||
if (!risk.id) return;
|
||||
|
||||
@@ -593,7 +635,7 @@ function ShowRiskViewContent({
|
||||
},
|
||||
});
|
||||
},
|
||||
[risk.id, createRiskMeasureMapping, toast, environment, loadQuery]
|
||||
[risk.id, createRiskMeasureMapping, toast, environment, loadQuery],
|
||||
);
|
||||
|
||||
// Handle unlinking a measure from this risk
|
||||
@@ -603,7 +645,7 @@ function ShowRiskViewContent({
|
||||
NonNullable<
|
||||
ShowRiskViewOrganizationMeasuresQuery$data["organization"]
|
||||
>["measures"]
|
||||
>["edges"][0]["node"]
|
||||
>["edges"][0]["node"],
|
||||
) => {
|
||||
if (!risk.id) return;
|
||||
|
||||
@@ -665,7 +707,7 @@ function ShowRiskViewContent({
|
||||
},
|
||||
});
|
||||
},
|
||||
[risk.id, deleteRiskMeasureMapping, toast, environment, loadQuery]
|
||||
[risk.id, deleteRiskMeasureMapping, toast, environment, loadQuery],
|
||||
);
|
||||
|
||||
// Handle linking a policy to this risk
|
||||
@@ -675,7 +717,7 @@ function ShowRiskViewContent({
|
||||
NonNullable<
|
||||
ShowRiskViewOrganizationPoliciesQuery$data["organization"]
|
||||
>["policies"]
|
||||
>["edges"][0]["node"]
|
||||
>["edges"][0]["node"],
|
||||
) => {
|
||||
if (!risk.id) return;
|
||||
|
||||
@@ -737,7 +779,7 @@ function ShowRiskViewContent({
|
||||
},
|
||||
});
|
||||
},
|
||||
[risk.id, createRiskPolicyMapping, toast, environment, loadQuery]
|
||||
[risk.id, createRiskPolicyMapping, toast, environment, loadQuery],
|
||||
);
|
||||
|
||||
// Handle unlinking a policy from this risk
|
||||
@@ -747,7 +789,7 @@ function ShowRiskViewContent({
|
||||
NonNullable<
|
||||
ShowRiskViewOrganizationPoliciesQuery$data["organization"]
|
||||
>["policies"]
|
||||
>["edges"][0]["node"]
|
||||
>["edges"][0]["node"],
|
||||
) => {
|
||||
if (!risk.id) return;
|
||||
|
||||
@@ -809,7 +851,7 @@ function ShowRiskViewContent({
|
||||
},
|
||||
});
|
||||
},
|
||||
[risk.id, deleteRiskPolicyMapping, toast, environment, loadQuery]
|
||||
[risk.id, deleteRiskPolicyMapping, toast, environment, loadQuery],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -890,7 +932,7 @@ function ShowRiskViewContent({
|
||||
<span className="w-3 h-3 rounded-full bg-orange-500 mr-2"></span>
|
||||
Initial Assessment
|
||||
</h4>
|
||||
|
||||
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
|
||||
@@ -900,23 +942,24 @@ function ShowRiskViewContent({
|
||||
>
|
||||
{getLikelihoodLabel(risk.inherentLikelihood!).label}
|
||||
</div>
|
||||
|
||||
|
||||
<span className="mx-4 text-xl">×</span>
|
||||
|
||||
|
||||
<div
|
||||
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
|
||||
style={getImpactLabel(risk.inherentImpact!).style}
|
||||
>
|
||||
{getImpactLabel(risk.inherentImpact!).label}
|
||||
</div>
|
||||
|
||||
|
||||
<span className="mx-4 text-xl">=</span>
|
||||
|
||||
|
||||
<div
|
||||
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
|
||||
style={severity.style}
|
||||
>
|
||||
{severity.level} ({risk.inherentLikelihood! * risk.inherentImpact!})
|
||||
{severity.level} (
|
||||
{risk.inherentLikelihood! * risk.inherentImpact!})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -927,7 +970,7 @@ function ShowRiskViewContent({
|
||||
<span className="w-3 h-3 rounded-full bg-green-500 mr-2"></span>
|
||||
After Treatment
|
||||
</h4>
|
||||
|
||||
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
|
||||
@@ -937,23 +980,24 @@ function ShowRiskViewContent({
|
||||
>
|
||||
{getLikelihoodLabel(risk.residualLikelihood!).label}
|
||||
</div>
|
||||
|
||||
|
||||
<span className="mx-4 text-xl">×</span>
|
||||
|
||||
|
||||
<div
|
||||
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
|
||||
style={getImpactLabel(risk.residualImpact!).style}
|
||||
>
|
||||
{getImpactLabel(risk.residualImpact!).label}
|
||||
</div>
|
||||
|
||||
|
||||
<span className="mx-4 text-xl">=</span>
|
||||
|
||||
|
||||
<div
|
||||
className="px-5 py-2 rounded-md font-medium text-sm flex justify-center items-center whitespace-nowrap"
|
||||
style={residualSeverity.style}
|
||||
>
|
||||
{residualSeverity.level} ({risk.residualLikelihood! * risk.residualImpact!})
|
||||
{residualSeverity.level} (
|
||||
{risk.residualLikelihood! * risk.residualImpact!})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -964,7 +1008,16 @@ function ShowRiskViewContent({
|
||||
risk.inherentImpact !== risk.residualImpact) && (
|
||||
<div className="mt-6 text-center pt-4 border-t">
|
||||
<p className="text-sm text-slate-600">
|
||||
Risk severity reduced from <span className="font-semibold">{severity.level} ({risk.inherentLikelihood! * risk.inherentImpact!})</span> to <span className="font-semibold">{residualSeverity.level} ({risk.residualLikelihood! * risk.residualImpact!})</span>
|
||||
Risk severity reduced from{" "}
|
||||
<span className="font-semibold">
|
||||
{severity.level} (
|
||||
{risk.inherentLikelihood! * risk.inherentImpact!})
|
||||
</span>{" "}
|
||||
to{" "}
|
||||
<span className="font-semibold">
|
||||
{residualSeverity.level} (
|
||||
{risk.residualLikelihood! * risk.residualImpact!})
|
||||
</span>
|
||||
{risk.treatment === "MITIGATED" &&
|
||||
risk.measures?.edges &&
|
||||
risk.measures.edges.length > 0 &&
|
||||
@@ -987,9 +1040,7 @@ function ShowRiskViewContent({
|
||||
<CardTitle>Note</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
{risk.note}
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none">{risk.note}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -1153,7 +1204,10 @@ function ShowRiskViewContent({
|
||||
</Tabs>
|
||||
|
||||
{/* Dialog for linking measures */}
|
||||
<Dialog open={isMeasureDialogOpen} onOpenChange={setIsMeasureDialogOpen}>
|
||||
<Dialog
|
||||
open={isMeasureDialogOpen}
|
||||
onOpenChange={setIsMeasureDialogOpen}
|
||||
>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -1209,10 +1263,11 @@ function ShowRiskViewContent({
|
||||
{filteredMeasures().map((measure) => {
|
||||
// For each render, recalculate linked status directly against the current risk data
|
||||
const isLinked = risk.measures?.edges?.some(
|
||||
(edge) => edge.node.id === measure.id
|
||||
(edge) => edge.node.id === measure.id,
|
||||
);
|
||||
const isLinking = linkingMeasures[measure.id] || false;
|
||||
const isUnlinking = unlinkingMeasures[measure.id] || false;
|
||||
const isUnlinking =
|
||||
unlinkingMeasures[measure.id] || false;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1310,7 +1365,7 @@ function ShowRiskViewContent({
|
||||
{filteredPolicies().map((policy) => {
|
||||
// For each render, recalculate linked status directly against the current risk data
|
||||
const isLinked = policies.some(
|
||||
(riskPolicy) => riskPolicy.id === policy.id
|
||||
(riskPolicy) => riskPolicy.id === policy.id,
|
||||
);
|
||||
const isLinking = linkingPolicies[policy.id] || false;
|
||||
const isUnlinking = unlinkingPolicies[policy.id] || false;
|
||||
|
||||
@@ -75,7 +75,10 @@ const vendorListFragment = graphql`
|
||||
websiteUrl
|
||||
createdAt
|
||||
updatedAt
|
||||
riskAssessments(first: 1, orderBy: { direction: DESC, field: ASSESSED_AT }) {
|
||||
riskAssessments(
|
||||
first: 1
|
||||
orderBy: { direction: DESC, field: ASSESSED_AT }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -191,7 +194,7 @@ function ListVendorContent({
|
||||
const { toast } = useToast();
|
||||
const data = usePreloadedQuery<ListVendorViewQuery>(
|
||||
listVendorViewQuery,
|
||||
queryRef
|
||||
queryRef,
|
||||
);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -263,18 +266,18 @@ function ListVendorContent({
|
||||
// Helper to format date (similar to "Mon, 8 Mar. 2025" in the design)
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
return date.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Get favicon URL from website URL
|
||||
const getFaviconUrl = (websiteUrl: string | null | undefined) => {
|
||||
if (!websiteUrl) return null;
|
||||
|
||||
|
||||
try {
|
||||
const url = new URL(websiteUrl);
|
||||
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`;
|
||||
@@ -304,14 +307,14 @@ function ListVendorContent({
|
||||
// Get risk assessment status
|
||||
const getRiskAssessmentStatus = (vendor: any) => {
|
||||
const latestAssessment = vendor.riskAssessments?.edges[0]?.node;
|
||||
|
||||
|
||||
if (!latestAssessment) {
|
||||
return {
|
||||
status: "NEEDED",
|
||||
dataSensitivity: null,
|
||||
businessImpact: null,
|
||||
isExpired: false,
|
||||
assessedAt: null
|
||||
assessedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -324,7 +327,7 @@ function ListVendorContent({
|
||||
dataSensitivity: latestAssessment.dataSensitivity,
|
||||
businessImpact: latestAssessment.businessImpact,
|
||||
isExpired,
|
||||
assessedAt: latestAssessment.assessedAt
|
||||
assessedAt: latestAssessment.assessedAt,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -410,12 +413,12 @@ function ListVendorContent({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{vendor.websiteUrl && (
|
||||
<img
|
||||
src={getFaviconUrl(vendor.websiteUrl) || ''}
|
||||
alt=""
|
||||
<img
|
||||
src={getFaviconUrl(vendor.websiteUrl) || ""}
|
||||
alt=""
|
||||
className="w-4 h-4"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -450,7 +453,8 @@ function ListVendorContent({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="font-medium">Create new vendor:</span> {searchTerm}
|
||||
<span className="font-medium">Create new vendor:</span>{" "}
|
||||
{searchTerm}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -538,7 +542,9 @@ function ListVendorContent({
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8 bg-white">
|
||||
{(() => {
|
||||
const faviconUrl = vendor.websiteUrl ? getFaviconUrl(vendor.websiteUrl) : null;
|
||||
const faviconUrl = vendor.websiteUrl
|
||||
? getFaviconUrl(vendor.websiteUrl)
|
||||
: null;
|
||||
return faviconUrl ? (
|
||||
<AvatarImage src={faviconUrl} alt={vendor.name} />
|
||||
) : (
|
||||
@@ -564,7 +570,9 @@ function ListVendorContent({
|
||||
Assessment expired
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className={`${getRiskBadgeStyle(riskStatus.dataSensitivity)} font-medium text-xs px-2 py-1 rounded-md`}>
|
||||
<Badge
|
||||
className={`${getRiskBadgeStyle(riskStatus.dataSensitivity)} font-medium text-xs px-2 py-1 rounded-md`}
|
||||
>
|
||||
{riskStatus.dataSensitivity || "NONE"}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -579,13 +587,17 @@ function ListVendorContent({
|
||||
Assessment expired
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className={`${getRiskBadgeStyle(riskStatus.businessImpact)} font-medium text-xs px-2 py-1 rounded-md`}>
|
||||
<Badge
|
||||
className={`${getRiskBadgeStyle(riskStatus.businessImpact)} font-medium text-xs px-2 py-1 rounded-md`}
|
||||
>
|
||||
{riskStatus.businessImpact || "LOW"}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
{riskStatus.assessedAt ? formatDate(riskStatus.assessedAt) : "N/A"}
|
||||
{riskStatus.assessedAt
|
||||
? formatDate(riskStatus.assessedAt)
|
||||
: "N/A"}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-right">
|
||||
<DropdownMenu>
|
||||
@@ -605,7 +617,7 @@ function ListVendorContent({
|
||||
e.preventDefault();
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to delete this vendor?"
|
||||
"Are you sure you want to delete this vendor?",
|
||||
)
|
||||
) {
|
||||
deleteVendor({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user