feat(ui): migrate from HeroUI to shadcn/ui (#11532)

This commit is contained in:
Alejandro Bailo
2026-07-08 16:57:02 +02:00
committed by GitHub
parent c665005790
commit 52875b5c7c
519 changed files with 4814 additions and 7013 deletions
+1 -1
View File
@@ -158,7 +158,7 @@ The MCP Server has a small direct-dependency surface and does not yet declare a
The UI uses [pnpm](https://pnpm.io) with supply-chain controls configured in [`ui/pnpm-workspace.yaml`](https://github.com/prowler-cloud/prowler/blob/master/ui/pnpm-workspace.yaml).
- **Minimum release age** (`minimumReleaseAge: 1440`): packages must publish at least 24 hours before install. This reduces exposure during the window when a compromised release has not yet been detected and yanked.
- **Lifecycle script allow-list** (`strictDepBuilds: true` + `allowBuilds`): only explicitly approved packages may run `install` or `postinstall` scripts (currently `sharp`, `esbuild`, `@sentry/cli`, `@heroui/shared-utils`, `unrs-resolver`, `msw`). Any unlisted package with lifecycle scripts fails the install.
- **Lifecycle script allow-list** (`strictDepBuilds: true` + `allowBuilds`): only explicitly approved packages may run `install` or `postinstall` scripts (currently `sharp`, `esbuild`, `@sentry/cli`, `unrs-resolver`, `msw`). Any unlisted package with lifecycle scripts fails the install.
- **Trust policy** (`trustPolicy: no-downgrade`): the install fails when a package's trust evidence drops, for example after a new publisher takes over.
- **Block exotic subdeps** (`blockExoticSubdeps: true`): transitive dependencies cannot ship as git URLs or tarballs. Every package in the tree resolves from the configured registry.
- **Transitive overrides** in [`ui/package.json`](https://github.com/prowler-cloud/prowler/blob/master/ui/package.json) force specific versions for transitive packages (`lodash`, `serialize-javascript`, `qs`, `rollup`, `minimatch`, `ajv`, and others).
+19 -24
View File
@@ -2,11 +2,11 @@
name: prowler-ui
description: >
Prowler UI-specific patterns. For generic patterns, see: typescript, react-19, nextjs-16, tailwind-4.
Trigger: When working inside ui/ on Prowler-specific conventions (shadcn vs HeroUI legacy, folder placement, actions/adapters, shared types/hooks/lib).
Trigger: When working inside ui/ on Prowler-specific conventions (shadcn, folder placement, actions/adapters, shared types/hooks/lib).
license: Apache-2.0
metadata:
author: prowler-cloud
version: "1.0"
version: "1.1"
scope: [root, ui]
auto_invoke:
- "Creating/modifying Prowler UI components"
@@ -32,13 +32,12 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
Next.js 16.2.3 | React 19.2.5 | Tailwind 4.1.18 | shadcn/ui
Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8
NextAuth 5.0.0-beta.30 | Recharts 2.15.4
HeroUI 2.8.4 (LEGACY - do not add new components)
```
## CRITICAL: Component Library Rule
- **ALWAYS**: Use `shadcn/ui` + Tailwind (`components/shadcn/`)
- **NEVER**: Add new HeroUI components (`components/ui/` is legacy only)
- **NEVER**: Add components to `components/ui/` (temporary re-export shims for the prowler-cloud overlay only)
## Design System Discipline (REQUIRED)
@@ -57,12 +56,11 @@ When reviewing UI PRs, flag: custom modals/primitives that duplicate shadcn, cal
### Component Placement
```text
New feature UI? → shadcn/ui + Tailwind
Existing HeroUI feature? → Keep HeroUI (don't mix)
Used 1 feature? → features/{feature}/components/
Used 2+ features? → components/shared/
Needs state/hooks? → "use client"
Server component? → No directive needed
New UI primitive? → components/shadcn/ (shadcn/ui + Tailwind)
Used by 1 domain? → components/{domain}/
Used by 2+ domains? → components/shared/
Needs state/hooks? "use client"
Server component? → No directive needed
```
### Code Location
@@ -76,10 +74,16 @@ Utils (shared 2+) → lib/
Utils (local 1) → {feature}/utils/
Hooks (shared 2+) → hooks/
Hooks (local 1) → {feature}/hooks.ts
shadcn components → components/shadcn/
HeroUI components → components/ui/ (LEGACY)
UI primitive → components/shadcn/
Domain component → components/{domain}/
```
> **Deprecated:** `components/ui/` is a temporary re-export shim that maps
> legacy import paths to `components/shadcn/` for the prowler-cloud overlay.
> HeroUI is fully removed. Never add or import components here — use
> `@/components/shadcn` (primitives) or `@/components/{domain}` instead.
> Delete the shim once the cloud repo migrates to `@/components/shadcn`.
### Styling Decision
```text
@@ -110,8 +114,9 @@ ui/
│ ├── services/
│ └── integrations/
├── components/
│ ├── shadcn/ # shadcn/ui (USE THIS)
│ ├── ui/ # HeroUI (LEGACY)
│ ├── shadcn/ # shadcn/ui primitives (USE THIS)
│ ├── shared/ # Cross-domain composed components (2+ domains)
│ ├── ui/ # DEPRECATED shim → re-exports shadcn (do not use)
│ ├── {domain}/ # Domain-specific (compliance, findings, providers, etc.)
│ ├── filters/ # Filter components
│ ├── graphs/ # Chart components
@@ -324,16 +329,6 @@ Before requesting re-review from a reviewer:
- [ ] If you disagreed: the reply explains why with clear reasoning — do not leave threads silently open
- [ ] Re-request review only after all threads are in a clean state
## Migrations Reference
| From | To | Key Changes |
|------|-----|-------------|
| React 18 | 19.1 | Async components, React Compiler (no useMemo/useCallback) |
| Next.js 14 | 15.5 | Improved App Router, better streaming |
| NextUI | HeroUI 2.8.4 | Package rename only, same API |
| Zod 3 | 4 | `z.email()` not `z.string().email()`, `error` not `message` |
| AI SDK 4 | 5 | `@ai-sdk/react`, `sendMessage` not `handleSubmit`, `parts` not `content` |
## Resources
- **Documentation**: See [references/](references/) for links to local developer guide
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+4 -4
View File
@@ -97,8 +97,8 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
### Component Placement
```text
New/Existing UI? → shadcn/ui + Tailwind (NEVER HeroUI for new code)
Used 1 feature? → features/{feature}/components | Used 2+? → components/{domain}/
New UI primitive? → components/shadcn/ (shadcn/ui + Tailwind)
Used by 1 domain? → components/{domain}/ | Used by 2+ domains? → components/shared/
Needs state/hooks? → "use client" | Server component? → No directive
```
@@ -194,7 +194,7 @@ test("action works", { tag: ["@critical", "@feature"] }, async ({ page }) => {
Next.js 16.2.3 | React 19.2.5 | Tailwind 4.1.18 | shadcn/ui
Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8 | NextAuth 5.0.0-beta.30 | Recharts 2.15.4
> **Note**: HeroUI exists in `components/ui/` as legacy code. Do NOT add new components there.
> **Note**: `components/ui/` only holds temporary re-export shims for the prowler-cloud overlay. Do NOT add new components there.
---
@@ -205,7 +205,7 @@ ui/
├── app/(auth)/ # Auth pages
├── app/(prowler)/ # Main app: compliance, findings, providers, scans
├── components/shadcn/ # shadcn/ui components (USE THIS)
├── components/ui/ # HeroUI (LEGACY - do not add here)
├── components/ui/ # Cloud-overlay re-export shims (do not add here)
├── actions/ # Server actions
├── types/ # Shared types
├── hooks/ # Shared hooks
+3 -4
View File
@@ -114,10 +114,9 @@ pnpm run dev
## Technologies Used
- [Next.js 14](https://nextjs.org/docs/getting-started)
- [NextUI v2](https://nextui.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Tailwind Variants](https://tailwind-variants.org)
- [Next.js 16](https://nextjs.org/docs/getting-started)
- [shadcn/ui](https://ui.shadcn.com/) (built on [Radix UI](https://www.radix-ui.com/))
- [Tailwind CSS 4](https://tailwindcss.com/)
- [TypeScript](https://www.typescriptlang.org/)
- [Framer Motion](https://www.framer.com/motion/)
- [next-themes](https://github.com/pacocoursey/next-themes)
+2 -1
View File
@@ -16,7 +16,8 @@ vi.mock("@/components/auth/oss/auth-layout", () => ({
),
}));
vi.mock("@/components/shadcn", () => ({
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
Button: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
@@ -16,7 +16,8 @@ vi.mock("@/components/auth/oss/auth-layout", () => ({
),
}));
vi.mock("@/components/shadcn", () => ({
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
Button: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
@@ -74,11 +74,11 @@ export function AcceptInvitationClient({
<div className="flex flex-col items-center gap-4">
<Icon
icon="solar:danger-triangle-bold"
className="text-warning"
className="text-text-warning-primary"
width={48}
/>
<h1 className="text-xl font-semibold">Invalid Invitation Link</h1>
<p className="text-default-500">
<p className="text-text-neutral-tertiary">
No invitation token was provided. Please check the link you
received.
</p>
@@ -93,11 +93,11 @@ export function AcceptInvitationClient({
<div className="flex flex-col items-center gap-4">
<Icon
icon="eos-icons:loading"
className="text-default-500"
className="text-text-neutral-tertiary"
width={48}
/>
<h1 className="text-xl font-semibold">Accepting Invitation...</h1>
<p className="text-default-500">
<p className="text-text-neutral-tertiary">
Please wait while we process your invitation.
</p>
</div>
@@ -108,13 +108,13 @@ export function AcceptInvitationClient({
<div className="flex flex-col items-center gap-4">
<Icon
icon="solar:danger-triangle-bold"
className="text-danger"
className="text-text-error-primary"
width={48}
/>
<h1 className="text-xl font-semibold">
Could Not Accept Invitation
</h1>
<p className="text-default-500">{state.message}</p>
<p className="text-text-neutral-tertiary">{state.message}</p>
<div className="flex gap-3">
{state.canRetry && <Button onClick={doAccept}>Retry</Button>}
<Button asChild variant="outline">
@@ -129,14 +129,14 @@ export function AcceptInvitationClient({
<div className="flex flex-col items-center gap-6">
<Icon
icon="solar:letter-bold"
className="text-primary"
className="text-button-primary"
width={48}
/>
<div>
<h1 className="text-xl font-semibold">
You&apos;ve Been Invited
</h1>
<p className="text-default-500 mt-2">
<p className="text-text-neutral-tertiary mt-2">
You&apos;ve been invited to join a tenant on Prowler. How would
you like to continue?
</p>
+4 -3
View File
@@ -6,8 +6,8 @@ import { connection } from "next/server";
import { ReactNode, Suspense } from "react";
import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
import { NavigationProgress, Toaster } from "@/components/ui";
import { fontSans } from "@/config/fonts";
import { NavigationProgress, Toaster } from "@/components/shadcn";
import { fontMono, fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib";
import { readGatedEnv } from "@/lib/integrations";
@@ -56,8 +56,9 @@ export default async function AuthLayout({
<body
suppressHydrationWarning
className={cn(
"bg-background min-h-screen font-sans antialiased",
"bg-bg-neutral-primary min-h-screen font-sans antialiased",
fontSans.variable,
fontMono.variable,
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
@@ -4,7 +4,7 @@ import { getLatestFindings } from "@/actions/findings/findings";
import { LinkToFindings } from "@/components/overview";
import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table";
import { CardTitle } from "@/components/shadcn";
import { DataTable } from "@/components/ui/table";
import { DataTable } from "@/components/shadcn/table";
import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib";
import { createDict } from "@/lib/utils";
import { FindingProps, SearchParamsProps } from "@/types";
@@ -1,7 +1,7 @@
import { Skeleton } from "@heroui/skeleton";
import { Suspense } from "react";
import { SkeletonTableNewFindings } from "@/components/overview/new-findings-table/table";
import { Skeleton } from "@/components/shadcn";
import { SearchParamsProps } from "@/types";
import { GraphsTabsClient } from "./_components/graphs-tabs-client";
@@ -40,7 +40,7 @@ vi.mock(
}),
);
vi.mock("@/components/ui/entities/entity-info", () => ({
vi.mock("@/components/shadcn/entities/entity-info", () => ({
EntityInfo: ({
entityAlias,
entityId,
@@ -61,11 +61,9 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(routerMocks.currentSearch),
}));
vi.mock("@/components/ui", () => ({
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({ toast: toastMock }),
}));
vi.mock("@/components/shadcn", () => ({
Button: ({
asChild,
children,
@@ -22,7 +22,7 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(navigationMocks.currentSearch),
}));
vi.mock("@/components/ui/table/data-table", () => ({
vi.mock("@/components/shadcn/table/data-table", () => ({
DataTable: ({
columns,
data,
@@ -81,7 +81,7 @@ vi.mock("@/components/ui/table/data-table", () => ({
),
}));
vi.mock("@/components/ui/table/data-table-column-header", () => ({
vi.mock("@/components/shadcn/table/data-table-column-header", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
}));
@@ -25,7 +25,8 @@ vi.mock("next/navigation", () => ({
useRouter: () => routerMocks,
}));
vi.mock("@/components/ui", () => ({
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ToastAction: ({
asChild,
children,
@@ -16,8 +16,8 @@ import {
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { DOCS_URLS } from "@/lib/external-urls";
import type { MetaDataProps } from "@/types";
import type { ScanEntity } from "@/types";
@@ -9,9 +9,9 @@ import {
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { DateWithTime } from "@/components/ui/entities";
import { DataTable } from "@/components/ui/table/data-table";
import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header";
import { DateWithTime } from "@/components/shadcn/entities";
import { DataTable } from "@/components/shadcn/table/data-table";
import { DataTableColumnHeader } from "@/components/shadcn/table/data-table-column-header";
import type { MetaDataProps } from "@/types";
interface AlertsTableProps {
@@ -28,8 +28,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { ToastAction, useToast } from "@/components/shadcn";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import { ToastAction, useToast } from "@/components/ui";
import type { ScanEntity } from "@/types";
import type { ProviderProps } from "@/types/providers";
+1 -1
View File
@@ -5,7 +5,7 @@ import { getAllProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions";
import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { createScanDetailsMapping } from "@/lib";
import type { MetaDataProps, ScanEntity, ScanProps } from "@/types";
@@ -128,45 +128,44 @@ export const VerticalSteps = forwardRef<HTMLButtonElement, VerticalStepsProps>(
"[--active-color:var(--step-color)]",
"[--complete-background-color:var(--step-color)]",
"[--complete-border-color:var(--step-color)]",
"[--inactive-border-color:hsl(var(--heroui-default-300))]",
"[--inactive-color:hsl(var(--heroui-default-300))]",
"[--inactive-border-color:var(--border-neutral-tertiary)]",
"[--inactive-color:var(--border-neutral-tertiary)]",
];
switch (color) {
case "primary":
userColor = "[--step-color:hsl(var(--heroui-primary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
break;
case "secondary":
userColor = "[--step-color:hsl(var(--heroui-secondary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-secondary-foreground))]";
userColor =
"[--step-color:var(--color-violet-600)] dark:[--step-color:var(--color-violet-400)]";
fgColor = "[--step-fg-color:var(--color-white)]";
break;
case "success":
userColor = "[--step-color:hsl(var(--heroui-success))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-success-foreground))]";
userColor = "[--step-color:var(--bg-pass-primary)]";
fgColor = "[--step-fg-color:var(--color-black)]";
break;
case "warning":
userColor = "[--step-color:hsl(var(--heroui-warning))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-warning-foreground))]";
userColor = "[--step-color:var(--bg-warning-primary)]";
fgColor = "[--step-fg-color:var(--color-black)]";
break;
case "danger":
userColor = "[--step-color:hsl(var(--heroui-error))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-error-foreground))]";
userColor = "[--step-color:var(--bg-fail-primary)]";
fgColor = "[--step-fg-color:var(--color-white)]";
break;
case "default":
userColor = "[--step-color:hsl(var(--heroui-default))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-default-foreground))]";
userColor =
"[--step-color:var(--color-zinc-300)] dark:[--step-color:var(--color-zinc-600)]";
fgColor = "[--step-fg-color:var(--text-neutral-primary)]";
break;
case "primary":
default:
userColor = "[--step-color:hsl(var(--heroui-primary))]";
fgColor = "[--step-fg-color:hsl(var(--heroui-primary-foreground))]";
userColor = "[--step-color:var(--bg-button-primary)]";
fgColor = "[--step-fg-color:var(--color-black)]";
break;
}
if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor);
if (!className?.includes("--step-color")) colorsVars.unshift(userColor);
if (!className?.includes("--inactive-bar-color"))
colorsVars.push("[--inactive-bar-color:hsl(var(--heroui-default-300))]");
colorsVars.push("[--inactive-bar-color:var(--border-neutral-tertiary)]");
const colors = colorsVars;
@@ -189,7 +188,7 @@ export const VerticalSteps = forwardRef<HTMLButtonElement, VerticalStepsProps>(
ref={ref}
aria-current={status === "active" ? "step" : undefined}
className={cn(
"group rounded-large flex w-full cursor-pointer items-center justify-center gap-4 px-3 py-2.5",
"group flex w-full cursor-pointer items-center justify-center gap-4 rounded-[14px] px-3 py-2.5",
stepClassName,
)}
onClick={() => setCurrentStep(stepIdx)}
@@ -201,7 +200,7 @@ export const VerticalSteps = forwardRef<HTMLButtonElement, VerticalStepsProps>(
<m.div
animate={status}
className={cn(
"border-medium text-large text-default-foreground relative flex h-[34px] w-[34px] items-center justify-center rounded-full font-semibold",
"text-text-neutral-primary relative flex h-[34px] w-[34px] items-center justify-center rounded-full border-2 text-lg font-semibold",
{
"shadow-lg": status === "complete",
},
@@ -242,9 +241,10 @@ export const VerticalSteps = forwardRef<HTMLButtonElement, VerticalStepsProps>(
<div>
<div
className={cn(
"text-medium text-default-foreground font-medium transition-[color,opacity] duration-300 group-active:opacity-70",
"text-text-neutral-primary text-base font-medium transition-[color,opacity] duration-300 group-active:opacity-70",
{
"text-default-500": status === "inactive",
"text-text-neutral-tertiary":
status === "inactive",
},
)}
>
@@ -252,9 +252,10 @@ export const VerticalSteps = forwardRef<HTMLButtonElement, VerticalStepsProps>(
</div>
<div
className={cn(
"text-tiny text-default-600 lg:text-small transition-[color,opacity] duration-300 group-active:opacity-70",
"text-text-neutral-secondary text-xs transition-[color,opacity] duration-300 group-active:opacity-70 lg:text-sm",
{
"text-default-500": status === "inactive",
"text-text-neutral-tertiary":
status === "inactive",
},
)}
>
@@ -38,7 +38,7 @@ export const WorkflowAttackPaths = () => {
style={{ width: `${progressPercentage}%` }}
/>
</div>
<h3 className="dark:text-prowler-theme-pale/90 text-sm font-semibold">
<h3 className="dark:text-text-neutral-primary/90 text-sm font-semibold">
Step {currentStep + 1} of {steps.length}
</h3>
</div>
@@ -7,7 +7,7 @@ import type { GraphNode } from "@/types/attack-paths";
import { NodeDetailPanel } from "./node-detail-panel";
vi.mock("@/components/ui/sheet/sheet", () => ({
vi.mock("@/components/shadcn/sheet/sheet", () => ({
Sheet: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SheetContent: ({ children }: { children: ReactNode }) => (
<div>{children}</div>
@@ -1,14 +1,14 @@
"use client";
import { Button, Card, CardContent } from "@/components/shadcn";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet/sheet";
} from "@/components/shadcn/sheet/sheet";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import type { GraphNode } from "@/types/attack-paths";
import { NodeFindings } from "./node-findings";
@@ -46,7 +46,7 @@ export const NodeDetailContent = ({
{/* Node Overview Section */}
<Card className="border-border-neutral-secondary">
<CardContent className="flex flex-col gap-3 p-4">
<h3 className="dark:text-prowler-theme-pale/90 text-sm font-semibold">
<h3 className="dark:text-text-neutral-primary/90 text-sm font-semibold">
Node Overview
</h3>
<NodeOverview node={node} />
@@ -57,7 +57,7 @@ export const NodeDetailContent = ({
{!isProwlerFinding && (
<Card className="border-border-neutral-secondary">
<CardContent className="flex flex-col gap-3 p-4">
<h3 className="dark:text-prowler-theme-pale/90 text-sm font-semibold">
<h3 className="dark:text-text-neutral-primary/90 text-sm font-semibold">
Related Findings
</h3>
<div className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
@@ -77,7 +77,7 @@ export const NodeDetailContent = ({
{isProwlerFinding && (
<Card className="border-border-neutral-secondary">
<CardContent className="flex flex-col gap-3 p-4">
<h3 className="dark:text-prowler-theme-pale/90 text-sm font-semibold">
<h3 className="dark:text-text-neutral-primary/90 text-sm font-semibold">
Affected Resources
</h3>
<div className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
@@ -112,7 +112,7 @@ export const NodeDetailPanel = ({
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose?.()}>
<SheetContent className="dark:bg-prowler-theme-midnight my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] overflow-y-auto rounded-l-xl pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
<SheetContent className="my-4 max-h-[calc(100vh-2rem)] max-w-[95vw] overflow-y-auto rounded-l-xl pt-10 md:my-8 md:max-h-[calc(100vh-4rem)] md:max-w-[55vw]">
<SheetHeader>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
@@ -2,7 +2,7 @@
import { Button } from "@/components/shadcn";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { SeverityBadge } from "@/components/ui/table/severity-badge";
import { SeverityBadge } from "@/components/shadcn/table/severity-badge";
import type { GraphNode } from "@/types/attack-paths";
const SEVERITY_LEVELS = {
@@ -80,7 +80,7 @@ export const NodeFindings = ({
severity={normalizeSeverity(finding.properties.severity)}
/>
)}
<h5 className="dark:text-prowler-theme-pale/90 text-sm font-medium">
<h5 className="dark:text-text-neutral-primary/90 text-sm font-medium">
{findingName}
</h5>
</div>
@@ -1,8 +1,8 @@
"use client";
import { CodeSnippet } from "@/components/shadcn/code-snippet/code-snippet";
import { DateWithTime } from "@/components/shadcn/entities/date-with-time";
import { InfoField } from "@/components/shadcn/info-field/info-field";
import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
import { formatNodeLabels } from "../../_lib";
@@ -47,7 +47,7 @@ export const NodeOverview = ({ node }: NodeOverviewProps) => {
{/* Display all properties */}
<div className="mt-4 border-t border-gray-200 pt-4 dark:border-gray-700">
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
<h4 className="dark:text-text-neutral-primary/90 mb-3 text-sm font-semibold">
Properties
</h4>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
@@ -3,6 +3,7 @@
import { Badge } from "@/components/shadcn/badge/badge";
import { Button } from "@/components/shadcn/button/button";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { StatusFindingBadge } from "@/components/shadcn/table";
interface Finding {
id: string;
@@ -40,12 +41,6 @@ export const NodeRemediation = ({
}
};
const getStatusVariant = (status: string) => {
if (status === "PASS") return "default";
if (status === "FAIL") return "destructive";
return "secondary";
};
return (
<div className="flex flex-col gap-3">
{findings.map((finding) => (
@@ -55,7 +50,7 @@ export const NodeRemediation = ({
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<h5 className="dark:text-prowler-theme-pale/90 text-sm font-medium">
<h5 className="dark:text-text-neutral-primary/90 text-sm font-medium">
{finding.title}
</h5>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
@@ -66,9 +61,7 @@ export const NodeRemediation = ({
<Badge variant={getSeverityVariant(finding.severity)}>
{finding.severity}
</Badge>
<Badge variant={getStatusVariant(finding.status)}>
{finding.status}
</Badge>
<StatusFindingBadge status={finding.status} />
</div>
</div>
<div className="mt-2">
@@ -63,7 +63,7 @@ export const NodeResources = ({ node, allNodes = [] }: NodeResourcesProps) => {
{resource.labels[0]}
</Badge>
)}
<h5 className="dark:text-prowler-theme-pale/90 text-sm font-medium">
<h5 className="dark:text-text-neutral-primary/90 text-sm font-medium">
{String(resource.properties?.name || resourceId)}
</h5>
</div>
@@ -9,7 +9,7 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
} from "@/components/shadcn/form";
import { cn } from "@/lib/utils";
import {
type AttackPathQuery,
@@ -37,7 +37,7 @@ export const QueryParametersForm = ({
return (
<div className="flex flex-col gap-4">
<h3 className="dark:text-prowler-theme-pale/90 text-sm font-semibold">
<h3 className="dark:text-text-neutral-primary/90 text-sm font-semibold">
Query Parameters
</h3>
@@ -63,7 +63,7 @@ export const QueryParametersForm = ({
}
onChange={(e) => field.onChange(e.target.checked)}
aria-label={param.label}
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
className="border-border-neutral-secondary bg-bg-neutral-primary text-text-primary focus:ring-button-primary dark:border-border-neutral-secondary dark:bg-bg-neutral-primary dark:text-text-primary h-4 w-4 rounded border focus:ring-2"
/>
</FormControl>
<div className="flex flex-col gap-1">
@@ -37,7 +37,7 @@ vi.mock("@/components/shadcn/tooltip", () => ({
),
}));
vi.mock("@/components/ui/entities/entity-info", () => ({
vi.mock("@/components/shadcn/entities/entity-info", () => ({
EntityInfo: ({
entityAlias,
entityId,
@@ -47,11 +47,11 @@ vi.mock("@/components/ui/entities/entity-info", () => ({
}) => <div>{entityAlias ?? entityId}</div>,
}));
vi.mock("@/components/ui/entities/date-with-time", () => ({
vi.mock("@/components/shadcn/entities/date-with-time", () => ({
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
}));
vi.mock("@/components/ui/table", () => ({
vi.mock("@/components/shadcn/table", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
DataTable: ({
columns,
@@ -5,18 +5,18 @@ import { Check, Minus } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useRef } from "react";
import { DateWithTime } from "@/components/shadcn/entities/date-with-time";
import { EntityInfo } from "@/components/shadcn/entities/entity-info";
import {
RadioGroup,
RadioGroupItem,
} from "@/components/shadcn/radio-group/radio-group";
import { DataTable, DataTableColumnHeader } from "@/components/shadcn/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { EntityInfo } from "@/components/ui/entities/entity-info";
import { DataTable, DataTableColumnHeader } from "@/components/ui/table";
import { formatDuration } from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import type { MetaDataProps, ProviderType } from "@/types";
@@ -101,6 +101,31 @@ describe("waiting states", () => {
});
describe("running a query", () => {
test("the query builder surface uses the shared card primitive", async ({
mountWith,
}) => {
const graph = await mountWith();
const card = await graph.waitFor(() => graph.queryBuilderCard, 10000);
expect(card).toHaveAttribute("data-slot", "card");
expect(card).toHaveClass("rounded-xl");
});
test("a parameterized query shows its required inputs after selection", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.parameterizedQuery());
await graph.selectQuery();
expect(graph.containsText(/Query Parameters/i)).toBe(true);
expect(graph.containsText(/Tag key/i)).toBe(true);
expect(graph.getInputByName("tag_key")).toBeTruthy();
expect(graph.containsText(/Tag value/i)).toBe(true);
expect(graph.getInputByName("tag_value")).toBeTruthy();
});
test("the graph renders with a background, a minimap, and a viewport", async ({
mountWith,
}) => {
@@ -242,6 +242,40 @@ export const resourcesOnly = (): PageFixture => ({
},
});
export const parameterizedQuery = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
queries: [
buildQuery(
"aws-internet-exposed-ec2-sensitive-s3-access",
"Internet-Exposed EC2 with Sensitive S3 Access",
{
parameters: [
{
name: "tag_key",
label: "Tag key",
data_type: "string",
description: "Tag key to filter the S3 bucket.",
placeholder: "DataClassification",
},
{
name: "tag_value",
label: "Tag value",
data_type: "string",
description: "Tag value to filter the S3 bucket.",
placeholder: "Sensitive",
},
],
},
),
],
queryId: "aws-internet-exposed-ec2-sensitive-s3-access",
queryResult: {
nodes: [buildResourceNode("s3-1", "S3Bucket", "sensitive-data")],
relationships: [],
},
});
export const disconnected = (): PageFixture => ({
scans: [buildScan(TYPICAL_SCAN_ID)],
scanId: TYPICAL_SCAN_ID,
@@ -322,6 +356,7 @@ export const fixtures = {
singleNode,
findingsOnly,
resourcesOnly,
parameterizedQuery,
disconnected,
large,
edgeCases,
@@ -216,6 +216,22 @@ export class AttackPathPageHarness {
return this.q(AttackPathPageHarness.VIEWPORT_SEL);
}
get queryBuilderCard(): HTMLElement | null {
return (
this.container
.querySelector<HTMLElement>(
'[data-tour-id="attack-paths-query-selector"]',
)
?.closest<HTMLElement>('[data-slot="card"]') ?? null
);
}
getInputByName(name: string): HTMLInputElement | null {
return this.container.querySelector<HTMLInputElement>(
`input[name="${name}"]`,
);
}
/**
* Inline `transform` of the React Flow viewport element. This is the
* pan/zoom matrix React Flow rewrites on every fit/zoom/pan, so comparing
@@ -16,7 +16,7 @@ import { FindingDetailDrawer } from "@/components/findings/table";
import { PageReady } from "@/components/onboarding";
import { useFindingDetails } from "@/components/resources/table/use-finding-details";
import { AutoRefresh } from "@/components/scans";
import { Button } from "@/components/shadcn";
import { Button, Card, useToast } from "@/components/shadcn";
import {
Dialog,
DialogContent,
@@ -26,7 +26,6 @@ import {
DialogTrigger,
} from "@/components/shadcn/dialog";
import { StatusAlert } from "@/components/shared/status-alert";
import { useToast } from "@/components/ui";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { isCloud } from "@/lib/shared/env";
import {
@@ -70,7 +69,7 @@ import {
} from "./_lib/get-attack-paths-view-state";
const SCROLL_CONTAINER_CLASS =
"minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4";
"minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm";
export default function AttackPathsPage() {
const searchParams = useSearchParams();
@@ -427,9 +426,9 @@ export default function AttackPathsPage() {
</div>
{viewState === ATTACK_PATHS_VIEW_STATES.LOADING ? (
<div className={SCROLL_CONTAINER_CLASS}>
<Card variant="base" className={SCROLL_CONTAINER_CLASS}>
<p className="text-sm">Loading scans...</p>
</div>
</Card>
) : viewState === ATTACK_PATHS_VIEW_STATES.NO_SCANS ? (
// Keep the empty-scans tour anchor: attackPathsEmptyTour targets
// data-tour-id="attack-paths-empty-scans-cta". The panel's NO_SCANS
@@ -463,7 +462,7 @@ export default function AttackPathsPage() {
)}
{scanId && (
<div className={SCROLL_CONTAINER_CLASS}>
<Card variant="base" className={SCROLL_CONTAINER_CLASS}>
{queriesLoading ? (
<p className="text-sm">Loading queries...</p>
) : queriesError ? (
@@ -514,14 +513,14 @@ export default function AttackPathsPage() {
)}
</>
)}
</div>
</Card>
)}
{(graphState.loading ||
(graphState.data &&
graphState.data.nodes &&
graphState.data.nodes.length > 0)) && (
<div className={SCROLL_CONTAINER_CLASS}>
<Card variant="base" className={SCROLL_CONTAINER_CLASS}>
{graphState.loading ? (
<GraphLoading />
) : graphState.data &&
@@ -664,7 +663,7 @@ export default function AttackPathsPage() {
</div>
</>
) : null}
</div>
</Card>
)}
{finding.findingDetails && (
+1 -1
View File
@@ -1,4 +1,4 @@
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
export default function AttackPathsLayout({
children,
@@ -1,4 +1,3 @@
import { Spacer } from "@heroui/spacer";
import { Suspense } from "react";
import {
@@ -25,7 +24,7 @@ import {
TopFailedSectionsCardSkeleton,
} from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import {
getReportTypeForCompliance,
@@ -350,7 +349,7 @@ const SSRComplianceContent = async ({
{/* <SectionsFailureRateCard categories={categoryHeatmapData} /> */}
</div>
<Spacer className="bg-border-neutral-primary h-1 w-full rounded-full" />
<div className="bg-border-neutral-primary h-1 w-full rounded-full" />
<ClientAccordionWrapper
hideExpandButton={complianceId.includes("mitre_attack")}
items={accordionItems}
+2 -2
View File
@@ -16,7 +16,7 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import {
ExpandedScanData,
@@ -274,7 +274,7 @@ const ComplianceOverviewPanel = ({
<Card
variant="base"
padding="none"
className="minimal-scrollbar shadow-small relative z-0 w-full gap-4 overflow-auto"
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
>
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
</Card>
@@ -4,9 +4,9 @@ import { ColumnDef } from "@tanstack/react-table";
import { CloudIcon, FolderIcon, ServerIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { DataTable } from "@/components/ui/table/data-table";
import { DataTableExpandAllToggle } from "@/components/ui/table/data-table-expand-all-toggle";
import { DataTableExpandableCell } from "@/components/ui/table/data-table-expandable-cell";
import { DataTable } from "@/components/shadcn/table/data-table";
import { DataTableExpandAllToggle } from "@/components/shadcn/table/data-table-expand-all-toggle";
import { DataTableExpandableCell } from "@/components/shadcn/table/data-table-expandable-cell";
/**
* Demo page for the Expandable DataTable component.
+1 -1
View File
@@ -135,7 +135,7 @@ export default function DemoTreeViewPage() {
<TooltipContent side="top">{item.name}</TooltipContent>
</Tooltip>
{hasChildren && !isLeaf && (
<span className="bg-prowler-white/10 inline-flex min-w-5 shrink-0 items-center justify-center rounded px-1 py-0.5 text-xs tabular-nums">
<span className="inline-flex min-w-5 shrink-0 items-center justify-center rounded bg-white/10 px-1 py-0.5 text-xs tabular-nums">
{item.children?.length}
</span>
)}
+1 -1
View File
@@ -12,7 +12,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/shadcn/card/card";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
export default function Error({
+2 -2
View File
@@ -15,7 +15,7 @@ import {
FindingsGroupTable,
SkeletonTableFindings,
} from "@/components/findings/table";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { FilterTransitionWrapper } from "@/contexts";
import {
applyDefaultMutedFilter,
@@ -166,7 +166,7 @@ const SSRDataTable = async ({
return (
<>
{findingGroupsData?.errors?.length > 0 && (
<div className="text-small mb-4 flex rounded-lg border border-red-500 bg-red-100 p-2 text-red-700">
<div className="mb-4 flex rounded-lg border border-red-500 bg-red-100 p-2 text-sm text-red-700">
<p className="mr-2 font-semibold">Error:</p>
<p>{findingGroupsData.errors[0].detail}</p>
</div>
@@ -4,7 +4,7 @@ import { getIntegrations } from "@/actions/integrations";
import { getAllProviders } from "@/actions/providers";
import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
interface S3IntegrationsProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
@@ -4,7 +4,7 @@ import { getIntegrations } from "@/actions/integrations";
import { getAllProviders } from "@/actions/providers";
import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
interface SecurityHubIntegrationsProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+1 -1
View File
@@ -1,7 +1,7 @@
import { getIntegrations } from "@/actions/integrations";
import { JiraIntegrationsManager } from "@/components/integrations/jira/jira-integrations-manager";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
interface JiraIntegrationsProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+1 -1
View File
@@ -5,7 +5,7 @@ import {
SecurityHubIntegrationCard,
SsoLinkCard,
} from "@/components/integrations";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
export default async function Integrations() {
return (
@@ -1,10 +1,9 @@
import "@/styles/globals.css";
import { Spacer } from "@heroui/spacer";
import React from "react";
import { WorkflowSendInvite } from "@/components/invitations/workflow";
import { NavigationHeader } from "@/components/ui";
import { NavigationHeader } from "@/components/shadcn";
interface InvitationLayoutProps {
children: React.ReactNode;
@@ -18,7 +17,7 @@ export default function InvitationLayout({ children }: InvitationLayoutProps) {
icon="icon-park-outline:close-small"
href="/invitations"
/>
<Spacer y={16} />
<div className="h-16" />
<div className="grid grid-cols-1 gap-8 px-4 lg:grid-cols-12 lg:px-0">
<div className="order-1 my-auto h-full lg:col-span-4 lg:col-start-2">
<WorkflowSendInvite />
+8 -11
View File
@@ -3,16 +3,14 @@ import { Suspense } from "react";
import { getInvitations } from "@/actions/invitations/invitation";
import { getRoles } from "@/actions/roles";
import { FilterControls } from "@/components/filters";
import { filterInvitations } from "@/components/filters/data-filters";
import { AddIcon } from "@/components/icons";
import {
ColumnsInvitation,
SkeletonTableInvitation,
} from "@/components/invitations/table";
import { Button } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { DataTable, DataTableFilterCustom } from "@/components/shadcn/table";
import { InvitationProps, Role, SearchParamsProps } from "@/types";
export default async function Invitations({
@@ -25,17 +23,15 @@ export default async function Invitations({
return (
<ContentLayout title="Invitations" icon="lucide:mail">
<FilterControls search />
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-between">
<DataTableFilterCustom filters={filterInvitations || []} />
<DataTableFilterCustom
filters={filterInvitations || []}
gridClassName="w-fit grid-cols-[14rem_auto] items-center gap-4 sm:grid-cols-[14rem_auto] lg:grid-cols-[14rem_auto] xl:grid-cols-[14rem_auto] 2xl:grid-cols-[14rem_auto]"
/>
<Button asChild>
<Link href="/invitations/new">
Send Invitation
<AddIcon size={20} />
</Link>
<Link href="/invitations/new">Send Invitation</Link>
</Button>
</div>
@@ -124,6 +120,7 @@ const SSRDataTable = async ({
columns={ColumnsInvitation}
data={expandedResponse?.data || []}
metadata={invitationsData?.meta}
showSearch
/>
);
};
+6 -5
View File
@@ -6,16 +6,16 @@ import { ReactNode, Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getScansByState } from "@/actions/scans/scans";
import MainLayout from "@/components/layout/main-layout/main-layout";
import {
OnboardingCheckpointWatcher,
OnboardingGate,
OnboardingSequenceBanner,
} from "@/components/onboarding";
import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
import MainLayout from "@/components/ui/main-layout/main-layout";
import { NavigationProgress } from "@/components/ui/navigation-progress";
import { Toaster } from "@/components/ui/toast";
import { fontSans } from "@/config/fonts";
import { NavigationProgress } from "@/components/shadcn/navigation-progress";
import { Toaster } from "@/components/shadcn/toast";
import { fontMono, fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
@@ -83,8 +83,9 @@ export default async function RootLayout({
<body
suppressHydrationWarning
className={cn(
"bg-background min-h-screen font-sans antialiased",
"bg-bg-neutral-primary min-h-screen font-sans antialiased",
fontSans.variable,
fontMono.variable,
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
@@ -29,7 +29,8 @@ vi.mock("next/navigation", () => ({
// Action feedback is delivered through toasts (rendered by the layout Toaster),
// so we assert the dispatched toast rather than in-page banner text.
vi.mock("@/components/ui", () => ({
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({ toast: toastMock, dismiss: vi.fn() }),
}));
@@ -11,8 +11,8 @@ import {
type LighthouseV2ProviderType,
type LighthouseV2SupportedProvider,
} from "@/app/(prowler)/lighthouse/_types";
import { useToast } from "@/components/shadcn";
import { Card } from "@/components/shadcn/card/card";
import { useToast } from "@/components/ui";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { LighthouseV2BusinessContextForm } from "./business-context-form";
+1 -1
View File
@@ -15,7 +15,7 @@ import { LighthouseV2NavigationModeSync } from "@/app/(prowler)/lighthouse/_comp
import { loadLighthouseV2ConnectedModels } from "@/app/(prowler)/lighthouse/_lib/model-loading";
import { LighthouseIcon } from "@/components/icons/Icons";
import { Chat } from "@/components/lighthouse-v1";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import { isCloud } from "@/lib/shared/env";
@@ -2,7 +2,6 @@
import "@/styles/globals.css";
import { Spacer } from "@heroui/spacer";
import { Icon } from "@iconify/react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
@@ -14,8 +13,8 @@ import {
import { DeleteLLMProviderForm } from "@/components/lighthouse-v1/forms/delete-llm-provider-form";
import { WorkflowConnectLLM } from "@/components/lighthouse-v1/workflow";
import { Button } from "@/components/shadcn";
import { NavigationHeader } from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { NavigationHeader } from "@/components/ui";
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
import type { LighthouseProvider } from "@/types/lighthouse-v1";
@@ -81,7 +80,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
icon="icon-park-outline:close-small"
href={LIGHTHOUSE_ROUTE.SETTINGS}
/>
<Spacer y={8} />
<div className="h-8" />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
<WorkflowConnectLLM />
@@ -114,7 +113,7 @@ export default function ConnectLLMLayout({ children }: ConnectLLMLayoutProps) {
Delete Provider
</Button>
</div>
<Spacer y={4} />
<div className="h-4" />
</>
)}
{children}
@@ -7,7 +7,7 @@ import {
LighthouseSettings,
LLMProvidersTable,
} from "@/components/lighthouse-v1";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
export const dynamic = "force-dynamic";
+1 -1
View File
@@ -2,7 +2,7 @@ import "@/styles/globals.css";
import React from "react";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
interface ProviderLayoutProps {
children: React.ReactNode;
@@ -1,6 +1,5 @@
"use client";
import { Textarea } from "@heroui/input";
import { Trash2 } from "lucide-react";
import { useActionState, useEffect, useState } from "react";
@@ -10,11 +9,18 @@ import {
getMutedFindingsConfig,
updateMutedFindingsConfig,
} from "@/actions/processors";
import { Button, Card, Skeleton } from "@/components/shadcn";
import {
Button,
Card,
FieldError,
Skeleton,
Textarea,
} from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { fontMono } from "@/config/fonts";
import { cn } from "@/lib/utils";
import {
convertToYaml,
defaultMutedFindingsConfig,
@@ -132,6 +138,13 @@ export function AdvancedMutelistForm() {
}
};
const isConfigInvalid =
(!hasUserStartedTyping && !!state?.errors?.configuration) ||
!yamlValidation.isValid;
const configErrorMessage =
(!hasUserStartedTyping && state?.errors?.configuration) ||
(!yamlValidation.isValid ? yamlValidation.error : "");
if (isLoading) {
return (
<Card variant="base" className="p-6">
@@ -159,7 +172,7 @@ export function AdvancedMutelistForm() {
size="md"
>
<div className="flex flex-col gap-4">
<p className="text-default-600 text-sm">
<p className="text-text-neutral-secondary text-sm">
Are you sure you want to delete this configuration? This action
cannot be undone.
</p>
@@ -193,10 +206,10 @@ export function AdvancedMutelistForm() {
<div className="flex flex-col gap-4">
<div>
<h3 className="text-default-700 mb-2 text-lg font-semibold">
<h3 className="text-text-neutral-secondary mb-2 text-lg font-semibold">
Advanced Mutelist Configuration
</h3>
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
<ul className="text-text-neutral-secondary mb-4 list-disc pl-5 text-sm">
<li>
<strong>
This Mutelist configuration will take effect on the next
@@ -227,7 +240,7 @@ export function AdvancedMutelistForm() {
<div className="flex flex-col gap-2">
<label
htmlFor="configuration"
className="text-default-700 text-sm font-medium"
className="text-text-neutral-secondary text-sm font-medium"
>
Mutelist Configuration (YAML)
</label>
@@ -236,29 +249,26 @@ export function AdvancedMutelistForm() {
id="configuration"
name="configuration"
placeholder={defaultMutedFindingsConfig}
variant="bordered"
value={configText}
onChange={(e) => handleConfigChange(e.target.value)}
minRows={20}
maxRows={20}
isInvalid={
(!hasUserStartedTyping && !!state?.errors?.configuration) ||
!yamlValidation.isValid
}
errorMessage={
(!hasUserStartedTyping && state?.errors?.configuration) ||
(!yamlValidation.isValid ? yamlValidation.error : "")
}
classNames={{
input: fontMono.className + " text-sm",
base: "min-h-[400px]",
errorMessage: "whitespace-pre-wrap",
}}
rows={20}
aria-invalid={isConfigInvalid}
className={cn(
fontMono.className,
"min-h-[400px] text-sm",
isConfigInvalid &&
"border-border-error focus:border-border-error focus:ring-border-error",
)}
/>
{isConfigInvalid && configErrorMessage && (
<FieldError className="my-1 px-1 whitespace-pre-wrap">
{configErrorMessage}
</FieldError>
)}
{yamlValidation.isValid &&
configText &&
hasUserStartedTyping && (
<div className="text-tiny text-success my-1 flex items-center px-1">
<div className="text-text-success-primary my-1 flex items-center px-1 text-xs">
<span>Valid YAML format</span>
</div>
)}
@@ -11,13 +11,7 @@ vi.mock("@/actions/mute-rules", () => ({
updateMuteRule: updateMuteRuleMock,
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({
toast: toastMock,
}),
}));
vi.mock("@/components/ui/form", () => ({
vi.mock("@/components/shadcn/form", () => ({
FormButtons: ({
onCancel,
submitText = "Save",
@@ -38,7 +32,9 @@ vi.mock("@/components/ui/form", () => ({
),
}));
vi.mock("@/components/shadcn", () => ({
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({ toast: toastMock }),
Input: ({
defaultValue,
...props
@@ -53,7 +49,7 @@ vi.mock("@/components/shadcn", () => ({
),
}));
vi.mock("@/components/ui/form/Label", () => ({
vi.mock("@/components/shadcn/form/Label", () => ({
Label: ({
children,
...props
@@ -5,8 +5,8 @@ import { FormEvent, useState } from "react";
import { updateMuteRule } from "@/actions/mute-rules";
import { MuteRuleActionState, MuteRuleData } from "@/actions/mute-rules/types";
import { Input, Textarea } from "@/components/shadcn";
import { FormButtons } from "@/components/ui/form";
import { Label } from "@/components/ui/form/Label";
import { FormButtons } from "@/components/shadcn/form";
import { Label } from "@/components/shadcn/form/Label";
import { useMuteRuleAction } from "@/hooks/use-mute-rule-action";
import {
enforceMuteRuleReasonLimit,
@@ -1,11 +1,11 @@
"use client";
import { Switch } from "@heroui/switch";
import { useState } from "react";
import { toggleMuteRule } from "@/actions/mute-rules";
import { MuteRuleData } from "@/actions/mute-rules/types";
import { useToast } from "@/components/ui";
import { Switch } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
interface MuteRuleEnabledToggleProps {
muteRule: MuteRuleData;
@@ -44,10 +44,9 @@ export function MuteRuleEnabledToggle({
return (
<Switch
isSelected={isEnabled}
onValueChange={handleToggle}
isDisabled={isLoading}
size="sm"
checked={isEnabled}
onCheckedChange={handleToggle}
disabled={isLoading}
aria-label={`Toggle mute rule ${muteRule.attributes.name}`}
/>
);
@@ -0,0 +1,84 @@
import { render, screen, within } from "@testing-library/react";
import { type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { type MuteRuleTableData } from "./mute-rule-target-previews";
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
children,
open,
title,
}: {
children: ReactNode;
open: boolean;
title?: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
}));
import { MuteRuleTargetsModal } from "./mute-rule-targets-modal";
const longMuteRule: MuteRuleTableData = {
type: "mute-rules",
id: "mute-rule-1",
attributes: {
inserted_at: "2026-04-22T09:00:00Z",
updated_at: "2026-04-22T09:05:00Z",
name: "Finding triage: Risk Accepted - 019f1d6f-b304-78a6-8a59-9f648f65d123",
reason: "Existing reason",
enabled: true,
finding_uids: ["uid-1"],
},
targetLabels: [
"Security Hub is enabled with standards or integrations configured • hub/unknown",
],
targetSummaryLabel:
"Security Hub is enabled with standards or integrations configured • hub/unknown",
hiddenTargetCount: 0,
};
describe("MuteRuleTargetsModal", () => {
it("keeps long mute rule and finding cards constrained to the modal width", () => {
// Given / When
render(
<MuteRuleTargetsModal
muteRule={longMuteRule}
open
onOpenChange={vi.fn()}
/>,
);
// Then
const dialog = screen.getByRole("dialog", { name: "Muted Findings" });
const content = dialog.firstElementChild;
if (!(content instanceof HTMLElement)) {
throw new Error("Expected modal content wrapper");
}
const muteRuleCard = within(dialog)
.getByText("Mute rule")
.closest("div")?.parentElement;
if (!(muteRuleCard instanceof HTMLElement)) {
throw new Error("Expected mute rule card");
}
const targetList = within(dialog).getByRole("list");
const targetsCard = targetList.parentElement;
if (!(targetsCard instanceof HTMLElement)) {
throw new Error("Expected targets card");
}
expect(content).toHaveClass("min-w-0", "max-w-full");
expect(muteRuleCard).toHaveClass(
"min-w-0",
"max-w-full",
"overflow-hidden",
);
expect(targetsCard).toHaveClass("min-w-0", "max-w-full", "overflow-hidden");
});
});
@@ -29,9 +29,9 @@ export function MuteRuleTargetsModal({
description="Review every finding currently muted by this rule."
size="xl"
>
<div className="flex flex-col gap-5">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex items-start justify-between gap-4 rounded-xl border p-4">
<div className="min-w-0">
<div className="flex max-w-full min-w-0 flex-col gap-5">
<div className="border-border-neutral-secondary bg-bg-neutral-tertiary flex max-w-full min-w-0 items-start justify-between gap-4 overflow-hidden rounded-xl border p-4">
<div className="min-w-0 flex-1">
<p className="text-text-neutral-tertiary text-xs font-medium tracking-[0.08em] uppercase">
Mute rule
</p>
@@ -48,7 +48,7 @@ export function MuteRuleTargetsModal({
</div>
</div>
<div className="minimal-scrollbar border-border-neutral-secondary bg-bg-neutral-tertiary max-h-[60vh] overflow-y-auto rounded-xl border">
<div className="minimal-scrollbar border-border-neutral-secondary bg-bg-neutral-tertiary max-h-[60vh] max-w-full min-w-0 overflow-hidden overflow-y-auto rounded-xl border">
<ul className="divide-border-neutral-secondary divide-y">
{muteRule.targetLabels.map((label, index) => {
const [title, ...metaParts] = label.split(" • ");
@@ -57,7 +57,7 @@ export function MuteRuleTargetsModal({
return (
<li
key={`${muteRule.id}-${label}-${index}`}
className="min-w-0 px-4 py-3"
className="max-w-full min-w-0 px-4 py-3"
>
<p className="text-text-neutral-primary text-sm font-medium break-all">
{title}
@@ -4,11 +4,11 @@ import { describe, expect, it, vi } from "vitest";
import { createMuteRulesColumns } from "./mute-rules-columns";
vi.mock("@/components/ui/entities", () => ({
vi.mock("@/components/shadcn/entities", () => ({
DateWithTime: () => null,
}));
vi.mock("@/components/ui/table", () => ({
vi.mock("@/components/shadcn/table", () => ({
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
}));
@@ -5,8 +5,8 @@ import { List } from "lucide-react";
import { MuteRuleData } from "@/actions/mute-rules/types";
import { Button } from "@/components/shadcn";
import { DateWithTime } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table";
import { DateWithTime } from "@/components/shadcn/entities";
import { DataTableColumnHeader } from "@/components/shadcn/table";
import { MuteRuleEnabledToggle } from "./mute-rule-enabled-toggle";
import { MuteRuleRowActions } from "./mute-rule-row-actions";
@@ -19,13 +19,9 @@ vi.mock("@/actions/mute-rules", () => ({
deleteMuteRule: deleteMuteRuleMock,
}));
vi.mock("@/components/ui", () => ({
useToast: () => ({
toast: toastMock,
}),
}));
vi.mock("@/components/shadcn", () => ({
vi.mock("@/components/shadcn", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useToast: () => ({ toast: toastMock }),
Button: ({
children,
...props
@@ -52,7 +48,7 @@ vi.mock("@/components/shadcn/modal", () => ({
) : null,
}));
vi.mock("@/components/ui/table", () => ({
vi.mock("@/components/shadcn/table", () => ({
DataTable: (props: {
columns: Array<{
id?: string;
@@ -1,6 +1,5 @@
"use client";
import { useDisclosure } from "@heroui/use-disclosure";
import { Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
@@ -8,9 +7,9 @@ import { FormEvent, useState } from "react";
import { deleteMuteRule } from "@/actions/mute-rules";
import { MuteRuleData } from "@/actions/mute-rules/types";
import { CardTitle } from "@/components/shadcn";
import { FormButtons } from "@/components/shadcn/form";
import { Modal } from "@/components/shadcn/modal";
import { FormButtons } from "@/components/ui/form";
import { DataTable } from "@/components/ui/table";
import { DataTable } from "@/components/shadcn/table";
import { useMuteRuleAction } from "@/hooks/use-mute-rule-action";
import { MetaDataProps } from "@/types";
@@ -37,27 +36,27 @@ export function MuteRulesTableClient({
const { isPending: isDeleting, runAction: runDeleteAction } =
useMuteRuleAction();
const editModal = useDisclosure();
const deleteModal = useDisclosure();
const targetsModal = useDisclosure();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isTargetsModalOpen, setIsTargetsModalOpen] = useState(false);
const handleEditClick = (muteRule: MuteRuleData) => {
setSelectedMuteRule(muteRule);
editModal.onOpen();
setIsEditModalOpen(true);
};
const handleDeleteClick = (muteRule: MuteRuleData) => {
setSelectedMuteRule(muteRule);
deleteModal.onOpen();
setIsDeleteModalOpen(true);
};
const handleViewTargets = (muteRule: MuteRuleTableData) => {
setSelectedTargetsRule(muteRule);
targetsModal.onOpen();
setIsTargetsModalOpen(true);
};
const handleEditSuccess = () => {
editModal.onClose();
setIsEditModalOpen(false);
router.refresh();
};
@@ -68,7 +67,7 @@ export function MuteRulesTableClient({
runDeleteAction(() => deleteMuteRule(null, formData), {
onSuccess: () => {
deleteModal.onClose();
setIsDeleteModalOpen(false);
router.refresh();
},
});
@@ -102,15 +101,15 @@ export function MuteRulesTableClient({
<MuteRuleTargetsModal
muteRule={selectedTargetsRule}
open={targetsModal.isOpen}
onOpenChange={targetsModal.onOpenChange}
open={isTargetsModalOpen}
onOpenChange={setIsTargetsModalOpen}
/>
{/* Edit Modal */}
{selectedMuteRule && (
<Modal
open={editModal.isOpen}
onOpenChange={editModal.onOpenChange}
open={isEditModalOpen}
onOpenChange={setIsEditModalOpen}
title="Edit Mute Rule"
description="Update the rule metadata without changing the muted findings linked to it."
size="lg"
@@ -119,7 +118,7 @@ export function MuteRulesTableClient({
key={selectedMuteRule.id}
muteRule={selectedMuteRule}
onSuccess={handleEditSuccess}
onCancel={editModal.onClose}
onCancel={() => setIsEditModalOpen(false)}
/>
</Modal>
)}
@@ -127,8 +126,8 @@ export function MuteRulesTableClient({
{/* Delete Confirmation Modal */}
{selectedMuteRule && (
<Modal
open={deleteModal.isOpen}
onOpenChange={deleteModal.onOpenChange}
open={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
title="Delete Mute Rule"
description="Remove this rule from Mutelist. Existing muted findings will remain muted."
size="md"
@@ -153,7 +152,7 @@ export function MuteRulesTableClient({
</div>
<FormButtons
onCancel={deleteModal.onClose}
onCancel={() => setIsDeleteModalOpen(false)}
submitText={isDeleting ? "Deleting..." : "Delete"}
isDisabled={isDeleting}
submitColor="danger"
@@ -43,8 +43,10 @@ export async function MuteRulesTable({ searchParams }: MuteRulesTableProps) {
</h3>
<p className="text-text-neutral-secondary mt-1 text-sm">
Mute rules are created when you mute findings from the Findings
page. Select findings and click &quot;Mute&quot; to create your
first rule.
page.
<br />
Select findings and click &quot;Mute&quot; to create your first
rule.
</p>
</div>
</div>
@@ -94,7 +96,7 @@ export function MuteRulesTableSkeleton() {
return (
<div
data-testid="mute-rules-table-skeleton"
className="rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col gap-4 overflow-hidden border p-4"
className="border-border-neutral-secondary bg-bg-neutral-secondary flex w-full flex-col gap-4 overflow-hidden rounded-[14px] border p-4 shadow-sm"
>
<div
data-testid="mute-rules-table-skeleton-intro"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Suspense } from "react";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { SearchParamsProps } from "@/types/components";
import { MuteRulesTable, MuteRulesTableSkeleton } from "./_components/simple";
+1 -1
View File
@@ -5,7 +5,7 @@ import { getAllProviders } from "@/actions/providers";
import { getLighthouseV2Configurations } from "@/app/(prowler)/lighthouse/_actions";
import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors";
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
+33
View File
@@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("profile page layout", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
it("uses one large card with profile sections stacked vertically", () => {
expect(source).toContain('aria-label="User profile settings"');
expect(source).toContain('className="w-full gap-4 p-4 md:p-5"');
expect(source).not.toContain("xl:grid-cols");
expect(source).not.toContain('className="flex w-full flex-col gap-6"');
const sectionOrder = [
"<UserBasicInfoCard",
"<RolesCard",
"<SamlIntegrationCard",
"<MembershipsCard",
"<ApiKeysCard",
];
const sectionIndexes = sectionOrder.map((section) =>
source.indexOf(section),
);
expect(sectionIndexes).not.toContain(-1);
expect(sectionIndexes).toEqual([...sectionIndexes].sort((a, b) => a - b));
});
});
+21 -20
View File
@@ -4,7 +4,8 @@ import { getSamlConfig } from "@/actions/integrations/saml";
import { getUserInfo } from "@/actions/users/users";
import { auth } from "@/auth.config";
import { SamlIntegrationCard } from "@/components/integrations/saml/saml-integration-card";
import { ContentLayout } from "@/components/ui";
import { Card } from "@/components/shadcn";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { ApiKeysCard, UserBasicInfoCard } from "@/components/users/profile";
import { MembershipsCard } from "@/components/users/profile/memberships-card";
import { RolesCard } from "@/components/users/profile/roles-card";
@@ -96,25 +97,25 @@ const SSRDataUser = async ({
const samlConfig = hasManageIntegrations ? await getSamlConfig() : undefined;
return (
<div className="flex w-full flex-col gap-6">
<Card
variant="base"
padding="none"
role="region"
aria-label="User profile settings"
className="w-full gap-4 p-4 md:p-5"
>
<UserBasicInfoCard user={userData} tenantId={userTenantId || ""} />
<div className="flex flex-col gap-6 xl:flex-row">
<div className="flex w-full flex-col gap-6 xl:max-w-[50%]">
<RolesCard roles={roleDetails} roleDetails={roleDetailsMap} />
{hasManageIntegrations && (
<SamlIntegrationCard samlConfig={samlConfig?.data?.[0]} />
)}
</div>
<div className="flex w-full flex-col gap-6 xl:max-w-[50%]">
<MembershipsCard
memberships={membershipsIncluded}
tenantsMap={tenantsMap}
hasManageAccount={hasManageAccount}
sessionTenantId={session?.tenantId}
/>
{hasManageAccount && <ApiKeysCard searchParams={searchParams} />}
</div>
</div>
</div>
<RolesCard roles={roleDetails} roleDetails={roleDetailsMap} />
{hasManageIntegrations && (
<SamlIntegrationCard samlConfig={samlConfig?.data?.[0]} />
)}
<MembershipsCard
memberships={membershipsIncluded}
tenantsMap={tenantsMap}
hasManageAccount={hasManageAccount}
sessionTenantId={session?.tenantId}
/>
{hasManageAccount && <ApiKeysCard searchParams={searchParams} />}
</Card>
);
};
+1 -1
View File
@@ -4,8 +4,8 @@ import { listScanConfigurations } from "@/actions/scan-configurations";
import { ProvidersAccountsView } from "@/components/providers";
import { SkeletonTableProviders } from "@/components/providers/table";
import { CliImportBanner } from "@/components/scans";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { ContentLayout } from "@/components/ui";
import { FilterTransitionWrapper } from "@/contexts";
import { SearchParamsProps } from "@/types";
import {
@@ -6,7 +6,7 @@ import { getAllProviders } from "@/actions/providers";
import { getRoles } from "@/actions/roles";
import { AddGroupForm, EditGroupForm } from "@/components/manage-groups/forms";
import { ColumnGroups } from "@/components/manage-groups/table";
import { DataTable } from "@/components/ui/table";
import { DataTable } from "@/components/shadcn/table";
import { ProviderProps, Role, SearchParamsProps } from "@/types";
export const ProviderGroupsContent = async ({
+2 -2
View File
@@ -12,7 +12,7 @@ import {
import { ResourcesFilters } from "@/components/resources/resources-filters";
import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources";
import { ResourcesTableWithSelection } from "@/components/resources/table";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { FilterTransitionWrapper } from "@/contexts";
import {
createDict,
@@ -172,7 +172,7 @@ const SSRDataTable = async ({
return (
<>
{resourcesData?.errors && (
<div className="text-small mb-4 flex rounded-lg border border-red-500 bg-red-100 p-2 text-red-700">
<div className="mb-4 flex rounded-lg border border-red-500 bg-red-100 p-2 text-sm text-red-700">
<p className="mr-2 font-semibold">Error:</p>
<p>{resourcesData.errors[0].detail}</p>
</div>
+2 -3
View File
@@ -1,10 +1,9 @@
import "@/styles/globals.css";
import { Spacer } from "@heroui/spacer";
import React from "react";
import { WorkflowAddEditRole } from "@/components/roles/workflow";
import { NavigationHeader } from "@/components/ui";
import { NavigationHeader } from "@/components/shadcn";
interface RoleLayoutProps {
children: React.ReactNode;
@@ -18,7 +17,7 @@ export default function RoleLayout({ children }: RoleLayoutProps) {
icon="icon-park-outline:close-small"
href="/roles"
/>
<Spacer y={16} />
<div className="h-16" />
<div className="grid grid-cols-1 gap-8 px-4 sm:px-6 lg:grid-cols-12 lg:px-0">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
<WorkflowAddEditRole />
+8 -11
View File
@@ -2,13 +2,11 @@ import Link from "next/link";
import { Suspense } from "react";
import { getRoles } from "@/actions/roles";
import { FilterControls } from "@/components/filters";
import { filterRoles } from "@/components/filters/data-filters";
import { AddIcon } from "@/components/icons";
import { ColumnsRoles, SkeletonTableRoles } from "@/components/roles/table";
import { Button } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { DataTable, DataTableFilterCustom } from "@/components/shadcn/table";
import { SearchParamsProps } from "@/types";
export default async function Roles({
@@ -21,16 +19,14 @@ export default async function Roles({
return (
<ContentLayout title="Roles" icon="lucide:user-cog">
<FilterControls search />
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-between">
<DataTableFilterCustom filters={filterRoles || []} />
<DataTableFilterCustom
filters={filterRoles || []}
gridClassName="w-fit grid-cols-[14rem_auto] items-center gap-4 sm:grid-cols-[14rem_auto] lg:grid-cols-[14rem_auto] xl:grid-cols-[14rem_auto] 2xl:grid-cols-[14rem_auto]"
/>
<Button asChild>
<Link href="/roles/new">
Add Role
<AddIcon size={20} />
</Link>
<Link href="/roles/new">Add Role</Link>
</Button>
</div>
@@ -67,6 +63,7 @@ const SSRDataTable = async ({
columns={ColumnsRoles}
data={rolesData?.data || []}
metadata={rolesData?.meta}
showSearch
/>
);
};
@@ -18,9 +18,9 @@ import {
Input,
Textarea,
} from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { DOCS_URLS } from "@/lib/external-urls";
import {
convertToYaml,
@@ -190,10 +190,10 @@ function ScanConfigurationForm({
<FieldLabel htmlFor="scan-configuration-yaml">
Configuration (YAML)
</FieldLabel>
<ul className="text-default-500 text-tiny mb-1 list-disc pl-5">
<ul className="text-text-neutral-tertiary mb-1 list-disc pl-5 text-xs">
<li>
Follows the structure of{" "}
<code className="bg-default-100 text-default-600 rounded px-1 py-0.5 font-mono">
<code className="bg-bg-neutral-tertiary text-text-neutral-secondary rounded px-1 py-0.5 font-mono">
prowler/config/config.yaml
</code>
; include only the keys you want to override. Learn more{" "}
@@ -225,7 +225,7 @@ function ScanConfigurationForm({
{!yamlSyntax.isValid ? (
<FieldError>{`Invalid YAML format: ${yamlSyntax.error}`}</FieldError>
) : configText.trim() && !configError ? (
<p className="text-tiny text-text-success-primary">
<p className="text-text-success-primary text-xs">
Valid YAML format
</p>
) : null}
@@ -235,7 +235,7 @@ function ScanConfigurationForm({
<Field>
<FieldLabel>Attach to providers (optional)</FieldLabel>
<p className="text-default-500 text-tiny">
<p className="text-text-neutral-tertiary text-xs">
Pick the providers that should use this configuration on their next
scan. You can save it without any and attach providers later it just
won&apos;t apply to a scan until one is attached.
@@ -249,7 +249,7 @@ function ScanConfigurationForm({
)}
</p>
{selectableProviders.length === 0 ? (
<p className="text-default-500 text-tiny italic">
<p className="text-text-neutral-tertiary text-xs italic">
{richProviders.length === 0
? "No providers available in this tenant."
: "All providers are already attached to other Scan Configurations."}
@@ -8,8 +8,8 @@ import {
ActionDropdownDangerZone,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { DateWithTime } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table";
import { DateWithTime } from "@/components/shadcn/entities";
import { DataTableColumnHeader } from "@/components/shadcn/table";
import { ScanConfigurationData } from "@/types/scan-configurations";
export const createScanConfigurationsColumns = (
@@ -11,10 +11,10 @@ import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts
import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout";
import { ClearFiltersButton } from "@/components/filters/clear-filters-button";
import { Button, Card } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { Modal } from "@/components/shadcn/modal";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { DataTable } from "@/components/ui/table";
import { DataTable } from "@/components/shadcn/table";
import { DOCS_URLS } from "@/lib/external-urls";
import { ProviderProps } from "@/types/providers";
import { ScanConfigurationData } from "@/types/scan-configurations";
@@ -199,12 +199,12 @@ export function ScanConfigurationsManager({
{noMatchForProvider ? (
<Card variant="base" padding="xl">
<div className="text-center">
<p className="text-default-700 text-sm font-medium">
<p className="text-text-neutral-secondary text-sm font-medium">
{providerFilter.length === 1
? "No Scan Configuration is attached to this provider."
: "No Scan Configuration is attached to any of the selected providers."}
</p>
<p className="text-default-500 mt-1 text-sm">
<p className="text-text-neutral-tertiary mt-1 text-sm">
The next scan{providerFilter.length === 1 ? "" : "s"} will use the
built-in defaults shipped with Prowler. Attach a Scan
Configuration from the editor to override them.
@@ -241,7 +241,7 @@ export function ScanConfigurationsManager({
size="md"
>
<div className="flex flex-col gap-4">
<p className="text-default-600 text-sm">
<p className="text-text-neutral-secondary text-sm">
Are you sure you want to delete{" "}
<strong>{pendingDelete?.attributes.name}</strong>? Attached
providers will fall back to the built-in scan defaults on their next
+1 -1
View File
@@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getProviders } from "@/actions/providers";
import { listScanConfigurations } from "@/actions/scan-configurations";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
import { ScanConfigurationsManager } from "./_components/scan-configurations-manager";
+1 -1
View File
@@ -24,7 +24,7 @@ import { ScansPageShell } from "@/components/scans/scans-page-shell";
import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state";
import { SkeletonTableScans } from "@/components/scans/table";
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
import {
buildProviderScheduleSummary,
buildSchedulesByProviderId,
+3 -5
View File
@@ -1,7 +1,5 @@
import { Spacer } from "@heroui/spacer";
import { FilterControls } from "@/components/filters";
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
export default async function Services() {
// const searchParamsKey = JSON.stringify(searchParams || {});
@@ -10,9 +8,9 @@ export default async function Services() {
title="Services"
icon="material-symbols:linked-services-outline"
>
<Spacer y={4} />
<div className="h-4" />
<FilterControls />
<Spacer y={4} />
<div className="h-4" />
{/* <Suspense key={searchParamsKey} fallback={<ServiceSkeletonGrid />}>
<SSRServiceGrid />
</Suspense> */}
+4 -10
View File
@@ -4,11 +4,9 @@ import { Suspense } from "react";
import { getRoles } from "@/actions/roles/roles";
import { getCurrentUserTenantRole, getUsers } from "@/actions/users/users";
import { auth } from "@/auth.config";
import { FilterControls } from "@/components/filters";
import { AddIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { ContentLayout } from "@/components/ui";
import { DataTable } from "@/components/ui/table";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { DataTable } from "@/components/shadcn/table";
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
import { Role, SearchParamsProps, UserProps } from "@/types";
import { TENANT_MEMBERSHIP_ROLE } from "@/types/users";
@@ -23,15 +21,10 @@ export default async function Users({
return (
<ContentLayout title="Users" icon="lucide:user">
<FilterControls search />
<div className="flex flex-col gap-6">
<div className="flex flex-row items-end justify-end">
<Button asChild>
<Link href="/invitations/new">
Invite User
<AddIcon size={20} />
</Link>
<Link href="/invitations/new">Invite User</Link>
</Button>
</div>
@@ -121,6 +114,7 @@ const SSRDataTable = async ({
columns={ColumnsUser}
data={expandedUsers || []}
metadata={usersData?.meta}
showSearch
/>
);
};
+1 -1
View File
@@ -1,4 +1,4 @@
import { ContentLayout } from "@/components/ui";
import { ContentLayout } from "@/components/shadcn/content-layout";
export default async function Workloads() {
return (
+3 -9
View File
@@ -1,25 +1,19 @@
"use client";
import { HeroUIProvider } from "@heroui/system";
import { useRouter } from "next/navigation";
import { SessionProvider } from "next-auth/react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ThemeProviderProps } from "next-themes/dist/types";
import * as React from "react";
import { ReactNode } from "react";
export interface ProvidersProps {
children: React.ReactNode;
children: ReactNode;
themeProps?: ThemeProviderProps;
}
export function Providers({ children, themeProps }: ProvidersProps) {
const router = useRouter();
return (
<SessionProvider>
<HeroUIProvider navigate={router.push}>
<NextThemesProvider {...themeProps}>{children}</NextThemesProvider>
</HeroUIProvider>
<NextThemesProvider {...themeProps}>{children}</NextThemesProvider>
</SessionProvider>
);
}
@@ -0,0 +1 @@
UI components migrated from HeroUI to shared shadcn primitives
+32 -71
View File
@@ -1,96 +1,57 @@
"use client";
import type { SwitchProps } from "@heroui/switch";
import { useSwitch } from "@heroui/switch";
import { useIsSSR } from "@react-aria/ssr";
import { VisuallyHidden } from "@react-aria/visually-hidden";
import clsx from "clsx";
import { useTheme } from "next-themes";
import { FC } from "react";
import React from "react";
import { ComponentProps, useSyncExternalStore } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import { MoonFilledIcon, SunFilledIcon } from "./icons";
export interface ThemeSwitchProps {
className?: string;
classNames?: SwitchProps["classNames"];
}
export type ThemeSwitchProps = ComponentProps<"button">;
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
className,
classNames,
}) => {
const emptySubscribe = () => () => {};
export function ThemeSwitch({ className, ...props }: ThemeSwitchProps) {
const { theme, setTheme } = useTheme();
const isSSR = useIsSSR();
// Hydration-safe mounted check: false on the server, true after hydration
const isHydrated = useSyncExternalStore(
emptySubscribe,
() => true,
() => false,
);
const onChange = () => {
theme === "light" ? setTheme("dark") : setTheme("light");
};
const {
Component,
slots,
isSelected,
getBaseProps,
getInputProps,
getWrapperProps,
} = useSwitch({
isSelected: theme === "light" || isSSR,
"aria-label": `Switch to ${theme === "light" || isSSR ? "dark" : "light"} mode`,
onChange,
});
const isLightMode = theme === "light" || !isHydrated;
return (
<Tooltip>
<TooltipTrigger asChild>
<Component
{...getBaseProps({
className: clsx(
"px-px transition-opacity hover:opacity-80 cursor-pointer",
className,
classNames?.base,
),
})}
<button
type="button"
{...props}
role="switch"
aria-checked={isLightMode}
aria-label={`Switch to ${isLightMode ? "dark" : "light"} mode`}
onClick={() => setTheme(isLightMode ? "dark" : "light")}
className={cn(
"text-neutral-tertiary flex cursor-pointer items-center justify-center rounded-lg px-px pt-px transition-opacity hover:opacity-80",
className,
)}
>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: clsx(
[
"h-auto w-auto",
"bg-transparent",
"rounded-lg",
"flex items-center justify-center",
"group-data-[selected=true]:bg-transparent",
"!text-default-500",
"pt-px",
"px-0",
"mx-0",
],
classNames?.wrapper,
),
})}
>
{!isSelected || isSSR ? (
<SunFilledIcon size={22} />
) : (
<MoonFilledIcon size={22} />
)}
</div>
</Component>
{isLightMode && isHydrated ? (
<MoonFilledIcon size={22} />
) : (
<SunFilledIcon size={22} />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{isSelected || isSSR ? "Switch to Dark Mode" : "Switch to Light Mode"}
{isLightMode ? "Switch to Dark Mode" : "Switch to Light Mode"}
</TooltipContent>
</Tooltip>
);
};
}
+4 -4
View File
@@ -1,11 +1,11 @@
import { Divider } from "@heroui/divider";
import { Separator } from "@/components/shadcn";
export const AuthDivider = () => {
return (
<div className="flex items-center gap-4 py-2">
<Divider className="flex-1" />
<p className="text-tiny text-default-500 shrink-0">OR</p>
<Divider className="flex-1" />
<Separator className="flex-1" />
<p className="text-text-neutral-tertiary shrink-0 text-xs">OR</p>
<Separator className="flex-1" />
</div>
);
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { CustomLink } from "@/components/ui/custom/custom-link";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
interface AuthFooterLinkProps {
text: string;
@@ -12,7 +12,7 @@ export const AuthFooterLink = ({
href,
}: AuthFooterLinkProps) => {
return (
<p className="text-small text-center">
<p className="text-center text-sm">
{text}&nbsp;
<CustomLink size="md" href={href} target="_self">
{linkText}
+1 -1
View File
@@ -27,7 +27,7 @@ export const AuthLayout = ({ title, children }: AuthLayoutProps) => {
</div>
{/* Auth Form Container */}
<div className="rounded-large border-divider shadow-small dark:bg-background/85 relative z-10 flex w-full max-w-sm flex-col gap-4 border bg-white/90 px-8 py-10 md:max-w-md">
<div className="border-border-neutral-secondary dark:bg-bg-neutral-primary/85 relative z-10 flex w-full max-w-sm flex-col gap-4 rounded-[14px] border bg-white/90 px-8 py-10 shadow-sm md:max-w-md">
{/* Header with Title and Theme Toggle */}
<div className="flex items-center justify-between">
<p className="pb-2 text-xl font-medium">{title}</p>
+6 -4
View File
@@ -13,10 +13,11 @@ import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { SocialButtons } from "@/components/auth/oss/social-buttons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { Form } from "@/components/shadcn/form";
import { getSafeCallbackPath } from "@/lib/auth-callback-url";
import { stripPasswordManagerHighlight } from "@/lib/password-manager";
import { SignInFormData, signInSchema } from "@/types";
export const SignInForm = ({
@@ -147,6 +148,7 @@ export const SignInForm = ({
<AuthLayout title={title}>
<Form {...form}>
<form
ref={stripPasswordManagerHighlight}
noValidate
method="post"
className="flex flex-col gap-4"
@@ -196,7 +198,7 @@ export const SignInForm = ({
>
{!isSamlMode && (
<Icon
className="text-default-500"
className="text-text-neutral-tertiary"
icon="mdi:shield-key"
width={24}
/>
+22 -16
View File
@@ -1,6 +1,5 @@
"use client";
import { Checkbox } from "@heroui/checkbox";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm, useWatch } from "react-hook-form";
@@ -16,16 +15,17 @@ import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link";
import { AuthLayout } from "@/components/auth/oss/auth-layout";
import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator";
import { SocialButtons } from "@/components/auth/oss/social-buttons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { Button, Checkbox } from "@/components/shadcn";
import { useToast } from "@/components/shadcn";
import { CustomInput } from "@/components/shadcn/custom";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import {
Form,
FormControl,
FormField,
FormMessage,
} from "@/components/ui/form";
} from "@/components/shadcn/form";
import { stripPasswordManagerHighlight } from "@/lib/password-manager";
import { ApiError, SignUpFormData, signUpSchema } from "@/types";
const AUTH_ERROR_PATHS = {
@@ -164,6 +164,7 @@ export const SignUpForm = ({
<AuthLayout title="Sign up">
<Form {...form}>
<form
ref={stripPasswordManagerHighlight}
noValidate
method="post"
className="flex flex-col gap-4"
@@ -217,14 +218,18 @@ export const SignUpForm = ({
name="termsAndConditions"
render={({ field }) => (
<>
<FormControl>
<Checkbox
isRequired
className="py-4"
size="sm"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
color="default"
<div className="flex items-center gap-2 py-4">
<FormControl>
<Checkbox
id="termsAndConditions"
size="sm"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<label
htmlFor="termsAndConditions"
className="cursor-pointer text-sm"
>
I agree with the&nbsp;
<CustomLink
@@ -234,8 +239,9 @@ export const SignUpForm = ({
Terms of Service
</CustomLink>
&nbsp;of Prowler
</Checkbox>
</FormControl>
<span className="text-text-error-primary">*</span>
</label>
</div>
<FormMessage className="text-text-error" />
</>
)}
+2 -2
View File
@@ -7,7 +7,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
import { appendCallbackState } from "@/lib/auth-callback-url";
type SocialProvider = {
@@ -73,7 +73,7 @@ const SocialButton = ({
{provider.isOAuthEnabled ? (
disabledTooltipContent
) : (
<div className="flex-inline text-small">
<div className="flex-inline text-sm">
{provider.disabledDocs.message}{" "}
<CustomLink href={provider.disabledDocs.href}>
Read the docs
@@ -12,8 +12,8 @@ import {
SkeletonTableFindings,
} from "@/components/findings/table";
import { Alert, AlertDescription, Button } from "@/components/shadcn";
import { Accordion } from "@/components/ui/accordion/Accordion";
import { DataTable } from "@/components/ui/table";
import { Accordion } from "@/components/shadcn/accordion/Accordion";
import { DataTable } from "@/components/shadcn/table";
import { FINDINGS_DEFAULT_SORT, MUTED_FILTER } from "@/lib";
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
@@ -122,7 +122,7 @@ export const ClientAccordionContent = ({
key: "checks",
title: (
<div className="flex items-center gap-2">
<span className="text-primary">{checks.length}</span>
<span className="text-button-primary">{checks.length}</span>
{checks.length > 1 ? <span>Checks</span> : <span>Check</span>}
</div>
),
@@ -197,7 +197,7 @@ export const ClientAccordionContent = ({
items={accordionChecksItems}
variant="light"
defaultExpandedKeys={[""]}
className="dark:bg-prowler-blue-400 rounded-lg bg-gray-50"
className="dark:bg-bg-neutral-secondary rounded-lg bg-gray-50"
/>
</div>
)}
@@ -3,7 +3,7 @@
import { useRef, useState } from "react";
import { Button } from "@/components/shadcn";
import { Accordion, AccordionItemProps } from "@/components/ui";
import { Accordion, AccordionItemProps } from "@/components/shadcn";
export const ClientAccordionWrapper = ({
items,
@@ -71,7 +71,7 @@ export const ClientAccordionWrapper = ({
lastScrolledKeyRef.current = scrollToKey;
// Two nested rAFs: the first lets the accordion children commit to
// the DOM, the second lands after the browser has run a layout pass
// so HeroUI's framer-motion expand has settled enough for
// so the Collapsible CSS expand animation has settled enough for
// scrollIntoView to read a stable offset.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
@@ -1,5 +1,5 @@
import { InfoTooltip } from "@/components/shadcn/info-field/info-field";
import { FindingStatus, StatusFindingBadge } from "@/components/ui/table";
import { FindingStatus, StatusFindingBadge } from "@/components/shadcn/table";
import { INVALID_CONFIG_NOTE } from "@/lib/compliance/commons";
interface ComplianceAccordionRequirementTitleProps {
@@ -19,7 +19,7 @@ export const ComplianceAccordionRequirementTitle = ({
<div className="flex w-full items-center justify-between gap-2">
<div className="flex w-5/6 items-center gap-2">
{type && (
<span className="bg-primary/10 text-primary rounded-md px-2 py-0.5 text-xs font-medium">
<span className="bg-button-primary/10 text-button-primary rounded-md px-2 py-0.5 text-xs font-medium">
{type}
</span>
)}
@@ -1,4 +1,4 @@
import { Tooltip } from "@heroui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/shadcn";
interface ComplianceAccordionTitleProps {
label: string;
@@ -43,72 +43,63 @@ export const ComplianceAccordionTitle = ({
{total > 0 ? (
<div className="flex w-full">
{pass > 0 && (
<Tooltip
content={
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-block h-full bg-[#3CEC6D] transition-all duration-200 hover:brightness-110"
style={{
width: `${passPercentage}%`,
marginRight: pass > 0 ? "2px" : "0",
}}
/>
</TooltipTrigger>
<TooltipContent side="top">
<div className="px-1 py-0.5">
<div className="text-xs font-medium">Pass</div>
<div className="text-tiny text-default-400">
<div className="text-text-neutral-tertiary text-xs">
{pass} ({passPercentage.toFixed(1)}%)
</div>
</div>
}
size="sm"
placement="top"
delay={0}
closeDelay={0}
>
<span
className="inline-block h-full bg-[#3CEC6D] transition-all duration-200 hover:brightness-110"
style={{
width: `${passPercentage}%`,
marginRight: pass > 0 ? "2px" : "0",
}}
/>
</TooltipContent>
</Tooltip>
)}
{fail > 0 && (
<Tooltip
content={
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-block h-full bg-[#FB718F] transition-all duration-200 hover:brightness-110"
style={{
width: `${failPercentage}%`,
marginRight: manual > 0 ? "2px" : "0",
}}
/>
</TooltipTrigger>
<TooltipContent side="top">
<div className="px-1 py-0.5">
<div className="text-xs font-medium">Fail</div>
<div className="text-tiny text-default-400">
<div className="text-text-neutral-tertiary text-xs">
{fail} ({failPercentage.toFixed(1)}%)
</div>
</div>
}
size="sm"
placement="top"
delay={0}
closeDelay={0}
>
<span
className="inline-block h-full bg-[#FB718F] transition-all duration-200 hover:brightness-110"
style={{
width: `${failPercentage}%`,
marginRight: manual > 0 ? "2px" : "0",
}}
/>
</TooltipContent>
</Tooltip>
)}
{manual > 0 && (
<Tooltip
content={
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-block h-full bg-[#868994] transition-all duration-200 hover:brightness-110"
style={{ width: `${manualPercentage}%` }}
/>
</TooltipTrigger>
<TooltipContent side="top">
<div className="px-1 py-0.5">
<div className="text-xs font-medium">Manual</div>
<div className="text-tiny text-default-400">
<div className="text-text-neutral-tertiary text-xs">
{manual} ({manualPercentage.toFixed(1)}%)
</div>
</div>
}
size="sm"
placement="top"
delay={0}
closeDelay={0}
>
<span
className="inline-block h-full bg-[#868994] transition-all duration-200 hover:brightness-110"
style={{ width: `${manualPercentage}%` }}
/>
</TooltipContent>
</Tooltip>
)}
</div>
@@ -117,19 +108,18 @@ export const ComplianceAccordionTitle = ({
)}
</div>
<Tooltip
content={
<Tooltip>
<TooltipTrigger asChild>
<span className="text-text-neutral-secondary min-w-[32px] text-center text-xs font-medium">
{total > 0 ? total : "—"}
</span>
</TooltipTrigger>
<TooltipContent side="top">
<div className="px-1 py-0.5">
<div className="text-xs font-medium">Total requirements</div>
<div className="text-tiny text-default-400">{total}</div>
<div className="text-text-neutral-tertiary text-xs">{total}</div>
</div>
}
size="sm"
placement="top"
>
<span className="text-default-600 min-w-[32px] text-center text-xs font-medium">
{total > 0 ? total : "—"}
</span>
</TooltipContent>
</Tooltip>
</div>
</div>
@@ -18,11 +18,6 @@ describe("ComplianceCard", () => {
expect(source).not.toContain("sm:flex-row");
});
it("uses the shadcn progress component instead of Hero UI", () => {
expect(source).toContain('from "@/components/shadcn/progress"');
expect(source).not.toContain("@heroui/progress");
});
it("places compact actions in the icon column on larger screens", () => {
expect(source).toContain('orientation="column"');
expect(source).toContain('buttonWidth="icon"');
+1 -1
View File
@@ -131,7 +131,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
<div className="flex min-w-0 flex-1 flex-col">
<Tooltip>
<TooltipTrigger asChild>
<h4 className="text-small truncate leading-5 font-bold">
<h4 className="truncate text-sm leading-5 font-bold">
{formatTitle(title)}
{version ? ` - ${version}` : ""}
</h4>
@@ -1,9 +1,9 @@
"use client";
import { cn } from "@heroui/theme";
import { useTheme } from "next-themes";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { CategoryData } from "@/types/compliance";
interface HeatmapChartProps {
@@ -1,12 +1,12 @@
"use client";
import { Progress } from "@heroui/progress";
import type { SectionScores } from "@/actions/overview/threat-score";
import { RadialChart } from "@/components/graphs/radial-chart";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { Progress } from "@/components/shadcn/progress";
import {
getScoreColor,
getScoreIndicatorClass,
getScoreLabel,
getScoreLevel,
getScoreTextClass,
@@ -72,7 +72,7 @@ export function ThreatScoreBreakdownCard({
{/* Pillar Breakdown */}
<Card variant="inner" padding="sm" className="min-w-0 flex-1">
<div className="mb-2">
<span className="text-default-600 text-xs font-medium tracking-wide uppercase">
<span className="text-text-neutral-secondary text-xs font-medium tracking-wide uppercase">
Score by Pillar
</span>
</div>
@@ -80,7 +80,9 @@ export function ThreatScoreBreakdownCard({
{pillars.map(({ name, score, hasData }) => (
<div key={name} className="space-y-0.5">
<div className="flex items-center justify-between text-xs">
<span className="text-default-700 truncate pr-2">{name}</span>
<span className="text-text-neutral-secondary truncate pr-2">
{name}
</span>
<span
className={`font-semibold ${
hasData
@@ -94,8 +96,9 @@ export function ThreatScoreBreakdownCard({
<Progress
aria-label={`${name} score`}
value={hasData ? score : 0}
color={getScoreColor(score)}
size="md"
indicatorClassName={getScoreIndicatorClass(
getScoreColor(score),
)}
className="w-full"
/>
</div>
@@ -113,22 +116,22 @@ export function ThreatScoreBreakdownCardSkeleton() {
return (
<Card variant="base" className="flex h-full w-full animate-pulse flex-col">
<CardHeader>
<div className="bg-default-200 h-5 w-40 rounded" />
<div className="bg-border-neutral-tertiary h-5 w-40 rounded" />
</CardHeader>
{/* Mobile: vertical, Tablet: horizontal, Desktop: vertical */}
<CardContent className="flex flex-1 flex-col gap-4 md:flex-row md:items-stretch lg:flex-col">
<div className="flex w-full flex-col items-center justify-center md:w-[160px] md:flex-shrink-0 lg:w-full">
<div className="bg-default-200 mx-auto h-[140px] w-[140px] rounded-full" />
<div className="bg-border-neutral-tertiary mx-auto h-[140px] w-[140px] rounded-full" />
</div>
<Card variant="inner" padding="sm" className="min-w-0 flex-1">
<div className="space-y-2">
{Array.from({ length: SKELETON_PILLAR_COUNT }, (_, i) => (
<div key={i} className="space-y-0.5">
<div className="flex justify-between">
<div className="bg-default-200 h-3 w-28 rounded" />
<div className="bg-default-200 h-3 w-10 rounded" />
<div className="bg-border-neutral-tertiary h-3 w-28 rounded" />
<div className="bg-border-neutral-tertiary h-3 w-10 rounded" />
</div>
<div className="bg-default-200 h-2 w-full rounded" />
<div className="bg-border-neutral-tertiary h-2 w-full rounded" />
</div>
))}
</div>

Some files were not shown because too many files have changed in this diff Show More