chore(ui): Merge UI repository

This commit is contained in:
Pedro De Castro
2024-11-25 13:15:14 +01:00
274 changed files with 33204 additions and 0 deletions

15
ui/.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
!.next/static
!.next/standalone
.git

6
ui/.env.template Normal file
View File

@@ -0,0 +1,6 @@
SITE_URL=http://localhost:3000
API_BASE_URL=http://localhost:8080/api/v1
AUTH_TRUST_HOST=true
# openssl rand -base64 32
AUTH_SECRET=your-secret-key

20
ui/.eslintignore Normal file
View File

@@ -0,0 +1,20 @@
.now/*
*.css
.changeset
dist
esm/*
public/*
tests/*
scripts/*
*.config.js
.DS_Store
node_modules
coverage
.next
build
!.commitlintrc.cjs
!.lintstagedrc.cjs
!jest.config.js
!plopfile.js
!react-shim.js
!tsup.config.ts

44
ui/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,44 @@
module.exports = {
env: {
node: true,
es2021: true,
},
parser: "@typescript-eslint/parser",
plugins: ["prettier", "@typescript-eslint", "simple-import-sort", "jsx-a11y"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:security/recommended-legacy",
"plugin:jsx-a11y/recommended",
"eslint-config-prettier",
"prettier",
],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
rules: {
"no-console": 1,
eqeqeq: 2,
quotes: ["error", "double", "avoid-escape"],
"@typescript-eslint/no-explicit-any": "off",
"security/detect-object-injection": "off",
"prettier/prettier": [
"error",
{
endOfLine: "auto",
tabWidth: 2,
useTabs: false,
},
],
"eol-last": ["error", "always"],
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/alt-text": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
};

5
ui/.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,5 @@
* @prowler-cloud/ui
# To protect a repository fully against unauthorized changes, you also need to define an owner for the CODEOWNERS file itself.
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-and-branch-protection
/.github/ @prowler-cloud/ui

7
ui/.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,7 @@
### Description
What was done in this PR
### How to Review
The reviewer should verify all these steps:

29
ui/.github/workflows/checks.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: CI - Code Quality and Health Check
on:
pull_request:
branches:
- main
jobs:
test-and-coverage:
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu-latest]
node-version: [20.x]
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
persist-credentials: false
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm install
- name: Run Healthcheck
run: npm run healthcheck
- name: Build the application
run: npm run build

36
ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

1
ui/.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npm run healthcheck

1
ui/.npmrc Normal file
View File

@@ -0,0 +1 @@
package-lock=true

1
ui/.nvmrc Normal file
View File

@@ -0,0 +1 @@
lts/*

1
ui/.prettierignore Normal file
View File

@@ -0,0 +1 @@
node_modules/

10
ui/.prettierrc.json Normal file
View File

@@ -0,0 +1,10 @@
{
"bracketSpacing": true,
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 2,
"useTabs": false,
"semi": true,
"printWidth": 80,
"plugins": ["prettier-plugin-tailwindcss"]
}

11
ui/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"prettier.printWidth": 80,
"prettier.tabWidth": 2,
"prettier.useTabs": false,
"prettier.singleQuote": false,
"prettier.trailingComma": "all",
"prettier.semi": true,
"prettier.bracketSpacing": true
}

70
ui/Dockerfile Normal file
View File

@@ -0,0 +1,70 @@
FROM node:20-alpine AS base
LABEL maintainer="https://github.com/prowler-cloud"
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
#hadolint ignore=DL3018
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f package-lock.json ]; then npm install; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED=1
RUN \
if [ -f package-lock.json ]; then npm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Development stage
FROM base AS dev
WORKDIR /app
# Set up environment for development
ENV NODE_ENV=development
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app /app
# Run development server with hot-reloading
CMD ["npm", "run", "dev"]
# Production stage
FROM base AS prod
WORKDIR /app
# Set up environment for production
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs &&\
adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]

114
ui/README.md Normal file
View File

@@ -0,0 +1,114 @@
# Description
This repository hosts the UI component for Prowler, providing a user-friendly web interface to interact seamlessly with Prowler's features.
## 🚀 Production deployment
### Docker deployment
#### Clone the repository
```console
# HTTPS
git clone https://github.com/prowler-cloud/ui.git
# SSH
git clone git@github.com:prowler-cloud/ui.git
```
#### Build the Docker image
```bash
docker build -t prowler-cloud/ui . --target prod
```
#### Run the Docker container
```bash
docker run -p 3000:3000 prowler-cloud/ui
```
### Local deployment
#### Clone the repository
```console
# HTTPS
git clone https://github.com/prowler-cloud/ui.git
# SSH
git clone git@github.com:prowler-cloud/ui.git
```
#### Build the project
```bash
npm run build
```
#### Run the production server
```bash
npm start
```
## 🧪 Development deployment
### Docker deployment
#### Clone the repository
```console
# HTTPS
git clone https://github.com/prowler-cloud/ui.git
# SSH
git clone git@github.com:prowler-cloud/ui.git
```
#### Build the Docker image
```bash
docker build -t prowler-cloud/ui . --target dev
```
#### Run the Docker container
```bash
docker run -p 3000:3000 prowler-cloud/ui
```
### Local deployment
#### Clone the repository
```console
# HTTPS
git clone https://github.com/prowler-cloud/ui.git
# SSH
git clone git@github.com:prowler-cloud/ui.git
```
#### Install dependencies
You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `npm`:
```bash
npm install
```
#### Run the development server
```bash
npm run dev
```
## Setup pnpm (optional)
If you are using `pnpm`, you need to add the following code to your `.npmrc` file:
```bash
public-hoist-pattern[]=*@nextui-org/*
```
After modifying the `.npmrc` file, you need to run `pnpm install` again to ensure that the dependencies are installed correctly.
## Technologies Used
- [Next.js 14](https://nextjs.org/docs/getting-started)
- [NextUI v2](https://nextui.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Tailwind Variants](https://tailwind-variants.org)
- [TypeScript](https://www.typescriptlang.org/)
- [Framer Motion](https://www.framer.com/motion/)
- [next-themes](https://github.com/pacocoursey/next-themes)

169
ui/actions/auth/auth.ts Normal file
View File

@@ -0,0 +1,169 @@
"use server";
import { AuthError } from "next-auth";
import { z } from "zod";
import { signIn, signOut } from "@/auth.config";
import { authFormSchema } from "@/types";
const formSchemaSignIn = authFormSchema("sign-in");
const formSchemaSignUp = authFormSchema("sign-up");
const defaultValues: z.infer<typeof formSchemaSignIn> = {
email: "",
password: "",
};
export async function authenticate(
prevState: unknown,
formData: z.infer<typeof formSchemaSignIn>,
) {
try {
await signIn("credentials", {
...formData,
redirect: false,
});
return {
message: "Success",
};
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return {
message: "Credentials error",
errors: {
...defaultValues,
credentials: "Incorrect email or password",
},
};
default:
return {
message: "Unknown error",
errors: {
...defaultValues,
unknown: "Unknown error",
},
};
}
}
}
}
export const createNewUser = async (
formData: z.infer<typeof formSchemaSignUp>,
) => {
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/users`);
if (formData.invitationToken) {
url.searchParams.append("invitation_token", formData.invitationToken);
}
const bodyData = {
data: {
type: "users",
attributes: {
name: formData.name,
email: formData.email,
password: formData.password,
...(formData.company && { company_name: formData.company }),
},
},
};
try {
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
},
body: JSON.stringify(bodyData),
});
const parsedResponse = await response.json();
if (!response.ok) {
return parsedResponse;
}
return parsedResponse;
} catch (error) {
return { errors: [{ detail: "Network error or server is unreachable" }] };
}
};
export const getToken = async (formData: z.infer<typeof formSchemaSignIn>) => {
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/tokens`);
const bodyData = {
data: {
type: "tokens",
attributes: {
email: formData.email,
password: formData.password,
},
},
};
try {
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
},
body: JSON.stringify(bodyData),
});
if (!response.ok) return null;
const parsedResponse = await response.json();
const accessToken = parsedResponse.data.attributes.access;
const refreshToken = parsedResponse.data.attributes.refresh;
return {
accessToken,
refreshToken,
};
} catch (error) {
throw new Error("Error in trying to get token");
}
};
export const getUserByMe = async (accessToken: string) => {
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/users/me`);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) throw new Error("Error in trying to get user by me");
const parsedResponse = await response.json();
const name = parsedResponse.data.attributes.name;
const email = parsedResponse.data.attributes.email;
const company = parsedResponse.data.attributes.company_name;
const dateJoined = parsedResponse.data.attributes.date_joined;
return {
name,
email,
company,
dateJoined,
};
} catch (error) {
throw new Error("Error in trying to get user by me");
}
};
export async function logOut() {
await signOut();
}

1
ui/actions/auth/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./auth";

View File

@@ -0,0 +1,43 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth.config";
import { parseStringify } from "@/lib";
export const getCompliancesOverview = async ({
scanId,
region,
}: {
scanId: string;
region?: string | string[];
}) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/compliance-overviews`);
if (scanId) url.searchParams.append("filter[scan_id]", scanId);
if (region) {
const regionValue = Array.isArray(region) ? region.join(",") : region;
url.searchParams.append("filter[region__in]", regionValue);
}
try {
const compliances = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await compliances.json();
const parsedData = parseStringify(data);
revalidatePath("/compliance");
return parsedData;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching providers:", error);
return undefined;
}
};

View File

@@ -0,0 +1 @@
export * from "./compliances";

View File

@@ -0,0 +1,49 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth.config";
import { parseStringify } from "@/lib";
export const getFindings = async ({
page = 1,
query = "",
sort = "",
filters = {},
}) => {
const session = await auth();
if (isNaN(Number(page)) || page < 1)
redirect("findings?include=resources.provider,scan");
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/findings?include=resources.provider,scan`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
const findings = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await findings.json();
const parsedData = parseStringify(data);
revalidatePath("/findings");
return parsedData;
} catch (error) {
console.error("Error fetching findings:", error);
return undefined;
}
};

View File

@@ -0,0 +1 @@
export * from "./findings";

View File

@@ -0,0 +1 @@
export * from "./invitation";

View File

@@ -0,0 +1,174 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth.config";
import { getErrorMessage, parseStringify, wait } from "@/lib";
export const getInvitations = async ({
page = 1,
query = "",
sort = "",
filters = {},
}) => {
const session = await auth();
if (isNaN(Number(page)) || page < 1) redirect("/invitations");
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/tenants/invitations`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
const invitations = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await invitations.json();
const parsedData = parseStringify(data);
revalidatePath("/invitations");
return parsedData;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching invitations:", error);
return undefined;
}
};
export const sendInvite = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const email = formData.get("email");
const url = new URL(`${keyServer}/tenants/invitations`);
const body = JSON.stringify({
data: {
type: "invitations",
attributes: {
email,
},
relationships: {},
},
});
try {
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
body,
});
const data = await response.json();
return parseStringify(data);
} catch (error) {
return {
error: getErrorMessage(error),
};
}
};
export const updateInvite = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const invitationId = formData.get("invitationId");
const invitationEmail = formData.get("invitationEmail");
const expiresAt = formData.get("expires_at");
const url = new URL(`${keyServer}/tenants/invitations/${invitationId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify({
data: {
type: "invitations",
id: invitationId,
attributes: {
email: invitationEmail,
...(expiresAt && { expires_at: expiresAt }),
},
},
}),
});
const data = await response.json();
revalidatePath("/invitations");
return parseStringify(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error updating invitation:", error);
return {
error: getErrorMessage(error),
};
}
};
export const getInvitationInfoById = async (invitationId: string) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/tenants/invitations/${invitationId}`);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await response.json();
return parseStringify(data);
} catch (error) {
return {
error: getErrorMessage(error),
};
}
};
export const revokeInvite = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const invitationId = formData.get("invitationId");
const url = new URL(`${keyServer}/tenants/invitations/${invitationId}`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers: {
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await response.json();
await wait(1000);
revalidatePath("/invitations");
return parseStringify(data);
} catch (error) {
return {
error: getErrorMessage(error),
};
}
};

View File

@@ -0,0 +1,49 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth.config";
import { parseStringify } from "@/lib";
export const getProvidersOverview = async ({
page = 1,
query = "",
sort = "",
filters = {},
}) => {
const session = await auth();
if (isNaN(Number(page)) || page < 1) redirect("/providers-overview");
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/overviews/providers`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
const response = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await response.json();
const parsedData = parseStringify(data);
revalidatePath("/providers-overview");
return parsedData;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching providers overview:", error);
return undefined;
}
};

View File

@@ -0,0 +1 @@
export * from "./providers";

View File

@@ -0,0 +1,322 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth.config";
import { getErrorMessage, parseStringify, wait } from "@/lib";
export const getProviders = async ({
page = 1,
query = "",
sort = "",
filters = {},
}) => {
const session = await auth();
if (isNaN(Number(page)) || page < 1) redirect("/providers");
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/providers`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
const providers = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await providers.json();
const parsedData = parseStringify(data);
revalidatePath("/providers");
return parsedData;
} catch (error) {
console.error("Error fetching providers:", error);
return undefined;
}
};
export const getProvider = async (formData: FormData) => {
const session = await auth();
const providerId = formData.get("id");
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/providers/${providerId}`);
try {
const providers = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await providers.json();
const parsedData = parseStringify(data);
return parsedData;
} catch (error) {
return {
error: getErrorMessage(error),
};
}
};
export const updateProvider = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const providerId = formData.get("providerId");
const providerAlias = formData.get("alias");
const url = new URL(`${keyServer}/providers/${providerId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify({
data: {
type: "providers",
id: providerId,
attributes: {
alias: providerAlias,
},
},
}),
});
const data = await response.json();
revalidatePath("/providers");
return parseStringify(data);
} catch (error) {
console.error(error);
return {
error: getErrorMessage(error),
};
}
};
export const addProvider = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const providerType = formData.get("providerType");
const providerUid = formData.get("providerUid");
const providerAlias = formData.get("providerAlias");
const url = new URL(`${keyServer}/providers`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify({
data: {
type: "providers",
attributes: {
provider: providerType,
uid: providerUid,
alias: providerAlias,
},
},
}),
});
const data = await response.json();
revalidatePath("/providers");
return parseStringify(data);
} catch (error) {
console.error(error);
return {
error: getErrorMessage(error),
};
}
};
export const addCredentialsProvider = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/providers/secrets`);
const secretName = formData.get("secretName");
const providerId = formData.get("providerId");
const providerType = formData.get("providerType");
const isRole = formData.get("role_arn") !== null;
let secret = {};
let secretType = "static"; // Default to static credentials
if (providerType === "aws") {
if (isRole) {
// Role-based configuration for AWS
secretType = "role";
secret = {
role_arn: formData.get("role_arn"),
aws_access_key_id: formData.get("aws_access_key_id") || undefined,
aws_secret_access_key:
formData.get("aws_secret_access_key") || undefined,
aws_session_token: formData.get("aws_session_token") || undefined,
session_duration:
parseInt(formData.get("session_duration") as string, 10) || 3600,
external_id: formData.get("external_id") || undefined,
role_session_name: formData.get("role_session_name") || undefined,
};
} else {
// Static credentials configuration for AWS
secret = {
aws_access_key_id: formData.get("aws_access_key_id"),
aws_secret_access_key: formData.get("aws_secret_access_key"),
aws_session_token: formData.get("aws_session_token") || undefined,
};
}
} else if (providerType === "azure") {
// Static credentials configuration for Azure
secret = {
client_id: formData.get("client_id"),
client_secret: formData.get("client_secret"),
tenant_id: formData.get("tenant_id"),
};
} else if (providerType === "gcp") {
// Static credentials configuration for GCP
secret = {
client_id: formData.get("client_id"),
client_secret: formData.get("client_secret"),
refresh_token: formData.get("refresh_token"),
};
} else if (providerType === "kubernetes") {
// Static credentials configuration for Kubernetes
secret = {
kubeconfig_content: formData.get("kubeconfig_content"),
};
}
const bodyData = {
data: {
type: "provider-secrets",
attributes: {
secret_type: secretType,
secret,
name: secretName,
},
relationships: {
provider: {
data: {
id: providerId,
type: "providers",
},
},
},
},
};
try {
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify(bodyData),
});
const data = await response.json();
revalidatePath("/providers");
return parseStringify(data);
} catch (error) {
console.error(error);
return {
error: getErrorMessage(error),
};
}
};
export const checkConnectionProvider = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const providerId = formData.get("providerId");
const url = new URL(`${keyServer}/providers/${providerId}/connection`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await response.json();
await wait(1000);
revalidatePath("/providers");
return parseStringify(data);
} catch (error) {
return {
error: getErrorMessage(error),
};
}
};
export const deleteCredentials = async (secretId: string) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/providers/secrets/${secretId}`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers: {
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await response.json();
revalidatePath("/providers");
return parseStringify(data);
} catch (error) {
return {
error: getErrorMessage(error),
};
}
};
export const deleteProvider = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const providerId = formData.get("id");
const url = new URL(`${keyServer}/providers/${providerId}`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers: {
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await response.json();
await wait(1000);
revalidatePath("/providers");
return parseStringify(data);
} catch (error) {
return {
error: getErrorMessage(error),
};
}
};

View File

@@ -0,0 +1 @@
export * from "./scans";

156
ui/actions/scans/scans.ts Normal file
View File

@@ -0,0 +1,156 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth.config";
import { getErrorMessage, parseStringify } from "@/lib";
export const getScans = async ({
page = 1,
query = "",
sort = "",
filters = {},
}) => {
const session = await auth();
if (isNaN(Number(page)) || page < 1) redirect("/scans");
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/scans`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
const scans = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await scans.json();
const parsedData = parseStringify(data);
revalidatePath("/scans");
return parsedData;
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching scans:", error);
return undefined;
}
};
export const getScan = async (scanId: string) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/scans/${scanId}`);
try {
const scan = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await scan.json();
const parsedData = parseStringify(data);
return parsedData;
} catch (error) {
return {
error: getErrorMessage(error),
};
}
};
export const scanOnDemand = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const providerId = formData.get("providerId");
const scanName = formData.get("scanName");
const url = new URL(`${keyServer}/scans`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify({
data: {
type: "scans",
attributes: {
name: scanName,
},
relationships: {
provider: {
data: {
type: "providers",
id: providerId,
},
},
},
},
}),
});
const data = await response.json();
revalidatePath("/scans");
return parseStringify(data);
} catch (error) {
console.error(error);
return {
error: getErrorMessage(error),
};
}
};
export const updateScan = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const scanId = formData.get("scanId");
const scanName = formData.get("scanName");
const url = new URL(`${keyServer}/scans/${scanId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify({
data: {
type: "scans",
id: scanId,
attributes: {
name: scanName,
},
},
}),
});
const data = await response.json();
revalidatePath("/scans");
return parseStringify(data);
} catch (error) {
console.error(error);
return {
error: getErrorMessage(error),
};
}
};

View File

@@ -0,0 +1 @@
export * from "./services";

View File

@@ -0,0 +1,99 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { parse } from "path";
import { auth } from "@/auth.config";
import { getErrorMessage, parseStringify } from "@/lib";
export const getServices = async ({}) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const servicesToFetch = [
{ id: "accessanalyzer", alias: "IAM Access Analyzer" },
{ id: "account", alias: "AWS Account" },
{ id: "acm", alias: "AWS Certificate Manager" },
{ id: "apigateway", alias: "Amazon API Gateway" },
{ id: "apigatewayv2", alias: "Amazon API Gateway V2" },
{ id: "athena", alias: "Amazon Athena" },
{ id: "autoscaling", alias: "Amazon EC2 Auto Scaling" },
{ id: "awslambda", alias: "AWS Lambda" },
{ id: "backup", alias: "AWS Backup" },
{ id: "cloudformation", alias: "AWS CloudFormation" },
{ id: "cloudfront", alias: "Amazon CloudFront" },
{ id: "cloudtrail", alias: "AWS CloudTrail" },
{ id: "cloudwatch", alias: "Amazon CloudWatch" },
{ id: "codeartifact", alias: "AWS CodeArtifact" },
{ id: "codebuild", alias: "AWS CodeBuild" },
{ id: "config", alias: "AWS Config" },
{ id: "dlm", alias: "Amazon Data Lifecycle Manager" },
{ id: "drs", alias: "AWS Data Replication Service" },
{ id: "dynamodb", alias: "Amazon DynamoDB" },
{ id: "ec2", alias: "Amazon EC2" },
{ id: "ecr", alias: "Amazon ECR" },
{ id: "ecs", alias: "Amazon ECS" },
{ id: "efs", alias: "Amazon EFS" },
{ id: "eks", alias: "Amazon EKS" },
{ id: "elasticache", alias: "Amazon ElastiCache" },
{ id: "elb", alias: "Elastic Load Balancing" },
{ id: "elbv2", alias: "Elastic Load Balancing v2" },
{ id: "emr", alias: "Amazon EMR" },
{ id: "fms", alias: "AWS Firewall Manager" },
{ id: "glacier", alias: "Amazon Glacier" },
{ id: "glue", alias: "AWS Glue" },
{ id: "guardduty", alias: "Amazon GuardDuty" },
{ id: "iam", alias: "AWS IAM" },
{ id: "inspector2", alias: "Amazon Inspector" },
{ id: "kms", alias: "AWS KMS" },
{ id: "macie", alias: "Amazon Macie" },
{ id: "networkfirewall", alias: "AWS Network Firewall" },
{ id: "organizations", alias: "AWS Organizations" },
{ id: "rds", alias: "Amazon RDS" },
{ id: "resourceexplorer2", alias: "AWS Resource Groups" },
{ id: "route53", alias: "Amazon Route 53" },
{ id: "s3", alias: "Amazon S3" },
{ id: "secretsmanager", alias: "AWS Secrets Manager" },
{ id: "securityhub", alias: "AWS Security Hub" },
{ id: "sns", alias: "Amazon SNS" },
{ id: "sqs", alias: "Amazon SQS" },
{ id: "ssm", alias: "AWS Systems Manager" },
{ id: "ssmincidents", alias: "AWS Systems Manager Incident Manager" },
{ id: "trustedadvisor", alias: "AWS Trusted Advisor" },
{ id: "vpc", alias: "Amazon VPC" },
{ id: "wafv2", alias: "AWS WAF" },
{ id: "wellarchitected", alias: "AWS Well-Architected Tool" },
];
const parsedData = [];
for (const service of servicesToFetch) {
const url = new URL(`${keyServer}/findings`);
url.searchParams.append("filter[service]", service.id);
url.searchParams.append("filter[status]", "FAIL");
try {
const response = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await response.json();
const failFindings = data.meta.pagination.count;
parsedData.push({
service_id: service.id,
service_alias: service.alias,
fail_findings: failFindings,
});
} catch (error) {
console.error(`Error fetching data for service ${service.id}:`, error);
}
}
revalidatePath("/services");
return parsedData;
};

1
ui/actions/task/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./tasks";

24
ui/actions/task/tasks.ts Normal file
View File

@@ -0,0 +1,24 @@
"use server";
import { auth } from "@/auth.config";
import { getErrorMessage, parseStringify } from "@/lib";
export const getTask = async (taskId: string) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/tasks/${taskId}`);
try {
const response = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await response.json();
return parseStringify(data);
} catch (error) {
return { error: getErrorMessage(error) };
}
};

116
ui/actions/users/users.ts Normal file
View File

@@ -0,0 +1,116 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth.config";
import { getErrorMessage, parseStringify, wait } from "@/lib";
export const getUsers = async ({
page = 1,
query = "",
sort = "",
filters = {},
}) => {
const session = await auth();
if (isNaN(Number(page)) || page < 1) redirect("/users");
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/users`);
if (page) url.searchParams.append("page[number]", page.toString());
if (query) url.searchParams.append("filter[search]", query);
if (sort) url.searchParams.append("sort", sort);
// Handle multiple filters
Object.entries(filters).forEach(([key, value]) => {
if (key !== "filter[search]") {
url.searchParams.append(key, String(value));
}
});
try {
const users = await fetch(url.toString(), {
headers: {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await users.json();
const parsedData = parseStringify(data);
revalidatePath("/users");
return parsedData;
} catch (error) {
console.error("Error fetching users:", error);
return undefined;
}
};
export const updateUser = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const userId = formData.get("userId");
const userName = formData.get("name");
const userPassword = formData.get("password");
const userEmail = formData.get("email");
const userCompanyName = formData.get("company_name");
const url = new URL(`${keyServer}/users/${userId}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
},
body: JSON.stringify({
data: {
type: "users",
id: userId,
attributes: {
name: userName,
password: userPassword,
email: userEmail,
company_name: userCompanyName,
},
},
}),
});
const data = await response.json();
revalidatePath("/users");
return parseStringify(data);
} catch (error) {
console.error(error);
return {
error: getErrorMessage(error),
};
}
};
export const deleteUser = async (formData: FormData) => {
const session = await auth();
const keyServer = process.env.API_BASE_URL;
const userId = formData.get("userId");
const url = new URL(`${keyServer}/users/${userId}`);
try {
const response = await fetch(url.toString(), {
method: "DELETE",
headers: {
Authorization: `Bearer ${session?.accessToken}`,
},
});
const data = await response.json();
await wait(1000);
revalidatePath("/users");
return parseStringify(data);
} catch (error) {
return {
error: getErrorMessage(error),
};
}
};

60
ui/app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,60 @@
import "@/styles/globals.css";
import { Metadata, Viewport } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth.config";
import { Toaster } from "@/components/ui";
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib";
import { Providers } from "../providers";
export const metadata: Metadata = {
title: {
default: siteConfig.name,
template: `%s - ${siteConfig.name}`,
},
description: siteConfig.description,
icons: {
icon: "/favicon.ico",
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (session?.user) {
redirect("/");
}
return (
<html suppressHydrationWarning lang="en">
<head />
<body
suppressHydrationWarning
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable,
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
{children}
<Toaster />
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,7 @@
import { AuthForm } from "@/components/auth/oss";
const SignIn = () => {
return <AuthForm type="sign-in" />;
};
export default SignIn;

View File

@@ -0,0 +1,13 @@
import { AuthForm } from "@/components/auth/oss";
import { SearchParamsProps } from "@/types";
const SignUp = ({ searchParams }: { searchParams: SearchParamsProps }) => {
const invitationToken =
typeof searchParams?.invitation_token === "string"
? searchParams.invitation_token
: null;
return <AuthForm type="sign-up" invitationToken={invitationToken} />;
};
export default SignUp;

View File

@@ -0,0 +1,12 @@
import { Spacer } from "@nextui-org/react";
import { Header } from "@/components/ui";
export default async function Categories() {
return (
<>
<Header title="Categories" icon="material-symbols:folder-open-outline" />
<Spacer y={4} />
</>
);
}

View File

@@ -0,0 +1,122 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getCompliancesOverview } from "@/actions/compliances";
import { getScans } from "@/actions/scans";
import {
ComplianceCard,
ComplianceSkeletonGrid,
} from "@/components/compliance";
import { DataCompliance } from "@/components/compliance/data-compliance";
import { Header } from "@/components/ui";
import { ComplianceOverviewData, SearchParamsProps } from "@/types";
export default async function Compliance({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const scansData = await getScans({});
const scanList = scansData?.data
.filter(
(scan: any) =>
scan.attributes.state === "completed" &&
scan.attributes.progress === 100,
)
.map((scan: any) => ({
id: scan.id,
name: scan.attributes.name || "Unnamed Scan",
state: scan.attributes.state,
progress: scan.attributes.progress,
}));
const selectedScanId = searchParams.scanId || scanList[0]?.id;
// Fetch compliance data for regions
const compliancesData = await getCompliancesOverview({
scanId: selectedScanId,
});
// Extract unique regions
const regions = compliancesData?.data
? Array.from(
new Set(
compliancesData.data.map(
(compliance: ComplianceOverviewData) =>
compliance.attributes.region as string,
),
),
)
: [];
return (
<>
<Header title="Compliance" icon="fluent-mdl2:compliance-audit" />
<Spacer y={4} />
<DataCompliance scans={scanList} regions={regions as string[]} />
<Spacer y={12} />
<Suspense fallback={<ComplianceSkeletonGrid />}>
<SSRComplianceGrid searchParams={searchParams} />
</Suspense>
</>
);
}
const SSRComplianceGrid = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const scanId = searchParams.scanId?.toString() || "";
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
// Fetch compliance data
const compliancesData = await getCompliancesOverview({
scanId,
region: regionFilter,
});
// Check if the response contains no data
if (!compliancesData || compliancesData?.data?.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-default-500">
No compliance data available for the selected scan.
</div>
</div>
);
}
// Handle errors returned by the API
if (compliancesData?.errors?.length > 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-default-500">Provide a valid scan ID.</div>
</div>
);
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{compliancesData.data.map((compliance: ComplianceOverviewData) => {
const { attributes } = compliance;
const {
framework,
requirements_status: { passed, total },
} = attributes;
return (
<ComplianceCard
key={compliance.id}
title={framework}
passingRequirements={passed}
totalRequirements={total}
prevPassingRequirements={passed}
prevTotalRequirements={total}
/>
);
})}
</div>
);
};

View File

@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { useEffect } from "react";
import { RocketIcon } from "@/components/icons";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui";
export default function Error({
error,
// reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
// Log the error to an error reporting service
/* eslint-disable no-console */
console.error(error);
}, [error]);
return (
<Alert className="mx-auto mt-[35%] w-fit">
<RocketIcon className="h-5 w-5" />
<AlertTitle className="text-lg">An unexpected error occurred</AlertTitle>
<AlertDescription className="mb-5">
We're sorry for the inconvenience. Please try again or contact support
if the problem persists.
</AlertDescription>
<Link href={"/"} className="font-bold">
{" "}
Go to the homepage
</Link>
</Alert>
);
}

View File

@@ -0,0 +1,174 @@
import { Spacer } from "@nextui-org/react";
import React, { Suspense } from "react";
import { getFindings } from "@/actions/findings";
import { getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { filterFindings } from "@/components/filters/data-filters";
import { FilterControls } from "@/components/filters/filter-controls";
import {
ColumnFindings,
SkeletonTableFindings,
} from "@/components/findings/table";
import { Header } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { createDict } from "@/lib";
import { FindingProps, SearchParamsProps } from "@/types/components";
export default async function Findings({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const searchParamsKey = JSON.stringify(searchParams || {});
// Get findings data
const findingsData = await getFindings({});
const providersData = await getProviders({});
const scansData = await getScans({});
// Extract provider UIDs
const providerUIDs = providersData?.data
?.map((provider: any) => provider.attributes.uid)
.filter(Boolean);
// Extract scan UUIDs with "completed" state and more than one resource
const completedScans = scansData?.data
?.filter(
(scan: any) =>
scan.attributes.state === "completed" &&
scan.attributes.unique_resource_count > 1 &&
scan.attributes.name, // Ensure it has a name
)
.map((scan: any) => ({
id: scan.id,
name: scan.attributes.name,
}));
const completedScanIds = completedScans?.map((scan: any) => scan.id) || [];
// Create resource dictionary
const resourceDict = createDict("resources", findingsData);
// Get unique regions and services
const allRegionsAndServices =
findingsData?.data
?.flatMap((finding: FindingProps) => {
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
return {
region: resource?.attributes?.region,
service: resource?.attributes?.service,
};
})
.filter(Boolean) || [];
const uniqueRegions = Array.from(
new Set<string>(
allRegionsAndServices
.map((item: { region: string }) => item.region)
.filter(Boolean) || [],
),
);
const uniqueServices = Array.from(
new Set<string>(
allRegionsAndServices
.map((item: { service: string }) => item.service)
.filter(Boolean) || [],
),
);
return (
<>
<Header title="Findings" icon="ph:list-checks-duotone" />
<Spacer />
<Spacer y={4} />
<FilterControls search date />
<Spacer y={8} />
<DataTableFilterCustom
filters={[
...filterFindings,
{
key: "region__in",
labelCheckboxGroup: "Regions",
values: uniqueRegions,
},
{
key: "service__in",
labelCheckboxGroup: "Services",
values: uniqueServices,
},
{
key: "provider_uid__in",
labelCheckboxGroup: "Account",
values: providerUIDs,
},
{
key: "scan__in",
labelCheckboxGroup: "Scans",
values: completedScanIds, // Use UUIDs in the filter
},
]}
defaultOpen={true}
/>
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableFindings />}>
<SSRDataTable searchParams={searchParams} />
</Suspense>
</>
);
}
const SSRDataTable = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const sort = searchParams.sort?.toString();
// Extract all filter parameters
const filters = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
);
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
const findingsData = await getFindings({ query, page, sort, filters });
// Create dictionaries for resources, scans, and providers
const resourceDict = createDict("resources", findingsData);
const scanDict = createDict("scans", findingsData);
const providerDict = createDict("providers", findingsData);
// Expand each finding with its corresponding resource, scan, and provider
const expandedFindings = findingsData?.data
? findingsData.data.map((finding: FindingProps) => {
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider =
providerDict[resource?.relationships?.provider?.data?.id];
return {
...finding,
relationships: { scan, resource, provider },
};
})
: [];
// Create the new object while maintaining the original structure
const expandedResponse = {
...findingsData,
data: expandedFindings,
};
return (
<DataTable
columns={ColumnFindings}
data={expandedResponse?.data || []}
metadata={findingsData?.meta}
/>
);
};

View File

@@ -0,0 +1,13 @@
import React from "react";
import { Header } from "@/components/ui";
export default function Integrations() {
return (
<>
<Header title="Integrations" icon="tabler:puzzle" />
<p>Hi hi from Integration page</p>
</>
);
}

View File

@@ -0,0 +1,43 @@
import { Suspense } from "react";
import { getInvitationInfoById } from "@/actions/invitations/invitation";
import { InvitationDetails } from "@/components/invitations";
import { SkeletonInvitationInfo } from "@/components/invitations/workflow";
import { SearchParamsProps } from "@/types";
export default async function CheckDetailsPage({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const searchParamsKey = JSON.stringify(searchParams || {});
return (
<Suspense key={searchParamsKey} fallback={<SkeletonInvitationInfo />}>
<SSRDataInvitation searchParams={searchParams} />
</Suspense>
);
}
const SSRDataInvitation = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const invitationId = searchParams.id;
if (!invitationId) {
return <div>Invalid invitation ID</div>;
}
const invitationData = (await getInvitationInfoById(invitationId as string))
.data;
if (!invitationData) {
return <div>Invitation not found</div>;
}
const { attributes, links } = invitationData;
return <InvitationDetails attributes={attributes} selfLink={links.self} />;
};

View File

@@ -0,0 +1,32 @@
import "@/styles/globals.css";
import { Spacer } from "@nextui-org/react";
import React from "react";
import { WorkflowSendInvite } from "@/components/invitations/workflow";
import { NavigationHeader } from "@/components/ui";
interface InvitationLayoutProps {
children: React.ReactNode;
}
export default function InvitationLayout({ children }: InvitationLayoutProps) {
return (
<>
<NavigationHeader
title="Send Invitation"
icon="icon-park-outline:close-small"
href="/invitations"
/>
<Spacer y={16} />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
<WorkflowSendInvite />
</div>
<div className="order-2 my-auto lg:col-span-5 lg:col-start-6">
{children}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,7 @@
import React from "react";
import { SendInvitationForm } from "@/components/invitations/workflow/forms/send-invitation-form";
export default function SendInvitationPage() {
return <SendInvitationForm />;
}

View File

@@ -0,0 +1,66 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getInvitations } from "@/actions/invitations/invitation";
import { FilterControls } from "@/components/filters";
import { filterInvitations } from "@/components/filters/data-filters";
import { SendInvitationButton } from "@/components/invitations";
import {
ColumnsInvitation,
SkeletonTableInvitation,
} from "@/components/invitations/table";
import { Header } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { SearchParamsProps } from "@/types";
export default async function Invitations({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const searchParamsKey = JSON.stringify(searchParams || {});
return (
<>
<Header title="Invitations" icon="ci:users" />
<Spacer y={4} />
<FilterControls search />
<Spacer y={8} />
<SendInvitationButton />
<Spacer y={4} />
<DataTableFilterCustom filters={filterInvitations || []} />
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableInvitation />}>
<SSRDataTable searchParams={searchParams} />
</Suspense>
</>
);
}
const SSRDataTable = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const sort = searchParams.sort?.toString();
// Extract all filter parameters
const filters = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
);
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
const invitationsData = await getInvitations({ query, page, sort, filters });
return (
<DataTable
columns={ColumnsInvitation}
data={invitationsData?.data || []}
metadata={invitationsData?.meta}
/>
);
};

View File

@@ -0,0 +1,58 @@
import "@/styles/globals.css";
import { Metadata, Viewport } from "next";
import React from "react";
import { SidebarWrap, Toaster } from "@/components/ui";
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib/utils";
import { Providers } from "../providers";
export const metadata: Metadata = {
title: {
default: siteConfig.name,
template: `%s - ${siteConfig.name}`,
},
description: siteConfig.description,
icons: {
icon: "/favicon.ico",
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "black" },
],
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html suppressHydrationWarning lang="en">
<head />
<body
suppressHydrationWarning
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable,
)}
>
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<div className="flex h-dvh items-center justify-center overflow-hidden">
<SidebarWrap />
<main className="no-scrollbar mb-auto h-full flex-1 flex-col overflow-y-auto px-6 py-4 xl:px-10">
{children}
<Toaster />
</main>
</div>
</Providers>
</body>
</html>
);
}

42
ui/app/(prowler)/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getProvidersOverview } from "@/actions/overview/overview";
import {
ProvidersOverview,
SkeletonProvidersOverview,
} from "@/components/overview";
import { Header } from "@/components/ui";
export default function Home() {
return (
<>
<Header title="Scan Overview" icon="solar:pie-chart-2-outline" />
<Spacer y={4} />
<div className="min-h-screen">
<div className="container mx-auto space-y-8 px-0 py-6">
{/* Providers Overview */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Suspense fallback={<SkeletonProvidersOverview />}>
<SSRProvidersOverview />
</Suspense>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"></div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"></div>
</div>
</div>
</>
);
}
const SSRProvidersOverview = async () => {
const providersOverview = await getProvidersOverview({});
if (!providersOverview) {
return <p>There is no providers overview info available</p>;
}
return <ProvidersOverview providersOverview={providersOverview} />;
};

View File

@@ -0,0 +1,30 @@
import { Spacer } from "@nextui-org/react";
import { redirect } from "next/navigation";
import React from "react";
// import { getUserByMe } from "@/actions/auth/auth";
import { auth } from "@/auth.config";
import { Header } from "@/components/ui";
export default async function Profile() {
const session = await auth();
if (!session?.user) {
// redirect("/sign-in?returnTo=/profile");
redirect("/sign-in");
}
// const user = await getUserByMe();
return (
<>
<Header title="User Profile" icon="ci:users" />
<Spacer y={4} />
<Spacer y={6} />
<pre>{JSON.stringify(session.user, null, 2)}</pre>
<pre>{JSON.stringify(session.userId, null, 2)}</pre>
<pre>{JSON.stringify(session.tenantId, null, 2)}</pre>
<pre>{JSON.stringify(session, null, 2)}</pre>
</>
);
}

View File

@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import React from "react";
import {
ViaCredentialsForm,
ViaRoleForm,
} from "@/components/providers/workflow/forms";
interface Props {
searchParams: { type: string; id: string; via?: string };
}
export default function AddCredentialsPage({ searchParams }: Props) {
if (
!searchParams.type ||
!searchParams.id ||
(searchParams.type === "aws" && !searchParams.via)
) {
redirect("/providers/connect-account");
}
const useCredentialsForm =
(searchParams.type === "aws" && searchParams.via === "credentials") ||
(searchParams.type !== "aws" && !searchParams.via);
const useRoleForm =
searchParams.type === "aws" && searchParams.via === "role";
return (
<>
{useCredentialsForm && <ViaCredentialsForm searchParams={searchParams} />}
{useRoleForm && <ViaRoleForm searchParams={searchParams} />}
</>
);
}

View File

@@ -0,0 +1,7 @@
import React from "react";
import { ConnectAccountForm } from "@/components/providers/workflow/forms";
export default function ConnectAccountPage() {
return <ConnectAccountForm />;
}

View File

@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import React from "react";
import { getProvider } from "@/actions/providers";
import { LaunchScanForm } from "@/components/providers/workflow/forms";
interface Props {
searchParams: { type: string; id: string };
}
export default async function LaunchScanPage({ searchParams }: Props) {
const providerId = searchParams.id;
if (!providerId) {
redirect("/providers/connect-account");
}
const formData = new FormData();
formData.append("id", providerId);
const providerData = await getProvider(formData);
const isConnected = providerData?.data?.attributes?.connection?.connected;
if (!isConnected) {
redirect("/providers/connect-account");
}
return (
<LaunchScanForm searchParams={searchParams} providerData={providerData} />
);
}

View File

@@ -0,0 +1,32 @@
import "@/styles/globals.css";
import { Spacer } from "@nextui-org/react";
import React from "react";
import { WorkflowAddProvider } from "@/components/providers/workflow";
import { NavigationHeader } from "@/components/ui";
interface ProviderLayoutProps {
children: React.ReactNode;
}
export default function ProviderLayout({ children }: ProviderLayoutProps) {
return (
<>
<NavigationHeader
title="Connect your cloud account"
icon="icon-park-outline:close-small"
href="/providers"
/>
<Spacer y={16} />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
<div className="order-1 my-auto hidden h-full lg:col-span-4 lg:col-start-2 lg:block">
<WorkflowAddProvider />
</div>
<div className="order-2 my-auto lg:col-span-5 lg:col-start-6">
{children}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import React, { Suspense } from "react";
import { getProvider } from "@/actions/providers";
import { TestConnectionForm } from "@/components/providers/workflow/forms";
interface Props {
searchParams: { type: string; id: string };
}
export default async function TestConnectionPage({ searchParams }: Props) {
const providerId = searchParams.id;
if (!providerId) {
redirect("/providers/connect-account");
}
return (
<Suspense fallback={<p>Loading...</p>}>
<SSRTestConnection searchParams={searchParams} />
</Suspense>
);
}
async function SSRTestConnection({
searchParams,
}: {
searchParams: { type: string; id: string };
}) {
const formData = new FormData();
formData.append("id", searchParams.id);
const providerData = await getProvider(formData);
if (providerData.errors) {
redirect("/providers/connect-account");
}
return (
<TestConnectionForm
searchParams={searchParams}
providerData={providerData}
/>
);
}

View File

@@ -0,0 +1,65 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { FilterControls, filterProviders } from "@/components/filters";
import { AddProvider } from "@/components/providers";
import {
ColumnProviders,
SkeletonTableProviders,
} from "@/components/providers/table";
import { Header } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { SearchParamsProps } from "@/types";
export default async function Providers({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const searchParamsKey = JSON.stringify(searchParams || {});
return (
<>
<Header title="Providers" icon="fluent:cloud-sync-24-regular" />
<Spacer y={4} />
<FilterControls search providers />
<Spacer y={8} />
<AddProvider />
<Spacer y={4} />
<DataTableFilterCustom filters={filterProviders || []} />
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableProviders />}>
<SSRDataTable searchParams={searchParams} />
</Suspense>
</>
);
}
const SSRDataTable = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const sort = searchParams.sort?.toString();
// Extract all filter parameters
const filters = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
);
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
const providersData = await getProviders({ query, page, sort, filters });
return (
<DataTable
columns={ColumnProviders}
data={providersData?.data || []}
metadata={providersData?.meta}
/>
);
};

View File

@@ -0,0 +1,104 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { filterScans } from "@/components/filters";
import { ButtonRefreshData } from "@/components/scans";
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
import { SkeletonTableScans } from "@/components/scans/table";
import { ColumnGetScans } from "@/components/scans/table/scans";
import { Header } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { ProviderProps, SearchParamsProps } from "@/types";
export default async function Scans({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const filteredParams = { ...searchParams };
delete filteredParams.scanId;
const searchParamsKey = JSON.stringify(filteredParams);
const providersData = await getProviders({});
const providerInfo = providersData?.data?.length
? providersData.data.map((provider: ProviderProps) => ({
providerId: provider.id,
alias: provider.attributes.alias,
providerType: provider.attributes.provider,
uid: provider.attributes.uid,
connected: provider.attributes.connection.connected,
}))
: [];
// const executingScans = await getExecutingScans();
return (
<>
<Header title="Scans" icon="lucide:scan-search" />
<Spacer y={4} />
<LaunchScanWorkflow providers={providerInfo} />
<Spacer y={8} />
<div className="flex flex-row justify-between">
<DataTableFilterCustom filters={filterScans || []} />
<ButtonRefreshData
onPress={async () => {
"use server";
await getScans({});
}}
/>
</div>
<Spacer y={8} />
<div className="grid grid-cols-12 items-start gap-4">
<div className="col-span-12">
<Suspense key={searchParamsKey} fallback={<SkeletonTableScans />}>
<SSRDataTableScans searchParams={searchParams} />
</Suspense>
</div>
</div>
</>
);
}
const SSRDataTableScans = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const sort = searchParams.sort?.toString();
// Extract all filter parameters, excluding scanId
const filters = Object.fromEntries(
Object.entries(searchParams).filter(
([key]) => key.startsWith("filter[") && key !== "scanId",
),
);
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
const scansData = await getScans({ query, page, sort, filters });
return (
<DataTable
columns={ColumnGetScans}
data={scansData?.data || []}
metadata={scansData?.meta}
/>
);
};
// const getExecutingScans = async () => {
// const scansData = await getScans({});
// return scansData?.data?.some(
// (scan: ScanProps) =>
// scan.attributes.state === "executing" && scan.attributes.progress < 100,
// );
// };

View File

@@ -0,0 +1,51 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getServices } from "@/actions/services";
import { FilterControls } from "@/components/filters";
import { ServiceCard, ServiceSkeletonGrid } from "@/components/services";
import { Header } from "@/components/ui";
import { SearchParamsProps } from "@/types";
export default async function Services({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const searchParamsKey = JSON.stringify(searchParams || {});
return (
<>
<Header
title="Services"
icon="material-symbols:linked-services-outline"
/>
<Spacer y={4} />
<FilterControls />
<Spacer y={4} />
<Suspense key={searchParamsKey} fallback={<ServiceSkeletonGrid />}>
<SSRServiceGrid searchParams={searchParams} />
</Suspense>
</>
);
}
const SSRServiceGrid = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const servicesData = await getServices(searchParams);
const [services] = await Promise.all([servicesData]);
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{services?.map((service: any) => (
<ServiceCard
key={service.service_id}
fidingsFailed={service.fail_findings}
serviceAlias={service.service_alias}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,12 @@
import { Spacer } from "@nextui-org/react";
import { Header } from "@/components/ui";
export default async function Settings() {
return (
<>
<Header title="Settings" icon="solar:settings-outline" />
<Spacer />
</>
);
}

View File

@@ -0,0 +1,63 @@
import { Spacer } from "@nextui-org/react";
import { Suspense } from "react";
import { getUsers } from "@/actions/users/users";
import { FilterControls } from "@/components/filters";
import { filterUsers } from "@/components/filters/data-filters";
import { Header } from "@/components/ui";
import { DataTable, DataTableFilterCustom } from "@/components/ui/table";
import { AddUserButton } from "@/components/users";
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
import { SearchParamsProps } from "@/types";
export default async function Users({
searchParams,
}: {
searchParams: SearchParamsProps;
}) {
const searchParamsKey = JSON.stringify(searchParams || {});
return (
<>
<Header title="Users" icon="ci:users" />
<Spacer y={4} />
<FilterControls search />
<Spacer y={8} />
<AddUserButton />
<Spacer y={4} />
<DataTableFilterCustom filters={filterUsers || []} />
<Spacer y={8} />
<Suspense key={searchParamsKey} fallback={<SkeletonTableUser />}>
<SSRDataTable searchParams={searchParams} />
</Suspense>
</>
);
}
const SSRDataTable = async ({
searchParams,
}: {
searchParams: SearchParamsProps;
}) => {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const sort = searchParams.sort?.toString();
// Extract all filter parameters
const filters = Object.fromEntries(
Object.entries(searchParams).filter(([key]) => key.startsWith("filter[")),
);
// Extract query from filters
const query = (filters["filter[search]"] as string) || "";
const usersData = await getUsers({ query, page, sort, filters });
return (
<DataTable
columns={ColumnsUser}
data={usersData?.data || []}
metadata={usersData?.meta}
/>
);
};

View File

@@ -0,0 +1,12 @@
import { Spacer } from "@nextui-org/react";
import { Header } from "@/components/ui";
export default async function Workloads() {
return (
<>
<Header title="Workloads" icon="lucide:tags" />
<Spacer y={4} />
</>
);
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth.config";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import data from "../../../dataServices.json";
export async function GET() {
// Simulate fetching data with a delay
await new Promise((resolve) => setTimeout(resolve, 2000));
return NextResponse.json({ services: data });
}

25
ui/app/providers.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client";
import { NextUIProvider } from "@nextui-org/system";
import { useRouter } from "next/navigation";
import { SessionProvider } from "next-auth/react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ThemeProviderProps } from "next-themes/dist/types";
import * as React from "react";
export interface ProvidersProps {
children: React.ReactNode;
themeProps?: ThemeProviderProps;
}
export function Providers({ children, themeProps }: ProvidersProps) {
const router = useRouter();
return (
<SessionProvider>
<NextUIProvider navigate={router.push}>
<NextThemesProvider {...themeProps}>{children}</NextThemesProvider>
</NextUIProvider>
</SessionProvider>
);
}

185
ui/auth.config.ts Normal file
View File

@@ -0,0 +1,185 @@
import { jwtDecode, JwtPayload } from "jwt-decode";
import NextAuth, { type NextAuthConfig, User } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import { getToken, getUserByMe } from "./actions/auth";
interface CustomJwtPayload extends JwtPayload {
user_id: string;
tenant_id: string;
}
const refreshAccessToken = async (token: JwtPayload) => {
const keyServer = process.env.API_BASE_URL;
const url = new URL(`${keyServer}/tokens/refresh`);
const bodyData = {
data: {
type: "tokens-refresh",
attributes: {
refresh: (token as any).refreshToken,
},
},
};
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
},
body: JSON.stringify(bodyData),
});
const newTokens = await response.json();
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return {
...token,
accessToken: newTokens.data.attributes.access,
refreshToken: newTokens.data.attributes.refresh,
};
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error refreshing access token:", error);
return {
error: "RefreshAccessTokenError",
};
}
};
export const authConfig = {
session: {
strategy: "jwt",
// The session will be valid for 24 hours
maxAge: 24 * 60 * 60,
},
pages: {
signIn: "/sign-in",
newUser: "/sign-up",
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "email", type: "text" },
password: { label: "password", type: "password" },
},
async authorize(credentials) {
const parsedCredentials = z
.object({
email: z.string().email(),
password: z.string().min(12),
})
.safeParse(credentials);
if (!parsedCredentials.success) return null;
const tokenResponse = await getToken(parsedCredentials.data);
if (!tokenResponse) return null;
const userMeResponse = await getUserByMe(tokenResponse.accessToken);
const user = {
name: userMeResponse.name,
email: userMeResponse.email,
company: userMeResponse?.company,
dateJoined: userMeResponse.dateJoined,
};
return {
...user,
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
};
},
}),
],
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith("/");
const isSignUpPage = nextUrl.pathname === "/sign-up";
// Allow access to sign-up page
if (isSignUpPage) return true;
if (isOnDashboard) {
if (isLoggedIn) return true;
return false; // Redirect users who are not logged in to the login page
} else if (isLoggedIn) {
return Response.redirect(new URL("/", nextUrl));
}
return true;
},
jwt: async ({ token, account, user }) => {
if (token?.accessToken) {
const decodedToken = jwtDecode(
token.accessToken as string,
) as CustomJwtPayload;
// eslint-disable-next-line no-console
// console.log("decodedToken", decodedToken);
token.accessTokenExpires = (decodedToken.exp as number) * 1000;
token.user_id = decodedToken.user_id;
token.tenant_id = decodedToken.tenant_id;
}
const userInfo = {
name: user?.name,
companyName: user?.company,
email: user?.email,
dateJoined: user?.dateJoined,
};
if (account && user) {
return {
...token,
userId: token.user_id,
tenantId: token.tenant_id,
accessToken: (user as User & { accessToken: JwtPayload }).accessToken,
refreshToken: (user as User & { refreshToken: JwtPayload })
.refreshToken,
user: userInfo,
};
}
// eslint-disable-next-line no-console
// console.log(
// "Access token expires",
// token.accessTokenExpires,
// new Date(Number(token.accessTokenExpires)),
// );
// If the access token is not expired, return the token
if (
typeof token.accessTokenExpires === "number" &&
Date.now() < token.accessTokenExpires
)
return token;
// If the access token is expired, try to refresh it
return refreshAccessToken(token as JwtPayload);
},
session: async ({ session, token }) => {
if (token) {
session.userId = token?.user_id as string;
session.tenantId = token?.tenant_id as string;
session.accessToken = token?.accessToken as string;
session.refreshToken = token?.refreshToken as string;
session.user = token.user as any;
}
// console.log("session", session);
return session;
},
},
} satisfies NextAuthConfig;
export const { signIn, signOut, auth, handlers } = NextAuth(authConfig);

View File

@@ -0,0 +1,82 @@
"use client";
import { SwitchProps, useSwitch } from "@nextui-org/react";
import { useIsSSR } from "@react-aria/ssr";
import { VisuallyHidden } from "@react-aria/visually-hidden";
import clsx from "clsx";
import { useTheme } from "next-themes";
import { FC } from "react";
import React from "react";
import { MoonFilledIcon, SunFilledIcon } from "./icons";
export interface ThemeSwitchProps {
className?: string;
classNames?: SwitchProps["classNames"];
}
export const ThemeSwitch: FC<ThemeSwitchProps> = ({
className,
classNames,
}) => {
const { theme, setTheme } = useTheme();
const isSSR = useIsSSR();
const onChange = () => {
theme === "light" ? setTheme("dark") : setTheme("light");
};
const {
Component,
slots,
isSelected,
getBaseProps,
getInputProps,
getWrapperProps,
} = useSwitch({
isSelected: theme === "light" || isSSR,
"aria-label": `Switch to ${theme === "light" || isSSR ? "dark" : "light"} mode`,
onChange,
});
return (
<Component
{...getBaseProps({
className: clsx(
"px-px transition-opacity hover:opacity-80 cursor-pointer",
className,
classNames?.base,
),
})}
>
<VisuallyHidden>
<input {...getInputProps()} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: clsx(
[
"h-auto w-auto",
"bg-transparent",
"rounded-lg",
"flex items-center justify-center",
"group-data-[selected=true]:bg-transparent",
"!text-default-500",
"pt-px",
"px-0",
"mx-0",
],
classNames?.wrapper,
),
})}
>
{!isSelected || isSSR ? (
<SunFilledIcon size={22} />
) : (
<MoonFilledIcon size={22} />
)}
</div>
</Component>
);
};

View File

@@ -0,0 +1,318 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Icon } from "@iconify/react";
import { Button, Checkbox, Divider, Link } from "@nextui-org/react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { authenticate, createNewUser } from "@/actions/auth";
import { NotificationIcon, ProwlerExtended } from "@/components/icons";
import { ThemeSwitch } from "@/components/ThemeSwitch";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import {
Form,
FormControl,
FormField,
FormMessage,
} from "@/components/ui/form";
import { ApiError, authFormSchema } from "@/types";
export const AuthForm = ({
type,
invitationToken,
}: {
type: string;
invitationToken?: string | null;
}) => {
const formSchema = authFormSchema(type);
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
...(type === "sign-up" && {
name: "",
company: "",
termsAndConditions: false,
confirmPassword: "",
...(invitationToken && { invitationToken }),
}),
},
});
const isLoading = form.formState.isSubmitting;
const { toast } = useToast();
const onSubmit = async (data: z.infer<typeof formSchema>) => {
if (type === "sign-in") {
const result = await authenticate(null, {
email: data.email.toLowerCase(),
password: data.password,
});
if (result?.message === "Success") {
router.push("/");
} else if (result?.errors && "credentials" in result.errors) {
form.setError("email", {
type: "server",
message: result.errors.credentials ?? "Incorrect email or password",
});
} else {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: "An unexpected error occurred. Please try again.",
});
}
}
if (type === "sign-up") {
const newUser = await createNewUser(data);
if (!newUser.errors) {
toast({
title: "Success!",
description: "The user was registered successfully.",
});
form.reset();
router.push("/sign-in");
} else {
newUser.errors.forEach((error: ApiError) => {
const errorMessage = error.detail;
switch (error.source.pointer) {
case "/data/attributes/name":
form.setError("name", { type: "server", message: errorMessage });
break;
case "/data/attributes/email":
form.setError("email", { type: "server", message: errorMessage });
break;
case "/data/attributes/company_name":
form.setError("company", {
type: "server",
message: errorMessage,
});
break;
case "/data/attributes/password":
form.setError("password", {
type: "server",
message: errorMessage,
});
break;
case "/data":
form.setError("invitationToken", {
type: "server",
message: errorMessage,
});
break;
default:
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMessage,
});
}
});
}
}
};
return (
<div className="relative flex h-screen w-screen">
{/* Auth Form */}
<div className="relative flex w-full items-center justify-center lg:w-full">
{/* Background Pattern */}
<div className="absolute h-full w-full bg-[radial-gradient(#6af400_1px,transparent_1px)] [background-size:16px_16px] [mask-image:radial-gradient(ellipse_50%_50%_at_50%_50%,#000_10%,transparent_80%)]"></div>
<div className="relative z-10 flex w-full max-w-sm flex-col gap-4 rounded-large border-1 border-divider bg-white/90 px-8 py-10 shadow-small dark:bg-background/85 md:max-w-md">
{/* Prowler Logo */}
<div className="absolute -top-[100px] left-1/2 z-10 flex h-fit w-fit -translate-x-1/2">
<ProwlerExtended width={300} />
</div>
<div className="flex items-center justify-between">
<p className="pb-2 text-xl font-medium">
{type === "sign-in" ? "Sign In" : "Sign Up"}
</p>
<ThemeSwitch aria-label="Toggle theme" />
</div>
<Form {...form}>
<form
className="flex flex-col gap-3"
onSubmit={form.handleSubmit(onSubmit)}
>
{type === "sign-up" && (
<>
<CustomInput
control={form.control}
name="name"
type="text"
label="Name"
placeholder="Enter your name"
isInvalid={!!form.formState.errors.name}
/>
<CustomInput
control={form.control}
name="company"
type="text"
label="Company Name"
placeholder="Enter your company name"
isRequired={false}
isInvalid={!!form.formState.errors.company}
/>
</>
)}
<CustomInput
control={form.control}
name="email"
type="email"
label="Email"
placeholder="Enter your email"
isInvalid={!!form.formState.errors.email}
/>
<CustomInput
control={form.control}
name="password"
password
isInvalid={!!form.formState.errors.password}
/>
{type === "sign-in" && (
<div className="flex items-center justify-between px-1 py-2">
<Checkbox name="remember" size="sm">
Remember me
</Checkbox>
<Link className="text-default-500" href="#">
Forgot password?
</Link>
</div>
)}
{type === "sign-up" && (
<>
<CustomInput
control={form.control}
name="confirmPassword"
confirmPassword
/>
{invitationToken && (
<CustomInput
control={form.control}
name="invitationToken"
type="text"
label="Invitation Token"
placeholder={invitationToken}
defaultValue={invitationToken}
isRequired={false}
isInvalid={!!form.formState.errors.invitationToken}
/>
)}
<FormField
control={form.control}
name="termsAndConditions"
render={({ field }) => (
<>
<FormControl>
<Checkbox
isRequired
className="py-4"
size="sm"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
>
I agree with the&nbsp;
<Link href="#" size="sm">
Terms
</Link>
&nbsp; and&nbsp;
<Link href="#" size="sm">
Privacy Policy
</Link>
</Checkbox>
</FormControl>
<FormMessage className="text-system-error dark:text-system-error" />
</>
)}
/>
</>
)}
{form.formState.errors?.email && (
<div className="flex flex-row items-center gap-2 text-system-error">
<NotificationIcon size={16} />
<p className="text-s">No user found</p>
</div>
)}
<CustomButton
type="submit"
ariaLabel={type === "sign-in" ? "Log In" : "Sign Up"}
ariaDisabled={isLoading}
className="w-full"
variant="solid"
color="action"
size="md"
radius="md"
isLoading={isLoading}
isDisabled={isLoading}
>
{isLoading ? (
<span>Loading</span>
) : (
<span>{type === "sign-in" ? "Log In" : "Sign Up"}</span>
)}
</CustomButton>
</form>
</Form>
{type === "sign-in" && (
<>
<div className="flex items-center gap-4 py-2">
<Divider className="flex-1" />
<p className="shrink-0 text-tiny text-default-500">OR</p>
<Divider className="flex-1" />
</div>
<div className="flex flex-col gap-2">
<Button
startContent={
<Icon icon="flat-color-icons:google" width={24} />
}
variant="bordered"
>
Continue with Google
</Button>
<Button
startContent={
<Icon
className="text-default-500"
icon="fe:github"
width={24}
/>
}
variant="bordered"
>
Continue with Github
</Button>
</div>
</>
)}
{type === "sign-in" ? (
<p className="text-center text-small">
Need to create an account?&nbsp;
<Link href="/sign-up">Sign Up</Link>
</p>
) : (
<p className="text-center text-small">
Already have an account?&nbsp;
<Link href="/sign-in">Log In</Link>
</p>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./auth-form";

View File

@@ -0,0 +1,76 @@
"use client";
import { Bar, BarChart, LabelList, XAxis, YAxis } from "recharts";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart/Chart";
const chartData = [
{ severity: "critical", findings: 32, fill: "var(--color-critical)" },
{ severity: "high", findings: 78, fill: "var(--color-high)" },
{ severity: "medium", findings: 117, fill: "var(--color-medium)" },
{ severity: "low", findings: 39, fill: "var(--color-low)" },
];
const chartConfig = {
findings: {
label: "Findings",
},
critical: {
label: "Critical",
color: "hsl(var(--chart-critical))",
},
high: {
label: "High",
color: "hsl(var(--chart-fail))",
},
medium: {
label: "Medium",
color: "hsl(var(--chart-medium))",
},
low: {
label: "Low",
color: "hsl(var(--chart-low))",
},
} satisfies ChartConfig;
export const SeverityChart = () => {
return (
<div className="my-auto">
<ChartContainer config={chartConfig}>
<BarChart accessibilityLayer data={chartData} layout="vertical">
<YAxis
dataKey="severity"
type="category"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) =>
chartConfig[value as keyof typeof chartConfig]?.label
}
/>
<XAxis dataKey="findings" type="number" hide>
<LabelList position="insideTop" offset={12} fontSize={12} />
</XAxis>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
/>
<Bar dataKey="findings" layout="vertical" radius={12}>
<LabelList
position="insideRight"
offset={10}
className="fill-foreground font-bold"
fontSize={12}
/>
</Bar>
</BarChart>
</ChartContainer>
</div>
);
};

View File

@@ -0,0 +1,148 @@
"use client";
import { Chip, Divider, Spacer } from "@nextui-org/react";
import { TrendingUp } from "lucide-react";
import * as React from "react";
import { Label, Pie, PieChart } from "recharts";
import { NotificationIcon, SuccessIcon } from "../icons";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "../ui";
const calculatePercent = (
chartData: { findings: string; number: number; fill: string }[],
) => {
const total = chartData.reduce((sum, item) => sum + item.number, 0);
return chartData.map((item) => ({
...item,
percent: Math.round((item.number / total) * 100) + "%",
}));
};
const chartData = [
{
findings: "Success",
number: 436,
fill: "var(--color-success)",
},
{ findings: "Fail", number: 293, fill: "var(--color-fail)" },
];
const updatedChartData = calculatePercent(chartData);
const chartConfig = {
number: {
label: "Findings",
},
success: {
label: "Success",
color: "hsl(var(--chart-success))",
},
fail: {
label: "Fail",
color: "hsl(var(--chart-fail))",
},
} satisfies ChartConfig;
export function StatusChart() {
const totalVisitors = React.useMemo(() => {
return chartData.reduce((acc, curr) => acc + curr.number, 0);
}, []);
return (
<div className="my-auto flex items-center justify-center self-center">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square min-w-[200px] md:min-h-[250px]"
>
<PieChart>
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<Pie
data={chartData}
dataKey="number"
nameKey="findings"
innerRadius={60}
strokeWidth={50}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
radius={70}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-3xl font-bold"
>
{totalVisitors.toLocaleString()}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="fill-foreground"
>
Findings
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
<div className="mx-6 flex flex-col justify-center gap-2 text-small">
<div className="flex space-x-4">
<Chip
className="h-5"
variant="flat"
startContent={<SuccessIcon size={18} />}
color="success"
radius="lg"
size="md"
>
{chartData[0].number}
</Chip>
<Divider orientation="vertical" />
<span>{updatedChartData[0].percent}</span>
<Divider orientation="vertical" />
</div>
<div className="flex items-center font-light leading-none">
No change from last scan
</div>
<Spacer y={4} />
<div className="text-muted-foreground flex flex-col gap-2 leading-none">
<div className="flex space-x-4">
<Chip
className="h-5"
variant="flat"
startContent={<NotificationIcon size={18} />}
color="danger"
radius="lg"
size="md"
>
{chartData[1].number}
</Chip>
<Divider orientation="vertical" />
<span>{updatedChartData[1].percent}</span>
<Divider orientation="vertical" />
</div>
<div className="flex items-center gap-1 font-medium leading-none">
+2 findings from last scan <TrendingUp className="h-4 w-4" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./SeverityChart";
export * from "./StatusChart";

View File

@@ -0,0 +1,83 @@
import { Card, CardBody, Progress } from "@nextui-org/react";
import Image from "next/image";
import React from "react";
import { getComplianceIcon } from "../icons";
interface ComplianceCardProps {
title: string;
passingRequirements: number;
totalRequirements: number;
prevPassingRequirements: number;
prevTotalRequirements: number;
}
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
title,
passingRequirements,
totalRequirements,
}) => {
const ratingPercentage = Math.floor(
(passingRequirements / totalRequirements) * 100,
);
// const prevRatingPercentage = Math.floor(
// (prevPassingRequirements / prevTotalRequirements) * 100,
// );
// const getScanChange = () => {
// const scanDifference = ratingPercentage - prevRatingPercentage;
// if (scanDifference < 0 && scanDifference <= -1) {
// return `${scanDifference}% from last scan`;
// }
// if (scanDifference > 0 && scanDifference >= 1) {
// return `+${scanDifference}% from last scan`;
// }
// return "No changes from last scan";
// };
const getRatingColor = (ratingPercentage: number) => {
if (ratingPercentage <= 10) {
return "danger";
}
if (ratingPercentage <= 40) {
return "warning";
}
return "success";
};
return (
<Card fullWidth isPressable isHoverable shadow="sm">
<CardBody className="flex flex-row items-center justify-between space-x-4 dark:bg-prowler-blue-800">
<div className="flex w-full items-center space-x-4">
<Image
src={getComplianceIcon(title)}
alt={`${title} logo`}
className="h-10 w-10 min-w-10 rounded-md border-1 border-gray-300 bg-white object-contain p-1"
/>
<div className="flex w-full flex-col">
<h4 className="text-md font-bold leading-5 3xl:text-lg">{title}</h4>
<Progress
label="Your Rating:"
size="sm"
aria-label="Your Rating"
value={ratingPercentage}
showValueLabel={true}
className="mt-2 font-semibold"
color={getRatingColor(ratingPercentage)}
/>
<div className="mt-2 flex justify-between">
<small>
<span className="mr-1 font-semibold">
{passingRequirements} / {totalRequirements}
</span>
Passing Requirements
</small>
{/* <small>{getScanChange()}</small> */}
</div>
</div>
</div>
</CardBody>
</Card>
);
};

View File

@@ -0,0 +1,18 @@
import { Card, Skeleton } from "@nextui-org/react";
import React from "react";
export const ComplianceSkeletonGrid = () => {
return (
<Card className="h-fit w-full p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 3xl:grid-cols-4">
{[...Array(28)].map((_, index) => (
<div key={index} className="flex flex-col space-y-4">
<Skeleton className="h-28 rounded-lg">
<div className="h-full bg-default-300"></div>
</Skeleton>
</div>
))}
</div>
</Card>
);
};

View File

@@ -0,0 +1,90 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { SelectScanComplianceData } from "@/components/compliance/data-compliance";
import { CrossIcon } from "@/components/icons";
import { CustomButton, CustomDropdownFilter } from "@/components/ui/custom";
interface DataComplianceProps {
scans: { id: string; name: string; state: string; progress: number }[];
regions: string[];
}
export const DataCompliance = ({ scans, regions }: DataComplianceProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [showClearButton, setShowClearButton] = useState(false);
const scanIdParam = searchParams.get("scanId");
const selectedScanId = scanIdParam || scans[0]?.id;
useEffect(() => {
const hasFilters = Array.from(searchParams.keys()).some(
(key) => key.startsWith("filter[") || key === "sort",
);
setShowClearButton(hasFilters);
}, [searchParams]);
const handleScanChange = (selectedKey: string) => {
const params = new URLSearchParams(searchParams);
params.set("scanId", selectedKey);
router.push(`?${params.toString()}`);
};
const pushDropdownFilter = useCallback(
(key: string, values: string[]) => {
const params = new URLSearchParams(searchParams);
const filterKey = `filter[${key}]`;
if (values.length === 0) {
params.delete(filterKey);
} else {
params.set(filterKey, values.join(","));
}
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const clearAllFilters = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
Array.from(params.keys()).forEach((key) => {
if (key.startsWith("filter[") || key === "sort") {
params.delete(key);
}
});
router.push(`?${params.toString()}`, { scroll: false });
}, [router, searchParams]);
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
<SelectScanComplianceData
scans={scans}
selectedScanId={selectedScanId}
onSelectionChange={handleScanChange}
/>
<CustomDropdownFilter
filter={{
key: "region__in",
values: regions,
labelCheckboxGroup: "Regions",
}}
onFilterChange={pushDropdownFilter}
/>
{showClearButton && (
<CustomButton
ariaLabel="Reset"
className="w-full md:w-fit"
onPress={clearAllFilters}
variant="dashed"
size="sm"
endContent={<CrossIcon size={24} />}
radius="sm"
>
Reset
</CustomButton>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./data-compliance";
export * from "./select-scan-compliance-data";

View File

@@ -0,0 +1,50 @@
import { Select, SelectItem } from "@nextui-org/react";
interface SelectScanComplianceDataProps {
scans: { id: string; name: string; state: string; progress: number }[];
selectedScanId: string;
onSelectionChange: (selectedKey: string) => void;
}
export const SelectScanComplianceData = ({
scans,
selectedScanId,
onSelectionChange,
}: SelectScanComplianceDataProps) => {
return (
<Select
aria-label="Select a Scan"
placeholder="Select a scan"
labelPlacement="outside"
size="md"
selectedKeys={new Set([selectedScanId])}
onSelectionChange={(keys) =>
onSelectionChange(Array.from(keys)[0] as string)
}
renderValue={() => {
const selectedItem = scans.find((item) => item.id === selectedScanId);
return selectedItem ? (
<div className="flex flex-col">
<span className="font-bold">{selectedItem.name}</span>
<span className="text-sm text-gray-500">
State: {selectedItem.state}, Progress: {selectedItem.progress}%
</span>
</div>
) : (
"Select a scan"
);
}}
>
{scans.map((scan) => (
<SelectItem key={scan.id} textValue={scan.name}>
<div className="flex flex-col">
<span className="font-bold">{scan.name}</span>
<span className="text-sm text-gray-500">
State: {scan.state}, Progress: {scan.progress}%
</span>
</div>
</SelectItem>
))}
</Select>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./compliance-card";
export * from "./compliance-skeleton-grid";

View File

@@ -0,0 +1,32 @@
"use client";
import { Select, SelectItem } from "@nextui-org/react";
const accounts = [
{ key: "audit-test-1", label: "740350143844" },
{ key: "audit-test-2", label: "890837126756" },
{ key: "audit-test-3", label: "563829104923" },
{ key: "audit-test-4", label: "678943217543" },
{ key: "audit-test-5", label: "932187465320" },
{ key: "audit-test-6", label: "492837106587" },
{ key: "audit-test-7", label: "812736459201" },
{ key: "audit-test-8", label: "374829106524" },
{ key: "audit-test-9", label: "926481053298" },
{ key: "audit-test-10", label: "748192364579" },
{ key: "audit-test-11", label: "501374829106" },
];
export const CustomAccountSelection = () => {
return (
<Select
label="Account"
aria-label="Select an Account"
placeholder="Select an account"
selectionMode="multiple"
className="w-full"
size="sm"
>
{accounts.map((acc) => (
<SelectItem key={acc.key}>{acc.label}</SelectItem>
))}
</Select>
);
};

View File

@@ -0,0 +1,15 @@
import { Checkbox } from "@nextui-org/react";
import React from "react";
export const CustomCheckboxMutedFindings = () => {
return (
<Checkbox
className="xl:-mt-8"
size="md"
color="danger"
aria-label="Include Muted Findings"
>
Include Muted Findings
</Checkbox>
);
};

View File

@@ -0,0 +1,97 @@
"use client";
import {
getLocalTimeZone,
startOfMonth,
startOfWeek,
today,
} from "@internationalized/date";
import { Button, ButtonGroup, DatePicker } from "@nextui-org/react";
import { useLocale } from "@react-aria/i18n";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useRef } from "react";
export const CustomDatePicker = () => {
const router = useRouter();
const searchParams = useSearchParams();
const defaultDate = today(getLocalTimeZone());
const [value, setValue] = React.useState(defaultDate);
const { locale } = useLocale();
const now = today(getLocalTimeZone());
const nextWeek = startOfWeek(now.add({ weeks: 1 }), locale);
const nextMonth = startOfMonth(now.add({ months: 1 }));
const applyDateFilter = useCallback(
(date: any) => {
const params = new URLSearchParams(searchParams.toString());
if (date) {
params.set("filter[updated_at]", date.toString());
} else {
params.delete("filter[updated_at]");
}
router.push(`?${params.toString()}`, { scroll: false });
},
[router, searchParams],
);
const initialRender = useRef(true);
useEffect(() => {
if (initialRender.current) {
initialRender.current = false;
return;
}
const params = new URLSearchParams(searchParams.toString());
if (params.size === 0) {
// If all params are cleared, reset to default date
setValue(defaultDate);
}
}, [searchParams]);
const handleDateChange = (newValue: any) => {
setValue(newValue);
applyDateFilter(newValue);
};
return (
<div className="flex w-full flex-col md:gap-2">
<DatePicker
aria-label="Select a Date"
CalendarTopContent={
<ButtonGroup
fullWidth
className="bg-content1 px-3 pb-2 pt-3 dark:bg-prowler-blue-400 [&>button]:border-default-200/60 [&>button]:text-default-500"
radius="full"
size="sm"
variant="bordered"
>
<Button onPress={() => handleDateChange(now)}>Today</Button>
<Button onPress={() => handleDateChange(nextWeek)}>
Next week
</Button>
<Button onPress={() => handleDateChange(nextMonth)}>
Next month
</Button>
</ButtonGroup>
}
calendarProps={{
focusedValue: value,
onFocusChange: setValue,
nextButtonProps: {
variant: "bordered",
},
prevButtonProps: {
variant: "bordered",
},
}}
value={value}
onChange={handleDateChange}
size="sm"
variant="flat"
/>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import React from "react";
import {
AWSProviderBadge,
AzureProviderBadge,
GCPProviderBadge,
KS8ProviderBadge,
} from "../icons/providers-badge";
export const CustomProviderInputAWS = () => {
return (
<div className="flex items-center gap-x-2">
<AWSProviderBadge width={25} height={25} />
<p className="text-sm">Amazon Web Services</p>
</div>
);
};
export const CustomProviderInputAzure = () => {
return (
<div className="flex items-center gap-x-2">
<AzureProviderBadge width={25} height={25} />
<p className="text-sm">Azure</p>
</div>
);
};
export const CustomProviderInputGCP = () => {
return (
<div className="flex items-center gap-x-2">
<GCPProviderBadge width={25} height={25} />
<p className="text-sm">Google Cloud Platform</p>
</div>
);
};
export const CustomProviderInputKubernetes = () => {
return (
<div className="flex items-center gap-x-2">
<KS8ProviderBadge width={25} height={25} />
<p className="text-sm">Kubernetes</p>
</div>
);
};

View File

@@ -0,0 +1,51 @@
"use client";
import { Select, SelectItem } from "@nextui-org/react";
const regions = [
{ key: "af-south-1", label: "AF South 1" },
{ key: "ap-east-1", label: "AP East 1" },
{ key: "ap-northeast-1", label: "AP Northeast 1" },
{ key: "ap-northeast-2", label: "AP Northeast 2" },
{ key: "ap-northeast-3", label: "AP Northeast 3" },
{ key: "ap-south-1", label: "AP South 1" },
{ key: "ap-south-2", label: "AP South 2" },
{ key: "ap-southeast-1", label: "AP Southeast 1" },
{ key: "ap-southeast-2", label: "AP Southeast 2" },
{ key: "ap-southeast-3", label: "AP Southeast 3" },
{ key: "ap-southeast-4", label: "AP Southeast 4" },
{ key: "ca-central-1", label: "CA Central 1" },
{ key: "ca-west-1", label: "CA West 1" },
{ key: "eu-central-1", label: "EU Central 1" },
{ key: "eu-central-2", label: "EU Central 2" },
{ key: "eu-north-1", label: "EU North 1" },
{ key: "eu-south-1", label: "EU South 1" },
{ key: "eu-south-2", label: "EU South 2" },
{ key: "eu-west-1", label: "EU West 1" },
{ key: "eu-west-2", label: "EU West 2" },
{ key: "eu-west-3", label: "EU West 3" },
{ key: "il-central-1", label: "IL Central 1" },
{ key: "me-central-1", label: "ME Central 1" },
{ key: "me-south-1", label: "ME South 1" },
{ key: "sa-east-1", label: "SA East 1" },
{ key: "us-east-1", label: "US East 1" },
{ key: "us-east-2", label: "US East 2" },
{ key: "us-west-1", label: "US West 1" },
{ key: "us-west-2", label: "US West 2" },
];
export const CustomRegionSelection = () => {
return (
<Select
label="Region"
aria-label="Select a Region"
placeholder="Select a region"
selectionMode="multiple"
className="w-full"
size="sm"
>
{regions.map((acc) => (
<SelectItem key={acc.key}>{acc.label}</SelectItem>
))}
</Select>
);
};

View File

@@ -0,0 +1,62 @@
import { Input } from "@nextui-org/react";
import debounce from "lodash.debounce";
import { SearchIcon, XCircle } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
export const CustomSearchInput: React.FC = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState("");
const applySearch = useCallback(
(query: string) => {
const params = new URLSearchParams(searchParams.toString());
if (query) {
params.set("filter[search]", query);
} else {
params.delete("filter[search]");
}
router.push(`?${params.toString()}`, { scroll: false });
},
[router, searchParams],
);
const debouncedChangeHandler = useCallback(debounce(applySearch, 300), []);
const clearIconSearch = () => {
setSearchQuery("");
applySearch("");
};
useEffect(() => {
const searchFromUrl = searchParams.get("filter[search]") || "";
setSearchQuery(searchFromUrl);
}, [searchParams]);
return (
<Input
variant="flat"
aria-label="Search"
placeholder="Search..."
labelPlacement="outside"
value={searchQuery}
startContent={<SearchIcon className="text-default-400" width={16} />}
onChange={(e) => {
const value = e.target.value;
setSearchQuery(value);
debouncedChangeHandler(value);
}}
endContent={
searchQuery && (
<button onClick={clearIconSearch} className="focus:outline-none">
<XCircle className="h-4 w-4 text-default-400" />
</button>
)
}
radius="sm"
size="sm"
/>
);
};

View File

@@ -0,0 +1,91 @@
"use client";
import { Select, SelectItem } from "@nextui-org/react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useMemo } from "react";
import {
CustomProviderInputAWS,
CustomProviderInputAzure,
CustomProviderInputGCP,
CustomProviderInputKubernetes,
} from "./custom-provider-inputs";
const dataInputsProvider = [
{
key: "aws",
label: "Amazon Web Services",
value: <CustomProviderInputAWS />,
},
{
key: "gcp",
label: "Google Cloud Platform",
value: <CustomProviderInputGCP />,
},
{
key: "azure",
label: "Microsoft Azure",
value: <CustomProviderInputAzure />,
},
{
key: "kubernetes",
label: "Kubernetes",
value: <CustomProviderInputKubernetes />,
},
];
export const CustomSelectProvider: React.FC = () => {
const router = useRouter();
const searchParams = useSearchParams();
const applyProviderFilter = useCallback(
(value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set("filter[provider_type]", value);
} else {
params.delete("filter[provider_type]");
}
router.push(`?${params.toString()}`, { scroll: false });
},
[router, searchParams],
);
const currentProvider = searchParams.get("filter[provider_type]") || "";
const selectedKeys = useMemo(() => {
return dataInputsProvider.some(
(provider) => provider.key === currentProvider,
)
? [currentProvider]
: [];
}, [currentProvider]);
return (
<Select
items={dataInputsProvider}
aria-label="Select a Provider"
placeholder="Select a provider"
labelPlacement="outside"
size="sm"
onChange={(e) => {
const value = e.target.value;
applyProviderFilter(value);
}}
selectedKeys={selectedKeys}
renderValue={(items) => {
return items.map((item) => (
<div key={item.key} className="flex items-center gap-2">
{item.data?.value}
</div>
));
}}
>
{(item) => (
<SelectItem key={item.key} textValue={item.key} aria-label={item.label}>
<div className="flex items-center gap-2">{item.value}</div>
</SelectItem>
)}
</Select>
);
};

View File

@@ -0,0 +1,74 @@
export const filterProviders = [
{
key: "connected",
labelCheckboxGroup: "Connection",
values: ["false", "true"],
},
// Add more filter categories as needed
];
export const filterScans = [
{
key: "provider_type__in",
labelCheckboxGroup: "Provider",
values: ["aws", "azure", "gcp", "kubernetes"],
},
{
key: "state",
labelCheckboxGroup: "State",
values: [
"available",
"scheduled",
"executing",
"completed",
"failed",
"cancelled",
],
},
{
key: "trigger",
labelCheckboxGroup: "Schedule",
values: ["scheduled", "manual"],
},
// Add more filter categories as needed
];
export const filterFindings = [
{
key: "severity__in",
labelCheckboxGroup: "Severity",
values: ["critical", "high", "medium", "low", "informational"],
},
{
key: "status__in",
labelCheckboxGroup: "Status",
values: ["PASS", "FAIL", "MANUAL", "MUTED"],
},
{
key: "delta__in",
labelCheckboxGroup: "Delta",
values: ["new", "changed"],
},
{
key: "provider_type__in",
labelCheckboxGroup: "Provider",
values: ["aws", "azure", "gcp", "kubernetes"],
},
// Add more filter categories as needed
];
export const filterUsers = [
{
key: "is_active",
labelCheckboxGroup: "Status",
values: ["true", "false"],
},
];
export const filterInvitations = [
{
key: "state",
labelCheckboxGroup: "State",
values: ["pending", "accepted", "expired", "revoked"],
},
];

View File

@@ -0,0 +1,75 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
import { FilterControlsProps } from "@/types";
import { CrossIcon } from "../icons";
import { CustomButton } from "../ui/custom";
import { DataTableFilterCustom } from "../ui/table";
import { CustomAccountSelection } from "./custom-account-selection";
import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings";
import { CustomDatePicker } from "./custom-date-picker";
import { CustomRegionSelection } from "./custom-region-selection";
import { CustomSearchInput } from "./custom-search-input";
import { CustomSelectProvider } from "./custom-select-provider";
export const FilterControls: React.FC<FilterControlsProps> = ({
search = false,
providers = false,
date = false,
regions = false,
accounts = false,
mutedFindings = false,
customFilters,
}) => {
const router = useRouter();
const searchParams = useSearchParams();
const [showClearButton, setShowClearButton] = useState(false);
useEffect(() => {
const hasFilters = Array.from(searchParams.keys()).some(
(key) => key.startsWith("filter[") || key === "sort",
);
setShowClearButton(hasFilters);
}, [searchParams]);
const clearAllFilters = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
Array.from(params.keys()).forEach((key) => {
if (key.startsWith("filter[") || key === "sort") {
params.delete(key);
}
});
router.push(`?${params.toString()}`, { scroll: false });
}, [router, searchParams]);
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
{search && <CustomSearchInput />}
{providers && <CustomSelectProvider />}
{date && <CustomDatePicker />}
{regions && <CustomRegionSelection />}
{accounts && <CustomAccountSelection />}
{mutedFindings && <CustomCheckboxMutedFindings />}
{showClearButton && (
<CustomButton
ariaLabel="Reset"
className="w-full md:w-fit"
onPress={clearAllFilters}
variant="dashed"
size="sm"
endContent={<CrossIcon size={24} />}
radius="sm"
>
Reset
</CustomButton>
)}
</div>
{customFilters && <DataTableFilterCustom filters={customFilters} />}
</div>
);
};

View File

@@ -0,0 +1,8 @@
export * from "./custom-account-selection";
export * from "./custom-checkbox-muted-findings";
export * from "./custom-date-picker";
export * from "./custom-provider-inputs";
export * from "./custom-region-selection";
export * from "./custom-select-provider";
export * from "./data-filters";
export * from "./filter-controls";

View File

@@ -0,0 +1,177 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { useSearchParams } from "next/navigation";
import { DataTableRowDetails } from "@/components/findings/table";
import { InfoIcon } from "@/components/icons";
import { TriggerSheet } from "@/components/ui/sheet";
import {
DataTableColumnHeader,
SeverityBadge,
StatusFindingBadge,
} from "@/components/ui/table";
import { FindingProps } from "@/types";
import { DataTableRowActions } from "./data-table-row-actions";
const getFindingsData = (row: { original: FindingProps }) => {
return row.original;
};
const getFindingsMetadata = (row: { original: FindingProps }) => {
return row.original.attributes.check_metadata;
};
const getResourceData = (
row: { original: FindingProps },
field: keyof FindingProps["relationships"]["resource"]["attributes"],
) => {
return (
row.original.relationships?.resource?.attributes?.[field] ||
`No ${field} found in resource`
);
};
const getProviderData = (
row: { original: FindingProps },
field: keyof FindingProps["relationships"]["provider"]["attributes"],
) => {
return (
row.original.relationships?.provider?.attributes?.[field] ||
`No ${field} found in provider`
);
};
const getScanData = (
row: { original: FindingProps },
field: keyof FindingProps["relationships"]["scan"]["attributes"],
) => {
return (
row.original.relationships?.scan?.attributes?.[field] ||
`No ${field} found in scan`
);
};
export const ColumnFindings: ColumnDef<FindingProps>[] = [
{
accessorKey: "check",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Check"} param="check_id" />
),
cell: ({ row }) => {
const { checktitle } = getFindingsMetadata(row);
return <p className="max-w-96 truncate text-small">{checktitle}</p>;
},
},
{
accessorKey: "severity",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Severity"}
param="severity"
/>
),
cell: ({ row }) => {
const {
attributes: { severity },
} = getFindingsData(row);
return <SeverityBadge severity={severity} />;
},
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Status"} param="status" />
),
cell: ({ row }) => {
const {
attributes: { status },
} = getFindingsData(row);
return <StatusFindingBadge size="sm" status={status} />;
},
},
{
accessorKey: "scanName",
header: "Scan Name",
cell: ({ row }) => {
const name = getScanData(row, "name");
return (
<p className="text-small">
{typeof name === "string" || typeof name === "number"
? name
: "Invalid data"}
</p>
);
},
},
{
accessorKey: "region",
header: "Region",
cell: ({ row }) => {
const region = getResourceData(row, "region");
return (
<>
<div>{typeof region === "string" ? region : "Invalid region"}</div>
</>
);
},
},
{
accessorKey: "service",
header: "Service",
cell: ({ row }) => {
const { servicename } = getFindingsMetadata(row);
return <p className="max-w-96 truncate text-small">{servicename}</p>;
},
},
{
accessorKey: "account",
header: "Account",
cell: ({ row }) => {
const account = getProviderData(row, "uid");
return (
<>
<p className="max-w-96 truncate text-small">
{typeof account === "string" ? account : "Invalid account"}
</p>
</>
);
},
},
{
id: "moreInfo",
header: "Details",
cell: ({ row }) => {
const searchParams = useSearchParams();
const findingId = searchParams.get("id");
const isOpen = findingId === row.original.id;
return (
<div className="flex justify-center">
<TriggerSheet
triggerComponent={<InfoIcon className="text-primary" size={16} />}
title="Finding Details"
description="View the finding details"
defaultOpen={isOpen}
>
<DataTableRowDetails
entityId={row.original.id}
findingDetails={row.original}
/>
</TriggerSheet>
</div>
);
},
},
{
id: "actions",
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
},
},
];

View File

@@ -0,0 +1,99 @@
"use client";
import {
Button,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@nextui-org/react";
import {
// AddNoteBulkIcon,
EditDocumentBulkIcon,
} from "@nextui-org/shared-icons";
import { Row } from "@tanstack/react-table";
// import { useState } from "react";
import { VerticalDotsIcon } from "@/components/icons";
// import { CustomAlertModal } from "@/components/ui/custom";
// import { EditForm } from "../forms";
// import { DeleteForm } from "../forms/delete-form";
interface DataTableRowActionsProps<FindingProps> {
row: Row<FindingProps>;
}
const iconClasses =
"text-2xl text-default-500 pointer-events-none flex-shrink-0";
export function DataTableRowActions<FindingProps>({
row,
}: DataTableRowActionsProps<FindingProps>) {
const findingId = (row.original as { id: string }).id;
return (
<>
{/* <CustomAlertModal
isOpen={isEditOpen}
onOpenChange={setIsEditOpen}
title="Edit Provider"
description={"Edit the provider details"}
>
<EditForm
providerId={providerId}
providerAlias={providerAlias}
setIsOpen={setIsEditOpen}
/>
</CustomAlertModal>
<CustomAlertModal
isOpen={isDeleteOpen}
onOpenChange={setIsDeleteOpen}
title="Are you absolutely sure?"
description="This action cannot be undone. This will permanently delete your provider account and remove your data from the server."
>
<DeleteForm providerId={providerId} setIsOpen={setIsDeleteOpen} />
</CustomAlertModal> */}
<div className="relative flex items-center justify-end gap-2">
<Dropdown
className="shadow-xl dark:bg-prowler-blue-800"
placement="bottom"
>
<DropdownTrigger>
<Button isIconOnly radius="full" size="sm" variant="light">
<VerticalDotsIcon className="text-default-400" />
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="Actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="jira"
description="Allows you to send the finding to Jira"
textValue="Send to Jira"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
// onClick={() => setIsEditOpen(true)}
>
<span className="hidden text-sm">{findingId}</span>
Send to Jira
</DropdownItem>
<DropdownItem
key="slack"
description="Allows you to send the finding to Slack"
textValue="Send to Slack"
startContent={<EditDocumentBulkIcon className={iconClasses} />}
// onClick={() => setIsEditOpen(true)}
>
Send to Slack
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
</>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
// import { usePathname, useRouter, useSearchParams } from "next/navigation";
// import { useEffect } from "react";
import { FindingProps } from "@/types/components";
import { FindingDetail } from "./finding-detail";
export const DataTableRowDetails = ({
// entityId,
findingDetails,
}: {
entityId: string;
findingDetails: FindingProps;
}) => {
// const router = useRouter();
// const pathname = usePathname();
// const searchParams = useSearchParams();
// useEffect(() => {
// if (entityId) {
// const params = new URLSearchParams(searchParams.toString());
// params.set("id", entityId);
// router.push(`${pathname}?${params.toString()}`, { scroll: false });
// }
// return () => {
// if (entityId) {
// const cleanupParams = new URLSearchParams(searchParams.toString());
// cleanupParams.delete("id");
// router.push(`${pathname}?${cleanupParams.toString()}`, {
// scroll: false,
// });
// }
// };
// }, [entityId, pathname, router, searchParams]);
return <FindingDetail findingDetails={findingDetails} />;
};

View File

@@ -0,0 +1,219 @@
"use client";
import { Snippet } from "@nextui-org/react";
import Link from "next/link";
import { SnippetId } from "@/components/ui/entities";
import { DateWithTime } from "@/components/ui/entities/date-with-time";
import { SeverityBadge } from "@/components/ui/table/severity-badge";
import { FindingProps } from "@/types";
export const FindingDetail = ({
findingDetails,
}: {
findingDetails: FindingProps;
}) => {
const finding = findingDetails;
const attributes = finding.attributes;
const resource = finding.relationships.resource.attributes;
const remediation = attributes.check_metadata.remediation;
return (
<div className="flex flex-col gap-6 rounded-lg">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="line-clamp-2 text-xl font-bold leading-tight text-gray-800 dark:text-prowler-theme-pale/90">
{attributes.check_metadata.checktitle}
</h2>
<p className="text-sm text-gray-500 dark:text-prowler-theme-pale/70">
{resource.service}
</p>
</div>
<div
className={`rounded-lg px-3 py-1 text-sm font-semibold ${
attributes.status === "PASS"
? "bg-green-100 text-green-600"
: attributes.status === "MANUAL"
? "bg-gray-100 text-gray-600"
: "bg-red-100 text-red-600"
}`}
>
{attributes.status}
</div>
</div>
{/* Check Metadata */}
<div className="flex flex-col gap-4 rounded-lg p-4 shadow dark:bg-prowler-blue-400">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-gray-800 dark:text-prowler-theme-pale/90">
Check Metadata
</h3>
<SeverityBadge severity={attributes.severity} />
</div>
{attributes.status === "FAIL" && (
<Snippet
className="max-w-full py-4"
color="danger"
hideCopyButton
hideSymbol
>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Risk
</p>
<p className="whitespace-pre-line text-gray-800 dark:text-prowler-theme-pale/90">
{attributes.check_metadata.risk}
</p>
</Snippet>
)}
<div className="flex flex-col gap-2">
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Description
</p>
<p className="text-gray-800 dark:text-prowler-theme-pale/90">
{attributes.check_metadata.description}
</p>
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold dark:text-prowler-theme-pale">
Remediation
</h3>
<div className="text-gray-800 dark:text-prowler-theme-pale/90">
{remediation.recommendation && (
<>
<p className="text-sm font-semibold">Recommendation:</p>
<p>{remediation.recommendation.text}</p>
<Link
target="_blank"
href={remediation.recommendation.url}
className="mt-2 inline-block text-sm text-blue-500 underline"
>
Learn more
</Link>
</>
)}
{remediation.code &&
Object.values(remediation.code).some(Boolean) && (
<div className="flex flex-col gap-2">
<p className="mt-4 text-sm font-semibold">
Check these links:
</p>
<div className="flex flex-col gap-2">
{remediation.code.cli && (
<div>
<p className="text-sm font-semibold">CLI Command:</p>
<Snippet hideSymbol size="sm" className="max-w-full">
<p className="whitespace-pre-line">
{remediation.code.cli}
</p>
</Snippet>
</div>
)}
<div className="flex flex-row gap-4">
{Object.entries(remediation.code)
.filter(([key]) => key !== "cli")
.map(([key, value]) =>
value ? (
<Link
key={key}
href={value}
target="_blank"
className="text-sm font-medium text-blue-500"
>
{key === "other"
? "External doc"
: key.charAt(0).toUpperCase() +
key.slice(1).toLowerCase()}
</Link>
) : null,
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Resources Section */}
<div className="flex flex-col gap-4 rounded-lg p-4 shadow dark:bg-prowler-blue-400">
<h3 className="text-lg font-bold text-gray-800 dark:text-prowler-theme-pale/90">
Resource Details
</h3>
<div className="grid grid-cols-2 gap-6">
<div className="col-span-2">
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Resource ID
</p>
<Snippet size="sm" hideSymbol className="max-w-full">
<p className="whitespace-pre-line">{resource.uid}</p>
</Snippet>
</div>
<div>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Resource Name
</p>
<p className="text-gray-800 dark:text-prowler-theme-pale/90">
{resource.name}
</p>
</div>
<div>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Region
</p>
<p className="text-gray-800 dark:text-prowler-theme-pale/90">
{resource.region}
</p>
</div>
<div>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Resource Type
</p>
<p className="text-gray-800 dark:text-prowler-theme-pale/90">
{resource.type}
</p>
</div>
<div>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Severity
</p>
<SeverityBadge severity={attributes.severity} />
</div>
{resource.tags &&
Object.entries(resource.tags).map(([key, value]) => (
<div key={key}>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Tag: {key}
</p>
<SnippetId
entityId={value}
hideSymbol
size="sm"
className="max-w-full"
>
<p className="whitespace-pre-line">{value}</p>
</SnippetId>
</div>
))}
<div className="col-span-2 grid grid-cols-2 gap-6">
<div>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Inserted At
</p>
<DateWithTime inline dateTime={resource.inserted_at} />
</div>
<div>
<p className="text-sm font-semibold dark:text-prowler-theme-pale">
Updated At
</p>
<DateWithTime inline dateTime={resource.updated_at} />
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,5 @@
export * from "./column-findings";
export * from "./data-table-row-actions";
export * from "./data-table-row-details";
export * from "./finding-detail";
export * from "./skeleton-table-findings";

View File

@@ -0,0 +1,65 @@
import { Card, Skeleton } from "@nextui-org/react";
import React from "react";
export const SkeletonTableFindings = () => {
return (
<Card className="h-full w-full space-y-5 p-4" radius="sm">
{/* Table headers */}
<div className="hidden justify-between md:flex">
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
</div>
{/* Table body */}
<div className="space-y-3">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="flex flex-col items-center justify-between space-x-0 md:flex-row md:space-x-4"
>
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
</div>
))}
</div>
</Card>
);
};

View File

@@ -0,0 +1,810 @@
import * as React from "react";
import { IconSvgProps } from "@/types";
export const TwitterIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
height={size || height}
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M19.633 7.997c.013.175.013.349.013.523 0 5.325-4.053 11.461-11.46 11.461-2.282 0-4.402-.661-6.186-1.809.324.037.636.05.973.05a8.07 8.07 0 0 0 5.001-1.721 4.036 4.036 0 0 1-3.767-2.793c.249.037.499.062.761.062.361 0 .724-.05 1.061-.137a4.027 4.027 0 0 1-3.23-3.953v-.05c.537.299 1.16.486 1.82.511a4.022 4.022 0 0 1-1.796-3.354c0-.748.199-1.434.548-2.032a11.457 11.457 0 0 0 8.306 4.215c-.062-.3-.1-.611-.1-.923a4.026 4.026 0 0 1 4.028-4.028c1.16 0 2.207.486 2.943 1.272a7.957 7.957 0 0 0 2.556-.973 4.02 4.02 0 0 1-1.771 2.22 8.073 8.073 0 0 0 2.319-.624 8.645 8.645 0 0 1-2.019 2.083z"
fill="currentColor"
/>
</svg>
);
};
export const GithubIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
height={size || height}
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
clipRule="evenodd"
d="M12.026 2c-5.509 0-9.974 4.465-9.974 9.974 0 4.406 2.857 8.145 6.821 9.465.499.09.679-.217.679-.481 0-.237-.008-.865-.011-1.696-2.775.602-3.361-1.338-3.361-1.338-.452-1.152-1.107-1.459-1.107-1.459-.905-.619.069-.605.069-.605 1.002.07 1.527 1.028 1.527 1.028.89 1.524 2.336 1.084 2.902.829.091-.645.351-1.085.635-1.334-2.214-.251-4.542-1.107-4.542-4.93 0-1.087.389-1.979 1.024-2.675-.101-.253-.446-1.268.099-2.64 0 0 .837-.269 2.742 1.021a9.582 9.582 0 0 1 2.496-.336 9.554 9.554 0 0 1 2.496.336c1.906-1.291 2.742-1.021 2.742-1.021.545 1.372.203 2.387.099 2.64.64.696 1.024 1.587 1.024 2.675 0 3.833-2.33 4.675-4.552 4.922.355.308.675.916.675 1.846 0 1.334-.012 2.41-.012 2.737 0 .267.178.577.687.479C19.146 20.115 22 16.379 22 11.974 22 6.465 17.535 2 12.026 2z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
);
};
export const MoonFilledIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M21.53 15.93c-.16-.27-.61-.69-1.73-.49a8.46 8.46 0 01-1.88.13 8.409 8.409 0 01-5.91-2.82 8.068 8.068 0 01-1.44-8.66c.44-1.01.13-1.54-.09-1.76s-.77-.55-1.83-.11a10.318 10.318 0 00-6.32 10.21 10.475 10.475 0 007.04 8.99 10 10 0 002.89.55c.16.01.32.02.48.02a10.5 10.5 0 008.47-4.27c.67-.93.49-1.519.32-1.79z"
fill="currentColor"
/>
</svg>
);
export const SunFilledIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
aria-hidden="true"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<g fill="currentColor">
<path d="M19 12a7 7 0 11-7-7 7 7 0 017 7z" />
<path d="M12 22.96a.969.969 0 01-1-.96v-.08a1 1 0 012 0 1.038 1.038 0 01-1 1.04zm7.14-2.82a1.024 1.024 0 01-.71-.29l-.13-.13a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.984.984 0 01-.7.29zm-14.28 0a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a1 1 0 01-.7.29zM22 13h-.08a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zM2.08 13H2a1 1 0 010-2 1.038 1.038 0 011.04 1 .969.969 0 01-.96 1zm16.93-7.01a1.024 1.024 0 01-.71-.29 1 1 0 010-1.41l.13-.13a1 1 0 011.41 1.41l-.13.13a.984.984 0 01-.7.29zm-14.02 0a1.024 1.024 0 01-.71-.29l-.13-.14a1 1 0 011.41-1.41l.13.13a1 1 0 010 1.41.97.97 0 01-.7.3zM12 3.04a.969.969 0 01-1-.96V2a1 1 0 012 0 1.038 1.038 0 01-1 1.04z" />
</g>
</svg>
);
export const SearchIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M11.5 21C16.7467 21 21 16.7467 21 11.5C21 6.25329 16.7467 2 11.5 2C6.25329 2 2 6.25329 2 11.5C2 16.7467 6.25329 21 11.5 21Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
<path
d="M22 22L20 20"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
export const ChevronDownIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
strokeWidth = 1.5,
...props
}) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<path
d="M19.92 8.95l-6.52 6.52c-.77.77-2.03.77-2.8 0L4.08 8.95"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit={10}
strokeWidth={strokeWidth}
/>
</svg>
);
export const PlusIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<g
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
>
<path d="M6 12h12" />
<path d="M12 18V6" />
</g>
</svg>
);
export const VerticalDotsIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<g fill="currentColor">
<path d="M12 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zM12 4c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zM12 16c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</g>
</svg>
);
export const DeleteIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size || height || 48}
viewBox="0 0 24 24"
width={size || width || 48}
aria-hidden="true"
{...props}
>
<g fill="none">
<path d="m12.593 23.258-.011.002-.071.035-.02.004-.014-.004-.071-.035q-.016-.005-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427q-.004-.016-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093q.019.005.029-.008l.004-.014-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014-.034.614q.001.018.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z" />
<path
fill="currentColor"
d="M14.28 2a2 2 0 0 1 1.897 1.368L16.72 5H20a1 1 0 1 1 0 2l-.003.071-.867 12.143A3 3 0 0 1 16.138 22H7.862a3 3 0 0 1-2.992-2.786L4.003 7.07 4 7a1 1 0 0 1 0-2h3.28l.543-1.632A2 2 0 0 1 9.721 2zm3.717 5H6.003l.862 12.071a1 1 0 0 0 .997.929h8.276a1 1 0 0 0 .997-.929zM10 10a1 1 0 0 1 .993.883L11 11v5a1 1 0 0 1-1.993.117L9 16v-5a1 1 0 0 1 1-1m4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1m.28-6H9.72l-.333 1h5.226z"
/>
</g>
</svg>
);
};
export const CheckIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 2048 2048"
{...props}
>
<path
fill="currentColor"
d="M2048 1024q0 142-36 272t-103 245t-160 207t-208 160t-245 103t-272 37q-142 0-272-36t-245-103t-207-160t-160-208t-103-245t-37-272q0-141 36-272t103-245t160-207t208-160T752 37t272-37q141 0 272 36t245 103t207 160t160 208t103 245t37 272m-1024 896q123 0 237-32t214-90t182-141t140-181t91-214t32-238q0-123-32-237t-90-214t-141-182t-181-140t-214-91t-238-32q-124 0-238 32t-213 90t-182 141t-140 181t-91 214t-32 238q0 124 32 238t90 213t141 182t181 140t214 91t238 32m0-512q55 0 107-15t98-45t81-69t61-91l116 56q-32 67-80 121t-109 92t-130 58t-144 21q-110 0-210-45t-174-128v173H512v-384h384v128H738q54 60 129 94t157 34m384-723V512h128v384h-384V768h158q-54-60-129-94t-157-34q-55 0-107 15t-98 45t-81 69t-61 91l-116-56q32-67 80-121t109-92t130-58t144-21q110 0 210 45t174 128"
/>
</svg>
);
export const CrossIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
d="M16.066 8.995a.75.75 0 1 0-1.06-1.061L12 10.939L8.995 7.934a.75.75 0 1 0-1.06 1.06L10.938 12l-3.005 3.005a.75.75 0 0 0 1.06 1.06L12 13.06l3.005 3.006a.75.75 0 0 0 1.06-1.06L13.062 12z"
/>
</svg>
);
export const PassIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 16 16"
{...props}
>
<g fill="currentColor">
<path d="M6.27 10.87h.71l4.56-4.56l-.71-.71l-4.2 4.21l-1.92-1.92L4 8.6z" />
<path
fillRule="evenodd"
d="M8.6 1c1.6.1 3.1.9 4.2 2c1.3 1.4 2 3.1 2 5.1c0 1.6-.6 3.1-1.6 4.4c-1 1.2-2.4 2.1-4 2.4s-3.2.1-4.6-.7s-2.5-2-3.1-3.5S.8 7.5 1.3 6c.5-1.6 1.4-2.9 2.8-3.8C5.4 1.3 7 .9 8.6 1m.5 12.9c1.3-.3 2.5-1 3.4-2.1c.8-1.1 1.3-2.4 1.2-3.8c0-1.6-.6-3.2-1.7-4.3c-1-1-2.2-1.6-3.6-1.7c-1.3-.1-2.7.2-3.8 1S2.7 4.9 2.3 6.3c-.4 1.3-.4 2.7.2 4q.9 1.95 2.7 3c1.2.7 2.6.9 3.9.6"
clipRule="evenodd"
/>
</g>
</svg>
);
export const RocketIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
d="m5.65 10.025l1.95.825q.35-.7.725-1.35t.825-1.3l-1.4-.275zM9.2 12.1l2.85 2.825q1.05-.4 2.25-1.225t2.25-1.875q1.75-1.75 2.738-3.887T20.15 4q-1.8-.125-3.95.863T12.3 7.6q-1.05 1.05-1.875 2.25T9.2 12.1m4.45-1.625q-.575-.575-.575-1.412t.575-1.413t1.425-.575t1.425.575t.575 1.413t-.575 1.412t-1.425.575t-1.425-.575m.475 8.025l2.1-2.1l-.275-1.4q-.65.45-1.3.812t-1.35.713zM21.95 2.175q.475 3.025-.587 5.888T17.7 13.525L18.2 16q.1.5-.05.975t-.5.825l-4.2 4.2l-2.1-4.925L7.075 12.8L2.15 10.7l4.175-4.2q.35-.35.838-.5t.987-.05l2.475.5q2.6-2.6 5.45-3.675t5.875-.6m-18.025 13.8q.875-.875 2.138-.887t2.137.862t.863 2.138t-.888 2.137q-.625.625-2.087 1.075t-4.038.8q.35-2.575.8-4.038t1.075-2.087m1.425 1.4q-.25.25-.5.913t-.35 1.337q.675-.1 1.338-.337t.912-.488q.3-.3.325-.725T6.8 17.35t-.725-.288t-.725.313"
/>
</svg>
);
};
export const AlertIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 24 24"
width={size || width}
{...props}
>
<g fill="none">
<path d="M24 0v24H0V0zM12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="m13.299 3.148l8.634 14.954a1.5 1.5 0 0 1-1.299 2.25H3.366a1.5 1.5 0 0 1-1.299-2.25l8.634-14.954c.577-1 2.02-1 2.598 0M12 4.898L4.232 18.352h15.536zM12 15a1 1 0 1 1 0 2a1 1 0 0 1 0-2m0-7a1 1 0 0 1 1 1v4a1 1 0 1 1-2 0V9a1 1 0 0 1 1-1"
/>
</g>
</svg>
);
export const NotificationIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
fill="none"
height={size || height || 24}
viewBox="0 0 24 24"
width={size || width || 24}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
clipRule="evenodd"
d="M18.707 8.796c0 1.256.332 1.997 1.063 2.85.553.628.73 1.435.73 2.31 0 .874-.287 1.704-.863 2.378a4.537 4.537 0 01-2.9 1.413c-1.571.134-3.143.247-4.736.247-1.595 0-3.166-.068-4.737-.247a4.532 4.532 0 01-2.9-1.413 3.616 3.616 0 01-.864-2.378c0-.875.178-1.682.73-2.31.754-.854 1.064-1.594 1.064-2.85V8.37c0-1.682.42-2.781 1.283-3.858C7.861 2.942 9.919 2 11.956 2h.09c2.08 0 4.204.987 5.466 2.625.82 1.054 1.195 2.108 1.195 3.745v.426zM9.074 20.061c0-.504.462-.734.89-.833.5-.106 3.545-.106 4.045 0 .428.099.89.33.89.833-.025.48-.306.904-.695 1.174a3.635 3.635 0 01-1.713.731 3.795 3.795 0 01-1.008 0 3.618 3.618 0 01-1.714-.732c-.39-.269-.67-.694-.695-1.173z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
);
export const IdIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
height={size || height || 24}
viewBox="0 0 24 24"
width={size || width || 24}
{...props}
>
<path d="M18 4v16H6V8.8L10.8 4zm0-2h-8L4 8v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2M9.5 19h-2v-2h2zm7 0h-2v-2h2zm-7-4h-2v-4h2zm3.5 4h-2v-4h2zm0-6h-2v-2h2zm3.5 2h-2v-4h2z" />
</svg>
);
export const DoneIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
width={size || width || 24}
height={size || height || 24}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="m2.394 13.742 4.743 3.62 7.616-8.704-1.506-1.316-6.384 7.296-3.257-2.486zm19.359-5.084-1.506-1.316-6.369 7.279-.753-.602-1.25 1.562 2.247 1.798z" />
</svg>
);
};
export const CopyIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
fill="none"
height={size || height || 20}
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size || width || 20}
{...props}
>
<path d="M6 17C4.89543 17 4 16.1046 4 15V5C4 3.89543 4.89543 3 6 3H13C13.7403 3 14.3866 3.4022 14.7324 4M11 21H18C19.1046 21 20 20.1046 20 19V9C20 7.89543 19.1046 7 18 7H11C9.89543 7 9 7.89543 9 9V19C9 20.1046 9.89543 21 11 21Z" />
</svg>
);
};
export const FlowIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size || height || 20}
viewBox="0 0 20 20"
width={size || width || 20}
{...props}
>
<path
fill="currentColor"
d="M16.4 4a2.4 2.4 0 1 0-4.8 0c0 .961.568 1.784 1.384 2.167c-.082 1.584-1.27 2.122-3.335 2.896c-.87.327-1.829.689-2.649 1.234V6.176A2.396 2.396 0 0 0 6 1.6a2.397 2.397 0 0 0-1 4.576v7.649A2.39 2.39 0 0 0 3.6 16a2.4 2.4 0 1 0 4.8 0c0-.961-.568-1.784-1.384-2.167c.082-1.583 1.271-2.122 3.335-2.896c2.03-.762 4.541-1.711 4.64-4.756A2.4 2.4 0 0 0 16.4 4M6 2.615a1.384 1.384 0 1 1 0 2.768a1.384 1.384 0 0 1 0-2.768m0 14.77a1.385 1.385 0 1 1 0-2.77a1.385 1.385 0 0 1 0 2.77m8-12a1.385 1.385 0 1 1 0-2.77a1.385 1.385 0 0 1 0 2.77"
/>
</svg>
);
};
export const ConnectionIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size || height || 20}
viewBox="0 0 20 20"
width={size || width || 20}
{...props}
>
<path
fill="currentColor"
d="M18 14.824V12.5A3.5 3.5 0 0 0 14.5 9h-2A1.5 1.5 0 0 1 11 7.5V5.176A2.4 2.4 0 0 0 12.4 3a2.4 2.4 0 1 0-4.8 0c0 .967.576 1.796 1.4 2.176V7.5A1.5 1.5 0 0 1 7.5 9h-2A3.5 3.5 0 0 0 2 12.5v2.324A2.396 2.396 0 0 0 3 19.4a2.397 2.397 0 0 0 1-4.576V12.5A1.5 1.5 0 0 1 5.5 11h2c.539 0 1.044-.132 1.5-.35v4.174a2.396 2.396 0 0 0 1 4.576a2.397 2.397 0 0 0 1-4.576V10.65c.456.218.961.35 1.5.35h2a1.5 1.5 0 0 1 1.5 1.5v2.324A2.4 2.4 0 0 0 14.6 17a2.4 2.4 0 1 0 4.8 0c0-.967-.575-1.796-1.4-2.176M10 1.615a1.384 1.384 0 1 1 0 2.768a1.384 1.384 0 0 1 0-2.768m-7 16.77a1.385 1.385 0 1 1 0-2.77a1.385 1.385 0 0 1 0 2.77m7 0a1.385 1.385 0 1 1 0-2.77a1.385 1.385 0 0 1 0 2.77m7 0a1.385 1.385 0 1 1 0-2.77a1.385 1.385 0 0 1 0 2.77"
/>
</svg>
);
};
export const ConnectionTrue: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
height={size || height || 24}
viewBox="0 0 24 24"
width={size || width || 24}
{...props}
>
<path
d="M12 20h.012M8.25 17c2-2 5.5-2 7.5 0m2.75-3c-3.768-3.333-9-3.333-13 0M2 11c3.158-2.667 6.579-4 10-4m3 .5s1 0 2 2c0 0 2.477-3.9 5-5.5"
color="currentColor"
/>
</svg>
);
export const ConnectionFalse: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
height={size || height || 24}
viewBox="0 0 24 24"
width={size || width || 24}
{...props}
>
<path
d="M12 18h.012M8.25 15c2-2 5.5-2 7.5 0m2.75-3a11 11 0 0 0-.231-.199M5.5 12c2.564-2.136 5.634-2.904 8.5-2.301M2 9c3.466-2.927 7.248-4.247 11-3.962M22 5l-6 6m6 0-6-6"
color="currentColor"
/>
</svg>
);
export const ConnectionPending: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
strokeWidth="1.05"
height={size || height || 24}
viewBox="0 0 24 24"
width={size || width || 24}
{...props}
>
<g fill="none" stroke="currentColor" strokeWidth="1.05">
<circle cx="12" cy="18" r="2" />
<path strokeOpacity=".2" d="M7.757 13.757a6 6 0 0 1 8.486 0" />
<path
strokeOpacity=".2"
d="M4.929 10.93c3.905-3.905 10.237-3.905 14.142 0"
opacity=".8"
/>
<path
strokeOpacity=".2"
d="M2.101 8.1c5.467-5.468 14.331-5.468 19.798 0"
opacity=".8"
/>
</g>
</svg>
);
export const SuccessIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
width={size || width || 24}
height={size || height || 24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 2C6.49 2 2 6.49 2 12C2 17.51 6.49 22 12 22C17.51 22 22 17.51 22 12C22 6.49 17.51 2 12 2ZM16.78 9.7L11.11 15.37C10.97 15.51 10.78 15.59 10.58 15.59C10.38 15.59 10.19 15.51 10.05 15.37L7.22 12.54C6.93 12.25 6.93 11.77 7.22 11.48C7.51 11.19 7.99 11.19 8.28 11.48L10.58 13.78L15.72 8.64C16.01 8.35 16.49 8.35 16.78 8.64C17.07 8.93 17.07 9.4 16.78 9.7Z"
fill="currentColor"
/>
</svg>
);
export const ArrowUpIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size || height || "1em"}
viewBox="0 0 12 12"
width={size || width || "1em"}
aria-hidden="true"
focusable="false"
role="presentation"
{...props}
>
<path
d="M3 7.5L6 4.5L9 7.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
};
export const ArrowDownIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size || height || "1em"}
viewBox="0 0 12 12"
width={size || width || "1em"}
aria-hidden="true"
focusable="false"
role="presentation"
{...props}
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
};
export const ChevronsLeftRightIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size || height || 24}
viewBox="0 0 24 24"
width={size || width || 24}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevrons-left-right ml-2 h-4 w-4 rotate-90"
{...props}
>
<path d="m9 7-5 5 5 5M15 7l5 5-5 5" />
</svg>
);
};
export const PlusCircleIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size || height || 15}
viewBox="0 0 15 15"
width={size || width || 15}
className="mr-2 size-4"
{...props}
>
<path
d="M7.5.877a6.623 6.623 0 1 0 0 13.246A6.623 6.623 0 0 0 7.5.877ZM1.827 7.5a5.673 5.673 0 1 1 11.346 0 5.673 5.673 0 0 1-11.346 0ZM7.5 4a.5.5 0 0 1 .5.5V7h2.5a.5.5 0 1 1 0 1H8v2.5a.5.5 0 0 1-1 0V8H4.5a.5.5 0 0 1 0-1H7V4.5a.5.5 0 0 1 .5-.5Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
/>
</svg>
);
};
export const CustomFilterIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
height={size || height || 16}
width={size || width || 16}
viewBox="0 0 24 24"
{...props}
>
<g fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M9.5 14a3 3 0 1 1 0 6 3 3 0 0 1 0-6Zm5-10a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z" />
<path strokeLinecap="round" d="M15 16.959h7m-13-10H2m0 10h2m18-10h-2" />
</g>
</svg>
);
};
export const SaveIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size || height || 48}
viewBox="0 0 24 24"
width={size || width || 48}
aria-hidden="true"
{...props}
>
<path
d="m20.71 9.29l-6-6a1 1 0 0 0-.32-.21A1.1 1.1 0 0 0 14 3H6a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3v-8a1 1 0 0 0-.29-.71M9 5h4v2H9Zm6 14H9v-3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1Zm4-1a1 1 0 0 1-1 1h-1v-3a3 3 0 0 0-3-3h-4a3 3 0 0 0-3 3v3H6a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1v3a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V6.41l4 4Z"
fill="currentColor"
/>
</svg>
);
};
export const AddIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
height={size || height || 20}
viewBox="0 0 24 24"
width={size || width || 20}
aria-hidden="true"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m.75-13a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25V15a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25z"
clipRule="evenodd"
/>
</svg>
);
};
export const ScheduleIcon: React.FC<IconSvgProps> = ({
size,
height,
width,
...props
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width || 24}
height={size || height || 24}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5" />
<path d="M16 2v4M8 2v4M3 10h5" />
<path d="M17.5 17.5L16 16.3V14" />
<circle cx="16" cy="16" r="6" />
</svg>
);
};
export const InfoIcon: React.FC<IconSvgProps> = ({
size = 24,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</svg>
);

View File

@@ -0,0 +1,63 @@
import AWSLogo from "./aws.svg";
import CISLogo from "./cis.svg";
import CISALogo from "./cisa.svg";
import ENSLogo from "./ens.png";
import FedRAMPLogo from "./fedramp.svg";
import FFIECLogo from "./ffiec.svg";
import GDPRLogo from "./gdpr.svg";
import GxPLogo from "./gxp-aws.svg";
import HIPAALogo from "./hipaa.svg";
import ISOLogo from "./iso-27001.svg";
import MITRELogo from "./mitre-attack.svg";
import NISTLogo from "./nist.svg";
import PCILogo from "./pci-dss.svg";
import RBILogo from "./rbi.svg";
import SOC2Logo from "./soc2.svg";
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;
}
};

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 304 182" style="enable-background:new 0 0 304 182;" xml:space="preserve">
<style type="text/css">
.st0{fill:#252F3E;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FF9900;}
</style>
<g>
<path class="st0" d="M86.4,66.4c0,3.7,0.4,6.7,1.1,8.9c0.8,2.2,1.8,4.6,3.2,7.2c0.5,0.8,0.7,1.6,0.7,2.3c0,1-0.6,2-1.9,3l-6.3,4.2
c-0.9,0.6-1.8,0.9-2.6,0.9c-1,0-2-0.5-3-1.4C76.2,90,75,88.4,74,86.8c-1-1.7-2-3.6-3.1-5.9c-7.8,9.2-17.6,13.8-29.4,13.8
c-8.4,0-15.1-2.4-20-7.2c-4.9-4.8-7.4-11.2-7.4-19.2c0-8.5,3-15.4,9.1-20.6c6.1-5.2,14.2-7.8,24.5-7.8c3.4,0,6.9,0.3,10.6,0.8
c3.7,0.5,7.5,1.3,11.5,2.2v-7.3c0-7.6-1.6-12.9-4.7-16c-3.2-3.1-8.6-4.6-16.3-4.6c-3.5,0-7.1,0.4-10.8,1.3c-3.7,0.9-7.3,2-10.8,3.4
c-1.6,0.7-2.8,1.1-3.5,1.3c-0.7,0.2-1.2,0.3-1.6,0.3c-1.4,0-2.1-1-2.1-3.1v-4.9c0-1.6,0.2-2.8,0.7-3.5c0.5-0.7,1.4-1.4,2.8-2.1
c3.5-1.8,7.7-3.3,12.6-4.5c4.9-1.3,10.1-1.9,15.6-1.9c11.9,0,20.6,2.7,26.2,8.1c5.5,5.4,8.3,13.6,8.3,24.6V66.4z M45.8,81.6
c3.3,0,6.7-0.6,10.3-1.8c3.6-1.2,6.8-3.4,9.5-6.4c1.6-1.9,2.8-4,3.4-6.4c0.6-2.4,1-5.3,1-8.7v-4.2c-2.9-0.7-6-1.3-9.2-1.7
c-3.2-0.4-6.3-0.6-9.4-0.6c-6.7,0-11.6,1.3-14.9,4c-3.3,2.7-4.9,6.5-4.9,11.5c0,4.7,1.2,8.2,3.7,10.6
C37.7,80.4,41.2,81.6,45.8,81.6z M126.1,92.4c-1.8,0-3-0.3-3.8-1c-0.8-0.6-1.5-2-2.1-3.9L96.7,10.2c-0.6-2-0.9-3.3-0.9-4
c0-1.6,0.8-2.5,2.4-2.5h9.8c1.9,0,3.2,0.3,3.9,1c0.8,0.6,1.4,2,2,3.9l16.8,66.2l15.6-66.2c0.5-2,1.1-3.3,1.9-3.9c0.8-0.6,2.2-1,4-1
h8c1.9,0,3.2,0.3,4,1c0.8,0.6,1.5,2,1.9,3.9l15.8,67l17.3-67c0.6-2,1.3-3.3,2-3.9c0.8-0.6,2.1-1,3.9-1h9.3c1.6,0,2.5,0.8,2.5,2.5
c0,0.5-0.1,1-0.2,1.6c-0.1,0.6-0.3,1.4-0.7,2.5l-24.1,77.3c-0.6,2-1.3,3.3-2.1,3.9c-0.8,0.6-2.1,1-3.8,1h-8.6c-1.9,0-3.2-0.3-4-1
c-0.8-0.7-1.5-2-1.9-4L156,23l-15.4,64.4c-0.5,2-1.1,3.3-1.9,4c-0.8,0.7-2.2,1-4,1H126.1z M254.6,95.1c-5.2,0-10.4-0.6-15.4-1.8
c-5-1.2-8.9-2.5-11.5-4c-1.6-0.9-2.7-1.9-3.1-2.8c-0.4-0.9-0.6-1.9-0.6-2.8v-5.1c0-2.1,0.8-3.1,2.3-3.1c0.6,0,1.2,0.1,1.8,0.3
c0.6,0.2,1.5,0.6,2.5,1c3.4,1.5,7.1,2.7,11,3.5c4,0.8,7.9,1.2,11.9,1.2c6.3,0,11.2-1.1,14.6-3.3c3.4-2.2,5.2-5.4,5.2-9.5
c0-2.8-0.9-5.1-2.7-7c-1.8-1.9-5.2-3.6-10.1-5.2L246,52c-7.3-2.3-12.7-5.7-16-10.2c-3.3-4.4-5-9.3-5-14.5c0-4.2,0.9-7.9,2.7-11.1
c1.8-3.2,4.2-6,7.2-8.2c3-2.3,6.4-4,10.4-5.2c4-1.2,8.2-1.7,12.6-1.7c2.2,0,4.5,0.1,6.7,0.4c2.3,0.3,4.4,0.7,6.5,1.1
c2,0.5,3.9,1,5.7,1.6c1.8,0.6,3.2,1.2,4.2,1.8c1.4,0.8,2.4,1.6,3,2.5c0.6,0.8,0.9,1.9,0.9,3.3v4.7c0,2.1-0.8,3.2-2.3,3.2
c-0.8,0-2.1-0.4-3.8-1.2c-5.7-2.6-12.1-3.9-19.2-3.9c-5.7,0-10.2,0.9-13.3,2.8c-3.1,1.9-4.7,4.8-4.7,8.9c0,2.8,1,5.2,3,7.1
c2,1.9,5.7,3.8,11,5.5l14.2,4.5c7.2,2.3,12.4,5.5,15.5,9.6c3.1,4.1,4.6,8.8,4.6,14c0,4.3-0.9,8.2-2.6,11.6
c-1.8,3.4-4.2,6.4-7.3,8.8c-3.1,2.5-6.8,4.3-11.1,5.6C264.4,94.4,259.7,95.1,254.6,95.1z"/>
<g>
<path class="st1" d="M273.5,143.7c-32.9,24.3-80.7,37.2-121.8,37.2c-57.6,0-109.5-21.3-148.7-56.7c-3.1-2.8-0.3-6.6,3.4-4.4
c42.4,24.6,94.7,39.5,148.8,39.5c36.5,0,76.6-7.6,113.5-23.2C274.2,133.6,278.9,139.7,273.5,143.7z"/>
<path class="st1" d="M287.2,128.1c-4.2-5.4-27.8-2.6-38.5-1.3c-3.2,0.4-3.7-2.4-0.8-4.5c18.8-13.2,49.7-9.4,53.3-5
c3.6,4.5-1,35.4-18.6,50.2c-2.7,2.3-5.3,1.1-4.1-1.9C282.5,155.7,291.4,133.4,287.2,128.1z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 128 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

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