style: give project machine identity page facelift

This commit is contained in:
Scott Wilson
2025-12-02 17:28:26 -08:00
parent 29eaa64a1b
commit e598199e97
40 changed files with 2675 additions and 815 deletions

View File

@@ -68,7 +68,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
secretVersioning: true,
pitRecovery: false,
ipAllowlisting: false,
rbac: false,
rbac: true,
githubOrgSync: false,
customRateLimits: false,
subOrganization: false,

View File

@@ -2,7 +2,7 @@ import { useMemo } from "react";
import type { Decorator } from "@storybook/react-vite";
import { createRootRoute, createRouter, RouterProvider } from "@tanstack/react-router";
export const RouterDecorator: Decorator = (Story) => {
export const RouterDecorator: Decorator = (Story, params) => {
const router = useMemo(() => {
const routeTree = createRootRoute({
component: Story
@@ -11,7 +11,7 @@ export const RouterDecorator: Decorator = (Story) => {
return createRouter({
routeTree
});
}, [Story]);
}, [Story, params]);
return <RouterProvider router={router as any} />;
};

View File

@@ -42,6 +42,7 @@
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
@@ -136,6 +137,7 @@
"prettier": "3.4.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.15.0",
"vite": "^6.2.0",
@@ -3353,6 +3355,85 @@
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
"integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -14777,6 +14858,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/tw-animate-css": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",

View File

@@ -51,6 +51,7 @@
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.1.3",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
@@ -145,6 +146,7 @@
"prettier": "3.4.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.15.0",
"vite": "^6.2.0",

View File

@@ -32,7 +32,7 @@ export const PageHeader = ({ title, description, children, className, scope }: P
<div className="mr-4 flex w-full items-center">
<h1
className={twMerge(
"text-3xl font-medium text-white capitalize underline decoration-2 underline-offset-4",
"text-2xl font-medium text-white capitalize underline decoration-2 underline-offset-4",
scope === "org" && "decoration-org/90",
scope === "instance" && "decoration-neutral/90",
scope === "namespace" && "decoration-sub-org/90",
@@ -51,6 +51,6 @@ export const PageHeader = ({ title, description, children, className, scope }: P
</div>
<div className="flex items-center gap-2">{children}</div>
</div>
<div className="mt-2 text-gray-400">{description}</div>
<div className="mt-1.5 text-mineshaft-300">{description}</div>
</div>
);

View File

@@ -0,0 +1,62 @@
/* eslint-disable react/prop-types */
import * as React from "react";
import { cva, type VariantProps } from "cva";
import { cn } from "../../utils";
const alertVariants = cva(
"relative w-full rounded-sm border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-container text-card-foreground",
info: "bg-info/10 text-info border-info/20",
org: "bg-org/10 text-org border-org/20"
}
},
defaultVariants: {
variant: "default"
}
}
);
function UnstableAlert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function UnstableAlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn("col-start-2 line-clamp-1 min-h-4 tracking-tight text-foreground", className)}
{...props}
/>
);
}
function UnstableAlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-foreground/75 [&_p]:leading-relaxed",
className
)}
{...props}
/>
);
}
export { UnstableAlert, UnstableAlertDescription, UnstableAlertTitle };

View File

@@ -0,0 +1 @@
export * from "./Alert";

View File

@@ -16,6 +16,7 @@ import {
} from "lucide-react";
import { OrgIcon, ProjectIcon, SubOrgIcon } from "../../platform";
import { UnstableButtonGroup } from "../ButtonGroup";
import { Badge } from "./Badge";
/**
@@ -33,13 +34,34 @@ const meta = {
argTypes: {
variant: {
control: "select",
options: ["neutral", "success", "info", "warning", "danger", "project", "org", "sub-org"]
options: [
"default",
"outline",
"neutral",
"success",
"info",
"warning",
"danger",
"project",
"org",
"sub-org"
]
},
isTruncatable: {
table: {
disable: true
}
},
isFullWidth: {
table: {
disable: true
}
},
isSquare: {
table: {
disable: true
}
},
asChild: {
table: {
disable: true
@@ -57,6 +79,38 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
name: "Variant: Default",
args: {
variant: "default",
children: <>Default</>
},
parameters: {
docs: {
description: {
story:
"Use this variant when other badge variants are not applicable or as the key when displaying key-value pairs with ButtonGroup."
}
}
}
};
export const Outline: Story = {
name: "Variant: Outline",
args: {
variant: "outline",
children: <>Outline</>
},
parameters: {
docs: {
description: {
story:
"Use this variant when other badge variants are not applicable or as the value when displaying key-value pairs with ButtonGroup."
}
}
}
};
export const Neutral: Story = {
name: "Variant: Neutral",
args: {
@@ -71,8 +125,7 @@ export const Neutral: Story = {
parameters: {
docs: {
description: {
story:
"Use this variant when indicating neutral or disabled states or when linking to external documents."
story: "Use this variant when indicating neutral or disabled states."
}
}
}
@@ -133,7 +186,8 @@ export const Info: Story = {
parameters: {
docs: {
description: {
story: "Use this variant when indicating informational states."
story:
"Use this variant when indicating informational states or when linking to external documentation."
}
}
}
@@ -374,3 +428,22 @@ export const IsFullWidth: Story = {
</div>
)
};
export const KeyValuePair: Story = {
name: "Example: Key-Value Pair",
args: {},
parameters: {
docs: {
description: {
story:
"Use a default and outline badge in conjunction with the `<ButtonGroup />` component to display key-value pairs."
}
}
},
decorators: () => (
<UnstableButtonGroup>
<Badge>Key</Badge>
<Badge variant="outline">Value</Badge>
</UnstableButtonGroup>
)
};

View File

@@ -6,7 +6,7 @@ import { cn } from "@app/components/v3/utils";
const badgeVariants = cva(
[
"select-none items-center align-middle rounded-sm h-4.5 px-1.5 text-xs",
"select-none border items-center align-middle rounded-sm h-4.5 px-1.5 text-xs",
"gap-x-1 [a&,button&]:cursor-pointer inline-flex font-normal",
"[&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:stroke-[2.25] [&_svg:not([class*='size-'])]:size-3",
"transition duration-200 ease-in-out"
@@ -24,19 +24,22 @@ const badgeVariants = cva(
true: "w-4.5 justify-center px-0.5"
},
variant: {
ghost: "text-mineshaft-200 gap-x-2",
neutral: "bg-neutral/25 text-neutral [a&,button&]:hover:bg-neutral/35",
success: "bg-success/25 text-success [a&,button&]:hover:bg-success/35",
info: "bg-info/25 text-info [a&,button&]:hover:bg-info/35",
warning: "bg-warning/25 text-warning [a&,button&]:hover:bg-warning/35",
danger: "bg-danger/25 text-danger [a&,button&]:hover:bg-danger/35",
project: "bg-project/25 text-project [a&,button&]:hover:bg-project/35",
org: "bg-org/25 text-org [a&,button&]:hover:bg-org/35",
"sub-org": "bg-sub-org/25 text-sub-org [a&,button&]:hover:bg-sub-org/35"
ghost: "text-foreground border-none",
default: "bg-foreground text-background [a&,button&]:hover:bg-primary/35",
outline: "text-foreground border-foreground border",
neutral: "bg-neutral/15 border-neutral/10 text-neutral [a&,button&]:hover:bg-neutral/35",
success: "bg-success/15 border-success/10 text-success [a&,button&]:hover:bg-success/35",
info: "bg-info/15 border-info/10 border text-info [a&,button&]:hover:bg-info/35",
warning: "bg-warning/15 border-warning/10 text-warning [a&,button&]:hover:bg-warning/35",
danger: "bg-danger/15 border-danger/10 text-danger border [a&,button&]:hover:bg-danger/35",
project:
"bg-project/15 text-project border-project/10 border [a&,button&]:hover:bg-project/35",
org: "bg-org/15 border border-org/10 text-org [a&,button&]:hover:bg-org/35",
"sub-org": "bg-sub-org/15 border-sub-org/10 text-sub-org [a&,button&]:hover:bg-sub-org/35"
}
},
defaultVariants: {
variant: "neutral"
variant: "default"
}
}
);
@@ -44,7 +47,6 @@ const badgeVariants = cva(
type TBadgeProps = VariantProps<typeof badgeVariants> &
React.ComponentProps<"span"> & {
asChild?: boolean;
variant: NonNullable<VariantProps<typeof badgeVariants>["variant"]>; // TODO: REMOVE
};
const Badge = forwardRef<HTMLSpanElement, TBadgeProps>(

View File

@@ -0,0 +1,357 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
AsteriskIcon,
BanIcon,
CheckIcon,
CircleXIcon,
ExternalLinkIcon,
InfoIcon,
RadarIcon,
TriangleAlertIcon,
UserIcon
} from "lucide-react";
import { OrgIcon, ProjectIcon, SubOrgIcon } from "../../platform";
import { UnstableButton } from "./Button";
/**
* Buttons act as an indicator that can optionally be made interactable.
* You can place text and icons inside a Button.
* Buttons are often used for the indication of a status, state or scope.
*/
const meta = {
title: "Generic/Button",
component: UnstableButton,
parameters: {
layout: "centered"
},
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: [
"default",
"outline",
"neutral",
"success",
"info",
"warning",
"danger",
"project",
"org",
"sub-org"
]
},
size: {
control: "select",
options: ["xs", "sm", "md", "lg"]
},
isPending: {
control: "boolean"
},
isFullWidth: {
control: "boolean"
},
isDisabled: {
control: "boolean"
},
as: {
table: {
disable: true
}
},
children: {
table: {
disable: true
}
}
},
args: { children: "Button", isPending: false, isDisabled: false, isFullWidth: false, size: "md" }
} satisfies Meta<typeof UnstableButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
name: "Variant: Default",
args: {
variant: "default",
children: <>Default</>
},
parameters: {
docs: {
description: {
story:
"Use this variant when other Button variants are not applicable or as the key when displaying key-value pairs with ButtonGroup."
}
}
}
};
export const Outline: Story = {
name: "Variant: Outline",
args: {
variant: "outline",
children: <>Outline</>
},
parameters: {
docs: {
description: {
story:
"Use this variant when other Button variants are not applicable or as the value when displaying key-value pairs with ButtonGroup."
}
}
}
};
export const Neutral: Story = {
name: "Variant: Neutral",
args: {
variant: "neutral",
children: (
<>
<BanIcon />
Disabled
</>
)
},
parameters: {
docs: {
description: {
story: "Use this variant when indicating neutral or disabled states."
}
}
}
};
export const Ghost: Story = {
name: "Variant: Ghost",
args: {
variant: "ghost",
children: (
<>
<UserIcon />
User
</>
)
},
parameters: {
docs: {
description: {
story:
"Use this variant when indicating a configuration or property value. Avoid using this variant as an interactive element as it is not intuitive to interact with."
}
}
}
};
export const Success: Story = {
name: "Variant: Success",
args: {
variant: "success",
children: (
<>
<CheckIcon />
Success
</>
)
},
parameters: {
docs: {
description: {
story: "Use this variant when indicating successful or healthy states."
}
}
}
};
export const Info: Story = {
name: "Variant: Info",
args: {
variant: "info",
children: (
<>
<InfoIcon />
Info
</>
)
},
parameters: {
docs: {
description: {
story:
"Use this variant when indicating informational states or when linking to external documentation."
}
}
}
};
export const Warning: Story = {
name: "Variant: Warning",
args: {
variant: "warning",
children: (
<>
<TriangleAlertIcon />
Warning
</>
)
},
parameters: {
docs: {
description: {
story: "Use this variant when indicating activity or attention warranting states."
}
}
}
};
export const Danger: Story = {
name: "Variant: Danger",
args: {
variant: "danger",
children: (
<>
<CircleXIcon />
Danger
</>
)
},
parameters: {
docs: {
description: {
story: "Use this variant when indicating destructive or error states."
}
}
}
};
export const Organization: Story = {
name: "Variant: Organization",
args: {
variant: "org",
children: (
<>
<OrgIcon />
Organization
</>
)
},
parameters: {
docs: {
description: {
story: "Use this variant when indicating organization scope or links."
}
}
}
};
export const SubOrganization: Story = {
name: "Variant: Sub-Organization",
args: {
variant: "sub-org",
children: (
<>
<SubOrgIcon />
Sub-Organization
</>
)
},
parameters: {
docs: {
description: {
story: "Use this variant when indicating sub-organization scope or links."
}
}
}
};
export const Project: Story = {
name: "Variant: Project",
args: {
variant: "project",
children: (
<>
<ProjectIcon />
Project
</>
)
},
parameters: {
docs: {
description: {
story: "Use this variant when indicating project scope or links."
}
}
}
};
export const AsExternalLink: Story = {
name: "Example: As External Link",
args: {
variant: "info",
as: "a",
href: "https://www.infisical.com",
children: (
<>
Link <ExternalLinkIcon />
</>
)
},
parameters: {
docs: {
description: {
story: 'Use the `as="a"` prop to use a Button as an external `a` tag component.'
}
}
}
};
export const AsRouterLink: Story = {
name: "Example: As Router Link",
args: {
variant: "project",
as: "link",
children: (
<>
<RadarIcon />
Secret Scanning
</>
)
},
parameters: {
docs: {
description: {
story: 'Use the `as="link"` prop to use a Button as an internal `Link` component.'
}
}
}
};
export const IsFullWidth: Story = {
name: "Example: isFullWidth",
args: {
variant: "neutral",
isFullWidth: true,
children: (
<>
<AsteriskIcon />
Secret Value
</>
)
},
parameters: {
docs: {
description: {
story:
"Use the `isFullWidth` prop to expand the Buttons width to fill it's parent container."
}
}
},
decorators: (Story) => (
<div className="w-32">
<Story />
</div>
)
};

View File

@@ -0,0 +1,139 @@
import * as React from "react";
import { forwardRef } from "react";
import { Link, LinkProps } from "@tanstack/react-router";
import { cva, type VariantProps } from "cva";
import { Lottie } from "@app/components/v2";
import { cn } from "@app/components/v3/utils";
const buttonVariants = cva(
cn(
"inline-flex items-center active:scale-[0.95] justify-center border cursor-pointer whitespace-nowrap",
" text-sm transition-all disabled:pointer-events-none disabled:opacity-75 shrink-0",
"[&>svg]:pointer-events-none [&>svg]:shrink-0",
"focus-visible:ring-ring outline-0 focus-visible:ring-2 select-none"
),
{
variants: {
variant: {
default:
"border-foreground bg-foreground text-background hover:bg-foreground/90 hover:border-foreground/90",
neutral:
"border-neutral/10 bg-neutral/40 text-foreground hover:bg-neutral/50 hover:border-neutral/20",
outline: "text-foreground hover:bg-foreground/10 border-border hover:border-foreground/20",
ghost: "text-foreground hover:bg-foreground/10 border-transparent",
project:
"border-project/25 bg-project/15 text-foreground hover:bg-project/30 hover:border-project/30",
org: "border-org/25 bg-org/15 text-foreground hover:bg-org/30 hover:border-org/30",
"sub-org":
"border-sub-org/25 bg-sub-org/15 text-foreground hover:bg-sub-org/30 hover:border-sub-org/30",
success:
"border-success/25 bg-success/15 text-foreground hover:bg-success/30 hover:border-success/30",
info: "border-info/25 bg-info/15 text-foreground hover:bg-info/30 hover:border-info/30",
warning:
"border-warning/25 bg-warning/15 text-foreground hover:bg-warning/30 hover:border-warning/30",
danger:
"border-danger/25 bg-danger/15 text-foreground hover:bg-danger/30 hover:border-danger/30"
},
size: {
xs: "h-7 px-2 rounded-[3px] text-xs [&>svg]:size-3 gap-1.5",
sm: "h-8 px-2.5 rounded-[4px] text-sm [&>svg]:size-3 gap-1.5",
md: "h-9 px-3 rounded-[5px] text-sm [&>svg]:size-3.5 gap-1.5",
lg: "h-10 px-3 rounded-[6px] text-sm [&>svg]:size-3.5 gap-1.5"
},
isPending: {
true: "text-transparent"
},
isFullWidth: {
true: "w-full",
false: "w-fit"
}
},
defaultVariants: {
variant: "default",
size: "md"
}
}
);
type UnstableButtonProps = (VariantProps<typeof buttonVariants> & {
isPending?: boolean;
isFullWidth?: boolean;
isDisabled?: boolean;
}) &
(
| ({ as?: "button" | undefined } & React.ComponentProps<"button">)
| ({ as: "link"; className?: string } & LinkProps)
| ({ as: "a" } & React.ComponentProps<"a">)
);
const UnstableButton = forwardRef<HTMLButtonElement | HTMLAnchorElement, UnstableButtonProps>(
(
{
className,
variant = "default",
size = "md",
isPending = false,
isFullWidth = false,
isDisabled = false,
children,
...props
},
ref
): JSX.Element => {
const sharedProps = {
"data-slot": "button",
className: cn(buttonVariants({ variant, size, isPending, isFullWidth }), className)
};
const child = (
<>
{children}
{isPending && (
<Lottie
icon={variant === "default" ? "infisical_loading_bw" : "infisical_loading_white"}
isAutoPlay
className="absolute w-8 rounded-xl"
/>
)}
</>
);
switch (props.as) {
case "a":
return (
<a
ref={ref as React.Ref<HTMLAnchorElement>}
target="_blank"
rel="noopener noreferrer"
{...props}
{...sharedProps}
>
{child}
</a>
);
case "link":
return (
<Link ref={ref as React.Ref<HTMLAnchorElement>} {...props} {...sharedProps}>
{child}
</Link>
);
default:
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
disabled={isPending || isDisabled}
{...props}
{...sharedProps}
>
{child}
</button>
);
}
}
);
UnstableButton.displayName = "Button";
export { buttonVariants, UnstableButton, type UnstableButtonProps };

View File

@@ -0,0 +1 @@
export * from "./Button";

View File

@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "cva";
import { cn } from "../../utils";
import { UnstableSeparator } from "../Separator";
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none"
}
},
defaultVariants: {
orientation: "horizontal"
}
}
);
function UnstableButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function UnstableButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function UnstableButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof UnstableSeparator>) {
return (
<UnstableSeparator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
);
}
export {
buttonGroupVariants,
UnstableButtonGroup,
UnstableButtonGroupSeparator,
UnstableButtonGroupText
};

View File

@@ -0,0 +1 @@
export * from "./ButtonGroup";

View File

@@ -0,0 +1,88 @@
/* eslint-disable react/prop-types */
import * as React from "react";
import { cn } from "../../utils";
function UnstableCard({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("flex h-fit flex-col gap-6 rounded-[6px] text-foreground shadow-sm", className)}
{...props}
/>
);
}
function UnstableCardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-center gap-1 border-mineshaft-400/60 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
);
}
function UnstableCardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"flex items-center gap-1.5 leading-none font-semibold [&>svg]:inline-block [&>svg]:size-[18px]",
className
)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn(
"flex items-center gap-1 text-sm text-accent [&>svg]:inline-block [&>svg]:size-[12px]",
className
)}
{...props}
/>
);
}
function UnstableCardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function UnstableCardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("", className)} {...props} />;
}
function UnstableCardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center border-border [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
UnstableCard,
UnstableCardAction,
UnstableCardContent,
CardDescription as UnstableCardDescription,
UnstableCardFooter,
UnstableCardHeader,
UnstableCardTitle
};

View File

@@ -0,0 +1 @@
export * from "./Card";

View File

@@ -0,0 +1,23 @@
import { cn } from "../../utils";
function Detail({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="detail" className={cn("flex flex-col gap-y-1", className)} {...props} />;
}
function DetailLabel({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="detail-label" className={cn("text-xs text-label", className)} {...props} />
);
}
function DetailValue({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="detail-value" className={cn("text-sm", className)} {...props} />;
}
function DetailGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="detail-group" className={cn("flex flex-col gap-y-4", className)} {...props} />
);
}
export { Detail, DetailGroup, DetailLabel, DetailValue };

View File

@@ -0,0 +1 @@
export * from "./Detail";

View File

@@ -0,0 +1,254 @@
/* eslint-disable react/prop-types */
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@app/components/v3/utils";
function UnstableDropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function UnstableDropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function UnstableDropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function UnstableDropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"max-h-(--radix-dropdown-menu-content-available-height) origin-(--radix-dropdown-menu-content-transform-origin)",
"z-50 overflow-x-hidden overflow-y-auto rounded-[6px] border border-border/50 bg-popover p-1 text-xs text-foreground shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function UnstableDropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function UnstableDropdownMenuItem({
className,
inset,
variant = "default",
isDisabled,
...props
}: Omit<React.ComponentProps<typeof DropdownMenuPrimitive.Item>, "disabled"> & {
inset?: boolean;
variant?: "default" | "danger";
isDisabled?: boolean;
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"text-xs",
"data-[variant=danger]:text-danger data-[variant=danger]:focus:bg-danger/10 data-[variant=danger]:*:[svg]:!text-danger",
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 pt-2 pb-1.5 outline-0 select-none focus:bg-foreground/10",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3",
className
)}
disabled={isDisabled}
{...props}
/>
);
}
function UnstableDropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-sm pt-2 pr-8 pb-1.5 pl-2 text-xs outline-0 select-none",
"focus:bg-foreground/10",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:mb-0.5 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function UnstableDropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function UnstableDropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-sm pt-2 pr-8 pb-1.5 pl-2 text-xs outline-0 select-none",
"focus:bg-foreground/10",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:mb-0.5 [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function UnstableDropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-xs font-medium text-accent data-[inset]:pl-8", className)}
{...props}
/>
);
}
function UnstableDropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function UnstableDropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("ml-auto text-xs tracking-widest text-accent", className)}
{...props}
/>
);
}
function UnstableDropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function UnstableDropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-xs outline-0 select-none focus:bg-foreground/10 data-[inset]:pl-8 data-[state=open]:bg-foreground/10",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-3.5" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function UnstableDropdownMenuSubContent({
className,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[6px] border border-border bg-popover p-1 text-foreground shadow-lg",
className
)}
sideOffset={sideOffset}
{...props}
/>
);
}
type UnstableDropdownMenuChecked = DropdownMenuPrimitive.DropdownMenuCheckboxItemProps["checked"];
export {
UnstableDropdownMenu,
UnstableDropdownMenuCheckboxItem,
type UnstableDropdownMenuChecked,
UnstableDropdownMenuContent,
UnstableDropdownMenuGroup,
UnstableDropdownMenuItem,
UnstableDropdownMenuLabel,
UnstableDropdownMenuPortal,
UnstableDropdownMenuRadioGroup,
UnstableDropdownMenuRadioItem,
UnstableDropdownMenuSeparator,
UnstableDropdownMenuShortcut,
UnstableDropdownMenuSub,
UnstableDropdownMenuSubContent,
UnstableDropdownMenuSubTrigger,
UnstableDropdownMenuTrigger
};

View File

@@ -0,0 +1 @@
export * from "./Dropdown";

View File

@@ -0,0 +1,100 @@
import { cn } from "../../utils";
function UnstableEmpty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 border-dashed border-border bg-container p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
);
}
function UnstableEmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2 text-center", className)}
{...props}
/>
);
}
// scott: TODO
// const emptyMediaVariants = cva(
// "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
// {
// variants: {
// variant: {
// default: "bg-transparent",
// icon: "bg-bunker-900 rounded text-foreground flex size-10 shrink-0 items-center justify-center [&_svg:not([class*='size-'])]:size-6"
// }
// },
// defaultVariants: {
// variant: "default"
// }
// }
// );
// function EmptyMedia({
// className,
// variant = "default",
// ...props
// }: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
// return (
// <div
// data-slot="empty-icon"
// data-variant={variant}
// className={cn(emptyMediaVariants({ variant, className }))}
// {...props}
// />
// );
// }
function UnstableEmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-sm font-medium tracking-tight", className)}
{...props}
/>
);
}
function UnstableEmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-xs/relaxed text-muted [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
);
}
function UnstableEmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
);
}
export {
UnstableEmpty,
UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle
};

View File

@@ -0,0 +1 @@
export * from "./Empty";

View File

@@ -0,0 +1,112 @@
import * as React from "react";
import { forwardRef } from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "cva";
import { twMerge } from "tailwind-merge";
import { Lottie } from "@app/components/v2";
import { UnstableButton } from "@app/components/v3/generic";
import { cn } from "@app/components/v3/utils";
const iconButtonVariants = cva(
cn(
"inline-flex items-center active:scale-[0.99] justify-center border cursor-pointer whitespace-nowrap rounded-[4px] text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-75 [&_svg]:pointer-events-none shrink-0 [&>svg]:shrink-0",
"focus-visible:ring-ring outline-0 focus-visible:ring-2"
),
{
variants: {
variant: {
default:
"border-foreground bg-foreground text-background hover:bg-foreground/90 hover:border-foreground/90",
accent:
"border-accent/10 bg-accent/40 text-foreground hover:bg-accent/50 hover:border-accent/20",
outline: "text-foreground hover:bg-foreground/20 border-border hover:border-foreground/50",
ghost: "text-foreground hover:bg-foreground/40 border-transparent",
project:
"border-project/75 bg-project/40 text-foreground hover:bg-project/50 hover:border-kms",
org: "border-org/75 bg-org/40 text-foreground hover:bg-org/50 hover:border-org",
"sub-org":
"border-sub-org/75 bg-sub-org/40 text-foreground hover:bg-sub-org/50 hover:border-namespace",
success:
"border-success/75 bg-success/40 text-foreground hover:bg-success/50 hover:border-success",
info: "border-info/75 bg-info/40 text-foreground hover:bg-info/50 hover:border-info",
warning:
"border-warning/75 bg-warning/40 text-foreground hover:bg-warning/50 hover:border-warning",
danger:
"border-danger/75 bg-danger/40 text-foreground hover:bg-danger/50 hover:border-danger"
},
size: {
xs: "h-6 w-6 [&>svg]:size-4 rounded-[5px] [&>svg]:stroke-[1.75]",
sm: "h-8 w-8 [&>svg]:size-5 [&>svg]:stroke-[1.5]",
md: "h-9 w-9 [&>svg]:size-6 [&>svg]:stroke-[1.5]",
lg: "h-10 w-10 [&>svg]:size-7 [&>svg]:stroke-[1.5]"
},
isPending: {
true: "text-transparent"
},
isFullWidth: {
true: "w-full",
false: "w-fit"
}
},
defaultVariants: {
variant: "default",
size: "md"
}
}
);
type UnstableIconButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof iconButtonVariants> & {
asChild?: boolean;
isPending?: boolean;
isDisabled?: boolean;
};
const UnstableIconButton = forwardRef<HTMLButtonElement, UnstableIconButtonProps>(
(
{
className,
variant = "default",
size = "md",
asChild = false,
isPending = false,
disabled = false,
isDisabled = false,
children,
...props
},
ref
): JSX.Element => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-slot="button"
className={cn(iconButtonVariants({ variant, size, isPending }), className)}
disabled={isPending || disabled || isDisabled}
{...props}
>
{children}
{isPending && (
<Lottie
icon={variant === "default" ? "infisical_loading_bw" : "infisical_loading_white"}
isAutoPlay
className={twMerge(
"absolute rounded-xl",
size === "xs" && "w-6",
size === "sm" && "w-7",
size === "md" && "w-8",
size === "lg" && "w-9"
)}
/>
)}
</Comp>
);
}
);
UnstableButton.displayName = "IconButton";
export { iconButtonVariants, UnstableIconButton, type UnstableIconButtonProps };

View File

@@ -0,0 +1 @@
export * from "./IconButton";

View File

@@ -0,0 +1,9 @@
import { Lottie } from "@app/components/v2";
export function UnstablePageLoader() {
return (
<div className="flex h-full w-full items-center justify-center">
<Lottie icon="infisical_loading" isAutoPlay className="w-24" />
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./PageLoader";

View File

@@ -0,0 +1,28 @@
/* eslint-disable react/prop-types */
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "../../utils";
function UnstableSeparator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
);
}
export { UnstableSeparator };

View File

@@ -0,0 +1 @@
export * from "./Separator";

View File

@@ -0,0 +1,138 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CopyIcon, EditIcon, MoreHorizontalIcon, TrashIcon } from "lucide-react";
import {
Badge,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableIconButton
} from "@app/components/v3/generic";
import { ProjectIcon } from "@app/components/v3/platform";
import {
UnstableTable,
UnstableTableBody,
UnstableTableCell,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "./Table";
const identities: {
name: string;
role: string;
managedBy?: { scope: "org" | "namespace"; name: string };
}[] = [
{
name: "machine-one",
role: "Admin",
managedBy: {
scope: "org",
name: "infisical"
}
},
{
name: "machine-two",
role: "Viewer",
managedBy: {
scope: "namespace",
name: "engineering"
}
},
{
name: "machine-three",
role: "Developer"
},
{
name: "machine-four",
role: "Admin",
managedBy: {
scope: "namespace",
name: "dev-ops"
}
},
{
name: "machine-five",
role: "Viewer",
managedBy: {
scope: "org",
name: "infisical"
}
},
{
name: "machine-six",
role: "Developer"
}
];
function TableDemo() {
return (
<UnstableTable className="w-[800px]">
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead className="w-1/3">Name</UnstableTableHead>
<UnstableTableHead className="w-1/3">Role</UnstableTableHead>
<UnstableTableHead className="w-1/3">Managed By</UnstableTableHead>
<UnstableTableHead className="text-right" />
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{identities.map((identity) => (
<UnstableTableRow key={identity.name}>
<UnstableTableCell className="font-medium">{identity.name}</UnstableTableCell>
<UnstableTableCell>{identity.role}</UnstableTableCell>
<UnstableTableCell>
<Badge variant="project">
<ProjectIcon />
Project
</Badge>
</UnstableTableCell>
<UnstableTableCell className="text-right">
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableIconButton variant="ghost" size="xs">
<MoreHorizontalIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end" className="w-36">
<UnstableDropdownMenuItem>
<CopyIcon />
Copy ID
</UnstableDropdownMenuItem>
<UnstableDropdownMenuItem>
<EditIcon />
Edit Identity
</UnstableDropdownMenuItem>
<UnstableDropdownMenuItem variant="danger">
<TrashIcon />
Delete Identity
</UnstableDropdownMenuItem>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
</UnstableTableRow>
))}
</UnstableTableBody>
</UnstableTable>
);
}
const meta = {
title: "Generic/Table",
component: TableDemo,
parameters: {
layout: "centered"
},
tags: ["autodocs"],
argTypes: {}
} satisfies Meta<typeof TableDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const KitchenSInk: Story = {
name: "Example: Kitchen Sink",
args: {}
};

View File

@@ -0,0 +1,109 @@
/* eslint-disable react/prop-types */
import * as React from "react";
import { cn } from "@app/components/v3/utils";
function UnstableTable({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto border border-border/50 bg-mineshaft-800/50"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function UnstableTableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("text-sm [&_tr]:border-b [&_tr]:hover:bg-transparent", className)}
{...props}
/>
);
}
function UnstableTableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody data-slot="table-body" className={cn("[&>tr]:last:border-b-0", className)} {...props} />
);
}
function UnstableTableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t border-border/50 bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
);
}
function UnstableTableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b border-border/50 transition-colors hover:bg-foreground/5 data-[state=selected]:bg-foreground/5",
className
)}
{...props}
/>
);
}
function UnstableTableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-[30px] border-x-0 border-t-0 border-b border-border/50 px-3 text-left align-middle text-xs whitespace-nowrap text-accent text-mineshaft-400 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function UnstableTableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"h-[40px] px-3 align-middle whitespace-nowrap text-mineshaft-200 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function UnstableTableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
UnstableTable,
UnstableTableBody,
UnstableTableCaption,
UnstableTableCell,
UnstableTableFooter,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
};

View File

@@ -0,0 +1 @@
export * from "./Table";

View File

@@ -1 +1,12 @@
export * from "./Alert";
export * from "./Badge";
export * from "./Button";
export * from "./ButtonGroup";
export * from "./Card";
export * from "./Detail";
export * from "./Dropdown";
export * from "./Empty";
export * from "./IconButton";
export * from "./PageLoader";
export * from "./Separator";
export * from "./Table";

View File

@@ -1,8 +1,8 @@
import { BoxesIcon, BoxIcon, Building2Icon, ServerIcon } from "lucide-react";
const InstanceIcon = ServerIcon;
const OrgIcon = Building2Icon;
const SubOrgIcon = BoxesIcon;
const ProjectIcon = BoxIcon;
export { InstanceIcon, OrgIcon, ProjectIcon, SubOrgIcon };
export {
ServerIcon as InstanceIcon,
Building2Icon as OrgIcon,
BoxIcon as ProjectIcon,
BoxesIcon as SubOrgIcon
};

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@import "tw-animate-css";
@source not "../public";
@@ -39,7 +40,7 @@
/* Colors v2 */
--color-background: #19191c;
--color-foreground: white;
--color-foreground: #ebebeb;
--color-success: #2ecc71;
--color-info: #63b0bd;
--color-warning: #f1c40f;
@@ -48,6 +49,14 @@
--color-sub-org: #96ff59;
--color-project: #e0ed34;
--color-neutral: #adaeb0;
--color-border: #323439;
--color-label: #adaeb0;
--color-muted: #707174;
--color-popover: #111419;
--color-ring: #2d2f33;
--color-container: #16181a;
--color-accent: #7d7f80;
--color-muted-foreground: ;
/*legacy color schema */
--color-org-v1: #30b3ff;

View File

@@ -1,24 +1,36 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { DropdownMenu } from "@radix-ui/react-dropdown-menu";
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { formatRelative } from "date-fns";
import { ChevronLeftIcon, EllipsisIcon, InfoIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan, ProjectPermissionCan } from "@app/components/permissions";
import {
Alert,
AlertDescription,
Button,
ConfirmActionModal,
DeleteActionModal,
EmptyState,
PageHeader,
Spinner
Tooltip
} from "@app/components/v2";
import {
OrgIcon,
UnstableAlert,
UnstableAlertDescription,
UnstableAlertTitle,
UnstableButton,
UnstableCard,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstablePageLoader
} from "@app/components/v3";
import {
OrgPermissionIdentityActions,
OrgPermissionSubjects,
@@ -36,7 +48,7 @@ import {
useGetProjectIdentityMembershipV2
} from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { projectIdentityQuery } from "@app/hooks/api/projectIdentity";
import { projectIdentityQuery, useDeleteProjectIdentity } from "@app/hooks/api/projectIdentity";
import { ProjectIdentityAuthenticationSection } from "@app/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityAuthSection";
import { ProjectIdentityDetailsSection } from "@app/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection";
import { ProjectAccessControlTabs } from "@app/types/project";
@@ -56,8 +68,7 @@ const Page = () => {
const { data: identityMembershipDetails, isPending: isMembershipDetailsLoading } =
useGetProjectIdentityMembershipV2(projectId, identityId);
const { mutateAsync: deleteMutateAsync, isPending: isDeletingIdentity } =
useDeleteProjectIdentityMembership();
const { mutateAsync: removeIdentityMutateAsync } = useDeleteProjectIdentityMembership();
const isProjectIdentity = Boolean(identityMembershipDetails?.identity.projectId);
const isNonScopedIdentity =
@@ -75,7 +86,10 @@ const Page = () => {
enabled: isProjectIdentity
});
const { mutateAsync: deleteIdentity } = useDeleteProjectIdentity();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"removeIdentity",
"deleteIdentity",
"assumePrivileges"
] as const);
@@ -104,7 +118,7 @@ const Page = () => {
};
const onRemoveIdentitySubmit = async () => {
await deleteMutateAsync({
await removeIdentityMutateAsync({
identityId,
projectId
});
@@ -112,7 +126,7 @@ const Page = () => {
text: "Successfully removed machine identity from project",
type: "success"
});
handlePopUpClose("deleteIdentity");
handlePopUpClose("removeIdentity");
navigate({
to: `${getProjectBaseURL(currentProject.type)}/access-management` as const,
params: {
@@ -125,16 +139,35 @@ const Page = () => {
});
};
const handleDeleteIdentity = async () => {
if (!identity) return;
try {
await deleteIdentity({
identityId: identity.id,
projectId: identity.projectId!
});
navigate({
to: `${getProjectBaseURL(currentProject.type)}/access-management`,
search: {
selectedTab: "identities"
}
});
} catch {
createNotification({
type: "error",
text: "Failed to delete project machine identity"
});
}
};
if (isMembershipDetailsLoading || (isProjectIdentity && isProjectIdentityPending)) {
return (
<div className="flex w-full items-center justify-center p-24">
<Spinner />
</div>
);
return <UnstablePageLoader />;
}
return (
<div className="mx-auto flex max-w-8xl flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex max-w-8xl flex-col">
{identityMembershipDetails ? (
<>
<Link
@@ -146,127 +179,140 @@ const Page = () => {
search={{
selectedTab: ProjectAccessControlTabs.Identities
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
className="mb-3 flex w-fit items-center gap-x-1 text-sm text-mineshaft-400 transition duration-100 hover:text-mineshaft-400/80"
>
<FontAwesomeIcon icon={faChevronLeft} />
<ChevronLeftIcon size={16} />
Project Machine Identities
</Link>
<PageHeader
scope={currentProject.type}
title={identityMembershipDetails?.identity?.name}
description={`Machine identity ${isProjectIdentity ? "created" : "added"} on ${identityMembershipDetails?.createdAt && formatRelative(new Date(identityMembershipDetails?.createdAt || ""), new Date())}`}
className={!isProjectIdentity ? "mb-4" : undefined}
className="mb-20"
description={`Configure and manage${isProjectIdentity ? " machine identity and " : " "}project access control`}
title={identityMembershipDetails.identity.name}
>
<div className="flex items-center gap-2">
<Button
variant="outline_bg"
size="xs"
onClick={() => {
navigator.clipboard.writeText(identityMembershipDetails.id);
createNotification({
text: "Membership ID copied to clipboard",
type: "success"
});
}}
>
Copy Membership ID
</Button>
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.AssumePrivileges}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails?.identity.id
})}
renderTooltip
allowedLabel="Assume privileges of the machine identity"
passThrough={false}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("assumePrivileges")}
>
Assume Privileges
</Button>
)}
</ProjectPermissionCan>
{!isProjectIdentity && (
<DropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableButton variant="outline">
Options
<EllipsisIcon />
</UnstableButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<UnstableDropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(identityMembershipDetails.id);
createNotification({
text: "Machine identity ID copied to clipboard",
type: "info"
});
}}
>
Copy Machine Identity ID
</UnstableDropdownMenuItem>
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.AssumePrivileges}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails?.identity.id
})}
passThrough={false}
>
{(isAllowed) => (
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("assumePrivileges")}
>
Assume Privileges
<Tooltip
side="bottom"
content="Assume the privileges of the machine identity, allowing you to replicate their access behavior."
>
<div>
<InfoIcon className="text-muted" />
</div>
</Tooltip>
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails?.identity?.id
})}
renderTooltip
allowedLabel="Remove from project"
>
{(isAllowed) => (
<Button
colorSchema="danger"
variant="outline_bg"
size="xs"
<UnstableDropdownMenuItem
variant="danger"
isDisabled={!isAllowed}
isLoading={isDeletingIdentity}
onClick={() => handlePopUpOpen("deleteIdentity")}
onClick={() =>
isProjectIdentity
? handlePopUpOpen("deleteIdentity")
: handlePopUpOpen("removeIdentity")
}
>
Remove Machine Identity
</Button>
{isProjectIdentity ? "Delete Machine Identity" : "Remove From Project"}
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
)}
</div>
</UnstableDropdownMenuContent>
</DropdownMenu>
</PageHeader>
{!isProjectIdentity && (
<Alert hideTitle iconClassName="text-info" className="mb-4 border-info/50 bg-info/10">
<AlertDescription>
This machine identity is managed by your organization.{" "}
<OrgPermissionCan
I={OrgPermissionIdentityActions.Read}
an={OrgPermissionSubjects.Identity}
>
{(isAllowed) =>
isAllowed ? (
<Link
to="/organizations/$orgId/identities/$identityId"
params={{
identityId,
orgId: currentOrg.id
}}
>
<span className="cursor-pointer text-info underline underline-offset-2">
Click here to manage machine identity.
</span>
</Link>
) : null
}
</OrgPermissionCan>
</AlertDescription>
</Alert>
)}
<div className="flex gap-x-4">
{identity ? (
<div className="flex w-72 flex-col gap-y-4">
<ProjectIdentityDetailsSection
identity={identity}
membership={identityMembershipDetails!}
/>
<div className="flex flex-col gap-20 lg:flex-row">
<ProjectIdentityDetailsSection
identity={identity || { ...identityMembershipDetails?.identity, projectId: "" }}
isOrgIdentity={!isProjectIdentity}
membership={identityMembershipDetails!}
/>
<div className="flex flex-1 flex-col gap-y-20">
{identity ? (
<ProjectIdentityAuthenticationSection
identity={identity}
refetchIdentity={() => refetchIdentity()}
/>
</div>
) : (
<div>
<div className="flex w-72 flex-col gap-y-4">
<ProjectIdentityDetailsSection
identity={{ ...identityMembershipDetails?.identity, projectId: "" }}
isOrgIdentity
membership={identityMembershipDetails!}
/>
</div>
</div>
)}
<div className="flex-1">
) : (
<UnstableCard>
<UnstableCardHeader className="border-b">
<UnstableCardTitle>Authentication</UnstableCardTitle>
<UnstableCardDescription>
Configure authentication methods
</UnstableCardDescription>
</UnstableCardHeader>
<UnstableCardContent>
<UnstableAlert variant="org">
<OrgIcon />
<UnstableAlertTitle>
Machine identity managed by organization
</UnstableAlertTitle>
<UnstableAlertDescription>
<p>
This machine identity&apos;s authentication methods are controlled by your
organization. To make changes,{" "}
<OrgPermissionCan
I={OrgPermissionIdentityActions.Read}
an={OrgPermissionSubjects.Identity}
>
{(isAllowed) =>
isAllowed ? (
<Link
to="/organizations/$orgId/identities/$identityId"
className="inline-block cursor-pointer text-foreground underline underline-offset-2"
params={{
identityId,
orgId: currentOrg.id
}}
>
go to organization access control
</Link>
) : null
}
</OrgPermissionCan>
.
</p>
</UnstableAlertDescription>
</UnstableAlert>
</UnstableCardContent>
</UnstableCard>
)}
<IdentityRoleDetailsSection
identityMembershipDetails={identityMembershipDetails}
isMembershipDetailsLoading={isMembershipDetailsLoading}
@@ -277,9 +323,9 @@ const Page = () => {
</div>
</div>
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
isOpen={popUp.removeIdentity.isOpen}
title={`Are you sure you want to remove ${identityMembershipDetails?.identity?.name} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
onChange={(isOpen) => handlePopUpToggle("removeIdentity", isOpen)}
deleteKey="remove"
onDeleteApproved={() => onRemoveIdentitySubmit()}
/>
@@ -292,6 +338,13 @@ const Page = () => {
onConfirmed={handleAssumePrivileges}
buttonText="Confirm"
/>
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure you want to delete ${identity?.name}?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleDeleteIdentity}
/>
</>
) : (
<EmptyState title="Error: Unable to find the machine identity." className="py-12" />

View File

@@ -1,6 +1,6 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCaretDown, faChevronLeft, faClock, faSave } from "@fortawesome/free-solid-svg-icons";
import { faCaretDown, faClock, faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
@@ -21,6 +21,7 @@ import {
Tag,
Tooltip
} from "@app/components/v2";
import { UnstableSeparator } from "@app/components/v3";
import {
ProjectPermissionIdentityActions,
ProjectPermissionSub,
@@ -180,55 +181,9 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<form className="flex flex-col gap-y-4" onSubmit={handleSubmit(onSubmit)}>
<FormProvider {...form}>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<Button
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
className="text-lg font-medium text-mineshaft-100"
variant="link"
onClick={onGoBack}
>
Back
</Button>
<div className="flex items-center space-x-4">
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={onGoBack}
>
Discard
</Button>
)}
<div className="flex items-center">
<Button
variant="outline_bg"
type="submit"
className={twMerge(
"mr-4 h-10 border border-primary",
isDirty && "bg-primary text-black"
)}
isDisabled={isSubmitting || !isDirty || isDisabled}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
<AddPoliciesButton isDisabled={isDisabled} projectType={currentProject.type} />
</div>
</div>
</div>
<div className="mt-2 border-b border-gray-800 p-4 pt-2 first:rounded-t-md last:rounded-b-md">
<div className="text-lg">Overview</div>
<p className="mb-4 text-sm text-mineshaft-300">
Additional privileges take precedence over roles when permissions conflict
</p>
<div>
<div className="flex items-end space-x-6">
<div className="w-full max-w-md">
<Controller
@@ -344,10 +299,29 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
</div>
</div>
</div>
<div className="p-4">
<div className="mb-2 text-lg">Policies</div>
<UnstableSeparator />
<div>
<div className="mb-3 flex w-full items-center justify-between">
<div className="text-lg">Policies</div>
<div className="flex items-center space-x-4">
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={onGoBack}
>
Discard Changes
</Button>
)}
<div className="flex items-center">
<AddPoliciesButton isDisabled={isDisabled} projectType={currentProject.type} />
</div>
</div>
</div>
{(isCreate || !isPending) && <PermissionEmptyState />}
<div>
<div className="scrollbar-thin max-h-[50vh] overflow-y-auto">
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map(
(permissionSubject) => (
<GeneralPermissionPolicies
@@ -363,6 +337,20 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
)}
</div>
</div>
<UnstableSeparator />
<div className="flex w-full items-center justify-end gap-x-2">
<Button colorSchema="secondary" variant="plain" onClick={onGoBack}>
Cancel
</Button>
<Button
type="submit"
isDisabled={isSubmitting || !isDirty || isDisabled}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
</div>
</FormProvider>
</form>
);

View File

@@ -1,27 +1,36 @@
import { subject } from "@casl/ability";
import { faEllipsisV, faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { ClockAlertIcon, ClockIcon, EllipsisIcon, PlusIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, Lottie, Modal, ModalContent, Tooltip } from "@app/components/v2";
import {
DeleteActionModal,
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
Badge,
UnstableButton,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableEmpty,
UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableIconButton,
UnstableTable,
UnstableTableBody,
UnstableTableCell,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3";
import {
ProjectPermissionActions,
ProjectPermissionIdentityActions,
@@ -67,193 +76,230 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
handlePopUpClose("deletePrivilege");
};
return (
<div className="relative">
<AnimatePresence>
{popUp?.modifyPrivilege.isOpen ? (
<motion.div
key="privilege-modify"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="absolute min-h-40 w-full"
>
<IdentityProjectAdditionalPrivilegeModifySection
onGoBack={() => handlePopUpClose("modifyPrivilege")}
identityId={identityId}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={permission.cannot(
ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, {
identityId
})
)}
/>
</motion.div>
) : (
<motion.div
key="privilege-list"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 0 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
className="absolute w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">
Project Additional Privileges
</h3>
const hasAdditionalPrivileges = Boolean(identityProjectPrivileges?.length);
return (
<>
<UnstableCard>
<UnstableCardHeader className="border-b">
<UnstableCardTitle>Project Additional Privileges</UnstableCardTitle>
<UnstableCardDescription>
Assign one-off policies to this machine identity
</UnstableCardDescription>
{hasAdditionalPrivileges && (
<UnstableCardAction>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId
})}
renderTooltip
allowedLabel="Add Privilege"
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
<UnstableButton
variant="project"
size="xs"
onClick={() => {
handlePopUpOpen("modifyPrivilege");
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
<PlusIcon />
Add Additional Privileges
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableCardAction>
)}
</UnstableCardHeader>
<UnstableCardContent>
{/* eslint-disable-next-line no-nested-ternary */}
{isPending ? (
// scott: todo proper loader
<div className="flex h-40 w-full items-center justify-center">
<Lottie icon="infisical_loading_white" isAutoPlay className="w-16" />
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Duration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && (
<TableSkeleton columns={3} innerKey="user-project-identity-memberships" />
)}
{!isPending &&
identityProjectPrivileges?.map((privilegeDetails) => {
const isTemporary = privilegeDetails?.isTemporary;
const isExpired =
privilegeDetails.isTemporary &&
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
) : identityProjectPrivileges?.length ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead className="w-1/2">Name</UnstableTableHead>
<UnstableTableHead className="w-1/2">Duration</UnstableTableHead>
<UnstableTableHead className="w-5" />
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{!isPending &&
identityProjectPrivileges?.map((privilegeDetails) => {
const isTemporary = privilegeDetails?.isTemporary;
const isExpired =
privilegeDetails.isTemporary &&
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non-Expiring Access";
if (privilegeDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(privilegeDetails.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(privilegeDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<Tr
key={`user-project-privilege-${privilegeDetails?.id}`}
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
handlePopUpOpen("modifyPrivilege", privilegeDetails);
}
}}
onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)}
>
<Td>{privilegeDetails.slug}</Td>
<Td>
<Tooltip asChild={false} content={toolTipText}>
<Tag
className={twMerge(
"capitalize",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{text}
</Tag>
</Tooltip>
</Td>
<Td>
<div className="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId
})}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handlePopUpOpen("deletePrivilege", {
id: privilegeDetails?.id,
slug: privilegeDetails?.slug
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
<IconButton ariaLabel="more-icon" variant="plain">
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</div>
</Td>
</Tr>
let text = "Permanent";
let toolTipText = "Non-Expiring Access";
if (privilegeDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(privilegeDetails.temporaryAccessEndTime || ""),
new Date()
);
})}
</TBody>
</Table>
{!isPending && !identityProjectPrivileges?.length && (
<EmptyState
title="This machine identity has no additional privileges"
icon={faFolder}
/>
)}
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
deleteKey="remove"
title={`Do you want to remove privilege ${
(popUp?.deletePrivilege?.data as { slug: string; id: string })?.slug
}?`}
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
onDeleteApproved={() => handlePrivilegeDelete()}
/>
</motion.div>
)}
</AnimatePresence>
</div>
toolTipText = `Until ${format(
new Date(privilegeDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<UnstableTableRow key={`user-project-privilege-${privilegeDetails?.id}`}>
<UnstableTableCell className="max-w-0 truncate">
{privilegeDetails.slug}
</UnstableTableCell>
<UnstableTableCell>
{isTemporary ? (
<Tooltip content={toolTipText}>
<Badge
className="capitalize"
variant={isExpired ? "danger" : "warning"}
>
{isExpired ? <ClockAlertIcon /> : <ClockIcon />}
{text}
</Badge>
</Tooltip>
) : (
text
)}
</UnstableTableCell>
<UnstableTableCell>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableIconButton size="xs" variant="ghost">
<EllipsisIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId
})}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("modifyPrivilege", privilegeDetails);
}}
>
Edit Additional Privilege
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId
})}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<UnstableDropdownMenuItem
isDisabled={!isAllowed}
variant="danger"
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deletePrivilege", {
id: privilegeDetails?.id,
slug: privilegeDetails?.slug
});
}}
>
Remove Additional Privilege
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
</UnstableTableRow>
);
})}
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
This machine identity has no additional privileges
</UnstableEmptyTitle>
<UnstableEmptyDescription>
Add an additional privilege to grant one-off access policies
</UnstableEmptyDescription>
</UnstableEmptyHeader>
<UnstableEmptyContent>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId
})}
>
{(isAllowed) => (
<UnstableButton
variant="project"
size="xs"
onClick={() => {
handlePopUpOpen("modifyPrivilege");
}}
isDisabled={!isAllowed}
>
<PlusIcon />
Add Additional Privileges
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableEmptyContent>
</UnstableEmpty>
)}
</UnstableCardContent>
</UnstableCard>
<Modal
isOpen={popUp.modifyPrivilege.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("modifyPrivilege", isOpen)}
>
<ModalContent
className="max-w-6xl"
title="Additional Privileges"
subTitle="Additional privileges take precedence over roles when permissions conflict"
>
<IdentityProjectAdditionalPrivilegeModifySection
onGoBack={() => handlePopUpClose("modifyPrivilege")}
identityId={identityId}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={permission.cannot(
ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, {
identityId
})
)}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
deleteKey="remove"
title={`Do you want to remove privilege ${
(popUp?.deletePrivilege?.data as { slug: string; id: string })?.slug
}?`}
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
onDeleteApproved={() => handlePrivilegeDelete()}
/>
</>
);
};

View File

@@ -1,28 +1,36 @@
import { subject } from "@casl/ability";
import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge";
import { ClockAlertIcon, ClockIcon, EllipsisIcon, PencilIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, Lottie, Modal, ModalContent, Tooltip } from "@app/components/v2";
import {
DeleteActionModal,
EmptyState,
IconButton,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
Badge,
UnstableButton,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableDropdownMenu,
UnstableDropdownMenuContent,
UnstableDropdownMenuItem,
UnstableDropdownMenuTrigger,
UnstableEmpty,
UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableIconButton,
UnstableTable,
UnstableTableBody,
UnstableTableCell,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { formatProjectRoleName } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
@@ -83,133 +91,186 @@ export const IdentityRoleDetailsSection = ({
handlePopUpClose("deleteRole");
};
const hasRoles = Boolean(identityMembershipDetails?.roles.length);
return (
<div className="mb-4 w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Project Roles</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails.identity.id
})}
renderTooltip
allowedLabel="Edit Role(s)"
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Role</Th>
<Th>Duration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isMembershipDetailsLoading && (
<TableSkeleton columns={3} innerKey="user-project-identities" />
)}
{!isMembershipDetailsLoading &&
identityMembershipDetails?.roles?.map((roleDetails) => {
const isTemporary = roleDetails?.isTemporary;
const isExpired =
roleDetails.isTemporary &&
new Date() > new Date(roleDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non-Expiring Access";
if (roleDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(roleDetails.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(roleDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<Tr className="group h-10" key={`user-project-identity-${roleDetails?.id}`}>
<Td className="capitalize">
{roleDetails.role === "custom"
? roleDetails.customRoleName
: formatProjectRoleName(roleDetails.role)}
</Td>
<Td>
<Tooltip asChild={false} content={toolTipText}>
<Tag
className={twMerge(
"capitalize",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{text}
</Tag>
</Tooltip>
</Td>
<Td>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails.identity.id
})}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", {
id: roleDetails?.id,
slug: roleDetails?.customRoleName || roleDetails?.role
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
<>
<UnstableCard>
<UnstableCardHeader className="border-b">
<UnstableCardTitle>Project Roles</UnstableCardTitle>
<UnstableCardDescription>
Manage roles assigned to this machine identity
</UnstableCardDescription>
{hasRoles && (
<UnstableCardAction>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails.identity.id
})}
</TBody>
</Table>
{!isMembershipDetailsLoading && !identityMembershipDetails?.roles?.length && (
<EmptyState title="This user has no roles" icon={faFolder} />
>
{(isAllowed) => (
<UnstableButton
size="xs"
variant="project"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed}
>
<PencilIcon />
Edit Roles
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableCardAction>
)}
</TableContainer>
</div>
</UnstableCardHeader>
<UnstableCardContent>
{
/* eslint-disable-next-line no-nested-ternary */
isMembershipDetailsLoading ? (
// scott: todo proper loader
<div className="flex h-40 w-full items-center justify-center">
<Lottie icon="infisical_loading_white" isAutoPlay className="w-16" />
</div>
) : hasRoles ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableRow>
<UnstableTableHead className="w-1/2">Role</UnstableTableHead>
<UnstableTableHead className="w-1/2">Duration</UnstableTableHead>
<UnstableTableHead className="w-5" />
</UnstableTableRow>
</UnstableTableHeader>
<UnstableTableBody>
{identityMembershipDetails?.roles?.map((roleDetails) => {
const isTemporary = roleDetails?.isTemporary;
const isExpired =
roleDetails.isTemporary &&
new Date() > new Date(roleDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non-Expiring Access";
if (roleDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(roleDetails.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(roleDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<UnstableTableRow
className="group h-10"
key={`user-project-identity-${roleDetails?.id}`}
>
<UnstableTableCell className="max-w-0 truncate">
{roleDetails.role === "custom"
? roleDetails.customRoleName
: formatProjectRoleName(roleDetails.role)}
</UnstableTableCell>
<UnstableTableCell>
{isTemporary ? (
<Tooltip content={toolTipText}>
<Badge
className="capitalize"
variant={isExpired ? "danger" : "warning"}
>
{isExpired ? <ClockAlertIcon /> : <ClockIcon />}
{text}
</Badge>
</Tooltip>
) : (
text
)}
</UnstableTableCell>
<UnstableTableCell>
<UnstableDropdownMenu>
<UnstableDropdownMenuTrigger asChild>
<UnstableIconButton size="xs" variant="ghost">
<EllipsisIcon />
</UnstableIconButton>
</UnstableDropdownMenuTrigger>
<UnstableDropdownMenuContent align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails.identity.id
})}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<UnstableDropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", {
id: roleDetails?.id,
slug: roleDetails?.customRoleName || roleDetails?.role
});
}}
isDisabled={!isAllowed}
variant="danger"
>
{/* <TrashIcon /> */}
Remove Role
</UnstableDropdownMenuItem>
)}
</ProjectPermissionCan>
</UnstableDropdownMenuContent>
</UnstableDropdownMenu>
</UnstableTableCell>
</UnstableTableRow>
);
})}
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="border">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
This machine identity doesn&nbsp;t have any roles
</UnstableEmptyTitle>
<UnstableEmptyDescription>
Give this machine identity one or more roles
</UnstableEmptyDescription>
</UnstableEmptyHeader>
<UnstableEmptyContent>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails.identity.id
})}
>
{(isAllowed) => (
<UnstableButton
variant="project"
size="xs"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed}
>
<PencilIcon />
Edit Roles
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableEmptyContent>
</UnstableEmpty>
)
}
</UnstableCardContent>
</UnstableCard>
<DeleteActionModal
isOpen={popUp.deleteRole.isOpen}
deleteKey="remove"
@@ -228,6 +289,6 @@ export const IdentityRoleDetailsSection = ({
<IdentityRoleModify identityProjectMembership={identityMembershipDetails} />
</ModalContent>
</Modal>
</div>
</>
);
};

View File

@@ -1,12 +1,31 @@
import { subject } from "@casl/ability";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { LockIcon, SettingsIcon } from "lucide-react";
import { EllipsisIcon, LockIcon, PlusIcon } from "lucide-react";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, Tooltip } from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { Tooltip } from "@app/components/v2";
import {
Badge,
UnstableButton,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableEmpty,
UnstableEmptyContent,
UnstableEmptyDescription,
UnstableEmptyHeader,
UnstableEmptyTitle,
UnstableIconButton,
UnstableTable,
UnstableTableBody,
UnstableTableCell,
UnstableTableHead,
UnstableTableHeader,
UnstableTableRow
} from "@app/components/v3";
import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/context";
import { IdentityAuthMethod, identityAuthToNameMap, TProjectIdentity } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
@@ -25,76 +44,126 @@ export const ProjectIdentityAuthenticationSection = ({ identity, refetchIdentity
"upgradePlan"
]);
const hasAuthMethods = Boolean(identity.authMethods.length);
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Authentication</h3>
</div>
{identity.authMethods.length > 0 ? (
<div className="flex flex-col divide-y divide-mineshaft-400/50">
{identity.authMethods.map((authMethod) => (
<button
key={authMethod}
onClick={() =>
handlePopUpOpen("viewAuthMethod", {
authMethod,
lockedOut: identity.activeLockoutAuthMethods?.includes(authMethod) ?? false,
refetchIdentity
})
}
type="button"
className="flex w-full items-center justify-between bg-mineshaft-900 px-4 py-2 text-sm hover:bg-mineshaft-700 data-[state=open]:bg-mineshaft-600"
>
<span>{identityAuthToNameMap[authMethod]}</span>
<div className="flex items-center gap-2">
{identity.activeLockoutAuthMethods?.includes(authMethod) && (
<Tooltip content="Auth method has active lockouts">
<Badge isSquare variant="danger">
<LockIcon />
</Badge>
</Tooltip>
)}
<SettingsIcon className="size-4 text-neutral" />
</div>
</button>
))}
</div>
) : (
<div className="w-full space-y-2 pt-2">
<p className="text-sm text-mineshaft-300">
No authentication methods configured. Get started by creating a new auth method.
</p>
</div>
)}
{!Object.values(IdentityAuthMethod).every((method) =>
identity.authMethods.includes(method)
) && (
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identity.id
})}
>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId: identity.id,
name: identity.name,
allAuthMethods: identity.authMethods
});
}}
variant="outline_bg"
className="mt-3 w-full"
size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
{identity.authMethods.length ? "Add" : "Create"} Auth Method
</Button>
<>
<UnstableCard>
<UnstableCardHeader className="border-b">
<UnstableCardTitle>Authentication</UnstableCardTitle>
<UnstableCardDescription>Configure authentication methods</UnstableCardDescription>
{hasAuthMethods &&
!Object.values(IdentityAuthMethod).every((method) =>
identity.authMethods.includes(method)
) && (
<UnstableCardAction>
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identity.id
})}
>
{(isAllowed) => (
<UnstableButton
variant="project"
isFullWidth
size="xs"
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId: identity.id,
name: identity.name,
allAuthMethods: identity.authMethods
});
}}
>
<PlusIcon />
Add Auth Method
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableCardAction>
)}
</UnstableCardHeader>
<UnstableCardContent>
{identity.authMethods.length > 0 ? (
<UnstableTable>
<UnstableTableHeader>
<UnstableTableHead className="w-full">Method</UnstableTableHead>
<UnstableTableHead className="w-5" />
</UnstableTableHeader>
<UnstableTableBody>
{identity.authMethods.map((authMethod) => (
<UnstableTableRow
key={authMethod}
className="cursor-pointer"
onClick={() =>
handlePopUpOpen("viewAuthMethod", {
authMethod,
lockedOut: identity.activeLockoutAuthMethods?.includes(authMethod) ?? false,
refetchIdentity
})
}
>
<UnstableTableCell>{identityAuthToNameMap[authMethod]}</UnstableTableCell>
<UnstableTableCell>
<div className="flex items-center gap-2">
{identity.activeLockoutAuthMethods?.includes(authMethod) && (
<Tooltip content="Auth method has active lockouts">
<Badge isSquare variant="danger">
<LockIcon />
</Badge>
</Tooltip>
)}
<UnstableIconButton variant="ghost" size="xs">
<EllipsisIcon />
</UnstableIconButton>
</div>
</UnstableTableCell>
</UnstableTableRow>
))}
</UnstableTableBody>
</UnstableTable>
) : (
<UnstableEmpty className="rounded-sm border bg-mineshaft-800/50">
<UnstableEmptyHeader>
<UnstableEmptyTitle>
This machine identity has no auth methods configured
</UnstableEmptyTitle>
<UnstableEmptyDescription>
Add an auth method to use this machine identity
</UnstableEmptyDescription>
</UnstableEmptyHeader>
<UnstableEmptyContent>
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identity.id
})}
>
{(isAllowed) => (
<UnstableButton
variant="project"
size="xs"
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("identityAuthMethod", {
identityId: identity.id,
name: identity.name,
allAuthMethods: identity.authMethods
});
}}
>
<PlusIcon />
Add Auth Method
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableEmptyContent>
</UnstableEmpty>
)}
</ProjectPermissionCan>
)}
</UnstableCardContent>
</UnstableCard>
<IdentityAuthMethodModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
@@ -114,6 +183,6 @@ export const ProjectIdentityAuthenticationSection = ({ identity, refetchIdentity
identityId={identity.id}
onResetAllLockouts={popUp.viewAuthMethod.data?.refetchIdentity}
/>
</div>
</>
);
};

View File

@@ -1,36 +1,30 @@
import { subject } from "@casl/ability";
import {
faCheck,
faChevronDown,
faCopy,
faEdit,
faKey,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { BanIcon, CheckIcon, ClipboardListIcon, PencilIcon } from "lucide-react";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Modal, ModalContent, Tooltip } from "@app/components/v2";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Modal,
ModalContent,
Tag,
Tooltip
} from "@app/components/v2";
import { ProjectPermissionIdentityActions, ProjectPermissionSub, useProject } from "@app/context";
import { getProjectBaseURL } from "@app/helpers/project";
Badge,
Detail,
DetailGroup,
DetailLabel,
DetailValue,
OrgIcon,
ProjectIcon,
UnstableButton,
UnstableButtonGroup,
UnstableCard,
UnstableCardAction,
UnstableCardContent,
UnstableCardDescription,
UnstableCardHeader,
UnstableCardTitle,
UnstableIconButton
} from "@app/components/v3";
import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/context";
import { usePopUp, useTimedReset } from "@app/hooks";
import { identityAuthToNameMap, TProjectIdentity, useDeleteProjectIdentity } from "@app/hooks/api";
import { identityAuthToNameMap, TProjectIdentity } from "@app/hooks/api";
import { IdentityProjectMembershipV1 } from "@app/hooks/api/identities/types";
import { ProjectIdentityModal } from "@app/pages/project/AccessControlPage/components/IdentityTab/components/ProjectIdentityModal";
@@ -41,190 +35,146 @@ type Props = {
};
export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, membership }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
// eslint-disable-next-line @typescript-eslint/naming-convention,@typescript-eslint/no-unused-vars
const [_, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { currentProject } = useProject();
const { mutateAsync: deleteIdentity } = useDeleteProjectIdentity();
const navigate = useNavigate();
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([
"editIdentity",
"deleteIdentity"
] as const);
const handleDeleteIdentity = async () => {
try {
await deleteIdentity({
identityId: identity.id,
projectId: identity.projectId!
});
navigate({
to: `${getProjectBaseURL(currentProject.type)}/access-management`,
search: {
selectedTab: "identities"
}
});
} catch {
createNotification({
type: "error",
text: "Failed to delete project machine identity"
});
}
};
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["editIdentity"] as const);
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Details</h3>
<DropdownMenu>
<>
<UnstableCard className="w-full max-w-84">
<UnstableCardHeader className="border-b">
<UnstableCardTitle>Details</UnstableCardTitle>
<UnstableCardDescription>Machine identity details</UnstableCardDescription>
{!isOrgIdentity && (
<DropdownMenuTrigger asChild>
<Button
size="xs"
rightIcon={
<FontAwesomeIcon
className="ml-1 transition-transform duration-200 group-data-[state=open]:rotate-180"
icon={faChevronDown}
/>
}
colorSchema="secondary"
className="group select-none"
<UnstableCardAction>
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identity.id
})}
>
Options
</Button>
</DropdownMenuTrigger>
)}
<DropdownMenuContent className="mt-3 min-w-[120px]" align="end">
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identity.id
})}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={async () => {
handlePopUpOpen("editIdentity");
}}
disabled={!isAllowed}
>
Edit Machine Identity
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
identityId: identity.id
})}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:bg-red-500! hover:text-white!"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("deleteIdentity");
}}
icon={<FontAwesomeIcon icon={faTrash} />}
disabled={!isAllowed}
>
Delete Machine Identity
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Machine Identity ID</p>
<div className="group flex align-top">
<p className="text-sm break-all text-mineshaft-300">{identity.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(identity.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Managed By</p>
<p className="text-sm text-mineshaft-300">
{identity.projectId ? "Project" : "Organization"}
</p>
</div>
{!isOrgIdentity && (
<>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Last Login Auth Method</p>
<p className="text-sm text-mineshaft-300">
{membership.lastLoginAuthMethod
? identityAuthToNameMap[membership.lastLoginAuthMethod]
: "-"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Last Login Time</p>
<p className="text-sm text-mineshaft-300">
{membership.lastLoginTime ? format(membership.lastLoginTime, "PPpp") : "-"}
</p>
</div>
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">Delete Protection</p>
<p className="text-sm text-mineshaft-300">
{identity.hasDeleteProtection ? "On" : "Off"}
</p>
</div>
</>
)}
<div>
<p className="text-sm font-medium text-mineshaft-300">Metadata</p>
{identity?.metadata?.length ? (
<div className="mt-1 flex flex-wrap gap-2 text-sm text-mineshaft-300">
{identity.metadata?.map((el) => (
<div key={el.id} className="flex items-center">
<Tag
{(isAllowed) => (
<UnstableButton
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("editIdentity");
}}
size="xs"
className="mr-0 flex items-center rounded-r-none border border-mineshaft-500"
variant="project"
>
<FontAwesomeIcon icon={faKey} size="xs" className="mr-1" />
<div>{el.key}</div>
</Tag>
<Tag
size="xs"
className="flex items-center rounded-l-none border border-mineshaft-500 bg-mineshaft-900 pl-1"
>
<div className="max-w-[150px] overflow-hidden text-ellipsis whitespace-nowrap">
{el.value}
</div>
</Tag>
</div>
))}
</div>
) : (
<p className="text-sm text-mineshaft-300">-</p>
<PencilIcon />
Edit Details
</UnstableButton>
)}
</ProjectPermissionCan>
</UnstableCardAction>
)}
</div>
</div>
</UnstableCardHeader>
<UnstableCardContent>
<DetailGroup>
<Detail>
<DetailLabel>Name</DetailLabel>
<DetailValue>{identity.name}</DetailValue>
</Detail>
<Detail>
<DetailLabel>ID</DetailLabel>
<DetailValue className="flex items-center gap-x-1">
{identity.id}
<Tooltip content="Copy machine identity ID to clipboard">
<UnstableIconButton
onClick={() => {
navigator.clipboard.writeText(identity.id);
setCopyTextId("Copied");
}}
variant="ghost"
size="xs"
>
{isCopyingId ? <CheckIcon /> : <ClipboardListIcon className="text-label" />}
</UnstableIconButton>
</Tooltip>
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Managed by</DetailLabel>
<DetailValue>
{isOrgIdentity ? (
<Badge variant="org">
<OrgIcon />
Organization
</Badge>
) : (
<Badge variant="project">
<ProjectIcon />
Project
</Badge>
)}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Metadata</DetailLabel>
<DetailValue className="flex flex-wrap gap-2">
{identity?.metadata?.length ? (
identity.metadata?.map((el) => (
<UnstableButtonGroup key={el.id}>
<Badge>{el.key}</Badge>
<Badge variant="outline">{el.value}</Badge>
</UnstableButtonGroup>
))
) : (
<span className="text-muted italic">No metadata</span>
)}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>{isOrgIdentity ? "Joined project" : "Created"}</DetailLabel>
<DetailValue>{format(membership.createdAt, "PPpp")}</DetailValue>
</Detail>
{!isOrgIdentity && (
<>
<Detail>
<DetailLabel>Last Login Method</DetailLabel>
<DetailValue>
{membership.lastLoginAuthMethod ? (
identityAuthToNameMap[membership.lastLoginAuthMethod]
) : (
<span className="text-muted italic">N/A</span>
)}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Last Logged In</DetailLabel>
<DetailValue>
{membership.lastLoginTime ? (
format(membership.lastLoginTime, "PPpp")
) : (
<span className="text-muted italic">N/A</span>
)}
</DetailValue>
</Detail>
<Detail>
<DetailLabel>Delete protection</DetailLabel>
<DetailValue>
{identity.hasDeleteProtection ? (
<Badge variant="success">
<CheckIcon />
Enabled
</Badge>
) : (
<Badge variant="neutral">
<BanIcon />
Disabled
</Badge>
)}
</DetailValue>
</Detail>
</>
)}
</DetailGroup>
</UnstableCardContent>
</UnstableCard>
<Modal
isOpen={popUp.editIdentity.isOpen}
onOpenChange={(open) => handlePopUpToggle("editIdentity", open)}
@@ -236,14 +186,6 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure you want to delete ${identity.name}?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleDeleteIdentity}
/>
</div>
</>
);
};