diff --git a/ui/.dockerignore b/ui/.dockerignore new file mode 100644 index 0000000000..df076390d2 --- /dev/null +++ b/ui/.dockerignore @@ -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 diff --git a/ui/.env.template b/ui/.env.template new file mode 100644 index 0000000000..ba212ad68a --- /dev/null +++ b/ui/.env.template @@ -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 diff --git a/ui/.eslintignore b/ui/.eslintignore new file mode 100644 index 0000000000..af6ab76f80 --- /dev/null +++ b/ui/.eslintignore @@ -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 \ No newline at end of file diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs new file mode 100644 index 0000000000..6f1426da12 --- /dev/null +++ b/ui/.eslintrc.cjs @@ -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: "^_" }], + }, +}; diff --git a/ui/.github/CODEOWNERS b/ui/.github/CODEOWNERS new file mode 100644 index 0000000000..2ad99bd49a --- /dev/null +++ b/ui/.github/CODEOWNERS @@ -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 diff --git a/ui/.github/pull_request_template.md b/ui/.github/pull_request_template.md new file mode 100644 index 0000000000..4bf3030402 --- /dev/null +++ b/ui/.github/pull_request_template.md @@ -0,0 +1,7 @@ +### Description + +What was done in this PR + +### How to Review + +The reviewer should verify all these steps: diff --git a/ui/.github/workflows/checks.yml b/ui/.github/workflows/checks.yml new file mode 100644 index 0000000000..e58e18c793 --- /dev/null +++ b/ui/.github/workflows/checks.yml @@ -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 diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000000..45c1abce86 --- /dev/null +++ b/ui/.gitignore @@ -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 diff --git a/ui/.husky/pre-commit b/ui/.husky/pre-commit new file mode 100644 index 0000000000..129b71298f --- /dev/null +++ b/ui/.husky/pre-commit @@ -0,0 +1 @@ +npm run healthcheck diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000000..cafe685a11 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1 @@ +package-lock=true diff --git a/ui/.nvmrc b/ui/.nvmrc new file mode 100644 index 0000000000..b009dfb9d9 --- /dev/null +++ b/ui/.nvmrc @@ -0,0 +1 @@ +lts/* diff --git a/ui/.prettierignore b/ui/.prettierignore new file mode 100644 index 0000000000..40b878db5b --- /dev/null +++ b/ui/.prettierignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/ui/.prettierrc.json b/ui/.prettierrc.json new file mode 100644 index 0000000000..0cbbafa6cc --- /dev/null +++ b/ui/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "bracketSpacing": true, + "singleQuote": false, + "trailingComma": "all", + "tabWidth": 2, + "useTabs": false, + "semi": true, + "printWidth": 80, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/ui/.vscode/settings.json b/ui/.vscode/settings.json new file mode 100644 index 0000000000..104c678edb --- /dev/null +++ b/ui/.vscode/settings.json @@ -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 +} diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000000..a8c698c014 --- /dev/null +++ b/ui/Dockerfile @@ -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"] diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000000..3e334368ff --- /dev/null +++ b/ui/README.md @@ -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) diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts new file mode 100644 index 0000000000..d5ec199b06 --- /dev/null +++ b/ui/actions/auth/auth.ts @@ -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 = { + email: "", + password: "", +}; + +export async function authenticate( + prevState: unknown, + formData: z.infer, +) { + 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, +) => { + 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) => { + 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(); +} diff --git a/ui/actions/auth/index.ts b/ui/actions/auth/index.ts new file mode 100644 index 0000000000..97ccf76494 --- /dev/null +++ b/ui/actions/auth/index.ts @@ -0,0 +1 @@ +export * from "./auth"; diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts new file mode 100644 index 0000000000..e9c8814c31 --- /dev/null +++ b/ui/actions/compliances/compliances.ts @@ -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; + } +}; diff --git a/ui/actions/compliances/index.ts b/ui/actions/compliances/index.ts new file mode 100644 index 0000000000..ba9df7e860 --- /dev/null +++ b/ui/actions/compliances/index.ts @@ -0,0 +1 @@ +export * from "./compliances"; diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts new file mode 100644 index 0000000000..9fc89b0907 --- /dev/null +++ b/ui/actions/findings/findings.ts @@ -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; + } +}; diff --git a/ui/actions/findings/index.ts b/ui/actions/findings/index.ts new file mode 100644 index 0000000000..eb3a674c67 --- /dev/null +++ b/ui/actions/findings/index.ts @@ -0,0 +1 @@ +export * from "./findings"; diff --git a/ui/actions/invitations/index.ts b/ui/actions/invitations/index.ts new file mode 100644 index 0000000000..addd041884 --- /dev/null +++ b/ui/actions/invitations/index.ts @@ -0,0 +1 @@ +export * from "./invitation"; diff --git a/ui/actions/invitations/invitation.ts b/ui/actions/invitations/invitation.ts new file mode 100644 index 0000000000..56aefc74e1 --- /dev/null +++ b/ui/actions/invitations/invitation.ts @@ -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), + }; + } +}; diff --git a/ui/actions/overview/overview.ts b/ui/actions/overview/overview.ts new file mode 100644 index 0000000000..8d83ab7088 --- /dev/null +++ b/ui/actions/overview/overview.ts @@ -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; + } +}; diff --git a/ui/actions/providers/index.ts b/ui/actions/providers/index.ts new file mode 100644 index 0000000000..5532383f5f --- /dev/null +++ b/ui/actions/providers/index.ts @@ -0,0 +1 @@ +export * from "./providers"; diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts new file mode 100644 index 0000000000..fc5cd97bb7 --- /dev/null +++ b/ui/actions/providers/providers.ts @@ -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), + }; + } +}; diff --git a/ui/actions/scans/index.ts b/ui/actions/scans/index.ts new file mode 100644 index 0000000000..7e4b1aceeb --- /dev/null +++ b/ui/actions/scans/index.ts @@ -0,0 +1 @@ +export * from "./scans"; diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts new file mode 100644 index 0000000000..367446daaa --- /dev/null +++ b/ui/actions/scans/scans.ts @@ -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), + }; + } +}; diff --git a/ui/actions/services/index.ts b/ui/actions/services/index.ts new file mode 100644 index 0000000000..b2221a94a8 --- /dev/null +++ b/ui/actions/services/index.ts @@ -0,0 +1 @@ +export * from "./services"; diff --git a/ui/actions/services/services.ts b/ui/actions/services/services.ts new file mode 100644 index 0000000000..c4ab342f01 --- /dev/null +++ b/ui/actions/services/services.ts @@ -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; +}; diff --git a/ui/actions/task/index.ts b/ui/actions/task/index.ts new file mode 100644 index 0000000000..078ba8fc62 --- /dev/null +++ b/ui/actions/task/index.ts @@ -0,0 +1 @@ +export * from "./tasks"; diff --git a/ui/actions/task/tasks.ts b/ui/actions/task/tasks.ts new file mode 100644 index 0000000000..fdf75c7cea --- /dev/null +++ b/ui/actions/task/tasks.ts @@ -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) }; + } +}; diff --git a/ui/actions/users/users.ts b/ui/actions/users/users.ts new file mode 100644 index 0000000000..30a5b26d1f --- /dev/null +++ b/ui/actions/users/users.ts @@ -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), + }; + } +}; diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx new file mode 100644 index 0000000000..a9fdc8c394 --- /dev/null +++ b/ui/app/(auth)/layout.tsx @@ -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 ( + + + + + {children} + + + + + ); +} diff --git a/ui/app/(auth)/sign-in/page.tsx b/ui/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000000..b5bd978029 --- /dev/null +++ b/ui/app/(auth)/sign-in/page.tsx @@ -0,0 +1,7 @@ +import { AuthForm } from "@/components/auth/oss"; + +const SignIn = () => { + return ; +}; + +export default SignIn; diff --git a/ui/app/(auth)/sign-up/page.tsx b/ui/app/(auth)/sign-up/page.tsx new file mode 100644 index 0000000000..28149bb200 --- /dev/null +++ b/ui/app/(auth)/sign-up/page.tsx @@ -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 ; +}; + +export default SignUp; diff --git a/ui/app/(prowler)/categories/page.tsx b/ui/app/(prowler)/categories/page.tsx new file mode 100644 index 0000000000..1a5650d8af --- /dev/null +++ b/ui/app/(prowler)/categories/page.tsx @@ -0,0 +1,12 @@ +import { Spacer } from "@nextui-org/react"; + +import { Header } from "@/components/ui"; + +export default async function Categories() { + return ( + <> +
+ + + ); +} diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx new file mode 100644 index 0000000000..38c9800467 --- /dev/null +++ b/ui/app/(prowler)/compliance/page.tsx @@ -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 ( + <> +
+ + + + }> + + + + ); +} + +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 ( +
+
+ No compliance data available for the selected scan. +
+
+ ); + } + + // Handle errors returned by the API + if (compliancesData?.errors?.length > 0) { + return ( +
+
Provide a valid scan ID.
+
+ ); + } + + return ( +
+ {compliancesData.data.map((compliance: ComplianceOverviewData) => { + const { attributes } = compliance; + const { + framework, + requirements_status: { passed, total }, + } = attributes; + + return ( + + ); + })} +
+ ); +}; diff --git a/ui/app/(prowler)/error.tsx b/ui/app/(prowler)/error.tsx new file mode 100644 index 0000000000..980e681220 --- /dev/null +++ b/ui/app/(prowler)/error.tsx @@ -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 ( + + + An unexpected error occurred + + We're sorry for the inconvenience. Please try again or contact support + if the problem persists. + + + {" "} + Go to the homepage + + + ); +} diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx new file mode 100644 index 0000000000..53c0795f23 --- /dev/null +++ b/ui/app/(prowler)/findings/page.tsx @@ -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( + allRegionsAndServices + .map((item: { region: string }) => item.region) + .filter(Boolean) || [], + ), + ); + const uniqueServices = Array.from( + new Set( + allRegionsAndServices + .map((item: { service: string }) => item.service) + .filter(Boolean) || [], + ), + ); + + return ( + <> +
+ + + + + + + }> + + + + ); +} + +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 ( + + ); +}; diff --git a/ui/app/(prowler)/integrations/page.tsx b/ui/app/(prowler)/integrations/page.tsx new file mode 100644 index 0000000000..a930f1ed36 --- /dev/null +++ b/ui/app/(prowler)/integrations/page.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +import { Header } from "@/components/ui"; + +export default function Integrations() { + return ( + <> +
+ +

Hi hi from Integration page

+ + ); +} diff --git a/ui/app/(prowler)/invitations/(send-invite)/check-details/page.tsx b/ui/app/(prowler)/invitations/(send-invite)/check-details/page.tsx new file mode 100644 index 0000000000..939667cdc1 --- /dev/null +++ b/ui/app/(prowler)/invitations/(send-invite)/check-details/page.tsx @@ -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 ( + }> + + + ); +} + +const SSRDataInvitation = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { + const invitationId = searchParams.id; + + if (!invitationId) { + return
Invalid invitation ID
; + } + + const invitationData = (await getInvitationInfoById(invitationId as string)) + .data; + + if (!invitationData) { + return
Invitation not found
; + } + + const { attributes, links } = invitationData; + + return ; +}; diff --git a/ui/app/(prowler)/invitations/(send-invite)/layout.tsx b/ui/app/(prowler)/invitations/(send-invite)/layout.tsx new file mode 100644 index 0000000000..2af2be2af2 --- /dev/null +++ b/ui/app/(prowler)/invitations/(send-invite)/layout.tsx @@ -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 ( + <> + + +
+
+ +
+
+ {children} +
+
+ + ); +} diff --git a/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx b/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx new file mode 100644 index 0000000000..87a2e33acd --- /dev/null +++ b/ui/app/(prowler)/invitations/(send-invite)/new/page.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +import { SendInvitationForm } from "@/components/invitations/workflow/forms/send-invitation-form"; + +export default function SendInvitationPage() { + return ; +} diff --git a/ui/app/(prowler)/invitations/page.tsx b/ui/app/(prowler)/invitations/page.tsx new file mode 100644 index 0000000000..b18cbbcba3 --- /dev/null +++ b/ui/app/(prowler)/invitations/page.tsx @@ -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 ( + <> +
+ + + + + + + + + }> + + + + ); +} + +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 ( + + ); +}; diff --git a/ui/app/(prowler)/layout.tsx b/ui/app/(prowler)/layout.tsx new file mode 100644 index 0000000000..40a2fd9bf9 --- /dev/null +++ b/ui/app/(prowler)/layout.tsx @@ -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 ( + + + + +
+ +
+ {children} + +
+
+
+ + + ); +} diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx new file mode 100644 index 0000000000..bae0d835c4 --- /dev/null +++ b/ui/app/(prowler)/page.tsx @@ -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 ( + <> +
+ +
+
+ {/* Providers Overview */} +
+ }> + + +
+ +
+ +
+
+
+ + ); +} + +const SSRProvidersOverview = async () => { + const providersOverview = await getProvidersOverview({}); + + if (!providersOverview) { + return

There is no providers overview info available

; + } + + return ; +}; diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx new file mode 100644 index 0000000000..0cfb4f8ab2 --- /dev/null +++ b/ui/app/(prowler)/profile/page.tsx @@ -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 ( + <> +
+ + +
{JSON.stringify(session.user, null, 2)}
+
{JSON.stringify(session.userId, null, 2)}
+
{JSON.stringify(session.tenantId, null, 2)}
+
{JSON.stringify(session, null, 2)}
+ + ); +} diff --git a/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx new file mode 100644 index 0000000000..6a525990fa --- /dev/null +++ b/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx @@ -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 && } + {useRoleForm && } + + ); +} diff --git a/ui/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx new file mode 100644 index 0000000000..eead6c2561 --- /dev/null +++ b/ui/app/(prowler)/providers/(set-up-provider)/connect-account/page.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +import { ConnectAccountForm } from "@/components/providers/workflow/forms"; + +export default function ConnectAccountPage() { + return ; +} diff --git a/ui/app/(prowler)/providers/(set-up-provider)/launch-scan/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/launch-scan/page.tsx new file mode 100644 index 0000000000..3e88a5ea46 --- /dev/null +++ b/ui/app/(prowler)/providers/(set-up-provider)/launch-scan/page.tsx @@ -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 ( + + ); +} diff --git a/ui/app/(prowler)/providers/(set-up-provider)/layout.tsx b/ui/app/(prowler)/providers/(set-up-provider)/layout.tsx new file mode 100644 index 0000000000..85b78ee687 --- /dev/null +++ b/ui/app/(prowler)/providers/(set-up-provider)/layout.tsx @@ -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 ( + <> + + +
+
+ +
+
+ {children} +
+
+ + ); +} diff --git a/ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx new file mode 100644 index 0000000000..13cac50f6d --- /dev/null +++ b/ui/app/(prowler)/providers/(set-up-provider)/test-connection/page.tsx @@ -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 ( + Loading...

}> + +
+ ); +} + +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 ( + + ); +} diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx new file mode 100644 index 0000000000..7aae9df08d --- /dev/null +++ b/ui/app/(prowler)/providers/page.tsx @@ -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 ( + <> +
+ + + + + + + + + + }> + + + + ); +} + +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 ( + + ); +}; diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx new file mode 100644 index 0000000000..5b92328cb8 --- /dev/null +++ b/ui/app/(prowler)/scans/page.tsx @@ -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 ( + <> +
+ + + + +
+ + { + "use server"; + await getScans({}); + }} + /> +
+ + + +
+
+ }> + + +
+
+ + ); +} + +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 ( + + ); +}; + +// const getExecutingScans = async () => { +// const scansData = await getScans({}); + +// return scansData?.data?.some( +// (scan: ScanProps) => +// scan.attributes.state === "executing" && scan.attributes.progress < 100, +// ); +// }; diff --git a/ui/app/(prowler)/services/page.tsx b/ui/app/(prowler)/services/page.tsx new file mode 100644 index 0000000000..1772165913 --- /dev/null +++ b/ui/app/(prowler)/services/page.tsx @@ -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 ( + <> +
+ + + + }> + + + + ); +} + +const SSRServiceGrid = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { + const servicesData = await getServices(searchParams); + const [services] = await Promise.all([servicesData]); + + return ( +
+ {services?.map((service: any) => ( + + ))} +
+ ); +}; diff --git a/ui/app/(prowler)/settings/page.tsx b/ui/app/(prowler)/settings/page.tsx new file mode 100644 index 0000000000..6653c38b1c --- /dev/null +++ b/ui/app/(prowler)/settings/page.tsx @@ -0,0 +1,12 @@ +import { Spacer } from "@nextui-org/react"; + +import { Header } from "@/components/ui"; + +export default async function Settings() { + return ( + <> +
+ + + ); +} diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx new file mode 100644 index 0000000000..364651ca22 --- /dev/null +++ b/ui/app/(prowler)/users/page.tsx @@ -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 ( + <> +
+ + + + + + + + + }> + + + + ); +} + +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 ( + + ); +}; diff --git a/ui/app/(prowler)/workloads/page.tsx b/ui/app/(prowler)/workloads/page.tsx new file mode 100644 index 0000000000..3cadd0d1ca --- /dev/null +++ b/ui/app/(prowler)/workloads/page.tsx @@ -0,0 +1,12 @@ +import { Spacer } from "@nextui-org/react"; + +import { Header } from "@/components/ui"; + +export default async function Workloads() { + return ( + <> +
+ + + ); +} diff --git a/ui/app/api/auth/[...nextauth]/route.ts b/ui/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000000..316cf49c14 --- /dev/null +++ b/ui/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth.config"; + +export const { GET, POST } = handlers; diff --git a/ui/app/api/services/route.ts b/ui/app/api/services/route.ts new file mode 100644 index 0000000000..dbd490c239 --- /dev/null +++ b/ui/app/api/services/route.ts @@ -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 }); +} diff --git a/ui/app/providers.tsx b/ui/app/providers.tsx new file mode 100644 index 0000000000..942bce43dc --- /dev/null +++ b/ui/app/providers.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/ui/auth.config.ts b/ui/auth.config.ts new file mode 100644 index 0000000000..56c44a14e5 --- /dev/null +++ b/ui/auth.config.ts @@ -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); diff --git a/ui/components/ThemeSwitch.tsx b/ui/components/ThemeSwitch.tsx new file mode 100644 index 0000000000..d397c4a678 --- /dev/null +++ b/ui/components/ThemeSwitch.tsx @@ -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 = ({ + 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 ( + + + + +
+ {!isSelected || isSSR ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx new file mode 100644 index 0000000000..1f1ec4384f --- /dev/null +++ b/ui/components/auth/oss/auth-form.tsx @@ -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>({ + 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) => { + 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 ( +
+ {/* Auth Form */} +
+ {/* Background Pattern */} +
+ +
+ {/* Prowler Logo */} +
+ +
+
+

+ {type === "sign-in" ? "Sign In" : "Sign Up"} +

+ +
+ +
+ + {type === "sign-up" && ( + <> + + + + )} + + + + + {type === "sign-in" && ( +
+ + Remember me + + + Forgot password? + +
+ )} + {type === "sign-up" && ( + <> + + {invitationToken && ( + + )} + ( + <> + + field.onChange(e.target.checked)} + > + I agree with the  + + Terms + +   and  + + Privacy Policy + + + + + + )} + /> + + )} + + {form.formState.errors?.email && ( +
+ +

No user found

+
+ )} + + + {isLoading ? ( + Loading + ) : ( + {type === "sign-in" ? "Log In" : "Sign Up"} + )} + + + + + {type === "sign-in" && ( + <> +
+ +

OR

+ +
+
+ + +
+ + )} + {type === "sign-in" ? ( +

+ Need to create an account?  + Sign Up +

+ ) : ( +

+ Already have an account?  + Log In +

+ )} +
+
+
+ ); +}; diff --git a/ui/components/auth/oss/index.ts b/ui/components/auth/oss/index.ts new file mode 100644 index 0000000000..82fc6feefa --- /dev/null +++ b/ui/components/auth/oss/index.ts @@ -0,0 +1 @@ +export * from "./auth-form"; diff --git a/ui/components/charts/SeverityChart.tsx b/ui/components/charts/SeverityChart.tsx new file mode 100644 index 0000000000..4c3060c8bd --- /dev/null +++ b/ui/components/charts/SeverityChart.tsx @@ -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 ( +
+ + + + chartConfig[value as keyof typeof chartConfig]?.label + } + /> + + + + } + /> + + + + + + +
+ ); +}; diff --git a/ui/components/charts/StatusChart.tsx b/ui/components/charts/StatusChart.tsx new file mode 100644 index 0000000000..d906db502e --- /dev/null +++ b/ui/components/charts/StatusChart.tsx @@ -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 ( +
+ + + } /> + + + + +
+
+ } + color="success" + radius="lg" + size="md" + > + {chartData[0].number} + + + {updatedChartData[0].percent} + +
+
+ No change from last scan +
+ +
+
+ } + color="danger" + radius="lg" + size="md" + > + {chartData[1].number} + + + {updatedChartData[1].percent} + +
+
+ +2 findings from last scan +
+
+
+
+ ); +} diff --git a/ui/components/charts/index.ts b/ui/components/charts/index.ts new file mode 100644 index 0000000000..b4d3debb45 --- /dev/null +++ b/ui/components/charts/index.ts @@ -0,0 +1,2 @@ +export * from "./SeverityChart"; +export * from "./StatusChart"; diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx new file mode 100644 index 0000000000..7fcfdbf5dd --- /dev/null +++ b/ui/components/compliance/compliance-card.tsx @@ -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 = ({ + 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 ( + + +
+ {`${title} +
+

{title}

+ +
+ + + {passingRequirements} / {totalRequirements} + + Passing Requirements + + {/* {getScanChange()} */} +
+
+
+
+
+ ); +}; diff --git a/ui/components/compliance/compliance-skeleton-grid.tsx b/ui/components/compliance/compliance-skeleton-grid.tsx new file mode 100644 index 0000000000..54ec78ce26 --- /dev/null +++ b/ui/components/compliance/compliance-skeleton-grid.tsx @@ -0,0 +1,18 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const ComplianceSkeletonGrid = () => { + return ( + +
+ {[...Array(28)].map((_, index) => ( +
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/compliance/data-compliance/data-compliance.tsx b/ui/components/compliance/data-compliance/data-compliance.tsx new file mode 100644 index 0000000000..10fcc44f6b --- /dev/null +++ b/ui/components/compliance/data-compliance/data-compliance.tsx @@ -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 ( +
+
+ + + {showClearButton && ( + } + radius="sm" + > + Reset + + )} +
+
+ ); +}; diff --git a/ui/components/compliance/data-compliance/index.ts b/ui/components/compliance/data-compliance/index.ts new file mode 100644 index 0000000000..14f430df04 --- /dev/null +++ b/ui/components/compliance/data-compliance/index.ts @@ -0,0 +1,2 @@ +export * from "./data-compliance"; +export * from "./select-scan-compliance-data"; diff --git a/ui/components/compliance/data-compliance/select-scan-compliance-data.tsx b/ui/components/compliance/data-compliance/select-scan-compliance-data.tsx new file mode 100644 index 0000000000..5ebc36515d --- /dev/null +++ b/ui/components/compliance/data-compliance/select-scan-compliance-data.tsx @@ -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 ( + + ); +}; diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts new file mode 100644 index 0000000000..bb1b06d995 --- /dev/null +++ b/ui/components/compliance/index.ts @@ -0,0 +1,2 @@ +export * from "./compliance-card"; +export * from "./compliance-skeleton-grid"; diff --git a/ui/components/filters/custom-account-selection.tsx b/ui/components/filters/custom-account-selection.tsx new file mode 100644 index 0000000000..f9920a988f --- /dev/null +++ b/ui/components/filters/custom-account-selection.tsx @@ -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 ( + + ); +}; diff --git a/ui/components/filters/custom-checkbox-muted-findings.tsx b/ui/components/filters/custom-checkbox-muted-findings.tsx new file mode 100644 index 0000000000..a5e52b3d92 --- /dev/null +++ b/ui/components/filters/custom-checkbox-muted-findings.tsx @@ -0,0 +1,15 @@ +import { Checkbox } from "@nextui-org/react"; +import React from "react"; + +export const CustomCheckboxMutedFindings = () => { + return ( + + Include Muted Findings + + ); +}; diff --git a/ui/components/filters/custom-date-picker.tsx b/ui/components/filters/custom-date-picker.tsx new file mode 100644 index 0000000000..2a982c1781 --- /dev/null +++ b/ui/components/filters/custom-date-picker.tsx @@ -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 ( +
+ + + + + + } + calendarProps={{ + focusedValue: value, + onFocusChange: setValue, + nextButtonProps: { + variant: "bordered", + }, + prevButtonProps: { + variant: "bordered", + }, + }} + value={value} + onChange={handleDateChange} + size="sm" + variant="flat" + /> +
+ ); +}; diff --git a/ui/components/filters/custom-provider-inputs.tsx b/ui/components/filters/custom-provider-inputs.tsx new file mode 100644 index 0000000000..6587bcb7e7 --- /dev/null +++ b/ui/components/filters/custom-provider-inputs.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +import { + AWSProviderBadge, + AzureProviderBadge, + GCPProviderBadge, + KS8ProviderBadge, +} from "../icons/providers-badge"; + +export const CustomProviderInputAWS = () => { + return ( +
+ +

Amazon Web Services

+
+ ); +}; + +export const CustomProviderInputAzure = () => { + return ( +
+ +

Azure

+
+ ); +}; + +export const CustomProviderInputGCP = () => { + return ( +
+ +

Google Cloud Platform

+
+ ); +}; + +export const CustomProviderInputKubernetes = () => { + return ( +
+ +

Kubernetes

+
+ ); +}; diff --git a/ui/components/filters/custom-region-selection.tsx b/ui/components/filters/custom-region-selection.tsx new file mode 100644 index 0000000000..dd84cb4c1f --- /dev/null +++ b/ui/components/filters/custom-region-selection.tsx @@ -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 ( + + ); +}; diff --git a/ui/components/filters/custom-search-input.tsx b/ui/components/filters/custom-search-input.tsx new file mode 100644 index 0000000000..f3d9808125 --- /dev/null +++ b/ui/components/filters/custom-search-input.tsx @@ -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 ( + } + onChange={(e) => { + const value = e.target.value; + setSearchQuery(value); + debouncedChangeHandler(value); + }} + endContent={ + searchQuery && ( + + ) + } + radius="sm" + size="sm" + /> + ); +}; diff --git a/ui/components/filters/custom-select-provider.tsx b/ui/components/filters/custom-select-provider.tsx new file mode 100644 index 0000000000..d2a419bbbb --- /dev/null +++ b/ui/components/filters/custom-select-provider.tsx @@ -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: , + }, + { + key: "gcp", + label: "Google Cloud Platform", + value: , + }, + { + key: "azure", + label: "Microsoft Azure", + value: , + }, + { + key: "kubernetes", + label: "Kubernetes", + value: , + }, +]; + +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 ( + + ); +}; diff --git a/ui/components/filters/data-filters.ts b/ui/components/filters/data-filters.ts new file mode 100644 index 0000000000..e396791b64 --- /dev/null +++ b/ui/components/filters/data-filters.ts @@ -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"], + }, +]; diff --git a/ui/components/filters/filter-controls.tsx b/ui/components/filters/filter-controls.tsx new file mode 100644 index 0000000000..6f08b5c928 --- /dev/null +++ b/ui/components/filters/filter-controls.tsx @@ -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 = ({ + 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 ( +
+
+ {search && } + {providers && } + {date && } + {regions && } + {accounts && } + {mutedFindings && } + + {showClearButton && ( + } + radius="sm" + > + Reset + + )} +
+ {customFilters && } +
+ ); +}; diff --git a/ui/components/filters/index.ts b/ui/components/filters/index.ts new file mode 100644 index 0000000000..915bbd3aec --- /dev/null +++ b/ui/components/filters/index.ts @@ -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"; diff --git a/ui/components/findings/table/column-findings.tsx b/ui/components/findings/table/column-findings.tsx new file mode 100644 index 0000000000..d5c06862db --- /dev/null +++ b/ui/components/findings/table/column-findings.tsx @@ -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[] = [ + { + accessorKey: "check", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { checktitle } = getFindingsMetadata(row); + return

{checktitle}

; + }, + }, + { + accessorKey: "severity", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { severity }, + } = getFindingsData(row); + return ; + }, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { status }, + } = getFindingsData(row); + + return ; + }, + }, + { + accessorKey: "scanName", + header: "Scan Name", + cell: ({ row }) => { + const name = getScanData(row, "name"); + + return ( +

+ {typeof name === "string" || typeof name === "number" + ? name + : "Invalid data"} +

+ ); + }, + }, + { + accessorKey: "region", + header: "Region", + cell: ({ row }) => { + const region = getResourceData(row, "region"); + + return ( + <> +
{typeof region === "string" ? region : "Invalid region"}
+ + ); + }, + }, + { + accessorKey: "service", + header: "Service", + cell: ({ row }) => { + const { servicename } = getFindingsMetadata(row); + return

{servicename}

; + }, + }, + { + accessorKey: "account", + header: "Account", + cell: ({ row }) => { + const account = getProviderData(row, "uid"); + + return ( + <> +

+ {typeof account === "string" ? account : "Invalid account"} +

+ + ); + }, + }, + { + id: "moreInfo", + header: "Details", + cell: ({ row }) => { + const searchParams = useSearchParams(); + const findingId = searchParams.get("id"); + const isOpen = findingId === row.original.id; + return ( +
+ } + title="Finding Details" + description="View the finding details" + defaultOpen={isOpen} + > + + +
+ ); + }, + }, + { + id: "actions", + cell: ({ row }) => { + return ; + }, + }, +]; diff --git a/ui/components/findings/table/data-table-row-actions.tsx b/ui/components/findings/table/data-table-row-actions.tsx new file mode 100644 index 0000000000..8e15c942a0 --- /dev/null +++ b/ui/components/findings/table/data-table-row-actions.tsx @@ -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 { + row: Row; +} +const iconClasses = + "text-2xl text-default-500 pointer-events-none flex-shrink-0"; + +export function DataTableRowActions({ + row, +}: DataTableRowActionsProps) { + const findingId = (row.original as { id: string }).id; + return ( + <> + {/* + + + + + */} + +
+ + + + + + + } + // onClick={() => setIsEditOpen(true)} + > + {findingId} + Send to Jira + + } + // onClick={() => setIsEditOpen(true)} + > + Send to Slack + + + + +
+ + ); +} diff --git a/ui/components/findings/table/data-table-row-details.tsx b/ui/components/findings/table/data-table-row-details.tsx new file mode 100644 index 0000000000..9f43d8b165 --- /dev/null +++ b/ui/components/findings/table/data-table-row-details.tsx @@ -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 ; +}; diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx new file mode 100644 index 0000000000..7f2a84e714 --- /dev/null +++ b/ui/components/findings/table/finding-detail.tsx @@ -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 ( +
+ {/* Header */} +
+
+

+ {attributes.check_metadata.checktitle} +

+

+ {resource.service} +

+
+
+ {attributes.status} +
+
+ + {/* Check Metadata */} +
+
+

+ Check Metadata +

+ +
+ {attributes.status === "FAIL" && ( + +

+ Risk +

+

+ {attributes.check_metadata.risk} +

+
+ )} + +
+

+ Description +

+

+ {attributes.check_metadata.description} +

+
+ +
+

+ Remediation +

+
+ {remediation.recommendation && ( + <> +

Recommendation:

+

{remediation.recommendation.text}

+ + Learn more + + + )} + {remediation.code && + Object.values(remediation.code).some(Boolean) && ( +
+

+ Check these links: +

+
+ {remediation.code.cli && ( +
+

CLI Command:

+ +

+ {remediation.code.cli} +

+
+
+ )} +
+ {Object.entries(remediation.code) + .filter(([key]) => key !== "cli") + .map(([key, value]) => + value ? ( + + {key === "other" + ? "External doc" + : key.charAt(0).toUpperCase() + + key.slice(1).toLowerCase()} + + ) : null, + )} +
+
+
+ )} +
+
+
+ + {/* Resources Section */} +
+

+ Resource Details +

+
+
+

+ Resource ID +

+ +

{resource.uid}

+
+
+
+

+ Resource Name +

+

+ {resource.name} +

+
+
+

+ Region +

+

+ {resource.region} +

+
+
+

+ Resource Type +

+

+ {resource.type} +

+
+
+

+ Severity +

+ +
+ {resource.tags && + Object.entries(resource.tags).map(([key, value]) => ( +
+

+ Tag: {key} +

+ +

{value}

+
+
+ ))} +
+
+

+ Inserted At +

+ +
+
+

+ Updated At +

+ +
+
+
+
+
+ ); +}; diff --git a/ui/components/findings/table/index.ts b/ui/components/findings/table/index.ts new file mode 100644 index 0000000000..fe19b5dd6e --- /dev/null +++ b/ui/components/findings/table/index.ts @@ -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"; diff --git a/ui/components/findings/table/skeleton-table-findings.tsx b/ui/components/findings/table/skeleton-table-findings.tsx new file mode 100644 index 0000000000..3af6403e7c --- /dev/null +++ b/ui/components/findings/table/skeleton-table-findings.tsx @@ -0,0 +1,65 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonTableFindings = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(3)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/icons/Icons.tsx b/ui/components/icons/Icons.tsx new file mode 100644 index 0000000000..b8f448afd3 --- /dev/null +++ b/ui/components/icons/Icons.tsx @@ -0,0 +1,810 @@ +import * as React from "react"; + +import { IconSvgProps } from "@/types"; + +export const TwitterIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ); +}; + +export const GithubIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ); +}; + +export const MoonFilledIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + +); + +export const SunFilledIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + +); + +export const SearchIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + +); + +export const ChevronDownIcon: React.FC = ({ + size = 24, + width, + height, + strokeWidth = 1.5, + ...props +}) => ( + +); + +export const PlusIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + +); + +export const VerticalDotsIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + +); + +export const DeleteIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + ); +}; + +export const CheckIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + + + +); + +export const CrossIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + + + +); + +export const PassIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + + + + + + +); + +export const RocketIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + + + ); +}; + +export const AlertIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + +); + +export const NotificationIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + + + +); + +export const IdIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + + + +); + +export const DoneIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + + + ); +}; + +export const CopyIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + + + ); +}; + +export const FlowIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + + + ); +}; + +export const ConnectionIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + + + ); +}; + +export const ConnectionTrue: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + + + +); + +export const ConnectionFalse: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + + + +); + +export const ConnectionPending: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + + + + + + + + +); + +export const SuccessIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + + + +); + +export const ArrowUpIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + ); +}; + +export const ArrowDownIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + ); +}; + +export const ChevronsLeftRightIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + + + ); +}; + +export const PlusCircleIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + + + ); +}; + +export const CustomFilterIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + ); +}; + +export const SaveIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + ); +}; + +export const AddIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + ); +}; + +export const ScheduleIcon: React.FC = ({ + size, + height, + width, + ...props +}) => { + return ( + + ); +}; + +export const InfoIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => ( + + + + +); diff --git a/ui/components/icons/compliance/IconCompliance.tsx b/ui/components/icons/compliance/IconCompliance.tsx new file mode 100644 index 0000000000..543f6c68c8 --- /dev/null +++ b/ui/components/icons/compliance/IconCompliance.tsx @@ -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; + } +}; diff --git a/ui/components/icons/compliance/aws.svg b/ui/components/icons/compliance/aws.svg new file mode 100644 index 0000000000..4715937ff0 --- /dev/null +++ b/ui/components/icons/compliance/aws.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/ui/components/icons/compliance/cis.svg b/ui/components/icons/compliance/cis.svg new file mode 100644 index 0000000000..013f35a75e --- /dev/null +++ b/ui/components/icons/compliance/cis.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/components/icons/compliance/cisa.svg b/ui/components/icons/compliance/cisa.svg new file mode 100644 index 0000000000..c26f681d51 --- /dev/null +++ b/ui/components/icons/compliance/cisa.svg @@ -0,0 +1,2 @@ + + diff --git a/ui/components/icons/compliance/ens.png b/ui/components/icons/compliance/ens.png new file mode 100644 index 0000000000..c3e6433f31 Binary files /dev/null and b/ui/components/icons/compliance/ens.png differ diff --git a/ui/components/icons/compliance/fedramp.svg b/ui/components/icons/compliance/fedramp.svg new file mode 100644 index 0000000000..7706bd4eff --- /dev/null +++ b/ui/components/icons/compliance/fedramp.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/components/icons/compliance/ffiec.svg b/ui/components/icons/compliance/ffiec.svg new file mode 100644 index 0000000000..40951851e9 --- /dev/null +++ b/ui/components/icons/compliance/ffiec.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/components/icons/compliance/gdpr.svg b/ui/components/icons/compliance/gdpr.svg new file mode 100644 index 0000000000..31175e9afe --- /dev/null +++ b/ui/components/icons/compliance/gdpr.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/components/icons/compliance/gxp-aws.svg b/ui/components/icons/compliance/gxp-aws.svg new file mode 100644 index 0000000000..7b22b53de3 --- /dev/null +++ b/ui/components/icons/compliance/gxp-aws.svg @@ -0,0 +1,455 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/components/icons/compliance/hipaa.svg b/ui/components/icons/compliance/hipaa.svg new file mode 100644 index 0000000000..b9537032f6 --- /dev/null +++ b/ui/components/icons/compliance/hipaa.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/components/icons/compliance/iso-27001.svg b/ui/components/icons/compliance/iso-27001.svg new file mode 100644 index 0000000000..109c19521d --- /dev/null +++ b/ui/components/icons/compliance/iso-27001.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/components/icons/compliance/mitre-attack.svg b/ui/components/icons/compliance/mitre-attack.svg new file mode 100644 index 0000000000..c546eedd83 --- /dev/null +++ b/ui/components/icons/compliance/mitre-attack.svg @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + diff --git a/ui/components/icons/compliance/nist.svg b/ui/components/icons/compliance/nist.svg new file mode 100644 index 0000000000..4231017c94 --- /dev/null +++ b/ui/components/icons/compliance/nist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/components/icons/compliance/pci-dss.svg b/ui/components/icons/compliance/pci-dss.svg new file mode 100644 index 0000000000..52f93be38b --- /dev/null +++ b/ui/components/icons/compliance/pci-dss.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/components/icons/compliance/rbi.svg b/ui/components/icons/compliance/rbi.svg new file mode 100644 index 0000000000..ce1d81f83d --- /dev/null +++ b/ui/components/icons/compliance/rbi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/components/icons/compliance/soc2.svg b/ui/components/icons/compliance/soc2.svg new file mode 100644 index 0000000000..d2b8782541 --- /dev/null +++ b/ui/components/icons/compliance/soc2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui/components/icons/index.ts b/ui/components/icons/index.ts new file mode 100644 index 0000000000..7912d466fd --- /dev/null +++ b/ui/components/icons/index.ts @@ -0,0 +1,4 @@ +export * from "./compliance/IconCompliance"; +export * from "./Icons"; +export * from "./prowler/ProwlerIcons"; +export * from "./services/IconServices"; diff --git a/ui/components/icons/providers-badge/AWSProviderBadge.tsx b/ui/components/icons/providers-badge/AWSProviderBadge.tsx new file mode 100644 index 0000000000..f0dc91958e --- /dev/null +++ b/ui/components/icons/providers-badge/AWSProviderBadge.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; + +import { IconSvgProps } from "@/types"; + +export const AWSProviderBadge: React.FC = ({ + size, + width, + height, + ...props +}) => ( + +); diff --git a/ui/components/icons/providers-badge/AzureProviderBadge.tsx b/ui/components/icons/providers-badge/AzureProviderBadge.tsx new file mode 100644 index 0000000000..983e4e41df --- /dev/null +++ b/ui/components/icons/providers-badge/AzureProviderBadge.tsx @@ -0,0 +1,80 @@ +import * as React from "react"; + +import { IconSvgProps } from "@/types"; + +export const AzureProviderBadge: React.FC = ({ + size, + width, + height, + ...props +}) => ( + +); diff --git a/ui/components/icons/providers-badge/GCPProviderBadge.tsx b/ui/components/icons/providers-badge/GCPProviderBadge.tsx new file mode 100644 index 0000000000..987a9fd074 --- /dev/null +++ b/ui/components/icons/providers-badge/GCPProviderBadge.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; + +import { IconSvgProps } from "@/types"; + +export const GCPProviderBadge: React.FC = ({ + size, + width, + height, + ...props +}) => ( + +); diff --git a/ui/components/icons/providers-badge/KS8ProviderBadge.tsx b/ui/components/icons/providers-badge/KS8ProviderBadge.tsx new file mode 100644 index 0000000000..280b4d381c --- /dev/null +++ b/ui/components/icons/providers-badge/KS8ProviderBadge.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +import { IconSvgProps } from "@/types"; + +export const KS8ProviderBadge: React.FC = ({ + size, + width, + height, + ...props +}) => ( + +); diff --git a/ui/components/icons/providers-badge/index.ts b/ui/components/icons/providers-badge/index.ts new file mode 100644 index 0000000000..8aa68424a0 --- /dev/null +++ b/ui/components/icons/providers-badge/index.ts @@ -0,0 +1,4 @@ +export * from "./AWSProviderBadge"; +export * from "./AzureProviderBadge"; +export * from "./GCPProviderBadge"; +export * from "./KS8ProviderBadge"; diff --git a/ui/components/icons/prowler/ProwlerIcons.tsx b/ui/components/icons/prowler/ProwlerIcons.tsx new file mode 100644 index 0000000000..ae0b989e6b --- /dev/null +++ b/ui/components/icons/prowler/ProwlerIcons.tsx @@ -0,0 +1,57 @@ +import React from "react"; + +import { IconSvgProps } from "../../../types/index"; + +export const ProwlerExtended: React.FC = ({ + size, + width = 216, + height, + ...props +}) => { + return ( + + + + ); +}; + +export const ProwlerShort: React.FC = ({ + size, + width = 30, + height, + ...props +}) => ( + + + +); diff --git a/ui/components/icons/services/IconServices.tsx b/ui/components/icons/services/IconServices.tsx new file mode 100644 index 0000000000..2678b3ef7a --- /dev/null +++ b/ui/components/icons/services/IconServices.tsx @@ -0,0 +1,923 @@ +import { IconSvgProps } from "@/types"; + +export const IAMAccessAnalyzerIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSAccountIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSCertificateManagerIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + + + +); + +export const AWSAthenaIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSLambdaIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSCloudFormationIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSCloudTrailIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSCloudWatchIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSConfigIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSDatabaseMigrationServiceIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AmazonEC2Icon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AmazonEMRIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSGlueIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AmazonGuardDutyIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSIAMIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AmazonInspectorIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AmazonMacieIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSNetworkFirewallIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSOrganizationsIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AmazonRDSIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSResourceExplorerIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AmazonRoute53Icon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AmazonS3Icon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + + + + + + +); + +export const AWSSecurityHubIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AmazonSNSIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSSystemsManagerIncidentManagerIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AWSTrustedAdvisorIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const AmazonVPCIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + + + + + + +); + +export const getAWSIcon = (serviceAlias: string) => { + switch (serviceAlias) { + case "Amazon EC2": + return ; + case "Amazon EMR": + return ; + case "Amazon GuardDuty": + return ; + case "Amazon Inspector": + return ; + case "Amazon Macie": + return ; + case "Amazon RDS": + return ; + case "Amazon Route 53": + return ; + case "Amazon S3": + return ; + case "Amazon SNS": + return ; + case "Amazon VPC": + return ; + case "AWS Account": + return ; + case "AWS Athena": + return ; + case "AWS Certificate Manager": + return ; + case "AWS CloudFormation": + return ; + case "AWS CloudTrail": + return ; + case "AWS CloudWatch": + return ; + case "AWS Config": + return ; + case "AWS Database Migration": + return ; + case "AWS Glue": + return ; + case "AWS IAM": + return ; + case "AWS Lambda": + return ; + case "AWS Network Firewall": + return ; + case "AWS Organizations": + return ; + case "AWS Resource Explorer": + return ; + case "AWS Security Hub": + return ; + case "AWS Systems Manager": + return ; + case "AWS Trusted Advisor": + return ; + case "IAM Access Analyzer": + return ; + default: + return null; + } +}; diff --git a/ui/components/invitations/forms/delete-form.tsx b/ui/components/invitations/forms/delete-form.tsx new file mode 100644 index 0000000000..bea018b2ae --- /dev/null +++ b/ui/components/invitations/forms/delete-form.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import React, { Dispatch, SetStateAction } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { revokeInvite } from "@/actions/invitations/invitation"; +import { DeleteIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; + +const formSchema = z.object({ + invitationId: z.string(), +}); + +export const DeleteForm = ({ + invitationId, + setIsOpen, +}: { + invitationId: string; + setIsOpen: Dispatch>; +}) => { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + invitationId, + }, + }); + const { toast } = useToast(); + const isLoading = form.formState.isSubmitting; + + async function onSubmitClient(values: z.infer) { + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + // client-side validation + const data = await revokeInvite(formData); + + if (data?.errors && data.errors.length > 0) { + const error = data.errors[0]; + const errorMessage = `${error.detail}`; + // show error + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } else { + toast({ + title: "Success!", + description: "The invitation was revoked successfully.", + }); + } + setIsOpen(false); // Close the modal on success + } + + return ( +
+ + +
+ setIsOpen(false)} + isDisabled={isLoading} + > + Cancel + + + } + > + {isLoading ? <>Loading : Revoke} + +
+
+ + ); +}; diff --git a/ui/components/invitations/forms/edit-form.tsx b/ui/components/invitations/forms/edit-form.tsx new file mode 100644 index 0000000000..17924cea97 --- /dev/null +++ b/ui/components/invitations/forms/edit-form.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Dispatch, SetStateAction } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { updateInvite } from "@/actions/invitations/invitation"; +import { SaveIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { editInviteFormSchema } from "@/types"; + +export const EditForm = ({ + invitationId, + invitationEmail, + setIsOpen, +}: { + invitationId: string; + invitationEmail?: string; + setIsOpen: Dispatch>; +}) => { + const formSchema = editInviteFormSchema; + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + invitationId, + invitationEmail: invitationEmail, + }, + }); + + const { toast } = useToast(); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: z.infer) => { + const formData = new FormData(); + console.log(values); + + Object.entries(values).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + + const data = await updateInvite(formData); + + if (data?.error) { + const errorMessage = `${data.error}`; + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } else { + toast({ + title: "Success!", + description: "The invitation was updated successfully.", + }); + setIsOpen(false); // Close the modal on success + } + }; + + return ( +
+ +
+ Current email: {invitationEmail} +
+
+ +
+ + +
+ setIsOpen(false)} + isDisabled={isLoading} + > + Cancel + + + } + > + {isLoading ? <>Loading : Save} + +
+
+ + ); +}; diff --git a/ui/components/invitations/forms/index.ts b/ui/components/invitations/forms/index.ts new file mode 100644 index 0000000000..a081952ade --- /dev/null +++ b/ui/components/invitations/forms/index.ts @@ -0,0 +1,2 @@ +export * from "./delete-form"; +export * from "./edit-form"; diff --git a/ui/components/invitations/index.ts b/ui/components/invitations/index.ts new file mode 100644 index 0000000000..e381a114df --- /dev/null +++ b/ui/components/invitations/index.ts @@ -0,0 +1,2 @@ +export * from "./invitation-details"; +export * from "./send-invitation-button"; diff --git a/ui/components/invitations/invitation-details.tsx b/ui/components/invitations/invitation-details.tsx new file mode 100644 index 0000000000..f335ad6487 --- /dev/null +++ b/ui/components/invitations/invitation-details.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Card, CardBody, Divider, Snippet } from "@nextui-org/react"; + +import { AddIcon } from "../icons"; +import { CustomButton } from "../ui/custom"; +import { DateWithTime } from "../ui/entities"; + +interface InvitationDetailsProps { + attributes: { + email: string; + state: string; + token: string; + expires_at: string; + inserted_at: string; + updated_at: string; + }; + relationships?: { + inviter: { + data: { + id: string; + }; + }; + }; + selfLink: string; +} + +export const InvitationDetails = ({ attributes }: InvitationDetailsProps) => { + const baseURL = process.env.SITE_URL || "http://localhost:3000"; + const invitationLink = `${baseURL}/sign-up?invitation_token=${attributes.token}`; + return ( +
+ + +

+ Invitation Details +

+ + +
+
+ Email: + {attributes.email} +
+ +
+ State: + {attributes.state} +
+ +
+ Token: + {attributes.token} +
+ +
+ Expires At: + +
+ +
+ Inserted At: + +
+ +
+ Updated At: + +
+
+ + +

+ Share this link with the user: +

+ +
+ +

+ {invitationLink} +

+
+
+
+
+
+ } + > + Back to Invitations + +
+
+ ); +}; diff --git a/ui/components/invitations/send-invitation-button.tsx b/ui/components/invitations/send-invitation-button.tsx new file mode 100644 index 0000000000..ae13d33b1d --- /dev/null +++ b/ui/components/invitations/send-invitation-button.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { AddIcon } from "../icons"; +import { CustomButton } from "../ui/custom"; + +export const SendInvitationButton = () => { + return ( +
+ } + > + Send Invitation + +
+ ); +}; diff --git a/ui/components/invitations/table/column-invitations.tsx b/ui/components/invitations/table/column-invitations.tsx new file mode 100644 index 0000000000..9e30eb8024 --- /dev/null +++ b/ui/components/invitations/table/column-invitations.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; + +import { DateWithTime } from "@/components/ui/entities"; +import { DataTableColumnHeader } from "@/components/ui/table"; +import { InvitationProps } from "@/types"; + +import { DataTableRowActions } from "./data-table-row-actions"; + +const getInvitationData = (row: { original: InvitationProps }) => { + return row.original.attributes; +}; + +export const ColumnsInvitation: ColumnDef[] = [ + { + accessorKey: "email", + header: () =>
Email
, + cell: ({ row }) => { + const data = getInvitationData(row); + return

{data?.email || "N/A"}

; + }, + }, + { + accessorKey: "state", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { state } = getInvitationData(row); + return

{state}

; + }, + }, + { + accessorKey: "inserted_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { inserted_at } = getInvitationData(row); + return ; + }, + }, + + { + accessorKey: "expires_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { expires_at } = getInvitationData(row); + return ; + }, + }, + + { + accessorKey: "actions", + header: () =>
Actions
, + id: "actions", + cell: ({ row }) => { + return ; + }, + }, +]; diff --git a/ui/components/invitations/table/data-table-row-actions.tsx b/ui/components/invitations/table/data-table-row-actions.tsx new file mode 100644 index 0000000000..6f60d2e0b7 --- /dev/null +++ b/ui/components/invitations/table/data-table-row-actions.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { + Button, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownSection, + DropdownTrigger, +} from "@nextui-org/react"; +import { + AddNoteBulkIcon, + DeleteDocumentBulkIcon, + EditDocumentBulkIcon, +} from "@nextui-org/shared-icons"; +import { Row } from "@tanstack/react-table"; +import clsx from "clsx"; +import { useState } from "react"; + +import { VerticalDotsIcon } from "@/components/icons"; +import { CustomAlertModal } from "@/components/ui/custom"; + +import { DeleteForm, EditForm } from "../forms"; + +interface DataTableRowActionsProps { + row: Row; +} +const iconClasses = + "text-2xl text-default-500 pointer-events-none flex-shrink-0"; + +export function DataTableRowActions({ + row, +}: DataTableRowActionsProps) { + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const invitationId = (row.original as { id: string }).id; + const invitationEmail = (row.original as any).attributes?.email; + return ( + <> + + + + + + + +
+ + + + + + + } + > + Check Details + + + } + onClick={() => setIsEditOpen(true)} + > + Edit Invitation + + + + + } + onClick={() => setIsDeleteOpen(true)} + > + Revoke Invitation + + + + +
+ + ); +} diff --git a/ui/components/invitations/table/index.ts b/ui/components/invitations/table/index.ts new file mode 100644 index 0000000000..177edf2bb9 --- /dev/null +++ b/ui/components/invitations/table/index.ts @@ -0,0 +1,3 @@ +export * from "./column-invitations"; +export * from "./data-table-row-actions"; +export * from "./skeleton-table-invitations"; diff --git a/ui/components/invitations/table/skeleton-table-invitations.tsx b/ui/components/invitations/table/skeleton-table-invitations.tsx new file mode 100644 index 0000000000..6d18313fe4 --- /dev/null +++ b/ui/components/invitations/table/skeleton-table-invitations.tsx @@ -0,0 +1,59 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonTableInvitation = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(10)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/invitations/workflow/forms/index.ts b/ui/components/invitations/workflow/forms/index.ts new file mode 100644 index 0000000000..3180c95b40 --- /dev/null +++ b/ui/components/invitations/workflow/forms/index.ts @@ -0,0 +1 @@ +export * from "./send-invitation-form"; diff --git a/ui/components/invitations/workflow/forms/send-invitation-form.tsx b/ui/components/invitations/workflow/forms/send-invitation-form.tsx new file mode 100644 index 0000000000..a75c6a60c6 --- /dev/null +++ b/ui/components/invitations/workflow/forms/send-invitation-form.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { SaveIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { sendInvite } from "@/actions/invitations/invitation"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { ApiError } from "@/types"; + +const sendInvitationFormSchema = z.object({ + email: z.string().email("Please enter a valid email"), +}); + +export type FormValues = z.infer; + +export const SendInvitationForm = () => { + const { toast } = useToast(); + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(sendInvitationFormSchema), + defaultValues: { + email: "", + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormValues) => { + const formData = new FormData(); + formData.append("email", values.email); + + try { + const data = await sendInvite(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + switch (error.source.pointer) { + case "/data/attributes/email": + form.setError("email", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + const invitationId = data?.data?.id || ""; + router.push(`/invitations/check-details/?id=${invitationId}`); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Error", + description: "An unexpected error occurred. Please try again.", + }); + } + }; + + return ( +
+ + + +
+ } + > + {isLoading ? <>Loading : Send Invitation} + +
+ + + ); +}; diff --git a/ui/components/invitations/workflow/index.ts b/ui/components/invitations/workflow/index.ts new file mode 100644 index 0000000000..a9a860f213 --- /dev/null +++ b/ui/components/invitations/workflow/index.ts @@ -0,0 +1,3 @@ +export * from "./skeleton-invitation-info"; +export * from "./vertical-steps"; +export * from "./workflow-send-invite"; diff --git a/ui/components/invitations/workflow/skeleton-invitation-info.tsx b/ui/components/invitations/workflow/skeleton-invitation-info.tsx new file mode 100644 index 0000000000..12abeb9da7 --- /dev/null +++ b/ui/components/invitations/workflow/skeleton-invitation-info.tsx @@ -0,0 +1,65 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonInvitationInfo = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(3)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/invitations/workflow/vertical-steps.tsx b/ui/components/invitations/workflow/vertical-steps.tsx new file mode 100644 index 0000000000..74eaa32747 --- /dev/null +++ b/ui/components/invitations/workflow/vertical-steps.tsx @@ -0,0 +1,291 @@ +"use client"; + +import type { ButtonProps } from "@nextui-org/react"; +import { cn } from "@nextui-org/react"; +import { useControlledState } from "@react-stately/utils"; +import { domAnimation, LazyMotion, m } from "framer-motion"; +import type { ComponentProps } from "react"; +import React from "react"; + +export type VerticalStepProps = { + className?: string; + description?: React.ReactNode; + title?: React.ReactNode; +}; + +export interface VerticalStepsProps + extends React.HTMLAttributes { + /** + * An array of steps. + * + * @default [] + */ + steps?: VerticalStepProps[]; + /** + * The color of the steps. + * + * @default "primary" + */ + color?: ButtonProps["color"]; + /** + * The current step index. + */ + currentStep?: number; + /** + * The default step index. + * + * @default 0 + */ + defaultStep?: number; + /** + * Whether to hide the progress bars. + * + * @default false + */ + hideProgressBars?: boolean; + /** + * The custom class for the steps wrapper. + */ + className?: string; + /** + * The custom class for the step. + */ + stepClassName?: string; + /** + * Callback function when the step index changes. + */ + onStepChange?: (stepIndex: number) => void; +} + +function CheckIcon(props: ComponentProps<"svg">) { + return ( + + + + ); +} + +export const VerticalSteps = React.forwardRef< + HTMLButtonElement, + VerticalStepsProps +>( + ( + { + color = "primary", + steps = [], + defaultStep = 0, + onStepChange, + currentStep: currentStepProp, + hideProgressBars = false, + stepClassName, + className, + ...props + }, + ref, + ) => { + const [currentStep, setCurrentStep] = useControlledState( + currentStepProp, + defaultStep, + onStepChange, + ); + + const colors = React.useMemo(() => { + let userColor; + let fgColor; + + const colorsVars = [ + "[--active-fg-color:var(--step-fg-color)]", + "[--active-border-color:var(--step-color)]", + "[--active-color:var(--step-color)]", + "[--complete-background-color:var(--step-color)]", + "[--complete-border-color:var(--step-color)]", + "[--inactive-border-color:hsl(var(--nextui-default-300))]", + "[--inactive-color:hsl(var(--nextui-default-300))]", + ]; + + switch (color) { + case "primary": + userColor = "[--step-color:hsl(var(--nextui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-primary-foreground))]"; + break; + case "secondary": + userColor = "[--step-color:hsl(var(--nextui-secondary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-secondary-foreground))]"; + break; + case "success": + userColor = "[--step-color:hsl(var(--nextui-success))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-success-foreground))]"; + break; + case "warning": + userColor = "[--step-color:hsl(var(--nextui-warning))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-warning-foreground))]"; + break; + case "danger": + userColor = "[--step-color:hsl(var(--nextui-error))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-error-foreground))]"; + break; + case "default": + userColor = "[--step-color:hsl(var(--nextui-default))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-default-foreground))]"; + break; + default: + userColor = "[--step-color:hsl(var(--nextui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-primary-foreground))]"; + break; + } + + if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor); + if (!className?.includes("--step-color")) colorsVars.unshift(userColor); + if (!className?.includes("--inactive-bar-color")) + colorsVars.push( + "[--inactive-bar-color:hsl(var(--nextui-default-300))]", + ); + + return colorsVars; + }, [color, className]); + + return ( + + ); + }, +); + +VerticalSteps.displayName = "VerticalSteps"; diff --git a/ui/components/invitations/workflow/workflow-send-invite.tsx b/ui/components/invitations/workflow/workflow-send-invite.tsx new file mode 100644 index 0000000000..9cbe848093 --- /dev/null +++ b/ui/components/invitations/workflow/workflow-send-invite.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { Progress, Spacer } from "@nextui-org/react"; +import { usePathname } from "next/navigation"; +import React from "react"; + +import { VerticalSteps } from "./vertical-steps"; + +const steps = [ + { + title: "Send Invitation", + description: + "Enter the email address of the person you want to invite and send the invitation.", + href: "/invitations/new", + }, + { + title: "Review Invitation Details", + description: + "Review the invitation details and share the information required for the person to accept the invitation.", + href: "/invitations/check-details", + }, +]; + +export const WorkflowSendInvite = () => { + const pathname = usePathname(); + + // Calculate current step based on pathname + const currentStepIndex = steps.findIndex((step) => + pathname.endsWith(step.href), + ); + const currentStep = currentStepIndex === -1 ? 0 : currentStepIndex; + + return ( +
+

+ Send invitation +

+

+ Follow the steps to send an invitation to the users. +

+ + + +
+ ); +}; diff --git a/ui/components/overview/AttackSurface.tsx b/ui/components/overview/AttackSurface.tsx new file mode 100644 index 0000000000..495ceb3d1c --- /dev/null +++ b/ui/components/overview/AttackSurface.tsx @@ -0,0 +1,44 @@ +import React from "react"; + +import { ActionCard } from "@/components/ui"; + +const cardData = [ + { + findings: 3, + title: "Internet Exposed Resources", + }, + { + findings: 15, + title: "Exposed Secrets", + }, + { + findings: 0, + title: "IAM Policies Leading to Privilege Escalation", + }, + { + findings: 0, + title: "EC2 with Metadata Service V1 (IMDSv1)", + }, +]; + +export const AttackSurface = () => { + return ( +
+ {cardData.map((card, index) => ( + 0 ? "fail" : "success"} + icon={ + card.findings > 0 + ? "solar:danger-triangle-bold" + : "heroicons:shield-check-solid" + } + title={card.title} + description={ + card.findings > 0 ? "Review Required" : "No Issues Found" + } + /> + ))} +
+ ); +}; diff --git a/ui/components/overview/index.ts b/ui/components/overview/index.ts new file mode 100644 index 0000000000..eb393767f1 --- /dev/null +++ b/ui/components/overview/index.ts @@ -0,0 +1,3 @@ +export * from "./AttackSurface"; +export * from "./provider-overview/provider-overview"; +export * from "./provider-overview/skeleton-provider-overview"; diff --git a/ui/components/overview/provider-overview/provider-overview.tsx b/ui/components/overview/provider-overview/provider-overview.tsx new file mode 100644 index 0000000000..cd69f02595 --- /dev/null +++ b/ui/components/overview/provider-overview/provider-overview.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { Card, CardBody, CardHeader } from "@nextui-org/react"; + +import { AddIcon } from "@/components/icons/Icons"; +import { + AWSProviderBadge, + AzureProviderBadge, + GCPProviderBadge, + KS8ProviderBadge, +} from "@/components/icons/providers-badge"; +import { CustomButton } from "@/components/ui/custom/custom-button"; +import { ProviderOverviewProps } from "@/types"; + +export const ProvidersOverview = ({ + providersOverview, +}: { + providersOverview: ProviderOverviewProps; +}) => { + console.log(providersOverview); + if (!providersOverview || !Array.isArray(providersOverview.data)) { + return

No provider data available

; + } + + const calculatePassingPercentage = (pass: number, total: number) => + total > 0 ? ((pass / total) * 100).toFixed(2) : "0.00"; + + const renderProviderBadge = (providerId: string) => { + switch (providerId) { + case "aws": + return ; + case "azure": + return ; + case "gcp": + return ; + case "kubernetes": + return ; + default: + return null; + } + }; + + const providers = [ + { id: "aws", name: "AWS" }, + { id: "azure", name: "Azure" }, + { id: "gcp", name: "GCP" }, + { id: "kubernetes", name: "Kubernetes" }, + ]; + + return ( + + +

Providers Overview

+
+ +
+
+ Provider + + Percent + Passing + + + Failing + Checks + + + Total + Resources + +
+ + {providers.map((providerTemplate) => { + const providerData = providersOverview.data.find( + (p) => p.id === providerTemplate.id, + ); + + return ( +
+ + {renderProviderBadge(providerTemplate.id)} + + + {providerData + ? calculatePassingPercentage( + providerData.attributes.findings.pass, + providerData.attributes.findings.total, + ) + : "0.00"} + % + + + {providerData ? providerData.attributes.findings.fail : "-"} + + + {providerData ? providerData.attributes.resources.total : "-"} + +
+ ); + })} + + {/* Totals row */} +
+ Total + + {calculatePassingPercentage( + providersOverview.data.reduce( + (sum, provider) => sum + provider.attributes.findings.pass, + 0, + ), + providersOverview.data.reduce( + (sum, provider) => sum + provider.attributes.findings.total, + 0, + ), + )} + % + + + {providersOverview.data.reduce( + (sum, provider) => sum + provider.attributes.findings.fail, + 0, + )} + + + {providersOverview.data.reduce( + (sum, provider) => sum + provider.attributes.resources.total, + 0, + )} + +
+
+
+ } + > + Add Provider + +
+
+
+ ); +}; diff --git a/ui/components/overview/provider-overview/skeleton-provider-overview.tsx b/ui/components/overview/provider-overview/skeleton-provider-overview.tsx new file mode 100644 index 0000000000..47febd1700 --- /dev/null +++ b/ui/components/overview/provider-overview/skeleton-provider-overview.tsx @@ -0,0 +1,64 @@ +import { Card, CardBody, CardHeader, Skeleton } from "@nextui-org/react"; + +export const SkeletonProvidersOverview = () => { + const rows = 4; + + return ( + + + +
+
+
+ +
+ {/* Header Skeleton */} +
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Row Skeletons */} + {Array.from({ length: rows }).map((_, index) => ( +
+ {/* Provider Name */} +
+ +
+
+ +
+
+
+ {/* Percent Passing */} + +
+
+ {/* Failing Checks */} + +
+
+ {/* Total Resources */} + +
+
+
+ ))} +
+
+
+ ); +}; diff --git a/ui/components/primitives.ts b/ui/components/primitives.ts new file mode 100644 index 0000000000..472973cbe9 --- /dev/null +++ b/ui/components/primitives.ts @@ -0,0 +1,53 @@ +import { tv } from "tailwind-variants"; + +export const title = tv({ + base: "tracking-tight inline font-semibold", + variants: { + color: { + violet: "from-[#FF1CF7] to-[#b249f8]", + yellow: "from-[#FF705B] to-[#FFB457]", + blue: "from-[#5EA2EF] to-[#0072F5]", + cyan: "from-[#00b7fa] to-[#01cfea]", + green: "from-[#6FEE8D] to-[#17c964]", + pink: "from-[#FF72E1] to-[#F54C7A]", + foreground: "dark:from-[#FFFFFF] dark:to-[#4B4B4B]", + }, + size: { + sm: "text-3xl lg:text-4xl", + md: "text-[2.3rem] lg:text-5xl leading-9", + lg: "text-4xl lg:text-6xl", + }, + fullWidth: { + true: "w-full block", + }, + }, + defaultVariants: { + size: "md", + }, + compoundVariants: [ + { + color: [ + "violet", + "yellow", + "blue", + "cyan", + "green", + "pink", + "foreground", + ], + class: "bg-clip-text text-transparent bg-gradient-to-b", + }, + ], +}); + +export const subtitle = tv({ + base: "w-full md:w-1/2 my-2 text-lg lg:text-xl text-default-600 block max-w-full", + variants: { + fullWidth: { + true: "!w-full", + }, + }, + defaultVariants: { + fullWidth: true, + }, +}); diff --git a/ui/components/providers/add-provider.tsx b/ui/components/providers/add-provider.tsx new file mode 100644 index 0000000000..c732d41007 --- /dev/null +++ b/ui/components/providers/add-provider.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { AddIcon } from "../icons"; +import { CustomButton } from "../ui/custom"; + +export const AddProvider = () => { + return ( +
+ } + > + Add Account + +
+ ); +}; diff --git a/ui/components/providers/forms/delete-form.tsx b/ui/components/providers/forms/delete-form.tsx new file mode 100644 index 0000000000..2f80b3294b --- /dev/null +++ b/ui/components/providers/forms/delete-form.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import React, { Dispatch, SetStateAction } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { deleteProvider } from "@/actions/providers"; +import { DeleteIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; + +const formSchema = z.object({ + providerId: z.string(), +}); + +export const DeleteForm = ({ + providerId, + setIsOpen, +}: { + providerId: string; + setIsOpen: Dispatch>; +}) => { + const form = useForm>({ + resolver: zodResolver(formSchema), + }); + const { toast } = useToast(); + const isLoading = form.formState.isSubmitting; + + async function onSubmitClient(formData: FormData) { + // client-side validation + const data = await deleteProvider(formData); + + if (data?.errors && data.errors.length > 0) { + const error = data.errors[0]; + const errorMessage = `${error.detail}`; + // show error + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } else { + toast({ + title: "Success!", + description: "The provider was removed successfully.", + }); + } + setIsOpen(false); // Close the modal on success + } + + return ( +
+ + +
+ setIsOpen(false)} + isDisabled={isLoading} + > + Cancel + + + } + > + {isLoading ? <>Loading : Delete} + +
+
+ + ); +}; diff --git a/ui/components/providers/forms/edit-form.tsx b/ui/components/providers/forms/edit-form.tsx new file mode 100644 index 0000000000..fba187a22f --- /dev/null +++ b/ui/components/providers/forms/edit-form.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Dispatch, SetStateAction } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { updateProvider } from "@/actions/providers"; +import { SaveIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { editProviderFormSchema } from "@/types"; + +export const EditForm = ({ + providerId, + providerAlias, + setIsOpen, +}: { + providerId: string; + providerAlias?: string; + setIsOpen: Dispatch>; +}) => { + const formSchema = editProviderFormSchema(providerAlias ?? ""); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId: providerId, + alias: providerAlias, + }, + }); + + const { toast } = useToast(); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: z.infer) => { + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + + const data = await updateProvider(formData); + + if (data?.errors && data.errors.length > 0) { + const error = data.errors[0]; + const errorMessage = `${error.detail}`; + // show error + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } else { + toast({ + title: "Success!", + description: "The provider was updated successfully.", + }); + setIsOpen(false); // Close the modal on success + } + }; + + return ( +
+ +
+ Current alias: {providerAlias} +
+
+ +
+ + +
+ setIsOpen(false)} + isDisabled={isLoading} + > + Cancel + + + } + > + {isLoading ? <>Loading : Save} + +
+
+ + ); +}; diff --git a/ui/components/providers/forms/index.ts b/ui/components/providers/forms/index.ts new file mode 100644 index 0000000000..a081952ade --- /dev/null +++ b/ui/components/providers/forms/index.ts @@ -0,0 +1,2 @@ +export * from "./delete-form"; +export * from "./edit-form"; diff --git a/ui/components/providers/index.ts b/ui/components/providers/index.ts new file mode 100644 index 0000000000..6fb3bcae31 --- /dev/null +++ b/ui/components/providers/index.ts @@ -0,0 +1,4 @@ +export * from "./add-provider"; +export * from "./forms/delete-form"; +export * from "./provider-info"; +export * from "./radio-group-provider"; diff --git a/ui/components/providers/provider-info.tsx b/ui/components/providers/provider-info.tsx new file mode 100644 index 0000000000..9d6a11c371 --- /dev/null +++ b/ui/components/providers/provider-info.tsx @@ -0,0 +1,78 @@ +import React from "react"; + +import { ConnectionFalse, ConnectionPending, ConnectionTrue } from "../icons"; +import { + AWSProviderBadge, + AzureProviderBadge, + GCPProviderBadge, + KS8ProviderBadge, +} from "../icons/providers-badge"; + +interface ProviderInfoProps { + connected: boolean | null; + provider: "aws" | "azure" | "gcp" | "kubernetes"; + providerAlias: string; +} + +export const ProviderInfo: React.FC = ({ + connected, + provider, + providerAlias, +}) => { + const getIcon = () => { + switch (connected) { + case true: + return ( +
+ +
+ ); + case false: + return ( +
+ +
+ ); + case null: + return ( +
+ +
+ ); + default: + return ; + } + }; + + const getProviderLogo = () => { + switch (provider) { + case "aws": + return ; + case "azure": + return ; + case "gcp": + return ; + case "kubernetes": + return ; + default: + return null; + } + }; + + return ( +
+
+
+
{getProviderLogo()}
+
{getIcon()}
+
+ + {providerAlias} + + {/* */} +
+
+
+
+ ); +}; diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx new file mode 100644 index 0000000000..e376e69ff4 --- /dev/null +++ b/ui/components/providers/radio-group-provider.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { RadioGroup } from "@nextui-org/react"; +import React from "react"; +import { Control, Controller } from "react-hook-form"; +import { z } from "zod"; + +import { addProviderFormSchema } from "@/types"; + +import { AWSProviderBadge, AzureProviderBadge } from "../icons/providers-badge"; +import { GCPProviderBadge } from "../icons/providers-badge/GCPProviderBadge"; +import { KS8ProviderBadge } from "../icons/providers-badge/KS8ProviderBadge"; +import { CustomRadio } from "../ui/custom"; +import { FormMessage } from "../ui/form"; + +interface RadioGroupProviderProps { + control: Control>; + isInvalid: boolean; + errorMessage?: string; +} + +export const RadioGroupProvider: React.FC = ({ + control, + isInvalid, + errorMessage, +}) => { + return ( + ( + <> + +
+ +
+ + Amazon Web Services +
+
+ +
+ + Google Cloud Platform +
+
+ +
+ + Microsoft Azure +
+
+ +
+ + Kubernetes +
+
+
+
+ {errorMessage && ( + + {errorMessage} + + )} + + )} + /> + ); +}; diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx new file mode 100644 index 0000000000..1680fd8000 --- /dev/null +++ b/ui/components/providers/table/column-providers.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; + +import { DateWithTime, SnippetId } from "@/components/ui/entities"; +import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table"; +import { ProviderProps } from "@/types"; + +import { ProviderInfo } from "../provider-info"; +import { DataTableRowActions } from "./data-table-row-actions"; + +const getProviderData = (row: { original: ProviderProps }) => { + return row.original; +}; + +export const ColumnProviders: ColumnDef[] = [ + // { + // header: " ", + // cell: ({ row }) =>

{row.index + 1}

, + // }, + { + accessorKey: "account", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { connection, provider, alias }, + } = getProviderData(row); + return ( + + ); + }, + }, + { + accessorKey: "uid", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { uid }, + } = getProviderData(row); + return ; + }, + }, + { + accessorKey: "status", + header: "Scan Status", + cell: () => { + // Temporarily overwriting the value until the API is functional. + return ; + }, + }, + { + accessorKey: "lastScan", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { updated_at }, + } = getProviderData(row); + return ; + }, + }, + { + accessorKey: "added", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { inserted_at }, + } = getProviderData(row); + return ; + }, + }, + { + id: "actions", + cell: ({ row }) => { + return ; + }, + }, +]; diff --git a/ui/components/providers/table/data-table-row-actions.tsx b/ui/components/providers/table/data-table-row-actions.tsx new file mode 100644 index 0000000000..29d0bc127e --- /dev/null +++ b/ui/components/providers/table/data-table-row-actions.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { + Button, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownSection, + DropdownTrigger, +} from "@nextui-org/react"; +import { + AddNoteBulkIcon, + DeleteDocumentBulkIcon, + EditDocumentBulkIcon, +} from "@nextui-org/shared-icons"; +import { Row } from "@tanstack/react-table"; +import clsx from "clsx"; +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 { + row: Row; +} +const iconClasses = + "text-2xl text-default-500 pointer-events-none flex-shrink-0"; + +export function DataTableRowActions({ + row, +}: DataTableRowActionsProps) { + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const providerId = (row.original as { id: string }).id; + const providerType = (row.original as any).attributes?.provider; + const providerAlias = (row.original as any).attributes?.alias; + return ( + <> + + + + + + + +
+ + + + + + + } + > + Test Connection + + } + onClick={() => setIsEditOpen(true)} + > + Edit Provider + + + + + } + onClick={() => setIsDeleteOpen(true)} + > + Delete Provider + + + + +
+ + ); +} diff --git a/ui/components/providers/table/index.ts b/ui/components/providers/table/index.ts new file mode 100644 index 0000000000..eb6e772b49 --- /dev/null +++ b/ui/components/providers/table/index.ts @@ -0,0 +1,3 @@ +export * from "./column-providers"; +export * from "./data-table-row-actions"; +export * from "./skeleton-table-provider"; diff --git a/ui/components/providers/table/skeleton-table-provider.tsx b/ui/components/providers/table/skeleton-table-provider.tsx new file mode 100644 index 0000000000..b5c852d55e --- /dev/null +++ b/ui/components/providers/table/skeleton-table-provider.tsx @@ -0,0 +1,65 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonTableProviders = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(3)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx new file mode 100644 index 0000000000..54acff1491 --- /dev/null +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { ChevronLeftIcon, ChevronRightIcon, SaveIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; + +import { addProvider } from "../../../../actions/providers/providers"; +import { addProviderFormSchema, ApiError } from "../../../../types"; +import { RadioGroupProvider } from "../../radio-group-provider"; +import { RadioGroupAWSViaCredentialsForm } from "./radio-group-aws-via-credentials-form"; + +export type FormValues = z.infer; + +export const ConnectAccountForm = () => { + const { toast } = useToast(); + const [prevStep, setPrevStep] = useState(1); + const router = useRouter(); + + const formSchema = addProviderFormSchema; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerType: undefined, + providerUid: "", + providerAlias: "", + awsCredentialsType: "", + }, + }); + const providerType = form.watch("providerType"); + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormValues) => { + const formValues = { ...values }; + + // If providerAlias is empty, set default value + if (!formValues.providerAlias.trim()) { + const date = new Date(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + const year = date.getFullYear(); + formValues.providerAlias = `${formValues.providerType}:${month}/${day}/${year}`; + } + + const formData = new FormData(); + + Object.entries(formValues).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + + const data = await addProvider(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + const pointer = error.source?.pointer; + + switch (pointer) { + case "/data/attributes/provider": + form.setError("providerType", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/uid": + case "/data/attributes/__all__": + form.setError("providerUid", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/alias": + form.setError("providerAlias", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + setPrevStep(1); + } else { + const { + id, + attributes: { provider: providerType }, + } = data.data; + const credentialsParam = values.awsCredentialsType + ? `&via=${values.awsCredentialsType}` + : ""; + router.push( + `/providers/add-credentials?type=${providerType}&id=${id}${credentialsParam}`, + ); + } + }; + + const handleNextStep = () => { + setPrevStep((prev) => prev + 1); + }; + + const handleBackStep = () => { + setPrevStep((prev) => prev - 1); + }; + + return ( +
+ + {prevStep === 1 && ( + <> + {/* Select a provider */} + + {/* Provider UID */} + + {/* Provider alias */} + + + )} + + {prevStep === 2 && ( + <> + {/* Select AWS credentials type */} + + + )} + +
+ {prevStep === 2 && ( + } + isDisabled={isLoading} + > + Back + + )} + + + ) + } + endContent={ + !isLoading && + prevStep === 1 && + providerType === "aws" && + } + onPress={() => { + if (prevStep === 1 && providerType === "aws") { + handleNextStep(); + } else { + form.handleSubmit(onSubmitClient)(); + } + }} + > + {isLoading ? ( + <>Loading + ) : ( + + {prevStep === 1 && providerType === "aws" ? "Next" : "Save"} + + )} + +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/index.ts b/ui/components/providers/workflow/forms/index.ts new file mode 100644 index 0000000000..be1bb3277d --- /dev/null +++ b/ui/components/providers/workflow/forms/index.ts @@ -0,0 +1,6 @@ +export * from "./connect-account-form"; +export * from "./launch-scan-form"; +export * from "./radio-group-aws-via-credentials-form"; +export * from "./test-connection-form"; +export * from "./via-credentials-form"; +export * from "./via-role-form"; diff --git a/ui/components/providers/workflow/forms/launch-scan-form.tsx b/ui/components/providers/workflow/forms/launch-scan-form.tsx new file mode 100644 index 0000000000..2532fd2e90 --- /dev/null +++ b/ui/components/providers/workflow/forms/launch-scan-form.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { scanOnDemand } from "@/actions/scans/scans"; +import { AddIcon } from "@/components/icons"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { ProviderProps } from "@/types"; // Asegรบrate de importar la interfaz correcta +import { launchScanFormSchema } from "@/types/formSchemas"; + +import { ProviderInfo } from "../../provider-info"; + +type FormValues = z.infer>; + +interface LaunchScanFormProps { + searchParams: { type: string; id: string }; + providerData: { + data: { + type: string; + id: string; + attributes: ProviderProps["attributes"]; + }; + }; +} + +export const LaunchScanForm = ({ + searchParams, + providerData, +}: LaunchScanFormProps) => { + const providerType = searchParams.type; + const providerId = searchParams.id; + + const [apiErrorMessage, setApiErrorMessage] = useState(null); + const router = useRouter(); + + const formSchema = launchScanFormSchema(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + providerType, + scannerArgs: { + checksToExecute: [], + }, + }, + }); + + // const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormValues) => { + const formData = new FormData(); + formData.append("providerId", values.providerId); + + // Generate default scan name using provider type and current date + const date = new Date(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + const year = date.getFullYear(); + const defaultScanName = `${providerType}:${month}/${day}/${year}`; + + formData.append("scanName", defaultScanName); + + try { + const data = await scanOnDemand(formData); + + if (data.error) { + setApiErrorMessage(data.error); + form.setError("providerId", { + type: "server", + message: data.error, + }); + } else { + router.push("/scans"); + } + } catch (error) { + form.setError("providerId", { + type: "server", + message: "An unexpected error occurred. Please try again.", + }); + } + }; + + return ( +
+ +
+
+ Scan started +
+
+ The scan has just started. From now on, a new scan will be launched + every 24 hours, starting from this moment. +
+
+ + {apiErrorMessage && ( +
+

{apiErrorMessage.toLowerCase()}

+
+ )} + + + + + + + {/*
+ } + isDisabled={true} + > + Schedule + + } + > + {isLoading ? <>Loading : Start now} + +
*/} +
+ } + > + Go to Scans + +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx b/ui/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx new file mode 100644 index 0000000000..7a870c79ff --- /dev/null +++ b/ui/components/providers/workflow/forms/radio-group-aws-via-credentials-form.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { RadioGroup } from "@nextui-org/react"; +import React from "react"; +import { Control, Controller } from "react-hook-form"; + +import { CustomRadio } from "@/components/ui/custom"; +import { FormMessage } from "@/components/ui/form"; + +import { FormValues } from "./connect-account-form"; + +type RadioGroupAWSViaCredentialsFormProps = { + control: Control; + isInvalid: boolean; + errorMessage?: string; +}; + +export const RadioGroupAWSViaCredentialsForm = ({ + control, + isInvalid, + errorMessage, +}: RadioGroupAWSViaCredentialsFormProps) => { + return ( + ( + <> + +
+ Using IAM Role + +
+ Connect assuming IAM Role +
+
+ + Using Credentials + + +
+ Connect via Credentials +
+
+
+
+ {errorMessage && ( + + {errorMessage} + + )} + + )} + /> + ); +}; diff --git a/ui/components/providers/workflow/forms/test-connection-form.tsx b/ui/components/providers/workflow/forms/test-connection-form.tsx new file mode 100644 index 0000000000..6431c1a814 --- /dev/null +++ b/ui/components/providers/workflow/forms/test-connection-form.tsx @@ -0,0 +1,286 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Icon } from "@iconify/react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { + checkConnectionProvider, + deleteCredentials, +} from "@/actions/providers"; +import { scanOnDemand } from "@/actions/scans"; +import { getTask } from "@/actions/task/tasks"; +import { CheckIcon, SaveIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { checkTaskStatus } from "@/lib/helper"; +import { ApiError, testConnectionFormSchema } from "@/types"; + +import { ProviderInfo } from "../.."; + +type FormValues = z.infer; + +export const TestConnectionForm = ({ + searchParams, + providerData, +}: { + searchParams: { type: string; id: string }; + providerData: { + data: { + id: string; + type: string; + attributes: { + connection: { + connected: boolean | null; + last_checked_at: string | null; + }; + provider: "aws" | "azure" | "gcp" | "kubernetes"; + alias: string; + scanner_args: Record; + }; + relationships: { + secret: { + data: { + type: string; + id: string; + } | null; + }; + }; + }; + }; +}) => { + const { toast } = useToast(); + const router = useRouter(); + const providerType = searchParams.type; + const providerId = searchParams.id; + console.log({ providerData }, "providerData from test connection form"); + const formSchema = testConnectionFormSchema; + const [apiErrorMessage, setApiErrorMessage] = useState(null); + const [connectionStatus, setConnectionStatus] = useState<{ + connected: boolean; + error: string | null; + } | null>(null); + const [isResettingCredentials, setIsResettingCredentials] = useState(false); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormValues) => { + console.log({ values }, "values from test connection form"); + const formData = new FormData(); + formData.append("providerId", values.providerId); + + const data = await checkConnectionProvider(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + + switch (errorMessage) { + case "Not found.": + setApiErrorMessage(errorMessage); + break; + default: + toast({ + variant: "destructive", + title: `Error ${error.status}`, + description: errorMessage, + }); + } + }); + } else { + const taskId = data.data.id; + setApiErrorMessage(null); + + // Use the helper function to check the task status + const taskResult = await checkTaskStatus(taskId); + + if (taskResult.completed) { + const task = await getTask(taskId); + const { connected, error } = task.data.attributes.result; + + setConnectionStatus({ + connected, + error: connected ? null : error || "Unknown error", + }); + + if (connected) { + try { + const data = await scanOnDemand(formData); + + if (data.error) { + setApiErrorMessage(data.error); + form.setError("providerId", { + type: "server", + message: data.error, + }); + } else { + router.push( + `/providers/launch-scan?type=${providerType}&id=${providerId}`, + ); + } + } catch (error) { + form.setError("providerId", { + type: "server", + message: "An unexpected error occurred. Please try again.", + }); + } + } else { + setConnectionStatus({ + connected: false, + error: error || "Connection failed, please review credentials.", + }); + } + } else { + setConnectionStatus({ + connected: false, + error: taskResult.error || "Unknown error", + }); + } + } + }; + + const onResetCredentials = async () => { + setIsResettingCredentials(true); + + // Check if provider the provider has no credentials + const providerSecretId = + providerData?.data?.relationships?.secret?.data?.id; + const hasNoCredentials = !providerSecretId; + + if (hasNoCredentials) { + // If no credentials, redirect to add credentials page + router.push( + `/providers/add-credentials?type=${providerType}&id=${providerId}`, + ); + return; + } + + // If provider has credentials, delete them first + try { + await deleteCredentials(providerSecretId); + // After successful deletion, redirect to add credentials page + router.push( + `/providers/add-credentials?type=${providerType}&id=${providerId}`, + ); + } catch (error) { + console.error("Failed to delete credentials:", error); + } finally { + setIsResettingCredentials(false); + } + }; + + return ( +
+ +
+
+ Test connection +
+

+ Ensure all required credentials and configurations are completed + accurately. A successful connection will enable the option to + initiate a scan in the following step. +

+
+ + {apiErrorMessage && ( +
+

{`Provider ID ${apiErrorMessage.toLowerCase()}. Please check and try again.`}

+
+ )} + + {connectionStatus && !connectionStatus.connected && ( + <> +
+
+ +
+
+

+ {connectionStatus.error || "Unknown error"} +

+
+
+

+ It seems there was an issue with your credentials. Please review + your credentials and try again. +

+ + )} + + + + + +
+ {apiErrorMessage ? ( + + + Back to providers + + ) : connectionStatus?.error ? ( + } + isDisabled={isResettingCredentials} + > + {isResettingCredentials ? ( + <>Loading + ) : ( + Reset credentials + )} + + ) : ( + } + > + {isLoading ? <>Loading : Test connection} + + )} +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials-form.tsx new file mode 100644 index 0000000000..176ed40a00 --- /dev/null +++ b/ui/components/providers/workflow/forms/via-credentials-form.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { SaveIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { Control, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { addCredentialsProvider } from "@/actions/providers/providers"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { + addCredentialsFormSchema, + ApiError, + AWSCredentials, + AzureCredentials, + GCPCredentials, + KubernetesCredentials, +} from "@/types"; + +import { AWScredentialsForm } from "./via-credentials/aws-credentials-form"; +import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form"; +import { GCPcredentialsForm } from "./via-credentials/gcp-credentials-form"; +import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form"; + +type CredentialsFormSchema = z.infer< + ReturnType +>; + +// Add this type intersection to include all fields +type FormType = CredentialsFormSchema & + AWSCredentials & + AzureCredentials & + GCPCredentials & + KubernetesCredentials; + +export const ViaCredentialsForm = ({ + searchParams, +}: { + searchParams: { type: string; id: string }; +}) => { + const router = useRouter(); + const { toast } = useToast(); + + const providerType = searchParams.type; + const providerId = searchParams.id; + const formSchema = addCredentialsFormSchema(providerType); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + providerType, + ...(providerType === "aws" + ? { + aws_access_key_id: "", + aws_secret_access_key: "", + aws_session_token: "", + } + : providerType === "azure" + ? { + client_id: "", + client_secret: "", + tenant_id: "", + } + : providerType === "gcp" + ? { + client_id: "", + client_secret: "", + refresh_token: "", + } + : providerType === "kubernetes" + ? { + kubeconfig_content: "", + } + : {}), + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormType) => { + console.log("via credentials form", values); + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + + const data = await addCredentialsProvider(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + switch (error.source.pointer) { + case "/data/attributes/secret/aws_access_key_id": + form.setError("aws_access_key_id", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/aws_secret_access_key": + form.setError("aws_secret_access_key", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/aws_session_token": + form.setError("aws_session_token", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/client_id": + form.setError("client_id", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/client_secret": + form.setError("client_secret", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/tenant_id": + form.setError("tenant_id", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/secret/kubeconfig_content": + form.setError("kubeconfig_content", { + type: "server", + message: errorMessage, + }); + break; + case "/data/attributes/name": + form.setError("secretName", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + router.push( + `/providers/test-connection?type=${providerType}&id=${providerId}`, + ); + } + }; + + return ( +
+ + + + + {providerType === "aws" && ( + } + /> + )} + {providerType === "azure" && ( + } + /> + )} + {providerType === "gcp" && ( + } + /> + )} + {providerType === "kubernetes" && ( + } + /> + )} + +
+ } + > + {isLoading ? <>Loading : Save} + +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-credentials/aws-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/aws-credentials-form.tsx new file mode 100644 index 0000000000..a024d138ff --- /dev/null +++ b/ui/components/providers/workflow/forms/via-credentials/aws-credentials-form.tsx @@ -0,0 +1,56 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { AWSCredentials } from "@/types"; + +export const AWScredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Credentials +
+
+ Please provide the information for your AWS credentials. +
+
+ + + + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-credentials/azure-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/azure-credentials-form.tsx new file mode 100644 index 0000000000..b78642b8d2 --- /dev/null +++ b/ui/components/providers/workflow/forms/via-credentials/azure-credentials-form.tsx @@ -0,0 +1,56 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { AzureCredentials } from "@/types"; + +export const AzureCredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Credentials +
+
+ Please provide the information for your Azure credentials. +
+
+ + + + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-credentials/gcp-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/gcp-credentials-form.tsx new file mode 100644 index 0000000000..784f173f49 --- /dev/null +++ b/ui/components/providers/workflow/forms/via-credentials/gcp-credentials-form.tsx @@ -0,0 +1,56 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { GCPCredentials } from "@/types"; + +export const GCPcredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Credentials +
+
+ Please provide the information for your GCP credentials. +
+
+ + + + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-credentials/index.ts b/ui/components/providers/workflow/forms/via-credentials/index.ts new file mode 100644 index 0000000000..d3198a1d0e --- /dev/null +++ b/ui/components/providers/workflow/forms/via-credentials/index.ts @@ -0,0 +1,4 @@ +export * from "./aws-credentials-form"; +export * from "./azure-credentials-form"; +export * from "./gcp-credentials-form"; +export * from "./k8s-credentials-form"; diff --git a/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx new file mode 100644 index 0000000000..0725d134eb --- /dev/null +++ b/ui/components/providers/workflow/forms/via-credentials/k8s-credentials-form.tsx @@ -0,0 +1,34 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { KubernetesCredentials } from "@/types"; + +export const KubernetesCredentialsForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect via Credentials +
+
+ Please provide the information for your Kubernetes credentials. +
+
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-role-form.tsx b/ui/components/providers/workflow/forms/via-role-form.tsx new file mode 100644 index 0000000000..39ba301447 --- /dev/null +++ b/ui/components/providers/workflow/forms/via-role-form.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { SaveIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { Control, useForm } from "react-hook-form"; +import * as z from "zod"; + +import { addCredentialsProvider } from "@/actions/providers/providers"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { + addCredentialsRoleFormSchema, + ApiError, + AWSCredentialsRole, +} from "@/types"; + +import { AWSCredentialsRoleForm } from "./via-role/aws-role-form"; + +export const ViaRoleForm = ({ + searchParams, +}: { + searchParams: { type: string; id: string }; +}) => { + const router = useRouter(); + const { toast } = useToast(); + + const providerType = searchParams.type; + const providerId = searchParams.id; + + const formSchema = addCredentialsRoleFormSchema(providerType); + type FormSchemaType = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId, + providerType, + ...(providerType === "aws" + ? { + role_arn: "", + aws_access_key_id: "", + aws_secret_access_key: "", + aws_session_token: "", + session_duration: 3600, + external_id: "", + role_session_name: "", + } + : {}), + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: FormSchemaType) => { + console.log("via ROLE form", values); + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => + value !== undefined && formData.append(key, String(value)), + ); + + const data = await addCredentialsProvider(formData); + + if (data?.errors && data.errors.length > 0) { + data.errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + switch (error.source.pointer) { + case "/data/attributes/secret/role_arn": + form.setError("role_arn" as keyof FormSchemaType, { + type: "server", + message: errorMessage, + }); + break; + + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + router.push( + `/providers/test-connection?type=${providerType}&id=${providerId}`, + ); + } + }; + + return ( +
+ + + + + {providerType === "aws" && ( + } + /> + )} + +
+ } + > + {isLoading ? <>Loading : Save} + +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-role/aws-role-form.tsx b/ui/components/providers/workflow/forms/via-role/aws-role-form.tsx new file mode 100644 index 0000000000..eb7f5d3a43 --- /dev/null +++ b/ui/components/providers/workflow/forms/via-role/aws-role-form.tsx @@ -0,0 +1,105 @@ +import { Control } from "react-hook-form"; + +import { CustomInput } from "@/components/ui/custom"; +import { AWSCredentialsRole } from "@/types"; + +export const AWSCredentialsRoleForm = ({ + control, +}: { + control: Control; +}) => { + return ( + <> +
+
+ Connect assuming IAM Role +
+
+ Please provide the information for your AWS credentials. +
+
+ + + Optional fields + + + + + +
+ + +
+ + ); +}; diff --git a/ui/components/providers/workflow/forms/via-role/index.ts b/ui/components/providers/workflow/forms/via-role/index.ts new file mode 100644 index 0000000000..763c90c0c0 --- /dev/null +++ b/ui/components/providers/workflow/forms/via-role/index.ts @@ -0,0 +1 @@ +export * from "./aws-role-form"; diff --git a/ui/components/providers/workflow/index.ts b/ui/components/providers/workflow/index.ts new file mode 100644 index 0000000000..fca9e92617 --- /dev/null +++ b/ui/components/providers/workflow/index.ts @@ -0,0 +1,2 @@ +export * from "./vertical-steps"; +export * from "./workflow-add-provider"; diff --git a/ui/components/providers/workflow/vertical-steps.tsx b/ui/components/providers/workflow/vertical-steps.tsx new file mode 100644 index 0000000000..74eaa32747 --- /dev/null +++ b/ui/components/providers/workflow/vertical-steps.tsx @@ -0,0 +1,291 @@ +"use client"; + +import type { ButtonProps } from "@nextui-org/react"; +import { cn } from "@nextui-org/react"; +import { useControlledState } from "@react-stately/utils"; +import { domAnimation, LazyMotion, m } from "framer-motion"; +import type { ComponentProps } from "react"; +import React from "react"; + +export type VerticalStepProps = { + className?: string; + description?: React.ReactNode; + title?: React.ReactNode; +}; + +export interface VerticalStepsProps + extends React.HTMLAttributes { + /** + * An array of steps. + * + * @default [] + */ + steps?: VerticalStepProps[]; + /** + * The color of the steps. + * + * @default "primary" + */ + color?: ButtonProps["color"]; + /** + * The current step index. + */ + currentStep?: number; + /** + * The default step index. + * + * @default 0 + */ + defaultStep?: number; + /** + * Whether to hide the progress bars. + * + * @default false + */ + hideProgressBars?: boolean; + /** + * The custom class for the steps wrapper. + */ + className?: string; + /** + * The custom class for the step. + */ + stepClassName?: string; + /** + * Callback function when the step index changes. + */ + onStepChange?: (stepIndex: number) => void; +} + +function CheckIcon(props: ComponentProps<"svg">) { + return ( + + + + ); +} + +export const VerticalSteps = React.forwardRef< + HTMLButtonElement, + VerticalStepsProps +>( + ( + { + color = "primary", + steps = [], + defaultStep = 0, + onStepChange, + currentStep: currentStepProp, + hideProgressBars = false, + stepClassName, + className, + ...props + }, + ref, + ) => { + const [currentStep, setCurrentStep] = useControlledState( + currentStepProp, + defaultStep, + onStepChange, + ); + + const colors = React.useMemo(() => { + let userColor; + let fgColor; + + const colorsVars = [ + "[--active-fg-color:var(--step-fg-color)]", + "[--active-border-color:var(--step-color)]", + "[--active-color:var(--step-color)]", + "[--complete-background-color:var(--step-color)]", + "[--complete-border-color:var(--step-color)]", + "[--inactive-border-color:hsl(var(--nextui-default-300))]", + "[--inactive-color:hsl(var(--nextui-default-300))]", + ]; + + switch (color) { + case "primary": + userColor = "[--step-color:hsl(var(--nextui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-primary-foreground))]"; + break; + case "secondary": + userColor = "[--step-color:hsl(var(--nextui-secondary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-secondary-foreground))]"; + break; + case "success": + userColor = "[--step-color:hsl(var(--nextui-success))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-success-foreground))]"; + break; + case "warning": + userColor = "[--step-color:hsl(var(--nextui-warning))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-warning-foreground))]"; + break; + case "danger": + userColor = "[--step-color:hsl(var(--nextui-error))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-error-foreground))]"; + break; + case "default": + userColor = "[--step-color:hsl(var(--nextui-default))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-default-foreground))]"; + break; + default: + userColor = "[--step-color:hsl(var(--nextui-primary))]"; + fgColor = "[--step-fg-color:hsl(var(--nextui-primary-foreground))]"; + break; + } + + if (!className?.includes("--step-fg-color")) colorsVars.unshift(fgColor); + if (!className?.includes("--step-color")) colorsVars.unshift(userColor); + if (!className?.includes("--inactive-bar-color")) + colorsVars.push( + "[--inactive-bar-color:hsl(var(--nextui-default-300))]", + ); + + return colorsVars; + }, [color, className]); + + return ( + + ); + }, +); + +VerticalSteps.displayName = "VerticalSteps"; diff --git a/ui/components/providers/workflow/workflow-add-provider.tsx b/ui/components/providers/workflow/workflow-add-provider.tsx new file mode 100644 index 0000000000..1f2e631f06 --- /dev/null +++ b/ui/components/providers/workflow/workflow-add-provider.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Progress, Spacer } from "@nextui-org/react"; +import { usePathname } from "next/navigation"; +import React from "react"; + +import { VerticalSteps } from "./vertical-steps"; + +const steps = [ + { + title: "Add your cloud account", + description: + "Select the cloud provider of the account you want to connect and choose whether to use IAM role or credentials for access.", + href: "/providers/connect-account", + }, + { + title: "Add credentials to your cloud account", + description: "Add the credentials needed to connect to your cloud account.", + href: "/providers/add-credentials", + }, + { + title: "Test connection", + description: + "Test your connection to verify that the credentials provided are valid for accessing your cloud account.", + href: "/providers/test-connection", + }, + { + title: "Launch scan", + description: + "Launch the scan now or schedule it for a later date and time.", + href: "/providers/launch-scan", + }, +]; + +export const WorkflowAddProvider = () => { + const pathname = usePathname(); + + // Calculate current step based on pathname + const currentStepIndex = steps.findIndex((step) => + pathname.endsWith(step.href), + ); + const currentStep = currentStepIndex === -1 ? 0 : currentStepIndex; + + return ( +
+

+ Add a cloud account +

+

+ Follow the steps to configure your cloud account. This allows you to + launch the first scan when the process is complete. +

+ + + +
+ ); +}; diff --git a/ui/components/scans/button-refresh-data.tsx b/ui/components/scans/button-refresh-data.tsx new file mode 100644 index 0000000000..41d05e3981 --- /dev/null +++ b/ui/components/scans/button-refresh-data.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { RefreshCcwIcon } from "lucide-react"; +import { useTransition } from "react"; + +import { CustomButton } from "../ui/custom"; + +export const ButtonRefreshData = ({ + onPress, +}: { + onPress: () => Promise; +}) => { + const [isPending, startTransition] = useTransition(); + + return ( + } + isLoading={isPending} + onPress={() => { + startTransition(async () => { + await onPress(); + }); + }} + /> + ); +}; diff --git a/ui/components/scans/forms/edit-scan-form.tsx b/ui/components/scans/forms/edit-scan-form.tsx new file mode 100644 index 0000000000..62c6e338b6 --- /dev/null +++ b/ui/components/scans/forms/edit-scan-form.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Dispatch, SetStateAction } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { updateScan } from "@/actions/scans"; +import { SaveIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { editScanFormSchema } from "@/types"; + +export const EditScanForm = ({ + scanId, + scanName, + setIsOpen, +}: { + scanId: string; + scanName: string; + setIsOpen: Dispatch>; +}) => { + const formSchema = editScanFormSchema(scanName); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + scanId: scanId, + scanName: scanName, + }, + }); + + const { toast } = useToast(); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: z.infer) => { + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + + const data = await updateScan(formData); + + if (data?.errors && data.errors.length > 0) { + const error = data.errors[0]; + const errorMessage = `${error.detail}`; + // show error + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } else { + toast({ + title: "Success!", + description: "The scan was updated successfully.", + }); + setIsOpen(false); // Close the modal on success + } + }; + + return ( +
+ +
+ Current name: {scanName} +
+
+ +
+ + +
+ setIsOpen(false)} + isDisabled={isLoading} + > + Cancel + + + } + > + {isLoading ? <>Loading : Save} + +
+
+ + ); +}; diff --git a/ui/components/scans/forms/index.ts b/ui/components/scans/forms/index.ts new file mode 100644 index 0000000000..4acd302cb9 --- /dev/null +++ b/ui/components/scans/forms/index.ts @@ -0,0 +1,2 @@ +export * from "./edit-scan-form"; +export * from "./schedule-form"; diff --git a/ui/components/scans/forms/schedule-form.tsx b/ui/components/scans/forms/schedule-form.tsx new file mode 100644 index 0000000000..2b75a3c263 --- /dev/null +++ b/ui/components/scans/forms/schedule-form.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Dispatch, SetStateAction } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { updateProvider } from "@/actions/providers"; +import { SaveIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { scheduleScanFormSchema } from "@/types"; + +export const ScheduleForm = ({ + providerId, + scheduleDate, + setIsOpen, +}: { + providerId: string; + scheduleDate: string; + setIsOpen: Dispatch>; +}) => { + const formSchema = scheduleScanFormSchema(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId: providerId, + scheduleDate: scheduleDate, + }, + }); + + const { toast } = useToast(); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: z.infer) => { + const formData = new FormData(); + + Object.entries(values).forEach( + ([key, value]) => value !== undefined && formData.append(key, value), + ); + const data = await updateProvider(formData); + + if (data?.errors && data.errors.length > 0) { + const error = data.errors[0]; + const errorMessage = `${error.detail}`; + // show error + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } else { + toast({ + title: "Success!", + description: "The scan was scheduled successfully.", + }); + setIsOpen(false); // Close the modal on success + } + }; + + return ( +
+ + + + +
+ setIsOpen(false)} + isDisabled={isLoading} + > + Cancel + + + } + isDisabled={true} + > + {isLoading ? <>Loading : Schedule} + +
+ + + ); +}; diff --git a/ui/components/scans/index.ts b/ui/components/scans/index.ts new file mode 100644 index 0000000000..7d435fad43 --- /dev/null +++ b/ui/components/scans/index.ts @@ -0,0 +1 @@ +export * from "./button-refresh-data"; diff --git a/ui/components/scans/launch-workflow/index.ts b/ui/components/scans/launch-workflow/index.ts new file mode 100644 index 0000000000..4f8a794d33 --- /dev/null +++ b/ui/components/scans/launch-workflow/index.ts @@ -0,0 +1,2 @@ +export * from "./launch-scan-workflow-form"; +export * from "./select-scan-provider"; diff --git a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx new file mode 100644 index 0000000000..a19acfea42 --- /dev/null +++ b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx @@ -0,0 +1,194 @@ +"use client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { AnimatePresence, motion } from "framer-motion"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { scanOnDemand } from "@/actions/scans"; +import { RocketIcon, ScheduleIcon } from "@/components/icons"; +import { CustomButton, CustomInput } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { toast } from "@/components/ui/toast"; +import { onDemandScanFormSchema } from "@/types"; + +import { SelectScanProvider } from "./select-scan-provider"; + +type ProviderInfo = { + providerId: string; + alias: string; + providerType: string; + uid: string; + connected: boolean; +}; + +export const LaunchScanWorkflow = ({ + providers, +}: { + providers: ProviderInfo[]; +}) => { + const formSchema = onDemandScanFormSchema(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + providerId: "", + scanName: "", + scannerArgs: undefined, + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmitClient = async (values: z.infer) => { + const formValues = { ...values }; + + const formData = new FormData(); + + // Loop through form values and add to formData + Object.entries(formValues).forEach( + ([key, value]) => + value !== undefined && + formData.append( + key, + typeof value === "object" ? JSON.stringify(value) : value, + ), + ); + + const data = await scanOnDemand(formData); + + if (data?.errors && data.errors.length > 0) { + const error = data.errors[0]; + const errorMessage = `${error.detail}`; + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } else { + toast({ + title: "Success!", + description: "The scan was launched successfully.", + }); + // Reset form after successful submission + form.reset(); + } + }; + + return ( +
+ +
+
+
+

+ Launch Scan +

+ +
+ + + {form.watch("providerId") && ( + + + + )} + +
+ +
+ + {form.watch("providerId") && ( + + + + )} + +
+ + + {form.watch("providerId") && ( + + form.reset()} + className="w-fit border-gray-200 bg-transparent" + ariaLabel="Clear form" + variant="bordered" + size="lg" + radius="lg" + > + Cancel + + } + isDisabled={true} + > + {isLoading ? <>Loading : Schedule} + + + } + > + {isLoading ? <>Loading : Start now} + + + )} + +
+
+ + ); +}; diff --git a/ui/components/scans/launch-workflow/select-scan-provider.tsx b/ui/components/scans/launch-workflow/select-scan-provider.tsx new file mode 100644 index 0000000000..c1805f488b --- /dev/null +++ b/ui/components/scans/launch-workflow/select-scan-provider.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { Select, SelectItem } from "@nextui-org/react"; +import { Control, FieldPath, FieldValues } from "react-hook-form"; + +import { AWSProviderBadge } from "@/components/icons/providers-badge/AWSProviderBadge"; +import { AzureProviderBadge } from "@/components/icons/providers-badge/AzureProviderBadge"; +import { GCPProviderBadge } from "@/components/icons/providers-badge/GCPProviderBadge"; +import { KS8ProviderBadge } from "@/components/icons/providers-badge/KS8ProviderBadge"; +import { FormControl, FormField, FormMessage } from "@/components/ui/form"; + +interface SelectScanProviderProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> { + providers: { + providerId: string; + alias: string; + providerType: string; + uid: string; + connected: boolean; + }[]; + control: Control; + name: TName; +} + +export const SelectScanProvider = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + providers, + control, + name, +}: SelectScanProviderProps) => { + const renderBadge = (providerType: string) => { + switch (providerType) { + case "aws": + return ; + case "azure": + return ; + case "gcp": + return ; + case "kubernetes": + return ; + default: + return null; + } + }; + + return ( + ( + <> + + + + + + )} + /> + ); +}; diff --git a/ui/components/scans/table/index.ts b/ui/components/scans/table/index.ts new file mode 100644 index 0000000000..0a8aee6de4 --- /dev/null +++ b/ui/components/scans/table/index.ts @@ -0,0 +1,2 @@ +export * from "./scan-detail"; +export * from "./skeleton-table-scans"; diff --git a/ui/components/scans/table/scan-detail.tsx b/ui/components/scans/table/scan-detail.tsx new file mode 100644 index 0000000000..d6d9fedf7b --- /dev/null +++ b/ui/components/scans/table/scan-detail.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { Card, CardBody, CardHeader, Divider } from "@nextui-org/react"; + +import { DateWithTime, SnippetId } from "@/components/ui/entities"; +import { StatusBadge } from "@/components/ui/table/status-badge"; +import { ScanProps, TaskDetails } from "@/types"; + +interface ScanDetailsProps { + scanDetails: ScanProps & { + taskDetails?: TaskDetails; + }; +} + +export const ScanDetail = ({ scanDetails }: ScanDetailsProps) => { + const scanOnDemand = scanDetails.attributes; + const taskDetails = scanDetails.taskDetails; + + return ( +
+ {/* Header */} +
+

+ Scan Details +

+ +
+ + + + {/* Details Section */} +
+
+
+ + } + /> + + + + +
+
+ + ) : ( + "Not Started" + ) + } + /> + + ) : ( + "Not Completed" + ) + } + /> + + ) : ( + "Not Scheduled" + ) + } + /> + + } + /> + + ) : ( + "N/A" + ) + } + /> +
+
+
+ + {/* Scan Arguments Section */} + + +

+ Scan Arguments +

+
+ + +
+ + Checks + + + {(scanOnDemand.scanner_args as any)?.checks_to_execute?.join( + ", ", + ) || "N/A"} + +
+
+
+ + {/* Task Details Section */} + {taskDetails && ( + + +

+ State Details +

+
+ + +
+ + + {taskDetails.attributes.result && ( + <> + + {taskDetails.attributes.result.exc_message && ( + + )} + + )} + +
+
+
+ )} +
+ ); +}; + +const DateItem = ({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) => ( +
+

+ {label}: +

+

{value}

+
+); + +const DetailItem = ({ + label, + value, +}: { + label: string; + value: React.ReactNode; +}) => ( +
+

+ {label}: +

+

{value}

+
+); diff --git a/ui/components/scans/table/scans/column-get-scans.tsx b/ui/components/scans/table/scans/column-get-scans.tsx new file mode 100644 index 0000000000..ed235d2965 --- /dev/null +++ b/ui/components/scans/table/scans/column-get-scans.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { useSearchParams } from "next/navigation"; + +import { InfoIcon } from "@/components/icons"; +import { DateWithTime, SnippetId } from "@/components/ui/entities"; +import { TriggerSheet } from "@/components/ui/sheet"; +import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table"; +import { ScanProps } from "@/types"; + +import { DataTableRowActions } from "./data-table-row-actions"; +import { DataTableRowDetails } from "./data-table-row-details"; + +const getScanData = (row: { original: ScanProps }) => { + return row.original; +}; + +export const ColumnGetScans: ColumnDef[] = [ + { + accessorKey: "started_at", + header: () =>

Started at

, + cell: ({ row }) => { + const { + attributes: { started_at }, + } = getScanData(row); + + return ; + }, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { state }, + } = getScanData(row); + return ( + + ); + }, + }, + // { + // accessorKey: "scanner_args", + // header: "Scanner Args", + // cell: ({ row }) => { + // const { + // attributes: { scanner_args }, + // } = getScanData(row); + // return

{scanner_args?.only_logs}

; + // }, + // }, + { + accessorKey: "resources", + header: "Resources", + cell: ({ row }) => { + const { + attributes: { unique_resource_count }, + } = getScanData(row); + return

{unique_resource_count}

; + }, + }, + { + accessorKey: "scheduled_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { scheduled_at }, + } = getScanData(row); + return ; + }, + }, + + { + accessorKey: "completed_at", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { completed_at }, + } = getScanData(row); + return ; + }, + }, + { + accessorKey: "trigger", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { trigger }, + } = getScanData(row); + return

{trigger}

; + }, + }, + + { + accessorKey: "id", + header: () => ID, + cell: ({ row }) => { + return ; + }, + }, + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const { + attributes: { name }, + } = getScanData(row); + + if (!name || name.length === 0) { + return -; + } + + return {name}; + }, + }, + { + id: "moreInfo", + header: "Details", + cell: ({ row }) => { + const searchParams = useSearchParams(); + const scanId = searchParams.get("scanId"); + const isOpen = scanId === row.original.id; + + return ( + } + title="Scan Details" + description="View the scan details" + defaultOpen={isOpen} + > + + + ); + }, + }, + + { + id: "actions", + cell: ({ row }) => { + return ; + }, + }, +]; diff --git a/ui/components/scans/table/scans/data-table-row-actions.tsx b/ui/components/scans/table/scans/data-table-row-actions.tsx new file mode 100644 index 0000000000..3909fc4533 --- /dev/null +++ b/ui/components/scans/table/scans/data-table-row-actions.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { + Button, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownSection, + DropdownTrigger, +} from "@nextui-org/react"; +import { + // DeleteDocumentBulkIcon, + EditDocumentBulkIcon, +} from "@nextui-org/shared-icons"; +import { Row } from "@tanstack/react-table"; +// import clsx from "clsx"; +import { useState } from "react"; + +import { VerticalDotsIcon } from "@/components/icons"; +import { CustomAlertModal } from "@/components/ui/custom"; + +import { EditScanForm } from "../../forms"; + +interface DataTableRowActionsProps { + row: Row; +} +const iconClasses = + "text-2xl text-default-500 pointer-events-none flex-shrink-0"; + +export function DataTableRowActions({ + row, +}: DataTableRowActionsProps) { + const [isEditOpen, setIsEditOpen] = useState(false); + const scanId = (row.original as { id: string }).id; + const scanName = (row.original as any).attributes?.name; + return ( + <> + + + + +
+ + + + + + + } + onClick={() => setIsEditOpen(true)} + > + Edit Scan + + + + +
+ + ); +} diff --git a/ui/components/scans/table/scans/data-table-row-details.tsx b/ui/components/scans/table/scans/data-table-row-details.tsx new file mode 100644 index 0000000000..92adbbe18c --- /dev/null +++ b/ui/components/scans/table/scans/data-table-row-details.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { getScan } from "@/actions/scans"; +import { getTask } from "@/actions/task"; +import { ScanDetail, SkeletonTableScans } from "@/components/scans/table"; +import { checkTaskStatus } from "@/lib"; +import { ScanProps } from "@/types"; + +export const DataTableRowDetails = ({ entityId }: { entityId: string }) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const [scanDetails, setScanDetails] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Add scanId to URL + const params = new URLSearchParams(searchParams.toString()); + params.set("scanId", entityId); + router.push(`?${params.toString()}`, { scroll: false }); + + // Cleanup function: remove scanId from URL when component unmounts + return () => { + const newParams = new URLSearchParams(searchParams.toString()); + newParams.delete("scanId"); + router.push(`?${newParams.toString()}`, { scroll: false }); + }; + }, [entityId, router, searchParams]); + + useEffect(() => { + const fetchScanDetails = async () => { + try { + const result = await getScan(entityId); + + const taskId = result.data.relationships.task?.data?.id; + + if (taskId) { + const taskResult = await checkTaskStatus(taskId); + + if (taskResult.completed !== undefined) { + const task = await getTask(taskId); + setScanDetails({ + ...result.data, + taskDetails: task.data, + }); + } + } else { + setScanDetails(result.data); + } + } catch (error) { + console.error("Error in fetchScanDetails:", error); + } finally { + setIsLoading(false); + } + }; + + fetchScanDetails(); + }, [entityId]); + + if (isLoading) { + return ; + } + + if (!scanDetails) { + return
No scan details available
; + } + + return ; +}; diff --git a/ui/components/scans/table/scans/index.ts b/ui/components/scans/table/scans/index.ts new file mode 100644 index 0000000000..2567c0098c --- /dev/null +++ b/ui/components/scans/table/scans/index.ts @@ -0,0 +1,3 @@ +export * from "./column-get-scans"; +export * from "./data-table-row-actions"; +export * from "./data-table-row-details"; diff --git a/ui/components/scans/table/skeleton-table-scans.tsx b/ui/components/scans/table/skeleton-table-scans.tsx new file mode 100644 index 0000000000..a0c4da0cf4 --- /dev/null +++ b/ui/components/scans/table/skeleton-table-scans.tsx @@ -0,0 +1,65 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonTableScans = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(3)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/services/ServiceCard.tsx b/ui/components/services/ServiceCard.tsx new file mode 100644 index 0000000000..07ac6c3591 --- /dev/null +++ b/ui/components/services/ServiceCard.tsx @@ -0,0 +1,47 @@ +import { Card, CardBody, Chip } from "@nextui-org/react"; + +import { getAWSIcon, NotificationIcon, SuccessIcon } from "../icons"; + +interface CardServiceProps { + fidingsFailed: number; + serviceAlias: string; +} +export const ServiceCard: React.FC = ({ + fidingsFailed, + serviceAlias, +}) => { + return ( + + +
+ {getAWSIcon(serviceAlias)} +
+

{serviceAlias}

+ + {fidingsFailed > 0 + ? `${fidingsFailed} Failed Findings` + : "No failed findings"} + +
+
+ + 0 ? ( + + ) : ( + + ) + } + color={fidingsFailed > 0 ? "danger" : "success"} + radius="full" + size="md" + > + {fidingsFailed > 0 ? fidingsFailed : ""} + +
+
+ ); +}; diff --git a/ui/components/services/ServiceSkeletonGrid.tsx b/ui/components/services/ServiceSkeletonGrid.tsx new file mode 100644 index 0000000000..2e43ced7a0 --- /dev/null +++ b/ui/components/services/ServiceSkeletonGrid.tsx @@ -0,0 +1,18 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const ServiceSkeletonGrid = () => { + return ( + +
+ {[...Array(25)].map((_, index) => ( +
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/services/index.ts b/ui/components/services/index.ts new file mode 100644 index 0000000000..d75bdbfb98 --- /dev/null +++ b/ui/components/services/index.ts @@ -0,0 +1,2 @@ +export * from "./ServiceCard"; +export * from "./ServiceSkeletonGrid"; diff --git a/ui/components/ui/action-card/ActionCard.tsx b/ui/components/ui/action-card/ActionCard.tsx new file mode 100644 index 0000000000..ea7e8295ce --- /dev/null +++ b/ui/components/ui/action-card/ActionCard.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { Icon } from "@iconify/react"; +import type { CardProps } from "@nextui-org/react"; +import { Card, CardBody } from "@nextui-org/react"; +import React from "react"; + +import { cn } from "@/lib"; + +export type ActionCardProps = CardProps & { + icon: string; + title: string; + color?: "success" | "secondary" | "warning" | "fail"; + description: string; +}; + +export const ActionCard = React.forwardRef( + ({ color, title, icon, description, children, className, ...props }, ref) => { + const colors = React.useMemo(() => { + switch (color) { + case "success": + return { + card: "border-system-success-medium", + iconWrapper: "bg-system-success-lighter border-system-success", + icon: "text-system-success", + }; + case "secondary": + return { + card: "border-secondary-100", + iconWrapper: "bg-secondary-50 border-secondary-100", + icon: "text-secondary", + }; + case "warning": + return { + card: "border-warning-500", + iconWrapper: "bg-warning-50 border-warning-100", + icon: "text-warning-600", + }; + case "fail": + return { + card: "border-danger-300", + iconWrapper: "bg-danger-50 border-danger-100", + icon: "text-danger", + }; + + default: + return { + card: "border-default-200", + iconWrapper: "bg-default-50 border-default-100", + icon: "text-default-500", + }; + } + }, [color]); + + return ( + + +
+ +
+
+

{title}

+

+ {description || children} +

+
+
+
+ ); + }, +); + +ActionCard.displayName = "ActionCard"; diff --git a/ui/components/ui/alert-dialog/AlertDialog.tsx b/ui/components/ui/alert-dialog/AlertDialog.tsx new file mode 100644 index 0000000000..26b8aec946 --- /dev/null +++ b/ui/components/ui/alert-dialog/AlertDialog.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { cn } from "@nextui-org/react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import * as React from "react"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/ui/components/ui/alert/Alert.tsx b/ui/components/ui/alert/Alert.tsx new file mode 100644 index 0000000000..49afe61c80 --- /dev/null +++ b/ui/components/ui/alert/Alert.tsx @@ -0,0 +1,61 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border border-slate-200 px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-slate-950 [&>svg~*]:pl-7 dark:border-slate-800 dark:[&>svg]:text-slate-50", + { + variants: { + variant: { + default: "bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50", + destructive: + "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => ( +
+ {children} +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertDescription, AlertTitle }; diff --git a/ui/components/ui/chart/Chart.tsx b/ui/components/ui/chart/Chart.tsx new file mode 100644 index 0000000000..9d096726f5 --- /dev/null +++ b/ui/components/ui/chart/Chart.tsx @@ -0,0 +1,371 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +// import { +// NameType, +// Payload, +// ValueType, +// } from "recharts/types/component/DefaultTooltipContent"; +import { cn } from "@/lib/utils"; + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"]; + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + + {children} + +
+
+ ); +}); +ChartContainer.displayName = "Chart"; + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color, + ); + + if (!colorConfig.length) { + return null; + } + + return ( +