mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 23:34:27 +00:00
Merge remote-tracking branch 'origin/master' into refactor/graph-components-kebab-case
This commit is contained in:
146 files changed
+6936
-8745
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
|
||||
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
|
||||
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
|
||||
import { SelectViaM365 } from "@/components/providers/workflow/forms/select-credentials-type/m365";
|
||||
import { ProviderType } from "@/types/providers";
|
||||
|
||||
interface UpdateCredentialsInfoProps {
|
||||
@@ -24,6 +25,9 @@ export const CredentialsUpdateInfo = ({
|
||||
if (providerType === "github") {
|
||||
return <SelectViaGitHub initialVia={initialVia} />;
|
||||
}
|
||||
if (providerType === "m365") {
|
||||
return <SelectViaM365 initialVia={initialVia} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
|
||||
import { useUIStore } from "@/store/ui/store";
|
||||
@@ -8,13 +10,38 @@ import { useUIStore } from "@/store/ui/store";
|
||||
import { MutedFindingsConfigForm } from "./forms";
|
||||
|
||||
export const MutedFindingsConfigButton = () => {
|
||||
const pathname = usePathname();
|
||||
const {
|
||||
isMutelistModalOpen,
|
||||
openMutelistModal,
|
||||
closeMutelistModal,
|
||||
hasProviders,
|
||||
shouldAutoOpenMutelist,
|
||||
resetMutelistModalRequest,
|
||||
} = useUIStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoOpenMutelist) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname !== "/providers") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasProviders) {
|
||||
openMutelistModal();
|
||||
}
|
||||
|
||||
resetMutelistModalRequest();
|
||||
}, [
|
||||
hasProviders,
|
||||
openMutelistModal,
|
||||
pathname,
|
||||
resetMutelistModalRequest,
|
||||
shouldAutoOpenMutelist,
|
||||
]);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
if (hasProviders) {
|
||||
openMutelistModal();
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
GCPDefaultCredentials,
|
||||
GCPServiceAccountKey,
|
||||
KubernetesCredentials,
|
||||
M365Credentials,
|
||||
M365CertificateCredentials,
|
||||
M365ClientSecretCredentials,
|
||||
ProviderType,
|
||||
} from "@/types";
|
||||
|
||||
@@ -26,10 +27,13 @@ import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credenti
|
||||
import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type";
|
||||
import { GCPServiceAccountKeyForm } from "./select-credentials-type/gcp/credentials-type/gcp-service-account-key-form";
|
||||
import {
|
||||
M365CertificateCredentialsForm,
|
||||
M365ClientSecretCredentialsForm,
|
||||
} from "./select-credentials-type/m365";
|
||||
import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form";
|
||||
import { GitHubCredentialsForm } from "./via-credentials/github-credentials-form";
|
||||
import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form";
|
||||
import { M365CredentialsForm } from "./via-credentials/m365-credentials-form";
|
||||
|
||||
type BaseCredentialsFormProps = {
|
||||
providerType: ProviderType;
|
||||
@@ -103,11 +107,22 @@ export const BaseCredentialsForm = ({
|
||||
control={form.control as unknown as Control<AzureCredentials>}
|
||||
/>
|
||||
)}
|
||||
{providerType === "m365" && (
|
||||
<M365CredentialsForm
|
||||
control={form.control as unknown as Control<M365Credentials>}
|
||||
/>
|
||||
)}
|
||||
{providerType === "m365" &&
|
||||
searchParamsObj.get("via") === "app_client_secret" && (
|
||||
<M365ClientSecretCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<M365ClientSecretCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "m365" &&
|
||||
searchParamsObj.get("via") === "app_certificate" && (
|
||||
<M365CertificateCredentialsForm
|
||||
control={
|
||||
form.control as unknown as Control<M365CertificateCredentials>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{providerType === "gcp" &&
|
||||
searchParamsObj.get("via") === "service-account" && (
|
||||
<GCPServiceAccountKeyForm
|
||||
@@ -159,6 +174,15 @@ export const BaseCredentialsForm = ({
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
endContent={!isLoading && <ChevronRightIcon size={24} />}
|
||||
onPress={(e) => {
|
||||
const formElement = e.target as HTMLElement;
|
||||
const form = formElement.closest("form");
|
||||
if (form) {
|
||||
form.dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? <>Loading</> : <span>{submitButtonText}</span>}
|
||||
</CustomButton>
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export { M365CertificateCredentialsForm } from "./m365-certificate-credentials-form";
|
||||
export { M365ClientSecretCredentialsForm } from "./m365-client-secret-credentials-form";
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import { CustomInput, CustomTextarea } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { M365CertificateCredentials } from "@/types";
|
||||
|
||||
export const M365CertificateCredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<M365CertificateCredentials>;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-default-foreground leading-9 font-bold">
|
||||
App Certificate Credentials
|
||||
</div>
|
||||
<div className="text-default-500 text-sm">
|
||||
Please provide your Microsoft 365 application credentials with
|
||||
certificate authentication.
|
||||
</div>
|
||||
</div>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name="tenant_id"
|
||||
type="text"
|
||||
label="Tenant ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Tenant ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!control._formState.errors.tenant_id}
|
||||
/>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name="client_id"
|
||||
type="text"
|
||||
label="Client ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Client ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!control._formState.errors.client_id}
|
||||
/>
|
||||
<CustomTextarea
|
||||
control={control}
|
||||
name="certificate_content"
|
||||
label="Certificate Content"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the base64 encoded certificate content"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!control._formState.errors.certificate_content}
|
||||
minRows={4}
|
||||
/>
|
||||
<p className="text-default-500 text-sm">
|
||||
The certificate content must be base64 encoded from an unsigned
|
||||
certificate. For detailed instructions on how to generate and encode
|
||||
your certificate, please refer to the{" "}
|
||||
<CustomLink
|
||||
href="https://docs.prowler.com/user-guide/providers/microsoft365/authentication#generate-the-certificate"
|
||||
size="sm"
|
||||
>
|
||||
certificate generation guide
|
||||
</CustomLink>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { M365ClientSecretCredentials } from "@/types";
|
||||
|
||||
export const M365ClientSecretCredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<M365ClientSecretCredentials>;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-default-foreground leading-9 font-bold">
|
||||
App Client Secret Credentials
|
||||
</div>
|
||||
<div className="text-default-500 text-sm">
|
||||
Please provide your Microsoft 365 application credentials.
|
||||
</div>
|
||||
</div>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name="tenant_id"
|
||||
type="text"
|
||||
label="Tenant ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Tenant ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!control._formState.errors.tenant_id}
|
||||
/>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name="client_id"
|
||||
type="text"
|
||||
label="Client ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Client ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!control._formState.errors.client_id}
|
||||
/>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name="client_secret"
|
||||
type="password"
|
||||
label="Client Secret"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Client Secret"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!control._formState.errors.client_secret}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
M365CertificateCredentialsForm,
|
||||
M365ClientSecretCredentialsForm,
|
||||
} from "./credentials-type";
|
||||
export { SelectViaM365 } from "./select-via-m365";
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { RadioGroup } from "@heroui/radio";
|
||||
import React from "react";
|
||||
import { Control, Controller } from "react-hook-form";
|
||||
|
||||
import { CustomRadio } from "@/components/ui/custom";
|
||||
import { FormMessage } from "@/components/ui/form";
|
||||
|
||||
type RadioGroupM365ViaCredentialsFormProps = {
|
||||
control: Control<any>;
|
||||
isInvalid: boolean;
|
||||
errorMessage?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export const RadioGroupM365ViaCredentialsTypeForm = ({
|
||||
control,
|
||||
isInvalid,
|
||||
errorMessage,
|
||||
onChange,
|
||||
}: RadioGroupM365ViaCredentialsFormProps) => {
|
||||
return (
|
||||
<Controller
|
||||
name="m365CredentialsType"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<RadioGroup
|
||||
className="flex flex-wrap"
|
||||
isInvalid={isInvalid}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-default-500 text-sm">
|
||||
Select Authentication Method
|
||||
</span>
|
||||
<CustomRadio
|
||||
description="Connect using Application Client Secret"
|
||||
value="app_client_secret"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="ml-2">App Client Secret Credentials</span>
|
||||
</div>
|
||||
</CustomRadio>
|
||||
<CustomRadio
|
||||
description="Connect using Application Certificate"
|
||||
value="app_certificate"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className="ml-2">App Certificate Credentials</span>
|
||||
</div>
|
||||
</CustomRadio>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{errorMessage && (
|
||||
<FormMessage className="text-system-error dark:text-system-error">
|
||||
{errorMessage}
|
||||
</FormMessage>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Form } from "@/components/ui/form";
|
||||
|
||||
import { RadioGroupM365ViaCredentialsTypeForm } from "./radio-group-m365-via-credentials-type-form";
|
||||
|
||||
interface SelectViaM365Props {
|
||||
initialVia?: string;
|
||||
}
|
||||
|
||||
export const SelectViaM365 = ({ initialVia }: SelectViaM365Props) => {
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
m365CredentialsType: initialVia || "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSelectionChange = (value: string) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("via", value);
|
||||
router.push(url.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<RadioGroupM365ViaCredentialsTypeForm
|
||||
control={form.control}
|
||||
isInvalid={!!form.formState.errors.m365CredentialsType}
|
||||
errorMessage={form.formState.errors.m365CredentialsType?.message}
|
||||
onChange={handleSelectionChange}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./azure-credentials-form";
|
||||
export * from "./github-credentials-form";
|
||||
export * from "./k8s-credentials-form";
|
||||
export * from "./m365-credentials-form";
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Control } from "react-hook-form";
|
||||
|
||||
import { InfoIcon } from "@/components/icons";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { M365Credentials } from "@/types";
|
||||
|
||||
export const M365CredentialsForm = ({
|
||||
control,
|
||||
}: {
|
||||
control: Control<M365Credentials>;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-md text-default-foreground leading-9 font-bold">
|
||||
Connect via Credentials
|
||||
</div>
|
||||
<div className="text-default-500 text-sm">
|
||||
Please provide the information for your Microsoft 365 credentials.
|
||||
</div>
|
||||
</div>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name="client_id"
|
||||
type="text"
|
||||
label="Client ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Client ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!control._formState.errors.client_id}
|
||||
/>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name="client_secret"
|
||||
type="password"
|
||||
label="Client Secret"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Client Secret"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!control._formState.errors.client_secret}
|
||||
/>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name="tenant_id"
|
||||
type="text"
|
||||
label="Tenant ID"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Tenant ID"
|
||||
variant="bordered"
|
||||
isRequired
|
||||
isInvalid={!!control._formState.errors.tenant_id}
|
||||
/>
|
||||
<p className="text-default-500 text-sm">
|
||||
{" "}
|
||||
User and password authentication is being deprecated due to
|
||||
Microsoft's on-going MFA enforcement across all tenants (see{" "}
|
||||
<CustomLink
|
||||
href="https://azure.microsoft.com/en-us/blog/announcing-mandatory-multi-factor-authentication-for-azure-sign-in/"
|
||||
size="sm"
|
||||
>
|
||||
Microsoft docs
|
||||
</CustomLink>
|
||||
).
|
||||
</p>
|
||||
|
||||
<div className="border-system-warning bg-system-warning-medium dark:text-default-300 flex items-center rounded-lg border p-2 text-sm">
|
||||
<InfoIcon className="mr-2 inline h-4 w-4 shrink-0" />
|
||||
<p className="text-xs font-extrabold">
|
||||
By October 2025, MFA will be mandatory.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-default-500 text-sm">
|
||||
Due to that change, you must only{" "}
|
||||
<CustomLink
|
||||
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/microsoft365/getting-started-m365/#step-3-configure-your-m365-account"
|
||||
size="sm"
|
||||
>
|
||||
use application authentication
|
||||
</CustomLink>{" "}
|
||||
to maintain all Prowler M365 scan capabilities.
|
||||
</p>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name="user"
|
||||
type="text"
|
||||
label="User"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the User"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
isInvalid={!!control._formState.errors.user}
|
||||
/>
|
||||
<CustomInput
|
||||
control={control}
|
||||
name="password"
|
||||
type="password"
|
||||
label="Password"
|
||||
labelPlacement="inside"
|
||||
placeholder="Enter the Password"
|
||||
variant="bordered"
|
||||
isRequired={false}
|
||||
isInvalid={!!control._formState.errors.password}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
# shadcn Components
|
||||
|
||||
This directory contains all shadcn/ui based components for the Prowler application.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
shadcn/
|
||||
├── card.tsx # shadcn Card component
|
||||
├── resource-stats-card/ # Custom ResourceStatsCard built on shadcn
|
||||
│ ├── resource-stats-card.tsx
|
||||
│ ├── resource-stats-card.example.tsx
|
||||
│ └── index.ts
|
||||
├── index.ts # Barrel exports
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
All shadcn components can be imported from `@/components/shadcn`:
|
||||
|
||||
```tsx
|
||||
import { Card, CardHeader, CardContent } from "@/components/shadcn";
|
||||
import { ResourceStatsCard } from "@/components/shadcn";
|
||||
```
|
||||
|
||||
## Adding New shadcn Components
|
||||
|
||||
When adding new shadcn components using the CLI:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add [component-name]
|
||||
```
|
||||
|
||||
The component will be automatically added to this directory due to the configuration in `components.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"aliases": {
|
||||
"ui": "@/components/shadcn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Component Guidelines
|
||||
|
||||
1. **shadcn base components** - Use as-is from shadcn/ui (e.g., `card.tsx`)
|
||||
2. **Custom components built on shadcn** - Create in subdirectories (e.g., `resource-stats-card/`)
|
||||
3. **CVA variants** - Use Class Variance Authority for type-safe variants
|
||||
4. **Theme support** - Include `dark:` classes for dark/light theme compatibility
|
||||
5. **TypeScript** - Always export types and use proper typing
|
||||
|
||||
## Resources
|
||||
|
||||
- [shadcn/ui Documentation](https://ui.shadcn.com)
|
||||
- [CVA Documentation](https://cva.style/docs)
|
||||
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ 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-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ 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 CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./card";
|
||||
export {
|
||||
ResourceStatsCard,
|
||||
ResourceStatsCardContainer,
|
||||
type ResourceStatsCardContainerProps,
|
||||
ResourceStatsCardContent,
|
||||
type ResourceStatsCardContentProps,
|
||||
ResourceStatsCardDivider,
|
||||
type ResourceStatsCardDividerProps,
|
||||
ResourceStatsCardHeader,
|
||||
type ResourceStatsCardHeaderProps,
|
||||
type ResourceStatsCardProps,
|
||||
type StatItem,
|
||||
} from "./resource-stats-card";
|
||||
@@ -0,0 +1,13 @@
|
||||
export type { ResourceStatsCardProps } from "./resource-stats-card";
|
||||
export { ResourceStatsCard } from "./resource-stats-card";
|
||||
export type { ResourceStatsCardContainerProps } from "./resource-stats-card-container";
|
||||
export { ResourceStatsCardContainer } from "./resource-stats-card-container";
|
||||
export type {
|
||||
ResourceStatsCardContentProps,
|
||||
StatItem,
|
||||
} from "./resource-stats-card-content";
|
||||
export { ResourceStatsCardContent } from "./resource-stats-card-content";
|
||||
export type { ResourceStatsCardDividerProps } from "./resource-stats-card-divider";
|
||||
export { ResourceStatsCardDivider } from "./resource-stats-card-divider";
|
||||
export type { ResourceStatsCardHeaderProps } from "./resource-stats-card-header";
|
||||
export { ResourceStatsCardHeader } from "./resource-stats-card-header";
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const containerVariants = cva(
|
||||
[
|
||||
"flex",
|
||||
"rounded-[12px]",
|
||||
"border",
|
||||
"backdrop-blur-[46px]",
|
||||
"border-[rgba(38,38,38,0.70)]",
|
||||
"bg-[rgba(23,23,23,0.50)]",
|
||||
"dark:border-[rgba(38,38,38,0.70)]",
|
||||
"dark:bg-[rgba(23,23,23,0.50)]",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
padding: {
|
||||
sm: "px-3 py-2",
|
||||
md: "px-[19px] py-[9px]",
|
||||
lg: "px-6 py-3",
|
||||
none: "p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
padding: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ResourceStatsCardContainerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof containerVariants> {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCardContainer = ({
|
||||
className,
|
||||
children,
|
||||
padding,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardContainerProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(containerVariants({ padding }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCardContainer.displayName = "ResourceStatsCardContainer";
|
||||
@@ -0,0 +1,204 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface StatItem {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const CardVariant = {
|
||||
default: "default",
|
||||
fail: "fail",
|
||||
pass: "pass",
|
||||
warning: "warning",
|
||||
info: "info",
|
||||
} as const;
|
||||
|
||||
export type CardVariant = (typeof CardVariant)[keyof typeof CardVariant];
|
||||
|
||||
const variantColors = {
|
||||
default: "#868994",
|
||||
fail: "#f54280",
|
||||
pass: "#4ade80",
|
||||
warning: "#fbbf24",
|
||||
info: "#60a5fa",
|
||||
} as const;
|
||||
|
||||
type BadgeVariant = keyof typeof variantColors;
|
||||
|
||||
const badgeVariants = cva(
|
||||
["flex", "items-center", "justify-center", "gap-0.5", "rounded-full"],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
[CardVariant.default]: "bg-[#535359]",
|
||||
[CardVariant.fail]: "bg-[#432232]",
|
||||
[CardVariant.pass]: "bg-[#204237]",
|
||||
[CardVariant.warning]: "bg-[#3d3520]",
|
||||
[CardVariant.info]: "bg-[#1e3a5f]",
|
||||
},
|
||||
size: {
|
||||
sm: "px-1 text-xs",
|
||||
md: "px-1.5 text-sm",
|
||||
lg: "px-2 text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: CardVariant.fail,
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const badgeIconVariants = cva("", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-2.5 w-2.5",
|
||||
md: "h-3 w-3",
|
||||
lg: "h-4 w-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
const labelTextVariants = cva(
|
||||
"leading-6 font-semibold text-zinc-300 dark:text-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const statIconVariants = cva("text-zinc-300 dark:text-zinc-300", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-2.5 w-2.5",
|
||||
md: "h-3 w-3",
|
||||
lg: "h-3.5 w-3.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
const statLabelVariants = cva(
|
||||
"leading-5 font-medium text-zinc-300 dark:text-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ResourceStatsCardContentProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
badge: {
|
||||
icon: LucideIcon;
|
||||
count: number | string;
|
||||
variant?: CardVariant;
|
||||
};
|
||||
label: string;
|
||||
stats?: StatItem[];
|
||||
accentColor?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCardContent = ({
|
||||
badge,
|
||||
label,
|
||||
stats = [],
|
||||
accentColor,
|
||||
size = "md",
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardContentProps) => {
|
||||
const BadgeIcon = badge.icon;
|
||||
const badgeVariant: BadgeVariant = badge.variant || "fail";
|
||||
|
||||
// Determine accent line color
|
||||
const lineColor = accentColor || variantColors[badgeVariant] || "#d4d4d8";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-[5px]", className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Badge and Label Row */}
|
||||
<div className="flex w-full items-center gap-1">
|
||||
{/* Badge */}
|
||||
<div className={cn(badgeVariants({ variant: badgeVariant, size }))}>
|
||||
<BadgeIcon
|
||||
className={badgeIconVariants({ size })}
|
||||
strokeWidth={2.5}
|
||||
style={{ color: variantColors[badgeVariant] }}
|
||||
/>
|
||||
<span
|
||||
className="leading-6 font-bold"
|
||||
style={{ color: variantColors[badgeVariant] }}
|
||||
>
|
||||
{badge.count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className={labelTextVariants({ size })}>{label}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
{stats.length > 0 && (
|
||||
<div className="flex w-full items-stretch gap-0">
|
||||
{/* Vertical Accent Line */}
|
||||
<div className="flex items-stretch px-3 py-1">
|
||||
<div
|
||||
className="w-px rounded-full"
|
||||
style={{ backgroundColor: lineColor }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats List */}
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
{stats.map((stat, index) => {
|
||||
const StatIcon = stat.icon;
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<StatIcon
|
||||
className={statIconVariants({ size })}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<span className={statLabelVariants({ size })}>
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCardContent.displayName = "ResourceStatsCardContent";
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const dividerVariants = cva("flex items-center justify-center", {
|
||||
variants: {
|
||||
spacing: {
|
||||
sm: "px-2",
|
||||
md: "px-[23px]",
|
||||
lg: "px-8",
|
||||
},
|
||||
orientation: {
|
||||
vertical: "h-full",
|
||||
horizontal: "w-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
spacing: "md",
|
||||
orientation: "vertical",
|
||||
},
|
||||
});
|
||||
|
||||
const lineVariants = cva("bg-[rgba(39,39,42,1)]", {
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "h-full w-px",
|
||||
horizontal: "w-full h-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ResourceStatsCardDividerProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof dividerVariants> {
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCardDivider = ({
|
||||
className,
|
||||
spacing,
|
||||
orientation,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardDividerProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(dividerVariants({ spacing, orientation }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div className={lineVariants({ orientation })} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCardDivider.displayName = "ResourceStatsCardDivider";
|
||||
@@ -0,0 +1,103 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const headerVariants = cva("flex w-full items-center gap-1", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "",
|
||||
md: "",
|
||||
lg: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
const iconVariants = cva("text-zinc-300 dark:text-zinc-300", {
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-3.5 w-3.5",
|
||||
md: "h-4 w-4",
|
||||
lg: "h-5 w-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
const titleVariants = cva(
|
||||
"leading-7 font-semibold text-zinc-300 dark:text-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-sm",
|
||||
md: "text-base",
|
||||
lg: "text-lg",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const countVariants = cva(
|
||||
"leading-4 font-normal text-zinc-300 dark:text-zinc-300",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "text-[9px]",
|
||||
md: "text-[10px]",
|
||||
lg: "text-xs",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ResourceStatsCardHeaderProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof headerVariants> {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
resourceCount?: number | string;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCardHeader = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
resourceCount,
|
||||
size = "md",
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardHeaderProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(headerVariants({ size }), className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<Icon className={iconVariants({ size })} strokeWidth={2} />
|
||||
<span className={titleVariants({ size })}>{title}</span>
|
||||
</div>
|
||||
{resourceCount !== undefined && (
|
||||
<span className={countVariants({ size })}>
|
||||
{typeof resourceCount === "number"
|
||||
? `${resourceCount} Resources`
|
||||
: resourceCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCardHeader.displayName = "ResourceStatsCardHeader";
|
||||
@@ -0,0 +1,164 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ResourceStatsCardContainer } from "./resource-stats-card-container";
|
||||
import type { StatItem } from "./resource-stats-card-content";
|
||||
import {
|
||||
CardVariant,
|
||||
ResourceStatsCardContent,
|
||||
} from "./resource-stats-card-content";
|
||||
import { ResourceStatsCardHeader } from "./resource-stats-card-header";
|
||||
|
||||
export type { StatItem };
|
||||
|
||||
// Todo: when the design system is ready, we must use the colors from the design system (semantic colors)
|
||||
// Variant styles using CVA for type safety and consistency
|
||||
// Colors are exact HEX values from Figma design system
|
||||
const cardVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
[CardVariant.default]: "",
|
||||
// Fail variant - rgba(67,34,50) from Figma
|
||||
[CardVariant.fail]:
|
||||
"border-[rgba(67,34,50,0.5)] bg-[rgba(67,34,50,0.2)] dark:border-[rgba(67,34,50,0.7)] dark:bg-[rgba(67,34,50,0.3)]",
|
||||
// Pass variant - rgba(32,66,55) from Figma
|
||||
[CardVariant.pass]:
|
||||
"border-[rgba(32,66,55,0.5)] bg-[rgba(32,66,55,0.2)] dark:border-[rgba(32,66,55,0.7)] dark:bg-[rgba(32,66,55,0.3)]",
|
||||
// Warning variant - rgba(61,53,32) from Figma
|
||||
[CardVariant.warning]:
|
||||
"border-[rgba(61,53,32,0.5)] bg-[rgba(61,53,32,0.2)] dark:border-[rgba(61,53,32,0.7)] dark:bg-[rgba(61,53,32,0.3)]",
|
||||
// Info variant - rgba(30,58,95) from Figma
|
||||
[CardVariant.info]:
|
||||
"border-[rgba(30,58,95,0.5)] bg-[rgba(30,58,95,0.2)] dark:border-[rgba(30,58,95,0.7)] dark:bg-[rgba(30,58,95,0.3)]",
|
||||
},
|
||||
size: {
|
||||
sm: "px-2 py-1.5 gap-1",
|
||||
md: "px-3 py-2 gap-2",
|
||||
lg: "px-4 py-3 gap-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: CardVariant.default,
|
||||
size: "md",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ResourceStatsCardProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "color">,
|
||||
VariantProps<typeof cardVariants> {
|
||||
// Optional header (icon + title + resource count)
|
||||
header?: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
resourceCount?: number | string;
|
||||
};
|
||||
|
||||
// Empty state message (when there's no data to display)
|
||||
emptyState?: {
|
||||
message: string;
|
||||
};
|
||||
|
||||
// Main badge (top section) - optional when using empty state
|
||||
badge?: {
|
||||
icon: LucideIcon;
|
||||
count: number | string;
|
||||
variant?: CardVariant;
|
||||
};
|
||||
|
||||
// Main label - optional when using empty state
|
||||
label?: string;
|
||||
|
||||
// Vertical accent line color (optional, auto-determined from variant)
|
||||
accentColor?: string;
|
||||
|
||||
// Sub-statistics array (flexible items)
|
||||
stats?: StatItem[];
|
||||
|
||||
// Render without container (no border, background, padding) - useful for composing multiple cards in a custom container
|
||||
containerless?: boolean;
|
||||
|
||||
// Ref for the root element
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const ResourceStatsCard = ({
|
||||
header,
|
||||
emptyState,
|
||||
badge,
|
||||
label,
|
||||
accentColor,
|
||||
stats = [],
|
||||
variant = CardVariant.default,
|
||||
size = "md",
|
||||
containerless = false,
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ResourceStatsCardProps) => {
|
||||
// Resolve size to ensure it's not null (CVA can return null but we need a defined value)
|
||||
const resolvedSize = size || "md";
|
||||
|
||||
// If containerless, render without outer wrapper
|
||||
if (containerless) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col gap-[5px]", className)}
|
||||
{...props}
|
||||
>
|
||||
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
|
||||
{emptyState ? (
|
||||
<div className="flex h-[51px] w-full flex-col items-center justify-center">
|
||||
<p className="text-center text-sm leading-5 font-medium text-zinc-300 dark:text-zinc-300">
|
||||
{emptyState.message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
badge &&
|
||||
label && (
|
||||
<ResourceStatsCardContent
|
||||
badge={badge}
|
||||
label={label}
|
||||
stats={stats}
|
||||
accentColor={accentColor}
|
||||
size={resolvedSize}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render with container
|
||||
return (
|
||||
<ResourceStatsCardContainer
|
||||
ref={ref}
|
||||
className={cn(cardVariants({ variant, size }), "flex-col", className)}
|
||||
{...props}
|
||||
>
|
||||
{header && <ResourceStatsCardHeader {...header} size={resolvedSize} />}
|
||||
{emptyState ? (
|
||||
<div className="flex h-[51px] w-full flex-col items-center justify-center">
|
||||
<p className="text-center text-sm leading-5 font-medium text-zinc-300 dark:text-zinc-300">
|
||||
{emptyState.message}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
badge &&
|
||||
label && (
|
||||
<ResourceStatsCardContent
|
||||
badge={badge}
|
||||
label={label}
|
||||
stats={stats}
|
||||
accentColor={accentColor}
|
||||
size={resolvedSize}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</ResourceStatsCardContainer>
|
||||
);
|
||||
};
|
||||
|
||||
ResourceStatsCard.displayName = "ResourceStatsCard";
|
||||
@@ -69,11 +69,13 @@ const hideMenuItems = (menuGroups: GroupProps[], labelsToHide: string[]) => {
|
||||
export const Menu = ({ isOpen }: { isOpen: boolean }) => {
|
||||
const pathname = usePathname();
|
||||
const { permissions } = useAuth();
|
||||
const { hasProviders, openMutelistModal } = useUIStore();
|
||||
const { hasProviders, openMutelistModal, requestMutelistModalOpen } =
|
||||
useUIStore();
|
||||
const menuList = getMenuList({
|
||||
pathname,
|
||||
hasProviders,
|
||||
openMutelistModal,
|
||||
requestMutelistModalOpen,
|
||||
});
|
||||
|
||||
const labelsToHide = MENU_HIDE_RULES.filter((rule) =>
|
||||
|
||||
Reference in new issue
Block a user