Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions skills/prowler-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ Follow conventional commits:
4. ✅ Branch is up to date with main
5. ✅ Commits are clean and descriptive

## Before Re-Requesting Review (REQUIRED)

Resolve or respond to **every** open inline review thread before re-requesting review:

1. **Agreed + fixed**: Commit the change. Reply with the commit hash so the reviewer can verify quickly:
> Fixed in `abc1234`.
2. **Agreed but deferred**: Explain why it's out of scope for this PR and where it's tracked.
3. **Disagreed**: Reply with clear technical reasoning. Do not leave threads silently open.
4. **Re-request review** only after all threads are in a clean state — either resolved or explicitly responded to.

> **Rule of thumb**: A reviewer should never have to wonder "did they see my comment?" when they re-open the PR.

## Resources

- **Documentation**: See [references/](references/) for links to local developer guide
112 changes: 112 additions & 0 deletions skills/prowler-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,109 @@ cd ui && pnpm run build
cd ui && pnpm start
```

## Batch vs Instant Component API (REQUIRED)

When a component supports both **batch** (deferred, submit-based) and **instant** (immediate callback) behavior, model the coupling with a discriminated union — never as independent optionals. Coupled props must be all-or-nothing.

```typescript
// ❌ NEVER: Independent optionals — allows invalid half-states
interface FilterProps {
onBatchApply?: (values: string[]) => void;
onInstantChange?: (value: string) => void;
isBatchMode?: boolean;
}

// ✅ ALWAYS: Discriminated union — one valid shape per mode
type BatchProps = {
mode: "batch";
onApply: (values: string[]) => void;
onCancel: () => void;
};

type InstantProps = {
mode: "instant";
onChange: (value: string) => void;
// onApply/onCancel are forbidden here via structural exclusion
onApply?: never;
onCancel?: never;
};

type FilterProps = BatchProps | InstantProps;
```

This makes invalid prop combinations a compile error, not a runtime surprise.

## Reuse Shared Display Utilities First (REQUIRED)

Before adding **local** display maps (labels, provider names, status strings, category formatters), search `ui/types/*` and `ui/lib/*` for existing helpers.

```typescript
// ✅ CHECK THESE FIRST before creating a new map:
// ui/lib/utils.ts → general formatters
// ui/types/providers.ts → provider display names, icons
// ui/types/findings.ts → severity/status display maps
// ui/types/compliance.ts → category/group formatters

// ❌ NEVER add a local map that already exists:
const SEVERITY_LABELS: Record<string, string> = {
critical: "Critical",
high: "High",
// ...duplicating an existing shared map
};

// ✅ Import and reuse instead:
import { severityLabel } from "@/types/findings";
```

If a helper doesn't exist and will be used in 2+ places, add it to `ui/lib/` or `ui/types/` and reuse it. Keep local only if used in exactly one place.

## Derived State Rule (REQUIRED)

Avoid `useState` + `useEffect` patterns that mirror props or searchParams — they create sync bugs and unnecessary re-renders. Derive values directly from the source of truth.

```typescript
// ❌ NEVER: Mirror props into state via effect
const [localFilter, setLocalFilter] = useState(filter);
useEffect(() => { setLocalFilter(filter); }, [filter]);

// ✅ ALWAYS: Derive directly
const localFilter = filter; // or compute inline
```

If local state is genuinely needed (e.g., optimistic UI, pending edits before submit), add a short comment:

```typescript
// Local state needed: user edits are buffered until "Apply" is clicked
const [pending, setPending] = useState(initialValues);
```

## Strict Key Typing for Label Maps (REQUIRED)

Avoid `Record<string, string>` when the key set is known. Use an explicit union type or a const-key object so typos are caught at compile time.

```typescript
// ❌ Loose — typos compile silently
const STATUS_LABELS: Record<string, string> = {
actve: "Active", // typo, no error
};

// ✅ Tight — union key
type Status = "active" | "inactive" | "pending";
const STATUS_LABELS: Record<Status, string> = {
active: "Active",
inactive: "Inactive",
pending: "Pending",
// actve: "Active" ← compile error
};

// ✅ Also fine — const satisfies
const STATUS_LABELS = {
active: "Active",
inactive: "Inactive",
pending: "Pending",
} as const satisfies Record<Status, string>;
```

## QA Checklist Before Commit

- [ ] `pnpm run typecheck` passes
Expand All @@ -199,6 +302,15 @@ cd ui && pnpm start
- [ ] Accessibility: keyboard navigation, ARIA labels
- [ ] Mobile responsive (if applicable)

## Pre-Re-Review Checklist (Review Thread Hygiene)

Before requesting re-review from a reviewer:

- [ ] Every unresolved inline thread has been either fixed or explicitly answered with a rationale
- [ ] If you agreed with a comment: the change is committed and the commit hash is mentioned in the reply
- [ ] If you disagreed: the reply explains why with clear reasoning — do not leave threads silently open
- [ ] Re-request review only after all threads are in a clean state

## Migrations Reference

| From | To | Key Changes |
Expand Down
32 changes: 32 additions & 0 deletions skills/typescript/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,38 @@ function isUser(value: unknown): value is User {
}
```

## Coupled Optional Props (REQUIRED)

Do not model semantically coupled props as independent optionals — this allows invalid half-states that compile but break at runtime. Use discriminated unions with `never` to make invalid combinations impossible.

```typescript
// ❌ BEFORE: Independent optionals — half-states allowed
interface PaginationProps {
onPageChange?: (page: number) => void;
pageSize?: number;
currentPage?: number;
}

// ✅ AFTER: Discriminated union — shape is all-or-nothing
type ControlledPagination = {
controlled: true;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
};

type UncontrolledPagination = {
controlled: false;
currentPage?: never;
pageSize?: never;
onPageChange?: never;
};

type PaginationProps = ControlledPagination | UncontrolledPagination;
```

**Key rule:** If two or more props are only meaningful together, they belong to the same discriminated union branch. Mixing them as independent optionals shifts correctness responsibility from the type system to runtime guards.

## Import Types

```typescript
Expand Down
1 change: 1 addition & 0 deletions ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All notable changes to the **Prowler UI** are documented in this file.

### 🔄 Changed

- Findings filters now use a batch-apply pattern with an Apply Filters button, filter summary strip, and independent filter options instead of triggering API calls on every selection
- Google Workspace provider support [(#10333)](https://github.com/prowler-cloud/prowler/pull/10333)
- Image (Container Registry) provider support in UI: badge icon, credentials form, and provider-type filtering [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167)
- Organization and organizational unit row actions (Edit Name, Update Credentials, Test Connections, Delete) in providers table dropdown [(#10317)](https://github.com/prowler-cloud/prowler/pull/10317)
Expand Down
106 changes: 63 additions & 43 deletions ui/app/(prowler)/_overview/_components/accounts-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import {
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useUrlFilters } from "@/hooks/use-url-filters";
import type { ProviderProps, ProviderType } from "@/types/providers";
import {
getProviderDisplayName,
type ProviderProps,
type ProviderType,
} from "@/types/providers";

const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
aws: <AWSProviderBadge width={18} height={18} />,
Expand All @@ -46,60 +50,73 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
openstack: <OpenStackProviderBadge width={18} height={18} />,
};

interface AccountsSelectorProps {
/** Common props shared by both batch and instant modes. */
interface AccountsSelectorBaseProps {
providers: ProviderProps[];
/**
* Currently selected provider types (from the pending ProviderTypeSelector state).
* Used only for contextual description/empty-state messaging — does NOT narrow
* the list of available accounts, which remains independent of provider selection.
*/
selectedProviderTypes?: string[];
}

/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */
interface AccountsSelectorBatchProps extends AccountsSelectorBaseProps {
/**
* Called instead of navigating immediately.
* Use this on pages that batch filter changes (e.g. Findings).
*
* @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_id__in"
* @param values - The selected values array
*/
onBatchChange: (filterKey: string, values: string[]) => void;
/**
* Pending selected values controlled by the parent.
* Reflects pending state before Apply is clicked.
*/
selectedValues: string[];
}

export function AccountsSelector({ providers }: AccountsSelectorProps) {
/** Instant mode: URL-driven — neither callback nor controlled value. */
interface AccountsSelectorInstantProps extends AccountsSelectorBaseProps {
onBatchChange?: never;
selectedValues?: never;
}

type AccountsSelectorProps =
| AccountsSelectorBatchProps
| AccountsSelectorInstantProps;

export function AccountsSelector({
providers,
onBatchChange,
selectedValues,
selectedProviderTypes,
}: AccountsSelectorProps) {
const searchParams = useSearchParams();
const { navigateWithParams } = useUrlFilters();

const filterKey = "filter[provider_id__in]";
const current = searchParams.get(filterKey) || "";
const selectedTypes = searchParams.get("filter[provider_type__in]") || "";
const selectedTypesList = selectedTypes
? selectedTypes.split(",").filter(Boolean)
: [];
const selectedIds = current ? current.split(",").filter(Boolean) : [];
const visibleProviders = providers
// .filter((p) => p.attributes.connection?.connected)
.filter((p) =>
selectedTypesList.length > 0
? selectedTypesList.includes(p.attributes.provider)
: true,
);
const urlSelectedIds = current ? current.split(",").filter(Boolean) : [];

// In batch mode, use the parent-controlled pending values; otherwise, use URL state.
const selectedIds = onBatchChange ? selectedValues : urlSelectedIds;
const visibleProviders = providers;
// .filter((p) => p.attributes.connection?.connected)

const handleMultiValueChange = (ids: string[]) => {
if (onBatchChange) {
onBatchChange("provider_id__in", ids);
return;
}
navigateWithParams((params) => {
params.delete(filterKey);

if (ids.length > 0) {
params.set(filterKey, ids.join(","));
}

// Auto-deselect provider types that no longer have any selected accounts
if (selectedTypesList.length > 0) {
// Get provider types of currently selected accounts
const selectedProviders = providers.filter((p) => ids.includes(p.id));
const selectedProviderTypes = new Set(
selectedProviders.map((p) => p.attributes.provider),
);

// Keep only provider types that still have selected accounts
const remainingProviderTypes = selectedTypesList.filter((type) =>
selectedProviderTypes.has(type as ProviderType),
);

// Update provider_type__in filter
if (remainingProviderTypes.length > 0) {
params.set(
"filter[provider_type__in]",
remainingProviderTypes.join(","),
);
} else {
params.delete("filter[provider_type__in]");
}
}
});
};

Expand All @@ -115,9 +132,12 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
);
};

// Build a contextual description based on currently selected provider types.
// This is purely for user guidance (aria label + empty state) and does NOT
// narrow the list of available accounts — all providers remain selectable.
const filterDescription =
selectedTypesList.length > 0
? `Showing accounts for ${selectedTypesList.join(", ")} providers`
selectedProviderTypes && selectedProviderTypes.length > 0
? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "All connected cloud provider accounts";

return (
Expand Down Expand Up @@ -176,8 +196,8 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
</>
) : (
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
{selectedTypesList.length > 0
? "No accounts available for selected providers"
{selectedProviderTypes && selectedProviderTypes.length > 0
? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}`
: "No connected accounts available"}
</div>
)}
Expand Down
Loading
Loading