Merge branch 'master' into PROWLER-179-aws-add-an-connect-the-provider

This commit is contained in:
StylusFrost authored and GitHub committed 2025-10-20 14:11:46 +02:00
commit d63ae0e40f
1364 files changed
+69177 -3010

No files matched your search

+854
View File
@@ -0,0 +1,854 @@
# Prowler UI Agent Guide
**Complete guide for AI agents and developers working on the Prowler UI Next.js application.**
## Mission & Scope
- Ship small, high-impact UI changes with minimal risk
- Align to current patterns: App Router, Server Components first, consistent styling, strict types
- Avoid broad refactors, library swaps, or reorganization unless requested
- Focus on safe, incremental frontend changes aligned with existing architecture
---
## Critical Architecture Rules (Non-Negotiable)
### 1. TypeScript Type Patterns (Required)
When defining union types for options, ALWAYS create a const object first, then extract the type:
**❌ DON'T:**
```typescript
type SortOption = "high-low" | "low-high" | "alphabetical";
```
**✅ DO:**
```typescript
const SORT_OPTIONS = {
HIGH_LOW: "high-low",
LOW_HIGH: "low-high",
ALPHABETICAL: "alphabetical",
} as const;
type SortOption = (typeof SORT_OPTIONS)[keyof typeof SORT_OPTIONS];
```
### 2. Tailwind 4 Theme Variables
This project uses Tailwind 4 with @theme variables. **Tailwind is mainly semantic** - prioritize using Tailwind's naming system whenever possible.
#### In Template/JSX (className)
- ✅ Use Tailwind utility classes: `bg-card-bg`, `text-white`, `text-slate-400`, `border-slate-700`
- ✅ Use arbitrary values with classes: `h-3`, `w-3`, `min-w-[200px]`, `bg-slate-700/50`
- ✅ Use Tailwind for conditional styles: `className={isActive ? "bg-blue-500" : "bg-gray-500"}`
- ✅ Use style props only for truly dynamic values: `style={{ width: \`\${percentage}%\` }}`
- ❌ NEVER use `var()` in className
- ❌ NEVER use hex colors in className
#### Constants with var() (Only for library props that don't accept className)
- ✅ Use CHART_COLORS constants: `stroke={CHART_COLORS.gridLine}`, `tick={{ fill: CHART_COLORS.textSecondary }}`
- These props don't accept className, so we use constants that internally reference `var()`
- This is the **only** valid use case for `var()` - when the library doesn't support className
#### Examples
```tsx
// ✅ GOOD - Template with Tailwind classes
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3">
<p className="text-sm font-semibold text-white">{title}</p>
<Bell size={14} className="text-slate-400" />
</div>
// ✅ GOOD - Conditional Tailwind classes
<button className={isActive ? "bg-blue-500" : "bg-gray-500"}>
Click me
</button>
// ✅ GOOD - Recharts library props with CHART_COLORS (var() only here)
<XAxis tick={{ fill: CHART_COLORS.textSecondary, fontSize: 12 }} />
<CartesianGrid stroke={CHART_COLORS.gridLine} />
// ✅ GOOD - Truly dynamic values (not available in Tailwind)
<div style={{ width: `${percentage}%`, opacity: isFaded ? 0.5 : 1 }} />
// ❌ BAD - var() in className
<div className="bg-[var(--color-card-bg)]" /> // Don't do this!
// ❌ BAD - Hex colors in className
<p className="text-[#ffffff]" /> // Use text-white instead
// ❌ BAD - Using var() for colors when Tailwind classes exist
const PROVIDER_COLORS = {
AWS: "var(--color-orange)", // Use Tailwind classes instead!
};
// ❌ BAD - Using style when className is available
<div style={{ backgroundColor: "blue" }} /> // Use className="bg-blue-500" instead
```
### 3. The `cn()` Utility Function
#### What is `cn()`?
The `cn()` function is a utility that combines `clsx` and `tailwind-merge`:
```typescript
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
**Components:**
- **`clsx`**: Constructs conditional className strings (handles booleans, arrays, objects)
- **`twMerge`**: Intelligently merges Tailwind classes, resolving conflicts (e.g., `p-4` + `p-2` → `p-2`)
#### When to Use `cn()`
Use `cn()` **ONLY** when you have:
##### 1. Conditional Classes
```tsx
// ✅ GOOD - Conditional logic
<div className={cn("h-3 w-3", isCircle ? "rounded-full" : "rounded-sm")} />
// ✅ GOOD - Boolean conditionals
<button className={cn("btn", isActive && "btn-active", isDisabled && "opacity-50")} />
```
##### 2. Merging Props with Conflicting Classes
```tsx
// ✅ GOOD - Component accepting className prop
interface ButtonProps {
className?: string;
}
function Button({ className }: ButtonProps) {
return <button className={cn("bg-blue-500 px-4 py-2", className)} />;
}
// Usage: <Button className="px-6" /> → Results in "px-6 py-2 bg-blue-500"
```
##### 3. Dynamic String Interpolation
```tsx
// ✅ GOOD - Dynamic values that need proper merging
<span className={cn(`text-${size}`, "font-semibold text-white")} />
```
#### When NOT to Use `cn()`
**DO NOT** use `cn()` for static classes without conditional logic:
```tsx
// ❌ BAD - Unnecessary, no conditional logic
<div className={cn("rounded-lg border border-slate-700 bg-slate-800 p-3")} />
// ✅ GOOD - Just use className directly
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3" />
// ❌ BAD - No conflicts or conditionals
<div className={cn("flex items-center gap-2")} />
// ✅ GOOD - Static classes don't need cn()
<div className="flex items-center gap-2" />
```
#### Real-World Examples
```tsx
// ❌ BAD - Overuse of cn()
function Tooltip({ active, payload }: any) {
return (
<div className={cn("rounded-lg border border-slate-700")}>
<div className={cn("flex items-center gap-2")}>
<div className={cn("h-3 w-3 rounded-sm")} />
<span className={cn("text-sm font-semibold text-white")}>
{payload.name}
</span>
</div>
</div>
);
}
// ✅ GOOD - Only use cn() where needed
function Tooltip({ active, payload, shape }: any) {
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 p-3">
<div className="flex items-center gap-2">
<div
className={cn(
"h-3 w-3",
shape === "circle" ? "rounded-full" : "rounded-sm",
)}
/>
<span className="text-sm font-semibold text-white">{payload.name}</span>
</div>
</div>
);
}
```
#### Key Takeaway
**`cn()` is a tool for conditional logic and conflict resolution, NOT a wrapper for every className.**
Use it purposefully where it adds value. Don't use it out of habit.
**Reference:** [The Story Behind Tailwind's cn() Function](https://tigerabrodi.blog/the-story-behind-tailwinds-cn-function)
### 4. React 19 with Compiler
This project uses React 19 with the React Compiler enabled. This means:
- **DO NOT use `useMemo`** - React Compiler handles memoization automatically
- **DO NOT use `useCallback`** - React Compiler optimizes callbacks automatically
- Only use these hooks if you have a specific, documented reason that the compiler cannot handle
### 5. Next.js 15 Architecture Principles
#### 1. App Router Architecture First
- **ALL routes MUST use App Router** - never use Pages Router for new projects
- Leverage Server Components by default, Client Components only when necessary
- Use proper file conventions: `page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`
- Implement route groups `(group-name)` for organization without affecting URL structure
- Use private folders `_folder` to opt out of routing system
#### 2. Server-First Architecture
- **Server Components by default** - add `"use client"` only when required
- Optimize data fetching at the server level
- Implement streaming with `loading.tsx` and Suspense boundaries
- Use Server Actions for form handling and mutations
- Leverage static generation and ISR for performance
- Use DAL (Data Access Layer) patterns to separate data logic
- To prevent accidental usage in Client Components, you can use the server-only package, this is a MUST for Server Actions and recommended for all server-only code
#### 3. The Scope Rule - Your Unbreakable Law
**"Scope determines structure"**
- Code used by 2+ features → MUST go in global/shared directories
- Code used by 1 feature → MUST stay local in that feature
- NO EXCEPTIONS - This rule is absolute and non-negotiable
#### 4. Screaming Architecture
Your structures must IMMEDIATELY communicate what the application does:
- Feature names must describe business functionality, not technical implementation
- Directory structure should tell the story of what the app does at first glance
- Route structure should mirror business logic, not technical concerns
#### 5. Component Placement Decision Framework
When analyzing where to place a component, you MUST follow this process:
1. **Identify component type**: Server Component, Client Component, or hybrid
2. **Count usage**: Identify exactly how many features/routes use the component
3. **Apply the Scope Rule**:
- Used by 1 feature → Local placement within that feature
- Used by 2+ features → Global/shared directory
4. **Consider performance**: Optimize bundle splitting and server-side rendering
5. **Document the decision**: Always explain WHY the placement was chosen
### 6. File Organization Patterns
#### Co-location Principles
Follow these patterns for organizing related files:
**For Server Actions:**
```
ui/actions/
└── feature-name/
├── feature-name.ts # Server actions
├── models.ts # Domain models/types
└── feature-name.adapter.ts # Data transformations (if needed)
```
**For Components:**
```
ui/components/
└── feature-name/
├── feature-component.tsx # Main component
├── feature-client.tsx # Client-specific logic
└── feature-name/ # Utilities folder
├── types.ts
├── utils.ts
├── constants.ts
└── hooks.ts
```
**Key Rules:**
- Models used by only one feature → Keep local in that feature's directory
- Utilities used by only one feature → Keep in feature's utils folder
- Types only used within a feature → Keep in feature's types file
- Only move to global/shared when used by 2+ features
---
## Project Overview
The Prowler UI is a Next.js 15 application providing a modern web interface for the Prowler security platform. It features a comprehensive dashboard for managing cloud security scans, compliance frameworks, and findings across multiple cloud providers.
## Tech Stack (Updated January 2025)
- **Framework**: Next.js 15.5.3 with App Router
- **Runtime**: React 19.1.1 (with Compiler enabled)
- **Language**: TypeScript 5.5.4
- **Styling**: Tailwind CSS 4.1.13 + **shadcn/ui** (new components) / HeroUI 2.8.4 (legacy)
- **State Management**: Zustand 5.0.8
- **Authentication**: NextAuth.js 5.0.0-beta.29
- **Forms/Validation**: React Hook Form 7.62.0 + Zod 4.1.11
- **AI/Chat**: AI SDK 5.0.59 + @ai-sdk/react 2.0.59
- **AI Backend**: LangChain @langchain/core 0.3.77 + @ai-sdk/langchain 1.0.59
- **Charts**: Recharts 2.15.4
- **Testing**: Playwright 1.53.2
- **Formatter**: Prettier 3.6.2
## Commands
### Development
```bash
npm install # Install dependencies
npm run dev # Start development server (localhost:3000)
npm run build # Build for production
npm start # Start production server
npm run start:standalone # Start standalone server
```
### Code Quality
```bash
npm run typecheck # TypeScript type checking
npm run lint:check # ESLint checking
npm run lint:fix # Fix ESLint issues
npm run format:check # Prettier format checking
npm run format:write # Format code with Prettier
npm run healthcheck # Run typecheck + lint together
```
### Testing
```bash
npm run test:e2e # Run Playwright tests
npm run test:e2e:ui # Run tests with UI
npm run test:e2e:debug # Debug tests
npm run test:e2e:headed # Run tests in headed mode
npm run test:e2e:report # Show test report
npm run test:e2e:install # Install Playwright browsers
```
## Project Structure
```
ui/
├── app/ # Next.js App Router
│ ├── (auth)/ # Authentication pages (sign-in, sign-up)
│ ├── (prowler)/ # Main application pages
│ │ ├── compliance/ # Compliance frameworks & reports
│ │ ├── findings/ # Security findings & vulnerabilities
│ │ ├── integrations/ # S3, Security Hub integrations
│ │ ├── lighthouse/ # AI-powered security assistant
│ │ ├── providers/ # Cloud provider management
│ │ ├── scans/ # Security scan management
│ │ └── services/ # Cloud services overview
│ └── api/ # API routes & server actions
├── components/ # Reusable UI components
│ ├── shadcn/ # shadcn/ui components (NEW)
│ │ ├── card.tsx # shadcn Card component
│ │ ├── resource-stats-card/ # Custom ResourceStatsCard built on shadcn
│ │ │ ├── resource-stats-card.tsx
│ │ │ ├── resource-stats-card.example.tsx
│ │ │ └── index.ts
│ │ ├── index.ts # Barrel exports
│ │ └── README.md
│ ├── ui/ # Base UI components (buttons, forms, etc.)
│ ├── compliance/ # Compliance-specific components
│ ├── findings/ # Findings table & filters
│ ├── providers/ # Provider management UI
│ ├── scans/ # Scan management UI
│ └── integrations/ # Integration configuration
├── actions/ # Server actions (data fetching/mutations)
├── lib/ # Utility functions & configurations
├── types/ # TypeScript type definitions
├── hooks/ # Custom React hooks
├── store/ # Zustand state management
└── styles/ # Global CSS & Tailwind config
```
## shadcn/ui Components
### Directory Structure
All shadcn/ui based components are located in `components/shadcn/`:
```
shadcn/
├── card.tsx # shadcn Card component
├── resource-stats-card/ # Custom ResourceStatsCard built on shadcn
│ ├── resource-stats-card.tsx
│ ├── resource-stats-card.example.tsx
│ └── index.ts
├── index.ts # Barrel exports
└── README.md
```
### Usage
All shadcn components can be imported from `@/components/shadcn`:
```tsx
import { Card, CardHeader, CardContent } from "@/components/shadcn";
import { ResourceStatsCard } from "@/components/shadcn";
```
### Adding New shadcn Components
When adding new shadcn components using the CLI:
```bash
npx shadcn@latest add [component-name]
```
The component will be automatically added to this directory due to the configuration in `components.json`:
```json
{
"aliases": {
"ui": "@/components/shadcn"
}
}
```
### Component Guidelines
1. **shadcn base components** - Use as-is from shadcn/ui (e.g., `card.tsx`)
2. **Custom components built on shadcn** - Create in subdirectories (e.g., `resource-stats-card/`)
3. **CVA variants** - Use Class Variance Authority for type-safe variants
4. **Theme support** - Include `dark:` classes for dark/light theme compatibility
5. **TypeScript** - Always export types and use proper typing
### Resources
- [shadcn/ui Documentation](https://ui.shadcn.com)
- [CVA Documentation](https://cva.style/docs)
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
## Key Features
### Authentication System
- NextAuth.js with multiple providers (credentials, OAuth, SAML)
- Server-side authentication with middleware protection
- Session management and role-based access control
### Cloud Provider Management
- Multi-cloud support (AWS, Azure, GCP, GitHub, K8s, M365)
- Credential management with secure forms
- Connection testing and status monitoring
### Security Scanning
- Real-time scan progress monitoring
- Bulk operations and filtering
- Scheduled scan management
- Download and export capabilities
### Compliance Frameworks
- 36+ compliance frameworks (CIS, NIST, PCI-DSS, etc.)
- Interactive compliance reports with charts
- Requirement-level drill-down views
- Custom compliance mapping
### Lighthouse AI Assistant
- LangChain-powered security chatbot
- Context-aware responses about findings
- Integration with scan data and compliance frameworks
### Findings Management
- Advanced filtering and search
- Mute/unmute functionality with reasons
- Severity-based classification
- Detailed finding analysis
## Patterns & Conventions
### Component Architecture
```typescript
// Prefer server components when possible
export default async function PageComponent() {
const data = await fetchData();
return <ClientComponent data={data} />;
}
```
### State Management
```typescript
// Use Zustand for global state
import { useStore } from "@/hooks/use-store";
const { filters, setFilters } = useStore();
```
### Server Actions
```typescript
"use server";
export async function updateProvider(formData: FormData) {
// Validate with Zod
// Update via API
// Revalidate cache
}
```
### Form Handling
```typescript
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const form = useForm({
resolver: zodResolver(schema),
});
```
## File & Code Style
### Naming & Organization
- **Component Naming**: `PascalCase` for components, `camelCase` for helpers
- **Foldering**: Colocate domain components under domain folders (e.g., `components/integrations/jira/`)
- **Imports**: Honor alias paths (`@/components/...`). Keep import order consistent with ESLint rules
- **CSS**: Prefer Tailwind classes; avoid ad-hoc CSS files unless justified
### Import Organization
```typescript
// External libraries
import React from "react";
import { Button } from "@heroui/react";
// Internal utilities
import { cn } from "@/lib/utils";
// Types
import type { ComponentProps } from "@/types";
```
## Styling Guidelines
### Tailwind + shadcn/ui (New) / HeroUI (Existing)
- **Use shadcn/ui components for new UI features/pages** with the new Tailwind theme
- Existing features/pages should continue using HeroUI (migrated from NextUI) for consistency
- Custom Prowler color palette defined in tailwind.config.js
- Dark/light theme support via next-themes
- Custom shadows and animations for Prowler brand
## Library-Specific Guidelines
### Zod v4 (Schema Validation)
**Breaking changes from v3:**
- ❌ `.nonempty()` → ✅ `.min(1)` for strings
- ❌ `z.string().email()` → ✅ `z.email()` (top-level function)
- ❌ `z.string().uuid()` → ✅ `z.uuid()` (top-level function)
- ❌ `z.string().url()` → ✅ `z.url()` (top-level function)
- ❌ `required_error` parameter → ✅ `error` parameter
- ❌ `message` parameter → ✅ `error` parameter
- ⚠️ `.optional()` type inference changed - fields are now `T | undefined` in inferred types
**Example migration:**
```typescript
// ❌ Zod v3
const schema = z.object({
email: z.string().email({ message: "Invalid email" }),
name: z.string().nonempty("Required"),
id: z.string().uuid(),
});
// ✅ Zod v4
const schema = z.object({
email: z.email({ error: "Invalid email" }),
name: z.string().min(1, "Required"),
id: z.uuid(),
});
```
### Zustand v5 (State Management)
**Breaking changes from v4:**
- ✅ No API changes required for basic usage
- ⚠️ `shallow` comparison must use `useShallow` hook from `zustand/react/shallow`
- ⚠️ Selectors must return stable references to avoid infinite loops
- ⚠️ `persist` middleware no longer auto-stores initial state - call `setState()` explicitly if needed
**Best practices:**
```typescript
import { create } from "zustand";
import { persist } from "zustand/middleware";
const useStore = create(
persist(
(set) => ({
value: 0,
increment: () => set((state) => ({ value: state.value + 1 })),
}),
{ name: "my-store" },
),
);
```
### AI SDK v5 (Chat & AI Features)
**Breaking changes from v4:**
- ❌ `Message` type → ✅ `UIMessage` type
- ❌ `message.content` string → ✅ `message.parts` array structure
- ❌ `handleSubmit` / `handleInputChange` → ✅ `sendMessage` with manual state
- ❌ `append()` → ✅ `sendMessage({ text: "..." })`
- ❌ `api: "/endpoint"` → ✅ `transport: new DefaultChatTransport({ api: "/endpoint" })`
- ❌ `LangChainAdapter.toDataStreamResponse()` → ✅ `toUIMessageStream()` from `@ai-sdk/langchain`
**Example migration:**
```typescript
// ❌ AI SDK v4
import { useChat } from "ai";
const { messages, handleSubmit, input, handleInputChange } = useChat({
api: "/api/chat",
});
// ✅ AI SDK v5
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
const { messages, sendMessage } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});
// Manual input state management required
const [input, setInput] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
sendMessage({ text: input });
setInput("");
};
```
**UIMessage structure:**
```typescript
// Message parts-based structure
const message: UIMessage = {
id: "msg-1",
role: "assistant",
parts: [{ type: "text", text: "Hello world" }],
};
// Extract text from parts
const text = message.parts
.filter((p) => p.type === "text")
.map((p) => ("text" in p ? p.text : ""))
.join("");
```
## Testing
### Playwright E2E Tests
- Add/update E2E tests for critical flows you modify
- Scope: run only affected specs when iterating
- Commit snapshot updates only with real UI changes
- Determinism: avoid relying on real external services; mock or stub where possible
### Component Testing (Future)
- Jest + React Testing Library
- Component unit tests
- Integration tests for complex flows
## Performance
- Keep Client Components lean; avoid heavy client-side logic where server boundary is possible
- Use streaming, partial rendering, and skeletons for long operations
- **DO NOT use `useMemo` or `useCallback`** - React 19 Compiler handles this automatically
- App Router with server components
- Image optimization with next/image
- Font optimization with next/font
- Bundle analysis and code splitting
## Security
### Best Practices
- Do not commit secrets or `.env.local` values. Use placeholders in examples
- Avoid logging sensitive data. Sanitize error messages shown to users
- Strict CSP headers in next.config.js
- XSS protection and CSRF mitigation
- Secure cookie configuration
### Data Handling
- Client-side validation with Zod
- Server-side sanitization
- Secure credential storage patterns
### Authentication
- NextAuth: use server helpers and `server-only` where appropriate
- Protect routes through middleware and layout boundaries
- JWT tokens via NextAuth
- Automatic token refresh
- Protected routes via middleware
## Quality Gates (before submitting changes) IMPORTANT!
1. `npm run typecheck` shows 0 new errors
2. `npm run lint:check` passes or is fixed via `npm run lint:fix`
3. `npm run format:check` passes or is corrected via `npm run format:write`
4. Relevant Playwright specs pass locally
5. UI states (loading, error, empty) are handled
## Common Development Tasks
### Adding New Pages
1. Create page component in `app/(prowler)/`
2. Add route to navigation in `lib/menu-list.ts`
3. Implement required server actions
4. Add proper TypeScript types (using const-based type pattern). IMPORTANT!: avoid "any" type.
### Creating Components
1. **Use shadcn/ui for new UI components that belong to new features/pages**
2. Existing features/pages should continue using HeroUI for consistency
3. Follow established patterns in `components/shadcn/` for new components
4. Implement proper TypeScript interfaces (using const-based type pattern)
5. Add to component index files
### Integrating with Backend
1. Create server actions in `actions/`
2. Define TypeScript types in `types/` (using const-based type pattern)
3. Handle loading and error states
4. Implement proper caching strategy
### When Implementing New UI
- **Use shadcn/ui components with the new Tailwind theme for new UI features/pages**
- **For existing features/pages, continue using HeroUI components for consistency**
- Start from existing patterns in the closest domain folder
- Reuse primitives from `components/shadcn` (for new features) or `components/ui` (HeroUI for existing) and existing composables
- Add types to `types/` if they're shared (2+ features); otherwise colocate types near usage (1 feature)
- Follow The Scope Rule strictly - used by 2+ features = shared, 1 feature = local
- Update feature docs and `ui/CHANGELOG.md` when behavior changes
## Environment Configuration
### Required Environment Variables
```bash
# Authentication
NEXTAUTH_SECRET=your_secret_here
NEXTAUTH_URL=http://localhost:3000
# API Configuration
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
# AI Features
OPENAI_API_KEY=your_openai_key
```
### Development Setup
1. Copy `.env.example` to `.env.local`
2. Configure authentication providers
3. Set up API backend connection
4. Install dependencies and start dev server
## Deployment
### Production Build
```bash
npm run build # Build optimized bundle
npm run start # Start production server
```
### Docker Deployment
- Dockerfile available for containerization
- Standalone output for minimal container size
- Health checks via `/api/health` endpoint
## Troubleshooting
### Common Issues
1. **TypeScript errors**: Run `npm run typecheck`
2. **Lint issues**: Run `npm run lint:fix`
3. **Build failures**: Check Next.js build logs
4. **Authentication issues**: Verify NextAuth configuration
### Debug Tools
- Next.js built-in debugger
- React Developer Tools
- Network tab for API debugging
- Lighthouse for performance analysis
## Recent Major Migrations (January 2025)
- ✅ React 18 → 19.1.1 (async components, useActionState, React Compiler)
- ✅ Next.js 14 → 15.5.3 (enhanced App Router)
- ✅ NextUI → HeroUI 2.8.4
- ✅ Zod 3.25.73 → 4.1.11 (breaking: deprecated methods)
- ✅ Zustand 4.5.7 → 5.0.8 (compatible)
- ✅ AI SDK 4.3.16 → 5.0.59 (breaking: new message structure)
- ✅ LangChain updates with new adapter patterns
## Summary
These guidelines ensure:
- ✅ Consistent code patterns across the project
- ✅ Optimal performance with Next.js 15 and React 19 with Compiler
- ✅ Clear, maintainable architecture following The Scope Rule
- ✅ Proper separation of concerns
- ✅ Type safety throughout the codebase using const-based types
- ✅ Semantic Tailwind usage without var() or hex colors in className
- ✅ Purposeful use of cn() only for conditionals and merging
**When in doubt, ask before deviating from these patterns.**
## References
- **High-level project guide**: `../AGENTS.md` (root Prowler project - takes priority)
- **UI Changelog**: `./CHANGELOG.md`
+4
View File
@@ -12,8 +12,11 @@ All notable changes to the **Prowler UI** are documented in this file.
- React Compiler support for automatic optimization [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
- Turbopack support for faster development builds [(#8748)](https://github.com/prowler-cloud/prowler/pull/8748)
- Add compliance name in compliance detail view [(#8775)](https://github.com/prowler-cloud/prowler/pull/8775)
- Support C5 compliance framework for the AWS provider [(#8830)](https://github.com/prowler-cloud/prowler/pull/8830)
- API key management in user profile [(#8308)](https://github.com/prowler-cloud/prowler/pull/8308)
- Refresh access token error handling [(#8864)](https://github.com/prowler-cloud/prowler/pull/8864)
- Support Common Cloud Controls for AWS, Azure and GCP [(#8000)](https://github.com/prowler-cloud/prowler/pull/8000)
- New M365 credentials certificate authentication method [(#8929)](https://github.com/prowler-cloud/prowler/pull/8929)
### 🔄 Changed
@@ -34,6 +37,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- ThreatScore for each pillar in Prowler ThreatScore specific view [(#8582)](https://github.com/prowler-cloud/prowler/pull/8582)
- Remove maxTokens model param for GPT-5 models [(#8843)](https://github.com/prowler-cloud/prowler/pull/8843)
- MITRE ATTACK compliance view now shows all requirements in charts [(#8886)](https://github.com/prowler-cloud/prowler/pull/8886)
- Mutelist menu item now doesn't blink [(#8932)](https://github.com/prowler-cloud/prowler/pull/8932)
---
@@ -10,6 +10,7 @@ import {
SelectViaGCP,
} from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
import { SelectViaM365 } from "@/components/providers/workflow/forms/select-credentials-type/m365";
import { getProviderFormType } from "@/lib/provider-helpers";
import { ProviderType } from "@/types/providers";
@@ -28,6 +29,7 @@ export default async function AddCredentialsPage({ searchParams }: Props) {
if (providerType === "gcp") return <SelectViaGCP initialVia={via} />;
if (providerType === "github")
return <SelectViaGitHub initialVia={via} />;
if (providerType === "m365") return <SelectViaM365 initialVia={via} />;
return null;
case "credentials":
+25 -27
View File
@@ -28,31 +28,36 @@ export default async function Providers({
<ContentLayout title="Cloud Providers" icon="lucide:cloud-cog">
<FilterControls search customFilters={filterProviders || []} />
<Spacer y={8} />
<Suspense
key={searchParamsKey}
fallback={
<>
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
<Spacer y={8} />
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
<SkeletonTableProviders />
</div>
</div>
</>
}
>
<ProvidersContent searchParams={resolvedSearchParams} />
<ProvidersActions />
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<ProvidersTableFallback />}>
<ProvidersTable searchParams={resolvedSearchParams} />
</Suspense>
</ContentLayout>
);
}
const ProvidersContent = async ({
const ProvidersActions = () => {
return (
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
);
};
const ProvidersTableFallback = () => {
return (
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
<SkeletonTableProviders />
</div>
</div>
);
};
const ProvidersTable = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
@@ -97,13 +102,6 @@ const ProvidersContent = async ({
return (
<>
<div className="flex items-center gap-4 md:justify-end">
<ManageGroupsButton />
<MutedFindingsConfigButton />
<AddProviderButton />
</div>
<Spacer y={8} />
<div className="grid grid-cols-12 gap-4">
<div className="col-span-12">
<DataTable
@@ -7,12 +7,18 @@ interface ComplianceAccordionRequirementTitleProps {
}
export const ComplianceAccordionRequirementTitle = ({
type,
name,
status,
}: ComplianceAccordionRequirementTitleProps) => {
return (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex w-5/6 items-center gap-1">
<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">
{type}
</span>
)}
<span>{name}</span>
</div>
<StatusFindingBadge status={status} />
+7 -5
View File
@@ -118,11 +118,13 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
onClick={navigateToDetail}
>
<div className="flex w-full items-center gap-4">
<Image
src={getComplianceIcon(title)}
alt={`${title} logo`}
className="h-10 w-10 min-w-10 rounded-md border border-gray-300 bg-white object-contain p-1"
/>
{getComplianceIcon(title) && (
<Image
src={getComplianceIcon(title)}
alt={`${title} logo`}
className="h-10 w-10 min-w-10 rounded-md border border-gray-300 bg-white object-contain p-1"
/>
)}
<div className="flex w-full flex-col">
<h4 className="text-small mb-1 leading-5 font-bold">
{formatTitle(title)}
@@ -0,0 +1,38 @@
import { Requirement } from "@/types/compliance";
import {
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
} from "./shared-components";
export const C5CustomDetails = ({
requirement,
}: {
requirement: Requirement;
}) => {
const sections = [
{
title: "Description",
content: requirement.description,
},
{
title: "About Criteria",
content: requirement.about_criteria as string | undefined,
},
{
title: "Complementary Criteria",
content: requirement.complementary_criteria as string | undefined,
},
].filter((section) => section.content);
return (
<ComplianceDetailContainer>
{sections.map((section) => (
<ComplianceDetailSection key={section.title} title={section.title}>
<ComplianceDetailText>{section.content}</ComplianceDetailText>
</ComplianceDetailSection>
))}
</ComplianceDetailContainer>
);
};
@@ -0,0 +1,89 @@
import { cn } from "@/lib";
import { CCC_MAPPING_SECTIONS, CCC_TEXT_SECTIONS } from "@/lib/compliance/ccc";
import { Requirement } from "@/types/compliance";
import {
ComplianceBadge,
ComplianceBadgeContainer,
ComplianceChipContainer,
ComplianceDetailContainer,
ComplianceDetailSection,
ComplianceDetailText,
} from "./shared-components";
interface CCCDetailsProps {
requirement: Requirement;
}
export const CCCCustomDetails = ({ requirement }: CCCDetailsProps) => {
// Map text sections with requirement data
const textSections = CCC_TEXT_SECTIONS.map((section) => ({
...section,
content: requirement[section.key] as string | undefined,
})).filter((section) => section.content);
// Map mapping sections with requirement data
const mappingSections = CCC_MAPPING_SECTIONS.map((section) => ({
...section,
data: requirement[section.key] as Array<{
ReferenceId: string;
Identifiers: string[];
}>,
})).filter((section) => section.data);
return (
<ComplianceDetailContainer>
{textSections.map((section) => (
<ComplianceDetailSection key={section.title} title={section.title}>
<ComplianceDetailText className={section.className}>
{section.content}
</ComplianceDetailText>
</ComplianceDetailSection>
))}
{requirement.family_name && (
<ComplianceBadgeContainer>
<ComplianceBadge
label="Family"
value={requirement.family_name as string}
color="purple"
/>
</ComplianceBadgeContainer>
)}
{requirement.applicability && (
<ComplianceChipContainer
title="Applicability"
items={requirement.applicability as string[]}
/>
)}
{mappingSections.map((section) => (
<ComplianceDetailSection key={section.title} title={section.title}>
<div className="flex flex-col gap-3">
{section.data.map((mapping, index) => (
<div key={index} className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs font-medium">
{mapping.ReferenceId}
</span>
<div className="flex flex-wrap gap-2">
{mapping.Identifiers.map((identifier, idx) => (
<span
key={idx}
className={cn(
"inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset",
section.colorClasses,
)}
>
{identifier}
</span>
))}
</div>
</div>
))}
</div>
</ComplianceDetailSection>
))}
</ComplianceDetailContainer>
);
};
@@ -1,4 +1,6 @@
import AWSLogo from "./aws.svg";
import C5Logo from "./c5.svg";
import CCCLogo from "./ccc.svg";
import CISLogo from "./cis.svg";
import CISALogo from "./cisa.svg";
import ENSLogo from "./ens.png";
@@ -17,59 +19,32 @@ import PROWLERTHREATLogo from "./prowlerThreat.svg";
import RBILogo from "./rbi.svg";
import SOC2Logo from "./soc2.svg";
const COMPLIANCE_LOGOS = {
aws: AWSLogo,
cisa: CISALogo,
cis: CISLogo,
ens: ENSLogo,
ffiec: FFIECLogo,
fedramp: FedRAMPLogo,
gdpr: GDPRLogo,
gxp: GxPLogo,
hipaa: HIPAALogo,
iso: ISOLogo,
mitre: MITRELogo,
nist: NISTLogo,
pci: PCILogo,
rbi: RBILogo,
soc2: SOC2Logo,
kisa: KISALogo,
prowlerthreatscore: PROWLERTHREATLogo,
nis2: NIS2Logo,
c5: C5Logo,
ccc: CCCLogo,
} as const;
export const getComplianceIcon = (complianceTitle: string) => {
if (complianceTitle.toLowerCase().includes("aws")) {
return AWSLogo;
}
if (complianceTitle.toLowerCase().includes("cisa")) {
return CISALogo;
}
if (complianceTitle.toLowerCase().includes("cis")) {
return CISLogo;
}
if (complianceTitle.toLowerCase().includes("ens")) {
return ENSLogo;
}
if (complianceTitle.toLowerCase().includes("ffiec")) {
return FFIECLogo;
}
if (complianceTitle.toLowerCase().includes("fedramp")) {
return FedRAMPLogo;
}
if (complianceTitle.toLowerCase().includes("gdpr")) {
return GDPRLogo;
}
if (complianceTitle.toLowerCase().includes("gxp")) {
return GxPLogo;
}
if (complianceTitle.toLowerCase().includes("hipaa")) {
return HIPAALogo;
}
if (complianceTitle.toLowerCase().includes("iso")) {
return ISOLogo;
}
if (complianceTitle.toLowerCase().includes("mitre")) {
return MITRELogo;
}
if (complianceTitle.toLowerCase().includes("nist")) {
return NISTLogo;
}
if (complianceTitle.toLowerCase().includes("pci")) {
return PCILogo;
}
if (complianceTitle.toLowerCase().includes("rbi")) {
return RBILogo;
}
if (complianceTitle.toLowerCase().includes("soc2")) {
return SOC2Logo;
}
if (complianceTitle.toLowerCase().includes("kisa")) {
return KISALogo;
}
if (complianceTitle.toLowerCase().includes("prowlerthreatscore")) {
return PROWLERTHREATLogo;
}
if (complianceTitle.toLowerCase().includes("nis2")) {
return NIS2Logo;
}
const lowerTitle = complianceTitle.toLowerCase();
return Object.entries(COMPLIANCE_LOGOS).find(([keyword]) =>
lowerTitle.includes(keyword),
)?.[1];
};
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="894" height="854" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<desc>PNG to SVG conversion by ConvertICO.com</desc>
<image x="0" y="0" width="894" height="854" xlink:href="data:image/png;base64,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 truncated
</svg>

After

Width:  |  Height:  |  Size: 31 KiB

+32
View File
@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 170" fill="none">
<defs>
<linearGradient id="cccGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#0586BF"/>
<stop offset="100%" style="stop-color:#00BCE7"/>
</linearGradient>
</defs>
<!-- Left C -->
<g>
<circle cx="81" cy="85" r="75" fill="url(#cccGradient)"/>
<circle cx="81" cy="85" r="45" fill="white"/>
</g>
<!-- Connecting pill 1 -->
<ellipse cx="140" cy="85" rx="35" ry="16" fill="url(#cccGradient)"/>
<!-- Middle C -->
<g>
<circle cx="200" cy="85" r="75" fill="url(#cccGradient)"/>
<circle cx="200" cy="85" r="45" fill="white"/>
</g>
<!-- Connecting pill 2 -->
<ellipse cx="260" cy="85" rx="35" ry="16" fill="url(#cccGradient)"/>
<!-- Right C -->
<g>
<circle cx="319" cy="85" r="75" fill="url(#cccGradient)"/>
<circle cx="319" cy="85" r="45" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 944 B

@@ -83,6 +83,7 @@ export const DeleteForm = ({
variant="solid"
color="danger"
size="lg"
radius="lg"
isLoading={isLoading}
startContent={!isLoading && <DeleteIcon size={24} />}
>
@@ -78,6 +78,7 @@ export const DeleteGroupForm = ({
variant="solid"
color="danger"
size="lg"
radius="lg"
isLoading={isLoading}
startContent={!isLoading && <DeleteIcon size={24} />}
>
@@ -3,6 +3,7 @@
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
import { SelectViaM365 } from "@/components/providers/workflow/forms/select-credentials-type/m365";
import { ProviderType } from "@/types/providers";
interface UpdateCredentialsInfoProps {
@@ -24,6 +25,9 @@ export const CredentialsUpdateInfo = ({
if (providerType === "github") {
return <SelectViaGitHub initialVia={initialVia} />;
}
if (providerType === "m365") {
return <SelectViaM365 initialVia={initialVia} />;
}
return null;
};
@@ -80,6 +80,7 @@ export const DeleteForm = ({
variant="solid"
color="danger"
size="lg"
radius="lg"
isLoading={isLoading}
startContent={!isLoading && <DeleteIcon size={24} />}
>
@@ -1,6 +1,8 @@
"use client";
import { SettingsIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import { useUIStore } from "@/store/ui/store";
@@ -8,13 +10,38 @@ import { useUIStore } from "@/store/ui/store";
import { MutedFindingsConfigForm } from "./forms";
export const MutedFindingsConfigButton = () => {
const pathname = usePathname();
const {
isMutelistModalOpen,
openMutelistModal,
closeMutelistModal,
hasProviders,
shouldAutoOpenMutelist,
resetMutelistModalRequest,
} = useUIStore();
useEffect(() => {
if (!shouldAutoOpenMutelist) {
return;
}
if (pathname !== "/providers") {
return;
}
if (hasProviders) {
openMutelistModal();
}
resetMutelistModalRequest();
}, [
hasProviders,
openMutelistModal,
pathname,
resetMutelistModalRequest,
shouldAutoOpenMutelist,
]);
const handleOpenModal = () => {
if (hasProviders) {
openMutelistModal();
@@ -17,7 +17,8 @@ import {
GCPDefaultCredentials,
GCPServiceAccountKey,
KubernetesCredentials,
M365Credentials,
M365CertificateCredentials,
M365ClientSecretCredentials,
ProviderType,
} from "@/types";
@@ -26,10 +27,13 @@ import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credenti
import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type";
import { GCPServiceAccountKeyForm } from "./select-credentials-type/gcp/credentials-type/gcp-service-account-key-form";
import {
M365CertificateCredentialsForm,
M365ClientSecretCredentialsForm,
} from "./select-credentials-type/m365";
import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form";
import { GitHubCredentialsForm } from "./via-credentials/github-credentials-form";
import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form";
import { M365CredentialsForm } from "./via-credentials/m365-credentials-form";
type BaseCredentialsFormProps = {
providerType: ProviderType;
@@ -103,11 +107,22 @@ export const BaseCredentialsForm = ({
control={form.control as unknown as Control<AzureCredentials>}
/>
)}
{providerType === "m365" && (
<M365CredentialsForm
control={form.control as unknown as Control<M365Credentials>}
/>
)}
{providerType === "m365" &&
searchParamsObj.get("via") === "app_client_secret" && (
<M365ClientSecretCredentialsForm
control={
form.control as unknown as Control<M365ClientSecretCredentials>
}
/>
)}
{providerType === "m365" &&
searchParamsObj.get("via") === "app_certificate" && (
<M365CertificateCredentialsForm
control={
form.control as unknown as Control<M365CertificateCredentials>
}
/>
)}
{providerType === "gcp" &&
searchParamsObj.get("via") === "service-account" && (
<GCPServiceAccountKeyForm
@@ -159,6 +174,15 @@ export const BaseCredentialsForm = ({
size="lg"
isLoading={isLoading}
endContent={!isLoading && <ChevronRightIcon size={24} />}
onPress={(e) => {
const formElement = e.target as HTMLElement;
const form = formElement.closest("form");
if (form) {
form.dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
}
}}
>
{isLoading ? <>Loading</> : <span>{submitButtonText}</span>}
</CustomButton>
@@ -0,0 +1,2 @@
export { M365CertificateCredentialsForm } from "./m365-certificate-credentials-form";
export { M365ClientSecretCredentialsForm } from "./m365-client-secret-credentials-form";
@@ -0,0 +1,72 @@
"use client";
import { Control } from "react-hook-form";
import { CustomInput, CustomTextarea } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { M365CertificateCredentials } from "@/types";
export const M365CertificateCredentialsForm = ({
control,
}: {
control: Control<M365CertificateCredentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
App Certificate Credentials
</div>
<div className="text-default-500 text-sm">
Please provide your Microsoft 365 application credentials with
certificate authentication.
</div>
</div>
<CustomInput
control={control}
name="tenant_id"
type="text"
label="Tenant ID"
labelPlacement="inside"
placeholder="Enter the Tenant ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.tenant_id}
/>
<CustomInput
control={control}
name="client_id"
type="text"
label="Client ID"
labelPlacement="inside"
placeholder="Enter the Client ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.client_id}
/>
<CustomTextarea
control={control}
name="certificate_content"
label="Certificate Content"
labelPlacement="inside"
placeholder="Enter the base64 encoded certificate content"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.certificate_content}
minRows={4}
/>
<p className="text-default-500 text-sm">
The certificate content must be base64 encoded from an unsigned
certificate. For detailed instructions on how to generate and encode
your certificate, please refer to the{" "}
<CustomLink
href="https://docs.prowler.com/user-guide/providers/microsoft365/authentication#generate-the-certificate"
size="sm"
>
certificate generation guide
</CustomLink>
.
</p>
</>
);
};
@@ -0,0 +1,58 @@
"use client";
import { Control } from "react-hook-form";
import { CustomInput } from "@/components/ui/custom";
import { M365ClientSecretCredentials } from "@/types";
export const M365ClientSecretCredentialsForm = ({
control,
}: {
control: Control<M365ClientSecretCredentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
App Client Secret Credentials
</div>
<div className="text-default-500 text-sm">
Please provide your Microsoft 365 application credentials.
</div>
</div>
<CustomInput
control={control}
name="tenant_id"
type="text"
label="Tenant ID"
labelPlacement="inside"
placeholder="Enter the Tenant ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.tenant_id}
/>
<CustomInput
control={control}
name="client_id"
type="text"
label="Client ID"
labelPlacement="inside"
placeholder="Enter the Client ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.client_id}
/>
<CustomInput
control={control}
name="client_secret"
type="password"
label="Client Secret"
labelPlacement="inside"
placeholder="Enter the Client Secret"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.client_secret}
/>
</>
);
};
@@ -0,0 +1,5 @@
export {
M365CertificateCredentialsForm,
M365ClientSecretCredentialsForm,
} from "./credentials-type";
export { SelectViaM365 } from "./select-via-m365";
@@ -0,0 +1,72 @@
"use client";
import { RadioGroup } from "@heroui/radio";
import React from "react";
import { Control, Controller } from "react-hook-form";
import { CustomRadio } from "@/components/ui/custom";
import { FormMessage } from "@/components/ui/form";
type RadioGroupM365ViaCredentialsFormProps = {
control: Control<any>;
isInvalid: boolean;
errorMessage?: string;
onChange?: (value: string) => void;
};
export const RadioGroupM365ViaCredentialsTypeForm = ({
control,
isInvalid,
errorMessage,
onChange,
}: RadioGroupM365ViaCredentialsFormProps) => {
return (
<Controller
name="m365CredentialsType"
control={control}
render={({ field }) => (
<>
<RadioGroup
className="flex flex-wrap"
isInvalid={isInvalid}
{...field}
value={field.value || ""}
onValueChange={(value) => {
field.onChange(value);
if (onChange) {
onChange(value);
}
}}
>
<div className="flex flex-col gap-4">
<span className="text-default-500 text-sm">
Select Authentication Method
</span>
<CustomRadio
description="Connect using Application Client Secret"
value="app_client_secret"
>
<div className="flex items-center">
<span className="ml-2">App Client Secret Credentials</span>
</div>
</CustomRadio>
<CustomRadio
description="Connect using Application Certificate"
value="app_certificate"
>
<div className="flex items-center">
<span className="ml-2">App Certificate Credentials</span>
</div>
</CustomRadio>
</div>
</RadioGroup>
{errorMessage && (
<FormMessage className="text-system-error dark:text-system-error">
{errorMessage}
</FormMessage>
)}
</>
)}
/>
);
};
@@ -0,0 +1,38 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { Form } from "@/components/ui/form";
import { RadioGroupM365ViaCredentialsTypeForm } from "./radio-group-m365-via-credentials-type-form";
interface SelectViaM365Props {
initialVia?: string;
}
export const SelectViaM365 = ({ initialVia }: SelectViaM365Props) => {
const router = useRouter();
const form = useForm({
defaultValues: {
m365CredentialsType: initialVia || "",
},
});
const handleSelectionChange = (value: string) => {
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());
};
return (
<Form {...form}>
<RadioGroupM365ViaCredentialsTypeForm
control={form.control}
isInvalid={!!form.formState.errors.m365CredentialsType}
errorMessage={form.formState.errors.m365CredentialsType?.message}
onChange={handleSelectionChange}
/>
</Form>
);
};
@@ -1,4 +1,3 @@
export * from "./azure-credentials-form";
export * from "./github-credentials-form";
export * from "./k8s-credentials-form";
export * from "./m365-credentials-form";
@@ -1,109 +0,0 @@
import { Control } from "react-hook-form";
import { InfoIcon } from "@/components/icons";
import { CustomInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { M365Credentials } from "@/types";
export const M365CredentialsForm = ({
control,
}: {
control: Control<M365Credentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
Connect via Credentials
</div>
<div className="text-default-500 text-sm">
Please provide the information for your Microsoft 365 credentials.
</div>
</div>
<CustomInput
control={control}
name="client_id"
type="text"
label="Client ID"
labelPlacement="inside"
placeholder="Enter the Client ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.client_id}
/>
<CustomInput
control={control}
name="client_secret"
type="password"
label="Client Secret"
labelPlacement="inside"
placeholder="Enter the Client Secret"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.client_secret}
/>
<CustomInput
control={control}
name="tenant_id"
type="text"
label="Tenant ID"
labelPlacement="inside"
placeholder="Enter the Tenant ID"
variant="bordered"
isRequired
isInvalid={!!control._formState.errors.tenant_id}
/>
<p className="text-default-500 text-sm">
{" "}
User and password authentication is being deprecated due to
Microsoft&apos;s on-going MFA enforcement across all tenants (see{" "}
<CustomLink
href="https://azure.microsoft.com/en-us/blog/announcing-mandatory-multi-factor-authentication-for-azure-sign-in/"
size="sm"
>
Microsoft docs
</CustomLink>
).
</p>
<div className="border-system-warning bg-system-warning-medium dark:text-default-300 flex items-center rounded-lg border p-2 text-sm">
<InfoIcon className="mr-2 inline h-4 w-4 shrink-0" />
<p className="text-xs font-extrabold">
By October 2025, MFA will be mandatory.
</p>
</div>
<p className="text-default-500 text-sm">
Due to that change, you must only{" "}
<CustomLink
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/microsoft365/getting-started-m365/#step-3-configure-your-m365-account"
size="sm"
>
use application authentication
</CustomLink>{" "}
to maintain all Prowler M365 scan capabilities.
</p>
<CustomInput
control={control}
name="user"
type="text"
label="User"
labelPlacement="inside"
placeholder="Enter the User"
variant="bordered"
isRequired={false}
isInvalid={!!control._formState.errors.user}
/>
<CustomInput
control={control}
name="password"
type="password"
label="Password"
labelPlacement="inside"
placeholder="Enter the Password"
variant="bordered"
isRequired={false}
isInvalid={!!control._formState.errors.password}
/>
</>
);
};
@@ -75,6 +75,7 @@ export const DeleteRoleForm = ({
variant="solid"
color="danger"
size="lg"
radius="lg"
isLoading={isLoading}
startContent={!isLoading && <DeleteIcon size={24} />}
>
+1 -1
View File
@@ -10,7 +10,7 @@ const alertVariants = cva(
variant: {
default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
destructive:
"bg-danger-50 border-red-500/50 text-red-200 dark:border-red-500 dark:border-red-900/50 dark:text-red-200 dark:dark:border-red-900",
"bg-danger-50 border-red-500/50 text-red-700 dark:border-red-500 dark:border-red-900/50 dark:text-red-700 dark:dark:border-red-900",
},
},
defaultVariants: {
@@ -1,3 +1,5 @@
import { ReactNode } from "react";
import { CustomButton } from "@/components/ui/custom/custom-button";
interface ModalButtonsProps {
@@ -7,6 +9,7 @@ interface ModalButtonsProps {
isDisabled?: boolean;
submitText?: string;
submitColor?: "action" | "danger";
submitIcon?: ReactNode;
}
export const ModalButtons = ({
@@ -16,24 +19,32 @@ export const ModalButtons = ({
isDisabled = false,
submitText = "Save",
submitColor = "action",
submitIcon,
}: ModalButtonsProps) => {
return (
<div className="flex w-full justify-end gap-3 pt-4">
<div className="flex w-full justify-center gap-6">
<CustomButton
size="lg"
radius="lg"
variant="faded"
type="button"
ariaLabel="Cancel"
color="transparent"
variant="light"
className="w-full bg-transparent"
onPress={onCancel}
isDisabled={isLoading}
>
Cancel
</CustomButton>
<CustomButton
size="lg"
radius="lg"
className="w-full"
ariaLabel={submitText}
color={submitColor}
onPress={onSubmit}
isLoading={isLoading}
isDisabled={isDisabled || isLoading}
startContent={submitIcon}
>
{submitText}
</CustomButton>
+18 -4
View File
@@ -8,7 +8,7 @@ import { SaveIcon } from "@/components/icons";
import { CustomButton } from "../custom";
interface FormCancelButtonProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
setIsOpen?: Dispatch<SetStateAction<boolean>>;
onCancel?: () => void;
children?: React.ReactNode;
leftIcon?: React.ReactNode;
@@ -19,10 +19,11 @@ interface FormSubmitButtonProps {
loadingText?: string;
isDisabled?: boolean;
rightIcon?: React.ReactNode;
color?: SubmitColorsType;
}
interface FormButtonsProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
setIsOpen?: Dispatch<SetStateAction<boolean>>;
onCancel?: () => void;
submitText?: string;
cancelText?: string;
@@ -30,8 +31,16 @@ interface FormButtonsProps {
isDisabled?: boolean;
rightIcon?: React.ReactNode;
leftIcon?: React.ReactNode;
submitColor?: SubmitColorsType;
}
export const SubmitColors = {
action: "action",
danger: "danger",
} as const;
export type SubmitColorsType = (typeof SubmitColors)[keyof typeof SubmitColors];
const FormCancelButton = ({
setIsOpen,
onCancel,
@@ -41,7 +50,7 @@ const FormCancelButton = ({
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
} else if (setIsOpen) {
setIsOpen(false);
}
};
@@ -53,6 +62,7 @@ const FormCancelButton = ({
className="w-full bg-transparent"
variant="faded"
size="lg"
radius="lg"
onPress={handleCancel}
startContent={leftIcon}
>
@@ -65,6 +75,7 @@ const FormSubmitButton = ({
children = "Save",
loadingText = "Loading",
isDisabled = false,
color = "action",
rightIcon,
}: FormSubmitButtonProps) => {
const { pending } = useFormStatus();
@@ -75,8 +86,9 @@ const FormSubmitButton = ({
ariaLabel="Save"
className="w-full"
variant="solid"
color="action"
color={color}
size="lg"
radius="lg"
isLoading={pending}
isDisabled={isDisabled}
startContent={!pending && rightIcon}
@@ -88,6 +100,7 @@ const FormSubmitButton = ({
export const FormButtons = ({
setIsOpen,
submitColor,
onCancel,
submitText = "Save",
cancelText = "Cancel",
@@ -110,6 +123,7 @@ export const FormButtons = ({
loadingText={loadingText}
isDisabled={isDisabled}
rightIcon={rightIcon}
color={submitColor}
>
{submitText}
</FormSubmitButton>
+3 -1
View File
@@ -69,11 +69,13 @@ const hideMenuItems = (menuGroups: GroupProps[], labelsToHide: string[]) => {
export const Menu = ({ isOpen }: { isOpen: boolean }) => {
const pathname = usePathname();
const { permissions } = useAuth();
const { hasProviders, openMutelistModal } = useUIStore();
const { hasProviders, openMutelistModal, requestMutelistModalOpen } =
useUIStore();
const menuList = getMenuList({
pathname,
hasProviders,
openMutelistModal,
requestMutelistModalOpen,
});
const labelsToHide = MENU_HIDE_RULES.filter((rule) =>
@@ -83,6 +83,7 @@ export const DeleteForm = ({
variant="solid"
color="danger"
size="lg"
radius="lg"
isLoading={isLoading}
startContent={!isLoading && <DeleteIcon size={24} />}
>
@@ -30,7 +30,7 @@ export const ApiKeySuccessModal = ({
>
<div className="flex flex-col gap-4">
<Alert variant="destructive">
<AlertTitle>⚠️ Important</AlertTitle>
<AlertTitle>⚠️ Warning</AlertTitle>
<AlertDescription>
This is the only time you will see this API key. Please copy it now
and store it securely. Once you close this dialog, the key cannot be
@@ -22,20 +22,20 @@ export const createApiKeyColumns = (
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="NAME" param="name" />
<DataTableColumnHeader column={column} title="Name" param="name" />
),
cell: ({ row }) => <NameCell apiKey={row.original} />,
},
{
accessorKey: "prefix",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="PREFIX" param="prefix" />
<DataTableColumnHeader column={column} title="Prefix" param="prefix" />
),
cell: ({ row }) => <PrefixCell apiKey={row.original} />,
},
{
id: "email",
header: "EMAIL",
header: "Email",
cell: ({ row }) => <EmailCell apiKey={row.original} />,
enableSorting: false,
},
@@ -44,7 +44,7 @@ export const createApiKeyColumns = (
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="CREATED"
title="Created"
param="inserted_at"
/>
),
@@ -52,7 +52,7 @@ export const createApiKeyColumns = (
},
{
accessorKey: "last_used_at",
header: "LAST USED",
header: "Last Used",
cell: ({ row }) => <LastUsedCell apiKey={row.original} />,
enableSorting: false,
},
@@ -61,7 +61,7 @@ export const createApiKeyColumns = (
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="EXPIRES"
title="Expires"
param="expires_at"
/>
),
@@ -70,7 +70,7 @@ export const createApiKeyColumns = (
{
accessorKey: "revoked",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="STATUS" param="revoked" />
<DataTableColumnHeader column={column} title="Status" param="revoked" />
),
cell: ({ row }) => <StatusCell apiKey={row.original} />,
},
@@ -33,6 +33,9 @@ export function DataTableRowActions({
onRevoke,
}: DataTableRowActionsProps) {
const apiKey = row.original;
const isRevoked = apiKey.attributes.revoked;
const isExpired = new Date(apiKey.attributes.expires_at) < new Date();
const canRevoke = !isRevoked && !isExpired;
return (
<div className="relative flex items-center justify-end gap-2">
@@ -62,23 +65,25 @@ export function DataTableRowActions({
Edit name
</DropdownItem>
</DropdownSection>
<DropdownSection title="Danger zone">
<DropdownItem
key="revoke"
className="text-danger"
color="danger"
description="Revoke this API key permanently"
textValue="Revoke"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-danger")}
/>
}
onPress={() => onRevoke(apiKey)}
>
Revoke
</DropdownItem>
</DropdownSection>
{canRevoke ? (
<DropdownSection title="Danger zone">
<DropdownItem
key="revoke"
className="text-danger"
color="danger"
description="Revoke this API key permanently"
textValue="Revoke"
startContent={
<DeleteDocumentBulkIcon
className={clsx(iconClasses, "!text-danger")}
/>
}
onPress={() => onRevoke(apiKey)}
>
Revoke
</DropdownItem>
</DropdownSection>
) : null}
</DropdownMenu>
</Dropdown>
</div>
@@ -8,10 +8,9 @@ import { createApiKey } from "@/actions/api-keys/api-keys";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { Form } from "@/components/ui/form";
import { Form, FormButtons } from "@/components/ui/form";
import { DEFAULT_EXPIRY_DAYS } from "./api-keys/constants";
import { ModalButtons } from "./api-keys/modal-buttons";
import { calculateExpiryDate } from "./api-keys/utils";
interface CreateApiKeyModalProps {
@@ -45,8 +44,6 @@ export const CreateApiKeyModal = ({
},
});
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: FormValues) => {
try {
const result = await createApiKey({
@@ -99,7 +96,7 @@ export const CreateApiKeyModal = ({
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col gap-4"
>
<div className="flex flex-col gap-2">
<div className="flex w-full justify-center gap-6">
<CustomInput
control={form.control}
name="name"
@@ -127,12 +124,12 @@ export const CreateApiKeyModal = ({
/>
</div>
<ModalButtons
<FormButtons
onCancel={handleClose}
onSubmit={form.handleSubmit(onSubmitClient)}
isLoading={isLoading}
isDisabled={!form.formState.isValid}
submitText="Create API Key"
cancelText="Cancel"
loadingText="Processing..."
isDisabled={!form.formState.isValid}
/>
</form>
</Form>
@@ -9,9 +9,8 @@ import { updateApiKey } from "@/actions/api-keys/api-keys";
import { useToast } from "@/components/ui";
import { CustomInput } from "@/components/ui/custom";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { Form } from "@/components/ui/form";
import { Form, FormButtons } from "@/components/ui/form";
import { ModalButtons } from "./api-keys/modal-buttons";
import { EnrichedApiKey } from "./api-keys/types";
import { isApiKeyNameDuplicate } from "./api-keys/utils";
@@ -45,8 +44,6 @@ export const EditApiKeyNameModal = ({
},
});
const isLoading = form.formState.isSubmitting;
// Sync form data when apiKey changes or modal opens
useEffect(() => {
if (isOpen && apiKey) {
@@ -124,12 +121,12 @@ export const EditApiKeyNameModal = ({
/>
</div>
<ModalButtons
<FormButtons
onCancel={handleClose}
onSubmit={form.handleSubmit(onSubmitClient)}
isLoading={isLoading}
isDisabled={!form.formState.isValid}
submitText="Save Changes"
cancelText="Cancel"
loadingText="Processing..."
isDisabled={!form.formState.isValid}
/>
</form>
</Form>
@@ -1,5 +1,8 @@
"use client";
import { Snippet } from "@heroui/snippet";
import { Trash2Icon } from "lucide-react";
import { revokeApiKey } from "@/actions/api-keys/api-keys";
import {
Alert,
@@ -7,9 +10,9 @@ import {
AlertTitle,
} from "@/components/ui/alert/Alert";
import { CustomAlertModal } from "@/components/ui/custom/custom-alert-modal";
import { ModalButtons } from "@/components/ui/custom/custom-modal-buttons";
import { FALLBACK_VALUES } from "./api-keys/constants";
import { ModalButtons } from "./api-keys/modal-buttons";
import { EnrichedApiKey } from "./api-keys/types";
import { useModalForm } from "./api-keys/use-modal-form";
@@ -54,23 +57,26 @@ export const RevokeApiKeyModal = ({
>
<div className="flex flex-col gap-4">
<Alert variant="destructive">
<AlertTitle className="text-danger-700">⚠️ Warning</AlertTitle>
<AlertDescription className="text-danger-600">
<AlertTitle>⚠️ Warning</AlertTitle>
<AlertDescription>
This action cannot be undone. This API key will be revoked and will
no longer work.
</AlertDescription>
</Alert>
<div className="text-sm">
<div className="flex flex-col gap-2">
<p>Are you sure you want to revoke this API key?</p>
<div className="mt-2 rounded-lg bg-slate-800 p-3">
<p className="font-medium text-white">
{apiKey?.attributes.name || FALLBACK_VALUES.UNNAMED_KEY}
</p>
<p className="mt-1 text-xs text-slate-400">
Prefix: {apiKey?.attributes.prefix}
</p>
</div>
<Snippet
hideSymbol
hideCopyButton={true}
classNames={{
pre: "font-mono text-sm break-all whitespace-pre-wrap",
}}
>
<p>{apiKey?.attributes.name || FALLBACK_VALUES.UNNAMED_KEY}</p>
<p className="mt-1 text-xs">Prefix: {apiKey?.attributes.prefix}</p>
</Snippet>
</div>
{error && (
@@ -87,6 +93,7 @@ export const RevokeApiKeyModal = ({
isDisabled={!apiKey}
submitText="Revoke API Key"
submitColor="danger"
submitIcon={<Trash2Icon size={24} />}
/>
</CustomAlertModal>
);
+1 -1
View File
@@ -1,4 +1,4 @@
import { Fira_Code as FontMono, Work_Sans as FontSans } from "next/font/google";
import { Fira_Code as FontMono, Inter as FontSans } from "next/font/google";
export const fontSans = FontSans({
subsets: ["latin"],
+30 -7
View File
@@ -40,8 +40,8 @@ export const useCredentialsForm = ({
if (providerType === "gcp" && via === "service-account") {
return addCredentialsServiceAccountFormSchema(providerType);
}
// For GitHub, we need to pass the via parameter to determine which fields are required
if (providerType === "github") {
// For GitHub and M365, we need to pass the via parameter to determine which fields are required
if (providerType === "github" || providerType === "m365") {
return addCredentialsFormSchema(providerType, via);
}
return addCredentialsFormSchema(providerType);
@@ -99,13 +99,27 @@ export const useCredentialsForm = ({
[ProviderCredentialFields.TENANT_ID]: "",
};
case "m365":
// M365 credentials based on via parameter
if (via === "app_client_secret") {
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
[ProviderCredentialFields.CLIENT_SECRET]: "",
[ProviderCredentialFields.TENANT_ID]: "",
};
}
if (via === "app_certificate") {
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
[ProviderCredentialFields.CERTIFICATE_CONTENT]: "",
[ProviderCredentialFields.TENANT_ID]: "",
};
}
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
[ProviderCredentialFields.CLIENT_SECRET]: "",
[ProviderCredentialFields.TENANT_ID]: "",
[ProviderCredentialFields.USER]: "",
[ProviderCredentialFields.PASSWORD]: "",
};
case "gcp":
return {
@@ -146,9 +160,14 @@ export const useCredentialsForm = ({
}
};
const defaultValues = getDefaultValues();
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: getDefaultValues(),
defaultValues: defaultValues,
mode: "onSubmit",
reValidateMode: "onChange",
criteriaMode: "all", // Show all errors for each field
});
const { handleServerResponse } = useFormServerErrors(
@@ -169,6 +188,7 @@ export const useCredentialsForm = ({
// Filter out empty values first, then append all remaining values
const filteredValues = filterEmptyValues(values);
Object.entries(filteredValues).forEach(([key, value]) => {
formData.append(key, value);
});
@@ -181,9 +201,12 @@ export const useCredentialsForm = ({
}
};
const { isSubmitting, errors } = form.formState;
return {
form,
isLoading: form.formState.isSubmitting,
isLoading: isSubmitting,
errors,
handleSubmit,
handleBackStep,
searchParamsObj,
+4 -3
View File
@@ -8,6 +8,7 @@ import {
AWSWellArchitectedAttributesMetadata,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -68,9 +69,9 @@ export const mapComplianceData = (
description: description,
status: finalStatus,
check_ids: checks,
pass: finalStatus === "PASS" ? 1 : 0,
fail: finalStatus === "FAIL" ? 1 : 0,
manual: finalStatus === "MANUAL" ? 1 : 0,
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
well_architected_name: attrs.Name,
well_architected_question_id: attrs.WellArchitectedQuestionId,
well_architected_practice_id: attrs.WellArchitectedPracticeId,
+179
View File
@@ -0,0 +1,179 @@
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
C5AttributesMetadata,
Control,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
import {
calculateFrameworkCounters,
createRequirementsMap,
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
} from "./commons";
const getStatusCounters = (status: RequirementStatus) => ({
pass: status === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: status === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: status === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
});
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
): Framework[] => {
const attributes = attributesData?.data || [];
const requirementsMap = createRequirementsMap(requirementsData);
const frameworks: Framework[] = [];
// Process attributes and merge with requirements data
for (const attributeItem of attributes) {
const id = attributeItem.id;
const metadataArray = attributeItem.attributes?.attributes
?.metadata as unknown as C5AttributesMetadata[];
const attrs = metadataArray?.[0];
if (!attrs) continue;
// Get corresponding requirement data
const requirementData = requirementsMap.get(id);
if (!requirementData) continue;
const frameworkName = attributeItem.attributes.framework;
const categoryName = attrs.Section; // Level 1: Section (e.g., "Organisation of Information Security (OIS)")
const controlLabel = attrs.SubSection; // Level 2: SubSection (e.g., "OIS-01 Information Security Management System (ISMS)")
const description = attributeItem.attributes.description;
const status = requirementData.attributes.status || "";
const checks = attributeItem.attributes.attributes.check_ids || [];
const requirementName = id;
// Find or create framework using common helper
const framework = findOrCreateFramework(frameworks, frameworkName);
// Find or create category (Section) using common helper
const category = findOrCreateCategory(framework.categories, categoryName);
// Find or create control (SubSection) using common helper
const control = findOrCreateControl(category.controls, controlLabel);
// Create requirement
const finalStatus: RequirementStatus = status as RequirementStatus;
const requirement: Requirement = {
name: requirementName,
description,
status: finalStatus,
check_ids: checks,
...getStatusCounters(finalStatus),
type: attrs.Type,
about_criteria: attrs.AboutCriteria,
complementary_criteria: attrs.ComplementaryCriteria,
};
control.requirements.push(requirement);
}
// Calculate counters using common helper
calculateFrameworkCounters(frameworks);
return frameworks;
};
const createRequirementItem = (
requirement: Requirement,
frameworkName: string,
categoryName: string,
controlIndex: number,
reqIndex: number,
scanId: string,
): AccordionItemProps => ({
key: `${frameworkName}-${categoryName}-control-${controlIndex}-req-${reqIndex}`,
title: (
<ComplianceAccordionRequirementTitle
type={requirement.type as string}
name={requirement.name}
status={requirement.status as FindingStatus}
/>
),
content: (
<ClientAccordionContent
requirement={requirement}
scanId={scanId}
framework={frameworkName}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
});
const createControlItem = (
control: Control,
frameworkName: string,
categoryName: string,
controlIndex: number,
scanId: string,
): AccordionItemProps => ({
key: `${frameworkName}-${categoryName}-control-${controlIndex}`,
title: (
<ComplianceAccordionTitle
label={control.label}
pass={control.pass}
fail={control.fail}
manual={control.manual}
/>
),
content: "",
items: control.requirements.map((requirement, reqIndex) =>
createRequirementItem(
requirement,
frameworkName,
categoryName,
controlIndex,
reqIndex,
scanId,
),
),
isDisabled: control.pass === 0 && control.fail === 0 && control.manual === 0,
});
export const toAccordionItems = (
data: Framework[],
scanId: string | undefined,
): AccordionItemProps[] => {
const safeId = scanId || "";
return data.flatMap((framework) =>
framework.categories.map((category) => ({
key: `${framework.name}-${category.name}`,
title: (
<ComplianceAccordionTitle
label={category.name}
pass={category.pass}
fail={category.fail}
manual={category.manual}
isParentLevel={true}
/>
),
content: "",
items: category.controls.map((control, controlIndex) =>
createControlItem(
control,
framework.name,
category.name,
controlIndex,
safeId,
),
),
})),
);
};
+243
View File
@@ -0,0 +1,243 @@
import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content";
import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title";
import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title";
import { AccordionItemProps } from "@/components/ui/accordion/Accordion";
import { FindingStatus } from "@/components/ui/table/status-finding-badge";
import {
AttributesData,
CCCAttributesMetadata,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
import {
calculateFrameworkCounters,
createRequirementsMap,
findOrCreateCategory,
findOrCreateControl,
findOrCreateFramework,
updateCounters,
} from "./commons";
interface ProcessedItem {
id: string;
attrs: CCCAttributesMetadata;
attributeItem: any;
requirementData: any;
}
// CCC-specific section configuration
export interface CCCTextSection {
title: string;
key: keyof Requirement;
className?: string;
}
export interface CCCMappingSection {
title: string;
key: keyof Requirement;
colorClasses: string;
}
export const CCC_TEXT_SECTIONS: CCCTextSection[] = [
{
title: "Description",
key: "description",
},
{
title: "Family Description",
key: "family_description",
},
{
title: "SubSection",
key: "subsection",
},
{
title: "SubSection Objective",
key: "subsection_objective",
className: "whitespace-pre-wrap",
},
{
title: "Recommendation",
key: "recommendation",
className: "whitespace-pre-wrap",
},
];
export const CCC_MAPPING_SECTIONS: CCCMappingSection[] = [
{
title: "Threat Mappings",
key: "section_threat_mappings",
colorClasses:
"bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20",
},
{
title: "Guideline Mappings",
key: "section_guideline_mappings",
colorClasses:
"bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20",
},
];
const createRequirement = (itemData: ProcessedItem): Requirement => {
const { id, attrs, attributeItem, requirementData } = itemData;
const description = attributeItem.attributes.description;
const status = requirementData.attributes.status || "";
const checks = attributeItem.attributes.attributes.check_ids || [];
const finalStatus: RequirementStatus = status as RequirementStatus;
return {
name: id,
description: description,
status: finalStatus,
check_ids: checks,
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
family_name: attrs.FamilyName,
family_description: attrs.FamilyDescription,
subsection: attrs.SubSection,
subsection_objective: attrs.SubSectionObjective,
applicability: attrs.Applicability,
recommendation: attrs.Recommendation,
section_threat_mappings: attrs.SectionThreatMappings,
section_guideline_mappings: attrs.SectionGuidelineMappings,
};
};
export const mapComplianceData = (
attributesData: AttributesData,
requirementsData: RequirementsData,
): Framework[] => {
const attributes = attributesData?.data || [];
const requirementsMap = createRequirementsMap(requirementsData);
const frameworks: Framework[] = [];
const itemsByFramework = new Map<string, ProcessedItem[]>();
// First pass: collect all data
for (const attributeItem of attributes) {
const id = attributeItem.id;
const metadataArray = attributeItem.attributes?.attributes
?.metadata as unknown as CCCAttributesMetadata[];
const attrs = metadataArray?.[0];
if (!attrs) continue;
const requirementData = requirementsMap.get(id);
if (!requirementData) continue;
const frameworkName = attributeItem.attributes.framework;
if (!itemsByFramework.has(frameworkName)) {
itemsByFramework.set(frameworkName, []);
}
itemsByFramework.get(frameworkName)!.push({
id,
attrs,
attributeItem,
requirementData,
});
}
// Process each framework
for (const [frameworkName, items] of Array.from(itemsByFramework.entries())) {
const framework = findOrCreateFramework(frameworks, frameworkName);
// Group by FamilyName (Category) -> Section (Control) -> Requirements
for (const itemData of items) {
const requirement = createRequirement(itemData);
const familyName = itemData.attrs.FamilyName;
const sectionName = itemData.attrs.Section;
// Create 3-level hierarchy: FamilyName -> Section -> Requirements
const category = findOrCreateCategory(framework.categories, familyName);
const control = findOrCreateControl(category.controls, sectionName);
control.requirements.push(requirement);
updateCounters(control, requirement.status);
}
}
// Calculate counters using common helper
calculateFrameworkCounters(frameworks);
return frameworks;
};
// Helper function to create accordion item for requirement
const createRequirementAccordionItem = (
requirement: Requirement,
itemKey: string,
scanId: string,
frameworkName: string,
): AccordionItemProps => ({
key: itemKey,
title: (
<ComplianceAccordionRequirementTitle
type=""
name={requirement.name}
status={requirement.status as FindingStatus}
/>
),
content: (
<ClientAccordionContent
requirement={requirement}
scanId={scanId}
framework={frameworkName}
disableFindings={
requirement.check_ids.length === 0 && requirement.manual === 0
}
/>
),
items: [],
});
export const toAccordionItems = (
data: Framework[],
scanId: string | undefined,
): AccordionItemProps[] => {
return data.flatMap((framework) =>
framework.categories.map((category) => ({
key: `${framework.name}-${category.name}`,
title: (
<ComplianceAccordionTitle
label={category.name}
pass={category.pass}
fail={category.fail}
manual={category.manual}
isParentLevel={true}
/>
),
content: "",
items: category.controls.map((control, i: number) => {
const baseKey = `${framework.name}-${category.name}-control-${i}`;
return {
key: baseKey,
title: (
<ComplianceAccordionTitle
label={control.label}
pass={control.pass}
fail={control.fail}
manual={control.manual}
/>
),
content: "",
items: control.requirements.map((requirement, j: number) =>
createRequirementAccordionItem(
requirement,
`${baseKey}-req-${j}`,
scanId || "",
framework.name,
),
),
isDisabled:
control.pass === 0 && control.fail === 0 && control.manual === 0,
};
}),
})),
);
};
+4 -3
View File
@@ -8,6 +8,7 @@ import {
CISAttributesMetadata,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -79,9 +80,9 @@ export const mapComplianceData = (
description: attrs.Description,
status: finalStatus,
check_ids: checks,
pass: finalStatus === "PASS" ? 1 : 0,
fail: finalStatus === "FAIL" ? 1 : 0,
manual: finalStatus === "MANUAL" ? 1 : 0,
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
profile: attrs.Profile,
subsection: attrs.SubSection || "",
assessment_status: attrs.AssessmentStatus,
+5 -4
View File
@@ -3,6 +3,7 @@ import {
FailedSection,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementItemData,
RequirementsData,
RequirementStatus,
@@ -12,11 +13,11 @@ export const updateCounters = (
target: { pass: number; fail: number; manual: number },
status: RequirementStatus,
) => {
if (status === "MANUAL") {
if (status === REQUIREMENT_STATUS.MANUAL) {
target.manual++;
} else if (status === "PASS") {
} else if (status === REQUIREMENT_STATUS.PASS) {
target.pass++;
} else if (status === "FAIL") {
} else if (status === REQUIREMENT_STATUS.FAIL) {
target.fail++;
}
};
@@ -30,7 +31,7 @@ export const getTopFailedSections = (
framework.categories.forEach((category) => {
category.controls.forEach((control) => {
control.requirements.forEach((requirement) => {
if (requirement.status === "FAIL") {
if (requirement.status === REQUIREMENT_STATUS.FAIL) {
const sectionName = category.name;
if (!failedSectionMap.has(sectionName)) {
+28
View File
@@ -1,6 +1,8 @@
import React from "react";
import { AWSWellArchitectedCustomDetails } from "@/components/compliance/compliance-custom-details/aws-well-architected-details";
import { C5CustomDetails } from "@/components/compliance/compliance-custom-details/c5-details";
import { CCCCustomDetails } from "@/components/compliance/compliance-custom-details/ccc-details";
import { CISCustomDetails } from "@/components/compliance/compliance-custom-details/cis-details";
import { ENSCustomDetails } from "@/components/compliance/compliance-custom-details/ens-details";
import { GenericCustomDetails } from "@/components/compliance/compliance-custom-details/generic-details";
@@ -22,6 +24,14 @@ import {
mapComplianceData as mapAWSWellArchitectedComplianceData,
toAccordionItems as toAWSWellArchitectedAccordionItems,
} from "./aws-well-architected";
import {
mapComplianceData as mapC5ComplianceData,
toAccordionItems as toC5AccordionItems,
} from "./c5";
import {
mapComplianceData as mapCCCComplianceData,
toAccordionItems as toCCCAccordionItems,
} from "./ccc";
import {
mapComplianceData as mapCISComplianceData,
toAccordionItems as toCISAccordionItems,
@@ -80,6 +90,15 @@ const getDefaultMapper = (): ComplianceMapper => ({
});
const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
C5: {
mapComplianceData: mapC5ComplianceData,
toAccordionItems: toC5AccordionItems,
getTopFailedSections,
calculateCategoryHeatmapData: (data: Framework[]) =>
calculateCategoryHeatmapData(data),
getDetailsComponent: (requirement: Requirement) =>
React.createElement(C5CustomDetails, { requirement }),
},
ENS: {
mapComplianceData: mapENSComplianceData,
toAccordionItems: toENSAccordionItems,
@@ -151,6 +170,15 @@ const getComplianceMappers = (): Record<string, ComplianceMapper> => ({
getDetailsComponent: (requirement: Requirement) =>
React.createElement(ThreatCustomDetails, { requirement }),
},
CCC: {
mapComplianceData: mapCCCComplianceData,
toAccordionItems: toCCCAccordionItems,
getTopFailedSections,
calculateCategoryHeatmapData: (data: Framework[]) =>
calculateCategoryHeatmapData(data),
getDetailsComponent: (requirement: Requirement) =>
React.createElement(CCCCustomDetails, { requirement }),
},
});
/**
+5 -4
View File
@@ -8,6 +8,7 @@ import {
ENSAttributesMetadata,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -82,7 +83,7 @@ export const mapComplianceData = (
// Create requirement
const finalStatus: RequirementStatus = isManual
? "MANUAL"
? REQUIREMENT_STATUS.MANUAL
: (status as RequirementStatus);
const requirement: Requirement = {
name: requirementName,
@@ -90,9 +91,9 @@ export const mapComplianceData = (
status: finalStatus,
type,
check_ids: checks,
pass: finalStatus === "PASS" ? 1 : 0,
fail: finalStatus === "FAIL" ? 1 : 0,
manual: finalStatus === "MANUAL" ? 1 : 0,
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
nivel: attrs.Nivel || "",
dimensiones: attrs.Dimensiones || [],
};
+4 -3
View File
@@ -8,6 +8,7 @@ import {
Framework,
GenericAttributesMetadata,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -41,9 +42,9 @@ const createRequirement = (itemData: ProcessedItem): Requirement => {
description: description,
status: finalStatus,
check_ids: checks,
pass: finalStatus === "PASS" ? 1 : 0,
fail: finalStatus === "FAIL" ? 1 : 0,
manual: finalStatus === "MANUAL" ? 1 : 0,
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
item_id: attrs.ItemId,
subsection: attrs.SubSection,
subgroup: attrs.SubGroup || undefined,
+4 -3
View File
@@ -8,6 +8,7 @@ import {
Framework,
ISO27001AttributesMetadata,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -66,9 +67,9 @@ export const mapComplianceData = (
description: description,
status: finalStatus,
check_ids: checks,
pass: finalStatus === "PASS" ? 1 : 0,
fail: finalStatus === "FAIL" ? 1 : 0,
manual: finalStatus === "MANUAL" ? 1 : 0,
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
objetive_name: objetiveName,
check_summary: checkSummary,
control_label: controlLabel,
+4 -3
View File
@@ -8,6 +8,7 @@ import {
Framework,
KISAAttributesMetadata,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -65,9 +66,9 @@ export const mapComplianceData = (
description: description,
status: finalStatus,
check_ids: checks,
pass: finalStatus === "PASS" ? 1 : 0,
fail: finalStatus === "FAIL" ? 1 : 0,
manual: finalStatus === "MANUAL" ? 1 : 0,
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
section: sectionName,
audit_checklist: attrs.AuditChecklist,
related_regulations: attrs.RelatedRegulations,
+5 -4
View File
@@ -9,6 +9,7 @@ import {
Framework,
MITREAttributesMetadata,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
} from "@/types/compliance";
@@ -62,9 +63,9 @@ export const mapComplianceData = (
description: description,
status: finalStatus,
check_ids: checks,
pass: finalStatus === "PASS" ? 1 : 0,
fail: finalStatus === "FAIL" ? 1 : 0,
manual: finalStatus === "MANUAL" ? 1 : 0,
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
// MITRE specific fields
technique_id: id,
technique_name: techniqueName,
@@ -154,7 +155,7 @@ export const getTopFailedSections = (
const requirements = (framework as any).requirements || [];
requirements.forEach((requirement: Requirement) => {
if (requirement.status === "FAIL") {
if (requirement.status === REQUIREMENT_STATUS.FAIL) {
const tactics = (requirement.tactics as string[]) || [];
tactics.forEach((tactic) => {
+5 -4
View File
@@ -7,6 +7,7 @@ import {
AttributesData,
Framework,
Requirement,
REQUIREMENT_STATUS,
RequirementsData,
RequirementStatus,
ThreatAttributesMetadata,
@@ -56,7 +57,7 @@ export const mapComplianceData = (
const totalFindings = requirementData.attributes.total_findings || 0;
// Calculate score: if PASS = levelOfRisk * weight, if FAIL = 0
const score = status === "PASS" ? levelOfRisk * weight : 0;
const score = status === REQUIREMENT_STATUS.PASS ? levelOfRisk * weight : 0;
// Find or create framework using common helper
const framework = findOrCreateFramework(frameworks, frameworkName);
@@ -74,9 +75,9 @@ export const mapComplianceData = (
description: description,
status: finalStatus,
check_ids: checks,
pass: finalStatus === "PASS" ? 1 : 0,
fail: finalStatus === "FAIL" ? 1 : 0,
manual: finalStatus === "MANUAL" ? 1 : 0,
pass: finalStatus === REQUIREMENT_STATUS.PASS ? 1 : 0,
fail: finalStatus === REQUIREMENT_STATUS.FAIL ? 1 : 0,
manual: finalStatus === REQUIREMENT_STATUS.MANUAL ? 1 : 0,
title: title,
levelOfRisk: levelOfRisk,
weight: weight,
+22 -3
View File
@@ -18,6 +18,7 @@ import {
VolumeX,
Warehouse,
} from "lucide-react";
import type { MouseEvent } from "react";
import { ProwlerShort } from "@/components/icons";
import {
@@ -39,12 +40,14 @@ interface MenuListOptions {
pathname: string;
hasProviders?: boolean;
openMutelistModal?: () => void;
requestMutelistModalOpen?: () => void;
}
export const getMenuList = ({
pathname,
hasProviders,
openMutelistModal,
requestMutelistModalOpen,
}: MenuListOptions): GroupProps[] => {
return [
{
@@ -164,12 +167,28 @@ export const getMenuList = ({
submenus: [
{ href: "/providers", label: "Cloud Providers", icon: CloudCog },
{
// Use trailing slash to prevent both menu items from being active at /providers
href: "/providers/",
href: "/providers",
label: "Mutelist",
icon: VolumeX,
disabled: hasProviders === false,
onClick: openMutelistModal,
active: false,
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
if (hasProviders === false) {
event.preventDefault();
event.stopPropagation();
return;
}
requestMutelistModalOpen?.();
if (pathname !== "/providers") {
return;
}
event.preventDefault();
event.stopPropagation();
openMutelistModal?.();
},
},
{ href: "/manage-groups", label: "Provider Groups", icon: Group },
{ href: "/scans", label: "Scan Jobs", icon: Timer },
@@ -80,14 +80,21 @@ export const buildAzureSecret = (formData: FormData) => {
export const buildM365Secret = (formData: FormData) => {
const secret = {
...buildAzureSecret(formData),
[ProviderCredentialFields.USER]: getFormValue(
[ProviderCredentialFields.CLIENT_ID]: getFormValue(
formData,
ProviderCredentialFields.USER,
ProviderCredentialFields.CLIENT_ID,
),
[ProviderCredentialFields.PASSWORD]: getFormValue(
[ProviderCredentialFields.TENANT_ID]: getFormValue(
formData,
ProviderCredentialFields.PASSWORD,
ProviderCredentialFields.TENANT_ID,
),
[ProviderCredentialFields.CLIENT_SECRET]: getFormValue(
formData,
ProviderCredentialFields.CLIENT_SECRET,
),
[ProviderCredentialFields.CERTIFICATE_CONTENT]: getFormValue(
formData,
ProviderCredentialFields.CERTIFICATE_CONTENT,
),
};
return filterEmptyValues(secret);
@@ -29,6 +29,7 @@ export const ProviderCredentialFields = {
TENANT_ID: "tenant_id",
USER: "user",
PASSWORD: "password",
CERTIFICATE_CONTENT: "certificate_content",
// GCP fields
REFRESH_TOKEN: "refresh_token",
@@ -70,6 +71,7 @@ export const ErrorPointers = {
OAUTH_APP_TOKEN: "/data/attributes/secret/oauth_app_token",
GITHUB_APP_ID: "/data/attributes/secret/github_app_id",
GITHUB_APP_KEY: "/data/attributes/secret/github_app_key_content",
CERTIFICATE_CONTENT: "/data/attributes/secret/certificate_content",
} as const;
export type ErrorPointer = (typeof ErrorPointers)[keyof typeof ErrorPointers];
+11 -1
View File
@@ -53,7 +53,7 @@ export const getProviderFormType = (
via?: string,
): ProviderFormType => {
// Providers that need credential type selection
const needsSelector = ["aws", "gcp", "github"].includes(providerType);
const needsSelector = ["aws", "gcp", "github", "m365"].includes(providerType);
// Show selector if no via parameter and provider needs it
if (needsSelector && !via) {
@@ -80,6 +80,14 @@ export const getProviderFormType = (
return "credentials";
}
// M365 credential types
if (
providerType === "m365" &&
["app_client_secret", "app_certificate"].includes(via || "")
) {
return "credentials";
}
// Other providers go directly to credentials form
if (!needsSelector) {
return "credentials";
@@ -99,6 +107,8 @@ export const requiresBackButton = (via?: string | null): boolean => {
"personal_access_token",
"oauth_app",
"github_app",
"app_client_secret",
"app_certificate",
];
return validViaTypes.includes(via);
+8
View File
@@ -74,6 +74,14 @@ export default defineConfig({
name: "invite-and-manage-users.auth.setup",
testMatch: "invite-and-manage-users.auth.setup.ts",
},
// All authentication setups combined
// Runs all authentication setup files to create all user states
{
name: "all.auth.setup",
testMatch: "**/*.auth.setup.ts",
},
// ===========================================
// Test Suite Projects
// ===========================================
+11 -1
View File
@@ -5,12 +5,15 @@ interface UIStoreState {
isSideMenuOpen: boolean;
isMutelistModalOpen: boolean;
hasProviders: boolean;
shouldAutoOpenMutelist: boolean;
openSideMenu: () => void;
closeSideMenu: () => void;
openMutelistModal: () => void;
closeMutelistModal: () => void;
setHasProviders: (value: boolean) => void;
requestMutelistModalOpen: () => void;
resetMutelistModalRequest: () => void;
}
export const useUIStore = create<UIStoreState>()(
@@ -19,11 +22,18 @@ export const useUIStore = create<UIStoreState>()(
isSideMenuOpen: false,
isMutelistModalOpen: false,
hasProviders: false,
shouldAutoOpenMutelist: false,
openSideMenu: () => set({ isSideMenuOpen: true }),
closeSideMenu: () => set({ isSideMenuOpen: false }),
openMutelistModal: () => set({ isMutelistModalOpen: true }),
openMutelistModal: () =>
set({
isMutelistModalOpen: true,
shouldAutoOpenMutelist: false,
}),
closeMutelistModal: () => set({ isMutelistModalOpen: false }),
setHasProviders: (value: boolean) => set({ hasProviders: value }),
requestMutelistModalOpen: () => set({ shouldAutoOpenMutelist: true }),
resetMutelistModalRequest: () => set({ shouldAutoOpenMutelist: false }),
}),
{
name: "ui-store",
-235
View File
@@ -1,241 +1,6 @@
@import "tailwindcss";
@config "../tailwind.config.js";
@theme {
/* Font Families are injected by Next.js from config/fonts.ts */
/* --font-sans: Work Sans (from Next.js) */
/* --font-mono: Fira Code (from Next.js) */
/* Text Sizes with Line Heights - Exact from Figma Design System */
--text-xs: 0.625rem; /* 10px */
--text-xs--line-height: 1.6;
--text-sm: 0.75rem; /* 12px */
--text-sm--line-height: 1.667;
--text-base: 0.875rem; /* 14px */
--text-base--line-height: 1.714;
--text-lg: 1rem; /* 16px */
--text-lg--line-height: 1.75;
--text-xl: 1.125rem; /* 18px */
--text-xl--line-height: 1.556;
--text-2xl: 1.25rem; /* 20px */
--text-2xl--line-height: 1.6;
--text-3xl: 1.5rem; /* 24px */
--text-3xl--line-height: 1.5;
--text-4xl: 1.875rem; /* 30px */
--text-4xl--line-height: 1.333;
--text-5xl: 2.25rem; /* 36px */
--text-5xl--line-height: 1.333;
--text-6xl: 3rem; /* 48px */
--text-6xl--line-height: 1.25;
--text-7xl: 3.75rem; /* 60px */
--text-7xl--line-height: 1.2;
--text-8xl: 4.5rem; /* 72px */
--text-8xl--line-height: 1.333;
--text-9xl: 6rem; /* 96px */
--text-9xl--line-height: 1.333;
/* Font Weights - Complete Inter Scale */
--font-weight-thin: 100;
--font-weight-extralight: 200;
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
--font-weight-black: 900;
/* Info & Primary Colors */
--color-info: #2e51b2;
--color-primary: #2e51b2;
/* Warning Colors */
--color-warning: #fdd34f;
--color-warning-emphasis: #ff7d19;
/* Danger & Destructive Colors */
--color-danger: #ff3077;
--color-danger-emphasis: #971348;
--color-destructive: #db2b49;
/* Success Colors */
--color-success: #86da26;
--color-success-emphasis: #20b853;
/* Provider-specific Colors */
--color-orange: #ff9800;
--color-cyan: #06b6d4;
--color-red: #ef4444;
/* Accent Colors */
--color-magenta: #b51c80;
--color-purple-dark: #5f1551;
/* Chart Card Colors - From Figma */
--color-card-bg: #080f23;
--color-card-border: #171d30;
--color-text-accent: #fface2;
--color-page-bg: #1a2034;
/* Alert Pill Colors */
--color-alert-pill-bg: #432232;
--color-alert-pill-text: #f54280;
/* ===========================
Border Radius
=========================== */
/* Widget Border Radius - From Figma */
--radius-widget: 12px;
/* White & Black */
--color-white: #ffffff;
--color-black: #000000;
/* Slate Scale */
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-300: #cbd5e1;
--color-slate-400: #94a3b8;
--color-slate-500: #64748b;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
--color-slate-950: #020617;
/* Gray Scale */
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
--color-gray-950: #030712;
/* Zinc Scale */
--color-zinc-50: #fafafa;
--color-zinc-100: #f4f4f5;
--color-zinc-200: #e4e4e7;
--color-zinc-300: #d4d4d8;
--color-zinc-400: #a1a1aa;
--color-zinc-500: #71717a;
--color-zinc-600: #52525b;
--color-zinc-700: #3f3f46;
--color-zinc-800: #27272a;
--color-zinc-900: #18181b;
--color-zinc-950: #09090b;
/* Stone Scale */
--color-stone-50: #fafaf9;
--color-stone-100: #f5f5f4;
--color-stone-200: #e7e5e4;
--color-stone-300: #d6d3d1;
--color-stone-400: #a8a29e;
--color-stone-500: #78716c;
--color-stone-600: #57534e;
--color-stone-700: #44403c;
--color-stone-800: #292524;
--color-stone-900: #1c1917;
--color-stone-950: #0c0a09;
/* Red Scale */
--color-red-50: #fef2f2;
--color-red-100: #fee2e2;
--color-red-200: #fecaca;
--color-red-300: #fca5a5;
--color-red-400: #f87171;
--color-red-500: #ef4444;
--color-red-600: #dc2626;
--color-red-700: #b91c1c;
--color-red-800: #991b1b;
--color-red-900: #7f1d1d;
--color-red-950: #450a0a;
/* Rose Scale */
--color-rose-50: #fff1f2;
--color-rose-100: #ffe4e6;
--color-rose-200: #fecdd3;
--color-rose-300: #fda4af;
--color-rose-400: #fb7185;
--color-rose-500: #f43f5e;
--color-rose-600: #e11d48;
--color-rose-700: #be123c;
--color-rose-800: #9f1239;
--color-rose-900: #881337;
--color-rose-950: #4c0519;
/* Pink Scale */
--color-pink-50: #fdf2f8;
--color-pink-100: #fce7f3;
--color-pink-200: #fbcfe8;
--color-pink-300: #f9a8d4;
--color-pink-400: #f472b6;
--color-pink-500: #ec4899;
--color-pink-600: #db2777;
--color-pink-700: #be185d;
--color-pink-800: #9d174d;
--color-pink-900: #831843;
--color-pink-950: #500724;
/* Fuchsia Scale */
--color-fuchsia-50: #fdf4ff;
--color-fuchsia-100: #fae8ff;
--color-fuchsia-200: #f5d0fe;
--color-fuchsia-300: #f0abfc;
--color-fuchsia-400: #e879f9;
--color-fuchsia-500: #d946ef;
--color-fuchsia-600: #c026d3;
--color-fuchsia-700: #a21caf;
--color-fuchsia-800: #86198f;
--color-fuchsia-900: #701a75;
--color-fuchsia-950: #4a044e;
/* Purple Scale */
--color-purple-50: #faf5ff;
--color-purple-100: #f3e8ff;
--color-purple-200: #e9d5ff;
--color-purple-300: #d8b4fe;
--color-purple-400: #c084fc;
--color-purple-500: #a855f7;
--color-purple-600: #9333ea;
--color-purple-700: #7e22ce;
--color-purple-800: #6b21a8;
--color-purple-900: #581c87;
--color-purple-950: #3b0764;
/* Violet Scale */
--color-violet-50: #f5f3ff;
--color-violet-100: #ede9fe;
--color-violet-200: #ddd6fe;
--color-violet-300: #c4b5fd;
--color-violet-400: #a78bfa;
--color-violet-500: #8b5cf6;
--color-violet-600: #7c3aed;
--color-violet-700: #6d28d9;
--color-violet-800: #5b21b6;
--color-violet-900: #4c1d95;
--color-violet-950: #2e1065;
/* Indigo Scale */
--color-indigo-50: #eef2ff;
--color-indigo-100: #e0e7ff;
--color-indigo-200: #c7d2fe;
--color-indigo-300: #a5b4fc;
--color-indigo-400: #818cf8;
--color-indigo-500: #6366f1;
--color-indigo-600: #4f46e5;
--color-indigo-700: #4338ca;
--color-indigo-800: #3730a3;
--color-indigo-900: #312e81;
--color-indigo-950: #1e1b4b;
}
@layer base {
:root {
--chart-success: 146 80% 35%;
+125
View File
@@ -0,0 +1,125 @@
import { Page, Locator, expect } from "@playwright/test";
export class HomePage {
readonly page: Page;
// Main content elements
readonly mainContent: Locator;
readonly breadcrumbs: Locator;
readonly overviewHeading: Locator;
// Navigation elements
readonly navigationMenu: Locator;
readonly userMenu: Locator;
readonly signOutButton: Locator;
// Dashboard elements
readonly dashboardCards: Locator;
readonly overviewSection: Locator;
// UI elements
readonly themeToggle: Locator;
readonly logo: Locator;
constructor(page: Page) {
this.page = page;
// Main content elements
this.mainContent = page.locator("main");
this.breadcrumbs = page.getByLabel("Breadcrumbs");
this.overviewHeading = page.getByRole("heading", { name: "Overview", exact: true });
// Navigation elements
this.navigationMenu = page.locator("nav");
this.userMenu = page.getByRole("button", { name: /user menu/i });
this.signOutButton = page.getByRole("button", { name: "Sign out" });
// Dashboard elements
this.dashboardCards = page.locator('[data-testid="dashboard-card"]');
this.overviewSection = page.locator('[data-testid="overview-section"]');
// UI elements
this.themeToggle = page.getByLabel("Toggle theme");
this.logo = page.locator('svg[width="300"]');
}
// Navigation methods
async goto(): Promise<void> {
await this.page.goto("/");
await this.waitForPageLoad();
}
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
}
// Verification methods
async verifyPageLoaded(): Promise<void> {
await expect(this.page).toHaveURL("/");
await expect(this.mainContent).toBeVisible();
await expect(this.overviewHeading).toBeVisible();
await this.waitForPageLoad();
}
async verifyBreadcrumbs(): Promise<void> {
await expect(this.breadcrumbs).toBeVisible();
await expect(this.overviewHeading).toBeVisible();
}
async verifyMainContent(): Promise<void> {
await expect(this.mainContent).toBeVisible();
}
// Navigation methods
async navigateToOverview(): Promise<void> {
await this.overviewHeading.click();
}
async openUserMenu(): Promise<void> {
await this.userMenu.click();
}
async signOut(): Promise<void> {
await this.openUserMenu();
await this.signOutButton.click();
}
// Dashboard methods
async verifyDashboardCards(): Promise<void> {
await expect(this.dashboardCards.first()).toBeVisible();
}
async verifyOverviewSection(): Promise<void> {
await expect(this.overviewSection).toBeVisible();
}
// Utility methods
async refresh(): Promise<void> {
await this.page.reload();
await this.waitForPageLoad();
}
async goBack(): Promise<void> {
await this.page.goBack();
await this.waitForPageLoad();
}
// Accessibility methods
async verifyKeyboardNavigation(): Promise<void> {
// Test tab navigation through main elements
await this.page.keyboard.press("Tab");
await expect(this.themeToggle).toBeFocused();
}
// Wait methods
async waitForRedirect(expectedUrl: string): Promise<void> {
await this.page.waitForURL(expectedUrl);
}
async waitForContentLoad(): Promise<void> {
await this.page.waitForFunction(() => {
const main = document.querySelector("main");
return main && main.offsetHeight > 0;
});
}
}
+316
View File
@@ -0,0 +1,316 @@
import { Page, Locator, expect } from "@playwright/test";
import { HomePage } from "./home-page";
export interface SignInCredentials {
email: string;
password: string;
}
export interface SocialAuthConfig {
googleEnabled: boolean;
githubEnabled: boolean;
}
export class SignInPage {
readonly page: Page;
readonly homePage: HomePage;
// Form elements
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly form: Locator;
// Social authentication buttons
readonly googleButton: Locator;
readonly githubButton: Locator;
readonly samlButton: Locator;
// Navigation elements
readonly signUpLink: Locator;
readonly backButton: Locator;
// UI elements
readonly title: Locator;
readonly logo: Locator;
readonly themeToggle: Locator;
// Error messages
readonly errorMessages: Locator;
readonly loadingIndicator: Locator;
// SAML specific elements
readonly samlModeTitle: Locator;
readonly samlEmailInput: Locator;
constructor(page: Page) {
this.page = page;
this.homePage = new HomePage(page);
// Form elements
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.loginButton = page.getByRole("button", { name: "Log in" });
this.form = page.locator("form");
// Social authentication buttons
this.googleButton = page.getByText("Continue with Google");
this.githubButton = page.getByText("Continue with Github");
this.samlButton = page.getByText("Continue with SAML SSO");
// Navigation elements
this.signUpLink = page.getByRole("link", { name: "Sign up" });
this.backButton = page.getByText("Back");
// UI elements
this.title = page.getByText("Sign in", { exact: true });
this.logo = page.locator('svg[width="300"]');
this.themeToggle = page.getByLabel("Toggle theme");
// Error messages
this.errorMessages = page.locator('[role="alert"], .error-message, [data-testid="error"]');
this.loadingIndicator = page.getByText("Loading");
// SAML specific elements
this.samlModeTitle = page.getByText("Sign in with SAML SSO");
this.samlEmailInput = page.getByLabel("Email");
}
// Navigation methods
async goto(): Promise<void> {
await this.page.goto("/sign-in");
await this.waitForPageLoad();
}
async waitForPageLoad(): Promise<void> {
await this.page.waitForLoadState("networkidle");
}
// Form interaction methods
async fillEmail(email: string): Promise<void> {
await this.emailInput.fill(email);
}
async fillPassword(password: string): Promise<void> {
await this.passwordInput.fill(password);
}
async fillCredentials(credentials: SignInCredentials): Promise<void> {
await this.fillEmail(credentials.email);
await this.fillPassword(credentials.password);
}
async submitForm(): Promise<void> {
await this.loginButton.click();
}
async login(credentials: SignInCredentials): Promise<void> {
await this.fillCredentials(credentials);
await this.submitForm();
}
// Social authentication methods
async clickGoogleAuth(): Promise<void> {
await this.googleButton.click();
}
async clickGithubAuth(): Promise<void> {
await this.githubButton.click();
}
async clickSamlAuth(): Promise<void> {
await this.samlButton.click();
}
// SAML SSO methods
async toggleSamlMode(): Promise<void> {
await this.clickSamlAuth();
}
async goBackFromSaml(): Promise<void> {
await this.backButton.click();
}
async fillSamlEmail(email: string): Promise<void> {
await this.samlEmailInput.fill(email);
}
async submitSamlForm(): Promise<void> {
await this.submitForm();
}
// Navigation methods
async goToSignUp(): Promise<void> {
await this.signUpLink.click();
}
// Validation and assertion methods
async verifyPageLoaded(): Promise<void> {
await expect(this.page).toHaveTitle(/Prowler/);
await expect(this.logo).toBeVisible();
await expect(this.title).toBeVisible();
await this.waitForPageLoad();
}
async verifyFormElements(): Promise<void> {
await expect(this.emailInput).toBeVisible();
await expect(this.passwordInput).toBeVisible();
await expect(this.loginButton).toBeVisible();
}
async verifySocialButtons(config: SocialAuthConfig): Promise<void> {
if (config.googleEnabled) {
await expect(this.googleButton).toBeVisible();
}
if (config.githubEnabled) {
await expect(this.githubButton).toBeVisible();
}
await expect(this.samlButton).toBeVisible();
}
async verifyNavigationLinks(): Promise<void> {
await expect(this.page.getByText("Need to create an account?")).toBeVisible();
await expect(this.signUpLink).toBeVisible();
}
async verifySuccessfulLogin(): Promise<void> {
await this.homePage.verifyPageLoaded();
}
async verifyLoginError(errorMessage: string = "Invalid email or password"): Promise<void> {
await expect(this.page.getByText(errorMessage).first()).toBeVisible();
await expect(this.page).toHaveURL("/sign-in");
}
async verifySamlModeActive(): Promise<void> {
await expect(this.samlModeTitle).toBeVisible();
await expect(this.passwordInput).not.toBeVisible();
await expect(this.backButton).toBeVisible();
}
async verifyNormalModeActive(): Promise<void> {
await expect(this.title).toBeVisible();
await expect(this.passwordInput).toBeVisible();
}
async verifyLoadingState(): Promise<void> {
await expect(this.loginButton).toHaveAttribute("aria-disabled", "true");
await expect(this.loadingIndicator).toBeVisible();
}
async verifyFormValidation(): Promise<void> {
// Check for common validation messages
const emailError = this.page.getByText("Please enter a valid email address.");
const passwordError = this.page.getByText("Password is required.");
// At least one validation error should be visible
await expect(emailError.or(passwordError)).toBeVisible();
}
// Accessibility methods
async verifyKeyboardNavigation(): Promise<void> {
// Test tab navigation through form elements
await this.page.keyboard.press("Tab"); // Theme toggle
await this.page.keyboard.press("Tab"); // Email field
await expect(this.emailInput).toBeFocused();
await this.page.keyboard.press("Tab"); // Password field
await expect(this.passwordInput).toBeFocused();
await this.page.keyboard.press("Tab"); // Show password button
await this.page.keyboard.press("Tab"); // Login button
await expect(this.loginButton).toBeFocused();
}
async verifyAriaLabels(): Promise<void> {
await expect(this.page.getByRole("textbox", { name: "Email" })).toBeVisible();
await expect(this.page.getByRole("textbox", { name: "Password" })).toBeVisible();
await expect(this.page.getByRole("button", { name: "Log in" })).toBeVisible();
}
// Utility methods
async clearForm(): Promise<void> {
await this.emailInput.clear();
await this.passwordInput.clear();
}
async isFormValid(): Promise<boolean> {
const emailValue = await this.emailInput.inputValue();
const passwordValue = await this.passwordInput.inputValue();
return emailValue.length > 0 && passwordValue.length > 0;
}
async getFormErrors(): Promise<string[]> {
const errorElements = await this.errorMessages.all();
const errors: string[] = [];
for (const element of errorElements) {
const text = await element.textContent();
if (text) {
errors.push(text.trim());
}
}
return errors;
}
// Browser interaction methods
async refresh(): Promise<void> {
await this.page.reload();
await this.waitForPageLoad();
}
async goBack(): Promise<void> {
await this.page.goBack();
await this.waitForPageLoad();
}
// Session management methods
async logout(): Promise<void> {
await this.homePage.signOut();
}
async verifyLogoutSuccess(): Promise<void> {
await expect(this.page).toHaveURL("/sign-in");
await expect(this.title).toBeVisible();
}
// Advanced interaction methods
async fillFormWithValidation(credentials: SignInCredentials): Promise<void> {
// Fill email first and check for validation
await this.fillEmail(credentials.email);
await this.page.keyboard.press("Tab"); // Trigger validation
// Fill password
await this.fillPassword(credentials.password);
}
async submitFormWithEnterKey(): Promise<void> {
await this.passwordInput.press("Enter");
}
async submitFormWithButtonClick(): Promise<void> {
await this.submitForm();
}
// Error handling methods
async handleSamlError(): Promise<void> {
const samlError = this.page.getByText("SAML Authentication Error");
if (await samlError.isVisible()) {
// Handle SAML error if present
console.log("SAML authentication error detected");
}
}
// Wait methods
async waitForFormSubmission(): Promise<void> {
await this.page.waitForFunction(() => {
const button = document.querySelector('button[aria-disabled="true"]');
return button === null;
});
}
async waitForRedirect(expectedUrl: string): Promise<void> {
await this.page.waitForURL(expectedUrl);
}
}
@@ -7,7 +7,6 @@ authManageAccountSetup('authenticate as manage account e2e user', async ({ page
const accountEmail = process.env.E2E_MANAGE_ACCOUNT_USER;
const accountPassword = process.env.E2E_MANAGE_ACCOUNT_PASSWORD;
if (!accountEmail || !accountPassword) {
throw new Error('E2E_MANAGE_ACCOUNT_USER and E2E_MANAGE_ACCOUNT_PASSWORD environment variables are required');
}
@@ -7,7 +7,6 @@ authManageCloudProvidersSetup('authenticate as manage cloud providers e2e user',
const cloudProvidersEmail = process.env.E2E_MANAGE_CLOUD_PROVIDERS_USER;
const cloudProvidersPassword = process.env.E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD;
if (!cloudProvidersEmail || !cloudProvidersPassword) {
throw new Error('E2E_MANAGE_CLOUD_PROVIDERS_USER and E2E_MANAGE_CLOUD_PROVIDERS_PASSWORD environment variables are required');
}
+37 -1
View File
@@ -1,4 +1,12 @@
export type RequirementStatus = "PASS" | "FAIL" | "MANUAL" | "No findings";
export const REQUIREMENT_STATUS = {
PASS: "PASS",
FAIL: "FAIL",
MANUAL: "MANUAL",
NO_FINDINGS: "No findings",
} as const;
export type RequirementStatus =
(typeof REQUIREMENT_STATUS)[keyof typeof REQUIREMENT_STATUS];
export interface CompliancesOverview {
data: ComplianceOverviewData[];
@@ -133,6 +141,14 @@ export interface KISAAttributesMetadata {
NonComplianceCases: string[];
}
export interface C5AttributesMetadata {
Section: string;
SubSection: string;
Type: string;
AboutCriteria: string;
ComplementaryCriteria: string;
}
export interface MITREAttributesMetadata {
// Dynamic cloud service field - could be AWSService, GCPService, AzureService, etc.
[key: string]: string;
@@ -150,6 +166,24 @@ export interface GenericAttributesMetadata {
Type: string | null;
}
export interface CCCAttributesMetadata {
FamilyName: string;
FamilyDescription: string;
Section: string;
SubSection: string;
SubSectionObjective: string;
Applicability: string[];
Recommendation: string;
SectionThreatMappings: Array<{
ReferenceId: string;
Identifiers: string[];
}>;
SectionGuidelineMappings: Array<{
ReferenceId: string;
Identifiers: string[];
}>;
}
export interface AttributesItemData {
type: "compliance-requirements-attributes";
id: string;
@@ -167,7 +201,9 @@ export interface AttributesItemData {
| AWSWellArchitectedAttributesMetadata[]
| ThreatAttributesMetadata[]
| KISAAttributesMetadata[]
| C5AttributesMetadata[]
| MITREAttributesMetadata[]
| CCCAttributesMetadata[]
| GenericAttributesMetadata[];
check_ids: string[];
// MITRE structure
+14 -5
View File
@@ -1,5 +1,5 @@
import { LucideIcon } from "lucide-react";
import { SVGProps } from "react";
import { MouseEvent, SVGProps } from "react";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
@@ -21,7 +21,7 @@ export type SubmenuProps = {
active?: boolean;
icon: IconComponent;
disabled?: boolean;
onClick?: () => void;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
};
export type MenuProps = {
@@ -213,15 +213,24 @@ export type AzureCredentials = {
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type M365Credentials = {
export type M365ClientSecretCredentials = {
[ProviderCredentialFields.CLIENT_ID]: string;
[ProviderCredentialFields.CLIENT_SECRET]: string;
[ProviderCredentialFields.TENANT_ID]: string;
[ProviderCredentialFields.USER]?: string;
[ProviderCredentialFields.PASSWORD]?: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type M365CertificateCredentials = {
[ProviderCredentialFields.CLIENT_ID]: string;
[ProviderCredentialFields.CERTIFICATE_CONTENT]: string;
[ProviderCredentialFields.TENANT_ID]: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type M365Credentials =
| M365ClientSecretCredentials
| M365CertificateCredentials;
export type GCPDefaultCredentials = {
client_id: string;
client_secret: string;
+30 -26
View File
@@ -168,12 +168,13 @@ export const addCredentialsFormSchema = (
.min(1, "Client ID is required"),
[ProviderCredentialFields.CLIENT_SECRET]: z
.string()
.min(1, "Client Secret is required"),
.optional(),
[ProviderCredentialFields.CERTIFICATE_CONTENT]: z
.string()
.optional(),
[ProviderCredentialFields.TENANT_ID]: z
.string()
.min(1, "Tenant ID is required"),
[ProviderCredentialFields.USER]: z.string().optional(),
[ProviderCredentialFields.PASSWORD]: z.string().optional(),
}
: providerType === "github"
? {
@@ -194,23 +195,26 @@ export const addCredentialsFormSchema = (
})
.superRefine((data: Record<string, any>, ctx) => {
if (providerType === "m365") {
const hasUser = !!data[ProviderCredentialFields.USER];
const hasPassword = !!data[ProviderCredentialFields.PASSWORD];
if (hasUser && !hasPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "If you provide a user, you must also provide a password",
path: [ProviderCredentialFields.PASSWORD],
});
}
if (hasPassword && !hasUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "If you provide a password, you must also provide a user",
path: [ProviderCredentialFields.USER],
});
// Validate based on the via parameter
if (via === "app_client_secret") {
const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET];
if (!clientSecret || clientSecret.trim() === "") {
ctx.addIssue({
code: "custom",
message: "Client Secret is required",
path: [ProviderCredentialFields.CLIENT_SECRET],
});
}
} else if (via === "app_certificate") {
const certificateContent =
data[ProviderCredentialFields.CERTIFICATE_CONTENT];
if (!certificateContent || certificateContent.trim() === "") {
ctx.addIssue({
code: "custom",
message: "Certificate Content is required",
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
});
}
}
}
@@ -219,7 +223,7 @@ export const addCredentialsFormSchema = (
if (via === "personal_access_token") {
if (!data[ProviderCredentialFields.PERSONAL_ACCESS_TOKEN]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "Personal Access Token is required",
path: [ProviderCredentialFields.PERSONAL_ACCESS_TOKEN],
});
@@ -227,7 +231,7 @@ export const addCredentialsFormSchema = (
} else if (via === "oauth_app") {
if (!data[ProviderCredentialFields.OAUTH_APP_TOKEN]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "OAuth App Token is required",
path: [ProviderCredentialFields.OAUTH_APP_TOKEN],
});
@@ -235,14 +239,14 @@ export const addCredentialsFormSchema = (
} else if (via === "github_app") {
if (!data[ProviderCredentialFields.GITHUB_APP_ID]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "GitHub App ID is required",
path: [ProviderCredentialFields.GITHUB_APP_ID],
});
}
if (!data[ProviderCredentialFields.GITHUB_APP_KEY]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: "GitHub App Private Key is required",
path: [ProviderCredentialFields.GITHUB_APP_KEY],
});
@@ -390,7 +394,7 @@ export const mutedFindingsConfigFormSchema = z.object({
const yamlValidation = validateYaml(val);
if (!yamlValidation.isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: `Invalid YAML format: ${yamlValidation.error}`,
});
return;
@@ -399,7 +403,7 @@ export const mutedFindingsConfigFormSchema = z.object({
const mutelistValidation = validateMutelistYaml(val);
if (!mutelistValidation.isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: `Invalid mutelist structure: ${mutelistValidation.error}`,
});
}