Merge remote-tracking branch 'origin/master' into refactor/graph-components-kebab-case

This commit is contained in:
alejandrobailo committed 2025-10-22 14:38:23 +02:00
commit 6fb6bebe59
146 files changed
+6936 -8745

No files matched your search

+2 -1
View File
@@ -33,4 +33,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
next-env.d.ts
playwright/.auth
+2
View File
@@ -16,6 +16,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- API key management in user profile [(#8308)](https://github.com/prowler-cloud/prowler/pull/8308)
- Refresh access token error handling [(#8864)](https://github.com/prowler-cloud/prowler/pull/8864)
- Support Common Cloud Controls for AWS, Azure and GCP [(#8000)](https://github.com/prowler-cloud/prowler/pull/8000)
- New M365 credentials certificate authentication method [(#8929)](https://github.com/prowler-cloud/prowler/pull/8929)
### 🔄 Changed
@@ -36,6 +37,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- ThreatScore for each pillar in Prowler ThreatScore specific view [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
- Remove maxTokens model param for GPT-5 models [(#8843)](https://github.com/prowler-cloud/prowler/pull/8843)
- MITRE ATTACK compliance view now shows all requirements in charts [(#8886)](https://github.com/prowler-cloud/prowler/pull/8886)
- Mutelist menu item now doesn't blink [(#8932)](https://github.com/prowler-cloud/prowler/pull/8932)
---
@@ -10,6 +10,7 @@ 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 { getProviderFormType } from "@/lib/provider-helpers";
import { ProviderType } from "@/types/providers";
@@ -28,6 +29,7 @@ export default async function AddCredentialsPage({ searchParams }: Props) {
if (providerType === "gcp") return <SelectViaGCP initialVia={via} />;
if (providerType === "github")
return <SelectViaGitHub initialVia={via} />;
if (providerType === "m365") return <SelectViaM365 initialVia={via} />;
return null;
case "credentials":
+25 -27
View File
@@ -28,31 +28,36 @@ export default async function Providers({
<ContentLayout title="Cloud Providers" icon="lucide:cloud-cog">
<FilterControls search customFilters={filterProviders || []} />
<Spacer y={8} />
<Suspense
key={searchParamsKey}
fallback={
<>
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
<Spacer y={8} />
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
<SkeletonTableProviders />
</div>
</div>
</>
}
>
<ProvidersContent searchParams={resolvedSearchParams} />
<ProvidersActions />
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<ProvidersTableFallback />}>
<ProvidersTable searchParams={resolvedSearchParams} />
</Suspense>
</ContentLayout>
);
}
const ProvidersContent = async ({
const ProvidersActions = () => {
return (
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
);
};
const ProvidersTableFallback = () => {
return (
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
<SkeletonTableProviders />
</div>
</div>
);
};
const ProvidersTable = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
@@ -97,13 +102,6 @@ const ProvidersContent = async ({
return (
<>
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
<Spacer y={8} />
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
<DataTable
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/shadcn",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -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>
@@ -0,0 +1,2 @@
export { M365CertificateCredentialsForm } from "./m365-certificate-credentials-form";
export { M365ClientSecretCredentialsForm } from "./m365-client-secret-credentials-form";
@@ -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>
</>
);
};
@@ -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";
@@ -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>
)}
</>
)}
/>
);
};
@@ -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&apos;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}
/>
</>
);
};
+57
View File
@@ -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)
+92
View File
@@ -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,
};
+21
View File
@@ -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";
+3 -1
View File
@@ -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) =>
+119 -111
View File
@@ -5,7 +5,7 @@
"from": "1.0.59",
"to": "1.0.59",
"strategy": "installed",
"generatedAt": "2025-10-01T11:13:12.025Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -13,7 +13,7 @@
"from": "2.0.59",
"to": "2.0.59",
"strategy": "installed",
"generatedAt": "2025-10-01T11:13:12.025Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -21,15 +21,15 @@
"from": "2.8.4",
"to": "2.8.4",
"strategy": "installed",
"generatedAt": "2025-09-29T14:26:25.838Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "@hookform/resolvers",
"from": "3.10.0",
"from": "5.2.2",
"to": "5.2.2",
"strategy": "installed",
"generatedAt": "2025-10-01T15:09:44.056Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -37,7 +37,7 @@
"from": "0.3.77",
"to": "0.3.77",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -45,23 +45,23 @@
"from": "0.4.9",
"to": "0.4.9",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "@langchain/langgraph-supervisor",
"from": "0.0.12",
"from": "0.0.20",
"to": "0.0.20",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "@langchain/openai",
"from": "0.6.9",
"from": "0.5.18",
"to": "0.5.18",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -69,7 +69,7 @@
"from": "15.3.5",
"to": "15.3.5",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -77,7 +77,7 @@
"from": "1.1.14",
"to": "1.1.14",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -85,7 +85,7 @@
"from": "1.1.14",
"to": "1.1.14",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -93,7 +93,7 @@
"from": "2.1.15",
"to": "2.1.15",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -101,7 +101,7 @@
"from": "1.3.2",
"to": "1.3.2",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -109,7 +109,7 @@
"from": "2.1.7",
"to": "2.1.7",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -117,7 +117,7 @@
"from": "2.2.5",
"to": "2.2.5",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -125,7 +125,7 @@
"from": "1.2.3",
"to": "1.2.3",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -133,7 +133,7 @@
"from": "1.2.14",
"to": "1.2.14",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -141,7 +141,7 @@
"from": "3.9.4",
"to": "3.9.4",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -149,7 +149,7 @@
"from": "3.8.12",
"to": "3.8.12",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -157,7 +157,7 @@
"from": "4.1.13",
"to": "4.1.13",
"strategy": "installed",
"generatedAt": "2025-09-24T15:04:48.761Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -165,7 +165,7 @@
"from": "0.5.16",
"to": "0.5.16",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -173,7 +173,7 @@
"from": "8.21.3",
"to": "8.21.3",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -181,15 +181,15 @@
"from": "4.0.9",
"to": "4.0.9",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "ai",
"from": "4.3.16",
"from": "5.0.59",
"to": "5.0.59",
"strategy": "installed",
"generatedAt": "2025-10-01T10:03:22.788Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -197,7 +197,7 @@
"from": "6.0.2",
"to": "6.0.2",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -205,7 +205,7 @@
"from": "0.7.1",
"to": "0.7.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -213,7 +213,7 @@
"from": "2.1.1",
"to": "2.1.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -221,7 +221,7 @@
"from": "7.9.0",
"to": "7.9.0",
"strategy": "installed",
"generatedAt": "2025-10-16T10:46:11.221Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -229,7 +229,7 @@
"from": "4.1.0",
"to": "4.1.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -237,7 +237,7 @@
"from": "11.18.2",
"to": "11.18.2",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -245,7 +245,7 @@
"from": "10.7.16",
"to": "10.7.16",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -253,7 +253,7 @@
"from": "5.10.0",
"to": "5.10.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -261,7 +261,7 @@
"from": "4.1.0",
"to": "4.1.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -269,7 +269,7 @@
"from": "4.0.0",
"to": "4.0.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -277,7 +277,7 @@
"from": "0.543.0",
"to": "0.543.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -285,15 +285,15 @@
"from": "15.0.12",
"to": "15.0.12",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "next",
"from": "14.2.32",
"from": "15.5.3",
"to": "15.5.3",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -301,7 +301,7 @@
"from": "5.0.0-beta.29",
"to": "5.0.0-beta.29",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -309,7 +309,7 @@
"from": "0.2.1",
"to": "0.2.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -317,23 +317,23 @@
"from": "1.4.2",
"to": "1.4.2",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "react",
"from": "18.3.1",
"from": "19.1.1",
"to": "19.1.1",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "react-dom",
"from": "18.3.1",
"from": "19.1.1",
"to": "19.1.1",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -341,7 +341,7 @@
"from": "7.62.0",
"to": "7.62.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -349,7 +349,7 @@
"from": "10.1.0",
"to": "10.1.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -357,7 +357,7 @@
"from": "2.15.4",
"to": "2.15.4",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -365,7 +365,7 @@
"from": "3.13.0",
"to": "3.13.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -373,15 +373,7 @@
"from": "0.0.1",
"to": "0.0.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
},
{
"section": "dependencies",
"name": "shadcn",
"from": "3.2.1",
"to": "3.2.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -389,7 +381,7 @@
"from": "0.33.5",
"to": "0.33.5",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -397,7 +389,7 @@
"from": "3.3.1",
"to": "3.3.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -405,7 +397,7 @@
"from": "1.0.7",
"to": "1.0.7",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -413,7 +405,15 @@
"from": "3.1.0",
"to": "3.1.0",
"strategy": "installed",
"generatedAt": "2025-10-16T10:46:11.221Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "tw-animate-css",
"from": "1.4.0",
"to": "1.4.0",
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -421,7 +421,7 @@
"from": "11.1.0",
"to": "11.1.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.548Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
@@ -429,23 +429,23 @@
"from": "2.0.2",
"to": "2.0.2",
"strategy": "installed",
"generatedAt": "2025-10-16T10:46:11.221Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "zod",
"from": "3.25.73",
"from": "4.1.11",
"to": "4.1.11",
"strategy": "installed",
"generatedAt": "2025-10-01T09:40:25.207Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "zustand",
"from": "4.5.7",
"from": "5.0.8",
"to": "5.0.8",
"strategy": "installed",
"generatedAt": "2025-10-01T09:40:25.207Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -453,15 +453,15 @@
"from": "5.2.1",
"to": "5.2.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
"name": "@playwright/test",
"from": "1.53.2",
"to": "1.53.2",
"from": "1.56.1",
"to": "1.56.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -469,7 +469,7 @@
"from": "7.4.3",
"to": "7.4.3",
"strategy": "installed",
"generatedAt": "2025-10-16T10:46:11.221Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -477,23 +477,23 @@
"from": "20.5.7",
"to": "20.5.7",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
"name": "@types/react",
"from": "18.3.3",
"from": "19.1.13",
"to": "19.1.13",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
"name": "@types/react-dom",
"from": "18.3.0",
"from": "19.1.9",
"to": "19.1.9",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -501,7 +501,7 @@
"from": "3.1.5",
"to": "3.1.5",
"strategy": "installed",
"generatedAt": "2025-10-16T10:46:11.221Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -509,7 +509,7 @@
"from": "10.0.0",
"to": "10.0.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -517,7 +517,7 @@
"from": "7.18.0",
"to": "7.18.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -525,7 +525,7 @@
"from": "7.18.0",
"to": "7.18.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -533,7 +533,7 @@
"from": "10.4.19",
"to": "10.4.19",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -541,7 +541,7 @@
"from": "19.1.0-rc.3",
"to": "19.1.0-rc.3",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -549,15 +549,15 @@
"from": "8.57.1",
"to": "8.57.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
"name": "eslint-config-next",
"from": "14.2.32",
"from": "15.5.3",
"to": "15.5.3",
"strategy": "installed",
"generatedAt": "2025-09-23T10:22:08.630Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -565,7 +565,7 @@
"from": "10.1.5",
"to": "10.1.5",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -573,7 +573,7 @@
"from": "2.32.0",
"to": "2.32.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -581,7 +581,7 @@
"from": "6.10.2",
"to": "6.10.2",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -589,7 +589,7 @@
"from": "11.1.0",
"to": "11.1.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -597,7 +597,7 @@
"from": "5.5.1",
"to": "5.5.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -605,7 +605,7 @@
"from": "7.37.5",
"to": "7.37.5",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -613,7 +613,7 @@
"from": "4.6.2",
"to": "4.6.2",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -621,7 +621,7 @@
"from": "3.0.1",
"to": "3.0.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -629,7 +629,7 @@
"from": "12.1.1",
"to": "12.1.1",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -637,7 +637,7 @@
"from": "3.2.0",
"to": "3.2.0",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -645,7 +645,7 @@
"from": "9.1.7",
"to": "9.1.7",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -653,7 +653,7 @@
"from": "15.5.2",
"to": "15.5.2",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -661,7 +661,7 @@
"from": "8.4.38",
"to": "8.4.38",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -669,15 +669,23 @@
"from": "3.6.2",
"to": "3.6.2",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
"name": "prettier-plugin-tailwindcss",
"from": "0.6.13",
"from": "0.6.14",
"to": "0.6.14",
"strategy": "installed",
"generatedAt": "2025-09-24T13:59:11.231Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
"name": "shadcn",
"from": "3.4.1",
"to": "3.4.1",
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -685,15 +693,15 @@
"from": "0.1.20",
"to": "0.1.20",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
"name": "tailwindcss",
"from": "3.4.3",
"from": "4.1.13",
"to": "4.1.13",
"strategy": "installed",
"generatedAt": "2025-09-24T13:59:11.231Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "devDependencies",
@@ -701,6 +709,6 @@
"from": "5.5.4",
"to": "5.5.4",
"strategy": "installed",
"generatedAt": "2025-09-10T11:50:17.554Z"
"generatedAt": "2025-10-22T12:36:37.962Z"
}
]
+30 -7
View File
@@ -40,8 +40,8 @@ export const useCredentialsForm = ({
if (providerType === "gcp" && via === "service-account") {
return addCredentialsServiceAccountFormSchema(providerType);
}
// For GitHub, we need to pass the via parameter to determine which fields are required
if (providerType === "github") {
// For GitHub and M365, we need to pass the via parameter to determine which fields are required
if (providerType === "github" || providerType === "m365") {
return addCredentialsFormSchema(providerType, via);
}
return addCredentialsFormSchema(providerType);
@@ -99,13 +99,27 @@ export const useCredentialsForm = ({
[ProviderCredentialFields.TENANT_ID]: "",
};
case "m365":
// M365 credentials based on via parameter
if (via === "app_client_secret") {
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
[ProviderCredentialFields.CLIENT_SECRET]: "",
[ProviderCredentialFields.TENANT_ID]: "",
};
}
if (via === "app_certificate") {
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
[ProviderCredentialFields.CERTIFICATE_CONTENT]: "",
[ProviderCredentialFields.TENANT_ID]: "",
};
}
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
[ProviderCredentialFields.CLIENT_SECRET]: "",
[ProviderCredentialFields.TENANT_ID]: "",
[ProviderCredentialFields.USER]: "",
[ProviderCredentialFields.PASSWORD]: "",
};
case "gcp":
return {
@@ -146,9 +160,14 @@ export const useCredentialsForm = ({
}
};
const defaultValues = getDefaultValues();
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: getDefaultValues(),
defaultValues: defaultValues,
mode: "onSubmit",
reValidateMode: "onChange",
criteriaMode: "all", // Show all errors for each field
});
const { handleServerResponse } = useFormServerErrors(
@@ -169,6 +188,7 @@ export const useCredentialsForm = ({
// Filter out empty values first, then append all remaining values
const filteredValues = filterEmptyValues(values);
Object.entries(filteredValues).forEach(([key, value]) => {
formData.append(key, value);
});
@@ -181,9 +201,12 @@ export const useCredentialsForm = ({
}
};
const { isSubmitting, errors } = form.formState;
return {
form,
isLoading: form.formState.isSubmitting,
isLoading: isSubmitting,
errors,
handleSubmit,
handleBackStep,
searchParamsObj,
+22 -3
View File
@@ -18,6 +18,7 @@ import {
VolumeX,
Warehouse,
} from "lucide-react";
import type { MouseEvent } from "react";
import { ProwlerShort } from "@/components/icons";
import {
@@ -39,12 +40,14 @@ interface MenuListOptions {
pathname: string;
hasProviders?: boolean;
openMutelistModal?: () => void;
requestMutelistModalOpen?: () => void;
}
export const getMenuList = ({
pathname,
hasProviders,
openMutelistModal,
requestMutelistModalOpen,
}: MenuListOptions): GroupProps[] => {
return [
{
@@ -164,12 +167,28 @@ export const getMenuList = ({
submenus: [
{ href: "/providers", label: "Cloud Providers", icon: CloudCog },
{
// Use trailing slash to prevent both menu items from being active at /providers
href: "/providers/",
href: "/providers",
label: "Mutelist",
icon: VolumeX,
disabled: hasProviders === false,
onClick: openMutelistModal,
active: false,
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
if (hasProviders === false) {
event.preventDefault();
event.stopPropagation();
return;
}
requestMutelistModalOpen?.();
if (pathname !== "/providers") {
return;
}
event.preventDefault();
event.stopPropagation();
openMutelistModal?.();
},
},
{ href: "/manage-groups", label: "Provider Groups", icon: Group },
{ href: "/scans", label: "Scan Jobs", icon: Timer },
@@ -80,14 +80,21 @@ export const buildAzureSecret = (formData: FormData) => {
export const buildM365Secret = (formData: FormData) => {
const secret = {
...buildAzureSecret(formData),
[ProviderCredentialFields.USER]: getFormValue(
[ProviderCredentialFields.CLIENT_ID]: getFormValue(
formData,
ProviderCredentialFields.USER,
ProviderCredentialFields.CLIENT_ID,
),
[ProviderCredentialFields.PASSWORD]: getFormValue(
[ProviderCredentialFields.TENANT_ID]: getFormValue(
formData,
ProviderCredentialFields.PASSWORD,
ProviderCredentialFields.TENANT_ID,
),
[ProviderCredentialFields.CLIENT_SECRET]: getFormValue(
formData,
ProviderCredentialFields.CLIENT_SECRET,
),
[ProviderCredentialFields.CERTIFICATE_CONTENT]: getFormValue(
formData,
ProviderCredentialFields.CERTIFICATE_CONTENT,
),
};
return filterEmptyValues(secret);
@@ -29,6 +29,7 @@ export const ProviderCredentialFields = {
TENANT_ID: "tenant_id",
USER: "user",
PASSWORD: "password",
CERTIFICATE_CONTENT: "certificate_content",
// GCP fields
REFRESH_TOKEN: "refresh_token",
@@ -70,6 +71,7 @@ export const ErrorPointers = {
OAUTH_APP_TOKEN: "/data/attributes/secret/oauth_app_token",
GITHUB_APP_ID: "/data/attributes/secret/github_app_id",
GITHUB_APP_KEY: "/data/attributes/secret/github_app_key_content",
CERTIFICATE_CONTENT: "/data/attributes/secret/certificate_content",
} as const;
export type ErrorPointer = (typeof ErrorPointers)[keyof typeof ErrorPointers];
+11 -1
View File
@@ -53,7 +53,7 @@ export const getProviderFormType = (
via?: string,
): ProviderFormType => {
// Providers that need credential type selection
const needsSelector = ["aws", "gcp", "github"].includes(providerType);
const needsSelector = ["aws", "gcp", "github", "m365"].includes(providerType);
// Show selector if no via parameter and provider needs it
if (needsSelector && !via) {
@@ -80,6 +80,14 @@ export const getProviderFormType = (
return "credentials";
}
// M365 credential types
if (
providerType === "m365" &&
["app_client_secret", "app_certificate"].includes(via || "")
) {
return "credentials";
}
// Other providers go directly to credentials form
if (!needsSelector) {
return "credentials";
@@ -99,6 +107,8 @@ export const requiresBackButton = (via?: string | null): boolean => {
"personal_access_token",
"oauth_app",
"github_app",
"app_client_secret",
"app_certificate",
];
return validViaTypes.includes(via);
+390 -18
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -15,10 +15,10 @@
"format:check": "./node_modules/.bin/prettier --check ./app",
"format:write": "./node_modules/.bin/prettier --config .prettierrc.json --write ./app",
"prepare": "husky",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:headed": "playwright test --headed",
"test:e2e": "playwright test --project=chromium",
"test:e2e:ui": "playwright test --project=chromium --ui",
"test:e2e:debug": "playwright test --project=chromium --debug",
"test:e2e:headed": "playwright test --project=chromium --headed",
"test:e2e:report": "playwright show-report",
"test:e2e:install": "playwright install"
},
@@ -70,11 +70,11 @@
"recharts": "2.15.4",
"rss-parser": "3.13.0",
"server-only": "0.0.1",
"shadcn": "3.2.1",
"sharp": "0.33.5",
"tailwind-merge": "3.3.1",
"tailwindcss-animate": "1.0.7",
"topojson-client": "3.1.0",
"tw-animate-css": "1.4.0",
"uuid": "11.1.0",
"world-atlas": "2.0.2",
"zod": "4.1.11",
@@ -82,8 +82,8 @@
},
"devDependencies": {
"@iconify/react": "5.2.1",
"@playwright/test": "1.53.2",
"@types/d3": "7.4.3",
"@playwright/test": "1.56.1",
"@types/node": "20.5.7",
"@types/react": "19.1.13",
"@types/react-dom": "19.1.9",
@@ -110,6 +110,7 @@
"postcss": "8.4.38",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.14",
"shadcn": "3.4.1",
"tailwind-variants": "0.1.20",
"tailwindcss": "4.1.13",
"typescript": "5.5.4"
+66
View File
@@ -20,6 +20,72 @@ export default defineConfig({
},
projects: [
// ===========================================
// Authentication Setup Projects
// ===========================================
// These projects handle user authentication for different permission levels
// Each setup creates authenticated state files that can be reused by test suites
// Admin user authentication setup
// Creates authenticated state for admin users with full system permissions
{
name: "admin.auth.setup",
testMatch: "admin.auth.setup.ts",
},
// Scans management user authentication setup
// Creates authenticated state for users with scan management permissions
{
name: "manage-scans.auth.setup",
testMatch: "manage-scans.auth.setup.ts",
},
// Integrations management user authentication setup
// Creates authenticated state for users with integration management permissions
{
name: "manage-integrations.auth.setup",
testMatch: "manage-integrations.auth.setup.ts",
},
// Account management user authentication setup
// Creates authenticated state for users with account management permissions
{
name: "manage-account.auth.setup",
testMatch: "manage-account.auth.setup.ts",
},
// Cloud providers management user authentication setup
// Creates authenticated state for users with cloud provider management permissions
{
name: "manage-cloud-providers.auth.setup",
testMatch: "manage-cloud-providers.auth.setup.ts",
},
// Unlimited visibility user authentication setup
// Creates authenticated state for users with unlimited visibility permissions
{
name: "unlimited-visibility.auth.setup",
testMatch: "unlimited-visibility.auth.setup.ts",
},
// Invite and manage users authentication setup
// Creates authenticated state for users with user invitation and management permissions
{
name: "invite-and-manage-users.auth.setup",
testMatch: "invite-and-manage-users.auth.setup.ts",
},
// All authentication setups combined
// Runs all authentication setup files to create all user states
{
name: "all.auth.setup",
testMatch: "**/*.auth.setup.ts",
},
// ===========================================
// Test Suite Projects
// ===========================================
// These projects run the actual test suites
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
+11 -1
View File
@@ -5,12 +5,15 @@ interface UIStoreState {
isSideMenuOpen: boolean;
isMutelistModalOpen: boolean;
hasProviders: boolean;
shouldAutoOpenMutelist: boolean;
openSideMenu: () => void;
closeSideMenu: () => void;
openMutelistModal: () => void;
closeMutelistModal: () => void;
setHasProviders: (value: boolean) => void;
requestMutelistModalOpen: () => void;
resetMutelistModalRequest: () => void;
}
export const useUIStore = create<UIStoreState>()(
@@ -19,11 +22,18 @@ export const useUIStore = create<UIStoreState>()(
isSideMenuOpen: false,
isMutelistModalOpen: false,
hasProviders: false,
shouldAutoOpenMutelist: false,
openSideMenu: () => set({ isSideMenuOpen: true }),
closeSideMenu: () => set({ isSideMenuOpen: false }),
openMutelistModal: () => set({ isMutelistModalOpen: true }),
openMutelistModal: () =>
set({
isMutelistModalOpen: true,
shouldAutoOpenMutelist: false,
}),
closeMutelistModal: () => set({ isMutelistModalOpen: false }),
setHasProviders: (value: boolean) => set({ hasProviders: value }),
requestMutelistModalOpen: () => set({ shouldAutoOpenMutelist: true }),
resetMutelistModalRequest: () => set({ shouldAutoOpenMutelist: false }),
}),
{
name: "ui-store",
+24
View File
@@ -1,4 +1,5 @@
import { Page, expect } from "@playwright/test";
import { SignInPage, SignInCredentials } from "./page-objects/sign-in-page";
export const ERROR_MESSAGES = {
INVALID_CREDENTIALS: "Invalid email or password",
@@ -138,6 +139,29 @@ export async function verifyDashboardRoute(page: Page) {
await expect(page).toHaveURL("/");
}
export async function authenticateAndSaveState(
page: Page,
email: string,
password: string,
storagePath: string,
) {
if (!email || !password) {
throw new Error('Email and password are required for authentication and save state');
}
// Create SignInPage instance
const signInPage = new SignInPage(page);
const credentials: SignInCredentials = { email, password };
// Perform authentication steps using Page Object Model
await signInPage.goto();
await signInPage.login(credentials);
await signInPage.verifySuccessfulLogin();
// Save authentication state
await page.context().storageState({ path: storagePath });
}
export async function getSession(page: Page) {
const response = await page.request.get("/api/auth/session");
return response.json();
+125
View File
@@ -0,0 +1,125 @@
import { Page, Locator, expect } from "@playwright/test";
export class HomePage {
readonly page: Page;
// Main content elements
readonly mainContent: Locator;
readonly breadcrumbs: Locator;
readonly overviewHeading: Locator;
// Navigation elements
readonly navigationMenu: Locator;
readonly userMenu: Locator;
readonly signOutButton: Locator;
// Dashboard elements
readonly dashboardCards: Locator;
readonly overviewSection: Locator;
// UI elements
readonly themeToggle: Locator;
readonly logo: Locator;
constructor(page: Page) {
this.page = page;
// Main content elements
this.mainContent = page.locator("main");
this.breadcrumbs = page.getByLabel("Breadcrumbs");
this.overviewHeading = page.getByRole("heading", { name: "Overview", exact: true });
// Navigation elements
this.navigationMenu = page.locator("nav");
this.userMenu = page.getByRole("button", { name: /user menu/i });
this.signOutButton = page.getByRole("button", { name: "Sign out" });
// Dashboard elements
this.dashboardCards = page.locator('[data-testid="dashboard-card"]');
this.overviewSection = page.locator('[data-testid="overview-section"]');
// UI elements
this.themeToggle = page.getByLabel("Toggle theme");
this.logo = page.locator('svg[width="300"]');
}
// Navigation methods
async goto(): Promise<void> {
await this.page.goto("/");
await this.waitForPageLoad();
}
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
}
// Verification methods
async verifyPageLoaded(): Promise<void> {
await expect(this.page).toHaveURL("/");
await expect(this.mainContent).toBeVisible();
await expect(this.overviewHeading).toBeVisible();
await this.waitForPageLoad();
}
async verifyBreadcrumbs(): Promise<void> {
await expect(this.breadcrumbs).toBeVisible();
await expect(this.overviewHeading).toBeVisible();
}
async verifyMainContent(): Promise<void> {
await expect(this.mainContent).toBeVisible();
}
// Navigation methods
async navigateToOverview(): Promise<void> {
await this.overviewHeading.click();
}
async openUserMenu(): Promise<void> {
await this.userMenu.click();
}
async signOut(): Promise<void> {
await this.openUserMenu();
await this.signOutButton.click();
}
// Dashboard methods
async verifyDashboardCards(): Promise<void> {
await expect(this.dashboardCards.first()).toBeVisible();
}
async verifyOverviewSection(): Promise<void> {
await expect(this.overviewSection).toBeVisible();
}
// Utility methods
async refresh(): Promise<void> {
await this.page.reload();
await this.waitForPageLoad();
}
async goBack(): Promise<void> {
await this.page.goBack();
await this.waitForPageLoad();
}
// Accessibility methods
async verifyKeyboardNavigation(): Promise<void> {
// Test tab navigation through main elements
await this.page.keyboard.press("Tab");
await expect(this.themeToggle).toBeFocused();
}
// Wait methods
async waitForRedirect(expectedUrl: string): Promise<void> {
await this.page.waitForURL(expectedUrl);
}
async waitForContentLoad(): Promise<void> {
await this.page.waitForFunction(() => {
const main = document.querySelector("main");
return main && main.offsetHeight > 0;
});
}
}
+316
View File
@@ -0,0 +1,316 @@
import { Page, Locator, expect } from "@playwright/test";
import { HomePage } from "./home-page";
export interface SignInCredentials {
email: string;
password: string;
}
export interface SocialAuthConfig {
googleEnabled: boolean;
githubEnabled: boolean;
}
export class SignInPage {
readonly page: Page;
readonly homePage: HomePage;
// Form elements
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly form: Locator;
// Social authentication buttons
readonly googleButton: Locator;
readonly githubButton: Locator;
readonly samlButton: Locator;
// Navigation elements
readonly signUpLink: Locator;
readonly backButton: Locator;
// UI elements
readonly title: Locator;
readonly logo: Locator;
readonly themeToggle: Locator;
// Error messages
readonly errorMessages: Locator;
readonly loadingIndicator: Locator;
// SAML specific elements
readonly samlModeTitle: Locator;
readonly samlEmailInput: Locator;
constructor(page: Page) {
this.page = page;
this.homePage = new HomePage(page);
// Form elements
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.loginButton = page.getByRole("button", { name: "Log in" });
this.form = page.locator("form");
// Social authentication buttons
this.googleButton = page.getByText("Continue with Google");
this.githubButton = page.getByText("Continue with Github");
this.samlButton = page.getByText("Continue with SAML SSO");
// Navigation elements
this.signUpLink = page.getByRole("link", { name: "Sign up" });
this.backButton = page.getByText("Back");
// UI elements
this.title = page.getByText("Sign in", { exact: true });
this.logo = page.locator('svg[width="300"]');
this.themeToggle = page.getByLabel("Toggle theme");
// Error messages
this.errorMessages = page.locator('[role="alert"], .error-message, [data-testid="error"]');
this.loadingIndicator = page.getByText("Loading");
// SAML specific elements
this.samlModeTitle = page.getByText("Sign in with SAML SSO");
this.samlEmailInput = page.getByLabel("Email");
}
// Navigation methods
async goto(): Promise<void> {
await this.page.goto("/sign-in");
await this.waitForPageLoad();
}
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
}
// Form interaction methods
async fillEmail(email: string): Promise<void> {
await this.emailInput.fill(email);
}
async fillPassword(password: string): Promise<void> {
await this.passwordInput.fill(password);
}
async fillCredentials(credentials: SignInCredentials): Promise<void> {
await this.fillEmail(credentials.email);
await this.fillPassword(credentials.password);
}
async submitForm(): Promise<void> {
await this.loginButton.click();
}
async login(credentials: SignInCredentials): Promise<void> {
await this.fillCredentials(credentials);
await this.submitForm();
}
// Social authentication methods
async clickGoogleAuth(): Promise<void> {
await this.googleButton.click();
}
async clickGithubAuth(): Promise<void> {
await this.githubButton.click();
}
async clickSamlAuth(): Promise<void> {
await this.samlButton.click();
}
// SAML SSO methods
async toggleSamlMode(): Promise<void> {
await this.clickSamlAuth();
}
async goBackFromSaml(): Promise<void> {
await this.backButton.click();
}
async fillSamlEmail(email: string): Promise<void> {
await this.samlEmailInput.fill(email);
}
async submitSamlForm(): Promise<void> {
await this.submitForm();
}
// Navigation methods
async goToSignUp(): Promise<void> {
await this.signUpLink.click();
}
// Validation and assertion methods
async verifyPageLoaded(): Promise<void> {
await expect(this.page).toHaveTitle(/Prowler/);
await expect(this.logo).toBeVisible();
await expect(this.title).toBeVisible();
await this.waitForPageLoad();
}
async verifyFormElements(): Promise<void> {
await expect(this.emailInput).toBeVisible();
await expect(this.passwordInput).toBeVisible();
await expect(this.loginButton).toBeVisible();
}
async verifySocialButtons(config: SocialAuthConfig): Promise<void> {
if (config.googleEnabled) {
await expect(this.googleButton).toBeVisible();
}
if (config.githubEnabled) {
await expect(this.githubButton).toBeVisible();
}
await expect(this.samlButton).toBeVisible();
}
async verifyNavigationLinks(): Promise<void> {
await expect(this.page.getByText("Need to create an account?")).toBeVisible();
await expect(this.signUpLink).toBeVisible();
}
async verifySuccessfulLogin(): Promise<void> {
await this.homePage.verifyPageLoaded();
}
async verifyLoginError(errorMessage: string = "Invalid email or password"): Promise<void> {
await expect(this.page.getByText(errorMessage).first()).toBeVisible();
await expect(this.page).toHaveURL("/sign-in");
}
async verifySamlModeActive(): Promise<void> {
await expect(this.samlModeTitle).toBeVisible();
await expect(this.passwordInput).not.toBeVisible();
await expect(this.backButton).toBeVisible();
}
async verifyNormalModeActive(): Promise<void> {
await expect(this.title).toBeVisible();
await expect(this.passwordInput).toBeVisible();
}
async verifyLoadingState(): Promise<void> {
await expect(this.loginButton).toHaveAttribute("aria-disabled", "true");
await expect(this.loadingIndicator).toBeVisible();
}
async verifyFormValidation(): Promise<void> {
// Check for common validation messages
const emailError = this.page.getByText("Please enter a valid email address.");
const passwordError = this.page.getByText("Password is required.");
// At least one validation error should be visible
await expect(emailError.or(passwordError)).toBeVisible();
}
// Accessibility methods
async verifyKeyboardNavigation(): Promise<void> {
// Test tab navigation through form elements
await this.page.keyboard.press("Tab"); // Theme toggle
await this.page.keyboard.press("Tab"); // Email field
await expect(this.emailInput).toBeFocused();
await this.page.keyboard.press("Tab"); // Password field
await expect(this.passwordInput).toBeFocused();
await this.page.keyboard.press("Tab"); // Show password button
await this.page.keyboard.press("Tab"); // Login button
await expect(this.loginButton).toBeFocused();
}
async verifyAriaLabels(): Promise<void> {
await expect(this.page.getByRole("textbox", { name: "Email" })).toBeVisible();
await expect(this.page.getByRole("textbox", { name: "Password" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Log in" })).toBeVisible();
}
// Utility methods
async clearForm(): Promise<void> {
await this.emailInput.clear();
await this.passwordInput.clear();
}
async isFormValid(): Promise<boolean> {
const emailValue = await this.emailInput.inputValue();
const passwordValue = await this.passwordInput.inputValue();
return emailValue.length > 0 && passwordValue.length > 0;
}
async getFormErrors(): Promise<string[]> {
const errorElements = await this.errorMessages.all();
const errors: string[] = [];
for (const element of errorElements) {
const text = await element.textContent();
if (text) {
errors.push(text.trim());
}
}
return errors;
}
// Browser interaction methods
async refresh(): Promise<void> {
await this.page.reload();
await this.waitForPageLoad();
}
async goBack(): Promise<void> {
await this.page.goBack();
await this.waitForPageLoad();
}
// Session management methods
async logout(): Promise<void> {
await this.homePage.signOut();
}
async verifyLogoutSuccess(): Promise<void> {
await expect(this.page).toHaveURL("/sign-in");
await expect(this.title).toBeVisible();
}
// Advanced interaction methods
async fillFormWithValidation(credentials: SignInCredentials): Promise<void> {
// Fill email first and check for validation
await this.fillEmail(credentials.email);
await this.page.keyboard.press("Tab"); // Trigger validation
// Fill password
await this.fillPassword(credentials.password);
}
async submitFormWithEnterKey(): Promise<void> {
await this.passwordInput.press("Enter");
}
async submitFormWithButtonClick(): Promise<void> {
await this.submitForm();
}
// Error handling methods
async handleSamlError(): Promise<void> {
const samlError = this.page.getByText("SAML Authentication Error");
if (await samlError.isVisible()) {
// Handle SAML error if present
console.log("SAML authentication error detected");
}
}
// Wait methods
async waitForFormSubmission(): Promise<void> {
await this.page.waitForFunction(() => {
const button = document.querySelector('button[aria-disabled="true"]');
return button === null;
});
}
async waitForRedirect(expectedUrl: string): Promise<void> {
await this.page.waitForURL(expectedUrl);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { test as authAdminSetup } from '@playwright/test';
import { authenticateAndSaveState } from '@/tests/helpers';
const adminUserFile = 'playwright/.auth/admin_user.json';
authAdminSetup('authenticate as admin e2e user', async ({ page }) => {
const adminEmail = process.env.E2E_ADMIN_USER;
const adminPassword = process.env.E2E_ADMIN_PASSWORD;
if (!adminEmail || !adminPassword) {
throw new Error('E2E_ADMIN_USER and E2E_ADMIN_PASSWORD environment variables are required');
}
await authenticateAndSaveState(page, adminEmail, adminPassword, adminUserFile);
});
@@ -0,0 +1,15 @@
import { test as authInviteAndManageUsersSetup } from '@playwright/test';
import { authenticateAndSaveState } from '@/tests/helpers';
const inviteAndManageUsersUserFile = 'playwright/.auth/invite_and_manage_users_user.json';
authInviteAndManageUsersSetup('authenticate as invite and manage users e2e user', async ({ page }) => {
const inviteAndManageUsersEmail = process.env.E2E_INVITE_AND_MANAGE_USERS_USER;
const inviteAndManageUsersPassword = process.env.E2E_INVITE_AND_MANAGE_USERS_PASSWORD;
if (!inviteAndManageUsersEmail || !inviteAndManageUsersPassword) {
throw new Error('E2E_INVITE_AND_MANAGE_USERS_USER and E2E_INVITE_AND_MANAGE_USERS_PASSWORD environment variables are required');
}
await authenticateAndSaveState(page, inviteAndManageUsersEmail, inviteAndManageUsersPassword, inviteAndManageUsersUserFile);
});
@@ -0,0 +1,15 @@
import { test as authManageAccountSetup } from '@playwright/test';
import { authenticateAndSaveState } from '@/tests/helpers';
const manageAccountUserFile = 'playwright/.auth/manage_account_user.json';
authManageAccountSetup('authenticate as manage account e2e user', async ({ page }) => {
const accountEmail = process.env.E2E_MANAGE_ACCOUNT_USER;
const accountPassword = process.env.E2E_MANAGE_ACCOUNT_PASSWORD;
if (!accountEmail || !accountPassword) {
throw new Error('E2E_MANAGE_ACCOUNT_USER and E2E_MANAGE_ACCOUNT_PASSWORD environment variables are required');
}
await authenticateAndSaveState(page, accountEmail, accountPassword, manageAccountUserFile);
});
@@ -0,0 +1,15 @@
import { test as authManageCloudProvidersSetup } from '@playwright/test';
import { authenticateAndSaveState } from '@/tests/helpers';
const manageCloudProvidersUserFile = 'playwright/.auth/manage_cloud_providers_user.json';
authManageCloudProvidersSetup('authenticate as manage cloud providers e2e user', async ({ page }) => {
const cloudProvidersEmail = process.env.E2E_MANAGE_CLOUD_PROVIDERS_USER;
const cloudProvidersPassword = process.env.E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD;
if (!cloudProvidersEmail || !cloudProvidersPassword) {
throw new Error('E2E_MANAGE_CLOUD_PROVIDERS_USER and E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD environment variables are required');
}
await authenticateAndSaveState(page, cloudProvidersEmail, cloudProvidersPassword, manageCloudProvidersUserFile);
});
@@ -0,0 +1,15 @@
import { test as authManageIntegrationsSetup } from '@playwright/test';
import { authenticateAndSaveState } from '@/tests/helpers';
const manageIntegrationsUserFile = 'playwright/.auth/manage_integrations_user.json';
authManageIntegrationsSetup('authenticate as integrations e2e user', async ({ page }) => {
const integrationsEmail = process.env.E2E_MANAGE_INTEGRATIONS_USER;
const integrationsPassword = process.env.E2E_MANAGE_INTEGRATIONS_PASSWORD;
if (!integrationsEmail || !integrationsPassword) {
throw new Error('E2E_MANAGE_INTEGRATIONS_USER and E2E_MANAGE_INTEGRATIONS_PASSWORD environment variables are required');
}
await authenticateAndSaveState(page, integrationsEmail, integrationsPassword, manageIntegrationsUserFile);
});
@@ -0,0 +1,15 @@
import { test as authManageScansSetup } from '@playwright/test';
import { authenticateAndSaveState } from '@/tests/helpers';
const manageScansUserFile = 'playwright/.auth/manage_scans_user.json';
authManageScansSetup('authenticate as scans e2e user', async ({ page }) => {
const scansEmail = process.env.E2E_MANAGE_SCANS_USER;
const scansPassword = process.env.E2E_MANAGE_SCANS_PASSWORD;
if (!scansEmail || !scansPassword) {
throw new Error('E2E_MANAGE_SCANS_USER and E2E_MANAGE_SCANS_PASSWORD environment variables are required');
}
await authenticateAndSaveState(page, scansEmail, scansPassword, manageScansUserFile);
});
@@ -0,0 +1,15 @@
import { test as authUnlimitedVisibilitySetup } from '@playwright/test';
import { authenticateAndSaveState } from '@/tests/helpers';
const unlimitedVisibilityUserFile = 'playwright/.auth/unlimited_visibility_user.json';
authUnlimitedVisibilitySetup('authenticate as unlimited visibility e2e user', async ({ page }) => {
const unlimitedVisibilityEmail = process.env.E2E_UNLIMITED_VISIBILITY_USER;
const unlimitedVisibilityPassword = process.env.E2E_UNLIMITED_VISIBILITY_PASSWORD;
if (!unlimitedVisibilityEmail || !unlimitedVisibilityPassword) {
throw new Error('E2E_UNLIMITED_VISIBILITY_USER and E2E_UNLIMITED_VISIBILITY_PASSWORD environment variables are required');
}
await authenticateAndSaveState(page, unlimitedVisibilityEmail, unlimitedVisibilityPassword, unlimitedVisibilityUserFile);
});
+14 -5
View File
@@ -1,5 +1,5 @@
import { LucideIcon } from "lucide-react";
import { SVGProps } from "react";
import { MouseEvent, SVGProps } from "react";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
@@ -21,7 +21,7 @@ export type SubmenuProps = {
active?: boolean;
icon: IconComponent;
disabled?: boolean;
onClick?: () => void;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
};
export type MenuProps = {
@@ -213,15 +213,24 @@ export type AzureCredentials = {
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type M365Credentials = {
export type M365ClientSecretCredentials = {
[ProviderCredentialFields.CLIENT_ID]: string;
[ProviderCredentialFields.CLIENT_SECRET]: string;
[ProviderCredentialFields.TENANT_ID]: string;
[ProviderCredentialFields.USER]?: string;
[ProviderCredentialFields.PASSWORD]?: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type M365CertificateCredentials = {
[ProviderCredentialFields.CLIENT_ID]: string;
[ProviderCredentialFields.CERTIFICATE_CONTENT]: string;
[ProviderCredentialFields.TENANT_ID]: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type M365Credentials =
| M365ClientSecretCredentials
| M365CertificateCredentials;
export type GCPDefaultCredentials = {
client_id: string;
client_secret: string;
+30 -26
View File
@@ -168,12 +168,13 @@ export const addCredentialsFormSchema = (
.min(1, "Client ID is required"),
[ProviderCredentialFields.CLIENT_SECRET]: z
.string()
.min(1, "Client Secret is required"),
.optional(),
[ProviderCredentialFields.CERTIFICATE_CONTENT]: z
.string()
.optional(),
[ProviderCredentialFields.TENANT_ID]: z
.string()
.min(1, "Tenant ID is required"),
[ProviderCredentialFields.USER]: z.string().optional(),
[ProviderCredentialFields.PASSWORD]: z.string().optional(),
}
: providerType === "github"
? {
@@ -194,23 +195,26 @@ export const addCredentialsFormSchema = (
})
.superRefine((data: Record<string, any>, ctx) => {
if (providerType === "m365") {
const hasUser = !!data[ProviderCredentialFields.USER];
const hasPassword = !!data[ProviderCredentialFields.PASSWORD];
if (hasUser && !hasPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "If you provide a user, you must also provide a password",
path: [ProviderCredentialFields.PASSWORD],
});
}
if (hasPassword && !hasUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "If you provide a password, you must also provide a user",
path: [ProviderCredentialFields.USER],
});
// Validate based on the via parameter
if (via === "app_client_secret") {
const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET];
if (!clientSecret || clientSecret.trim() === "") {
ctx.addIssue({
code: "custom",
message: "Client Secret is required",
path: [ProviderCredentialFields.CLIENT_SECRET],
});
}
} else if (via === "app_certificate") {
const certificateContent =
data[ProviderCredentialFields.CERTIFICATE_CONTENT];
if (!certificateContent || certificateContent.trim() === "") {
ctx.addIssue({
code: "custom",
message: "Certificate Content is required",
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
});
}
}
}
@@ -219,7 +223,7 @@ export const addCredentialsFormSchema = (
if (via === "personal_access_token") {
if (!data[ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "Personal Access Token is required",
path: [ProviderCredentialFields.PERSONAL_ACCESS_TOKEN],
});
@@ -227,7 +231,7 @@ export const addCredentialsFormSchema = (
} else if (via === "oauth_app") {
if (!data[ProviderCredentialFields.OAUTH_APP_TOKEN]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "OAuth App Token is required",
path: [ProviderCredentialFields.OAUTH_APP_TOKEN],
});
@@ -235,14 +239,14 @@ export const addCredentialsFormSchema = (
} else if (via === "github_app") {
if (!data[ProviderCredentialFields.GITHUB_APP_ID]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "GitHub App ID is required",
path: [ProviderCredentialFields.GITHUB_APP_ID],
});
}
if (!data[ProviderCredentialFields.GITHUB_APP_KEY]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "GitHub App Private Key is required",
path: [ProviderCredentialFields.GITHUB_APP_KEY],
});
@@ -390,7 +394,7 @@ export const mutedFindingsConfigFormSchema = z.object({
const yamlValidation = validateYaml(val);
if (!yamlValidation.isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: `Invalid YAML format: ${yamlValidation.error}`,
});
return;
@@ -399,7 +403,7 @@ export const mutedFindingsConfigFormSchema = z.object({
const mutelistValidation = validateMutelistYaml(val);
if (!mutelistValidation.isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: `Invalid mutelist structure: ${mutelistValidation.error}`,
});
}