diff --git a/actions/auth.ts b/actions/auth.ts deleted file mode 100644 index 2cdf70bfd2..0000000000 --- a/actions/auth.ts +++ /dev/null @@ -1,55 +0,0 @@ -"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 defaultValues: z.infer = { - email: "", - password: "", -}; - -export async function authenticate( - prevState: unknown, - formData: z.infer, -) { - try { - await new Promise((resolve) => setTimeout(resolve, 2000)); - 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 async function logOut() { - await signOut(); -} diff --git a/actions/auth/auth.ts b/actions/auth/auth.ts new file mode 100644 index 0000000000..394bdf4f3e --- /dev/null +++ b/actions/auth/auth.ts @@ -0,0 +1,166 @@ +"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`); + + const bodyData = { + data: { + type: "User", + attributes: { + name: formData.name, + company_name: formData.company, + 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), + }); + + 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: "Token", + 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/actions/auth/index.ts b/actions/auth/index.ts new file mode 100644 index 0000000000..97ccf76494 --- /dev/null +++ b/actions/auth/index.ts @@ -0,0 +1 @@ +export * from "./auth"; diff --git a/actions/index.ts b/actions/providers/index.ts similarity index 54% rename from actions/index.ts rename to actions/providers/index.ts index 0085735bb3..5532383f5f 100644 --- a/actions/index.ts +++ b/actions/providers/index.ts @@ -1,2 +1 @@ -export * from "./auth"; export * from "./providers"; diff --git a/actions/providers.ts b/actions/providers/providers.ts similarity index 80% rename from actions/providers.ts rename to actions/providers/providers.ts index a35bbb1d98..200bc79bd6 100644 --- a/actions/providers.ts +++ b/actions/providers/providers.ts @@ -6,10 +6,6 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth.config"; import { parseStringify } from "@/lib"; -// Credentials for basic auth -const username = process.env.API_USERNAME; -const password = process.env.API_PASSWORD; - export const getProviders = async ({ page = 1, query = "", @@ -17,12 +13,11 @@ export const getProviders = async ({ filters = {}, }) => { const session = await auth(); - const tenantId = session?.user.tenantId; if (isNaN(Number(page)) || page < 1) redirect("/providers"); const keyServer = process.env.API_BASE_URL; - const url = new URL(`${keyServer}/providers?sort=-inserted_at`); + const url = new URL(`${keyServer}/providers`); if (page) url.searchParams.append("page[number]", page.toString()); if (query) url.searchParams.append("filter[search]", query); @@ -38,8 +33,8 @@ export const getProviders = async ({ try { const providers = await fetch(url.toString(), { headers: { - "X-Tenant-ID": `${tenantId}`, - Authorization: "Basic " + btoa(`${username}:${password}`), + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, }, }); const data = await providers.json(); @@ -54,7 +49,6 @@ export const getProviders = async ({ export const getProvider = async (formData: FormData) => { const session = await auth(); - const tenantId = session?.user.tenantId; const providerId = formData.get("id"); const keyServer = process.env.API_BASE_URL; @@ -63,8 +57,8 @@ export const getProvider = async (formData: FormData) => { try { const providers = await fetch(url.toString(), { headers: { - "X-Tenant-ID": `${tenantId}`, - Authorization: "Basic " + btoa(`${username}:${password}`), + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, }, }); const data = await providers.json(); @@ -81,7 +75,6 @@ export const updateProvider = async (formData: FormData) => { const session = await auth(); const keyServer = process.env.API_BASE_URL; - const tenantId = session?.user.tenantId; const providerId = formData.get("providerId"); const providerAlias = formData.get("alias"); @@ -91,9 +84,9 @@ export const updateProvider = async (formData: FormData) => { const response = await fetch(url.toString(), { method: "PATCH", headers: { - "X-Tenant-ID": `${tenantId}`, - Authorization: "Basic " + btoa(`${username}:${password}`), "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, }, body: JSON.stringify({ data: { @@ -120,8 +113,6 @@ export const addProvider = async (formData: FormData) => { const session = await auth(); const keyServer = process.env.API_BASE_URL; - const tenantId = session?.user.tenantId; - const providerType = formData.get("providerType"); const providerId = formData.get("providerId"); const providerAlias = formData.get("providerAlias"); @@ -132,9 +123,9 @@ export const addProvider = async (formData: FormData) => { const response = await fetch(url.toString(), { method: "POST", headers: { - "X-Tenant-ID": `${tenantId}`, - Authorization: "Basic " + btoa(`${username}:${password}`), "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, }, body: JSON.stringify({ data: { @@ -159,9 +150,8 @@ export const addProvider = async (formData: FormData) => { }; export const checkConnectionProvider = async (formData: FormData) => { - const session = await auth(); + // const session = await auth(); const keyServer = process.env.API_BASE_URL; - const tenantId = session?.user.tenantId; const providerId = formData.get("id"); @@ -171,8 +161,7 @@ export const checkConnectionProvider = async (formData: FormData) => { const response = await fetch(url.toString(), { method: "POST", headers: { - "X-Tenant-ID": `${tenantId}`, - Authorization: "Basic " + btoa(`${username}:${password}`), + Accept: "application/vnd.api+json", }, }); const data = await response.json(); @@ -188,7 +177,6 @@ export const checkConnectionProvider = async (formData: FormData) => { export const deleteProvider = async (formData: FormData) => { const session = await auth(); const keyServer = process.env.API_BASE_URL; - const tenantId = session?.user.tenantId; const providerId = formData.get("id"); const url = new URL(`${keyServer}/providers/${providerId}`); @@ -197,8 +185,8 @@ export const deleteProvider = async (formData: FormData) => { const response = await fetch(url.toString(), { method: "DELETE", headers: { - "X-Tenant-ID": `${tenantId}`, - Authorization: "Basic " + btoa(`${username}:${password}`), + Accept: "application/vnd.api+json", + Authorization: `Bearer ${session?.accessToken}`, }, }); const data = await response.json(); @@ -211,7 +199,7 @@ export const deleteProvider = async (formData: FormData) => { } }; -export const getErrorMessage = (error: unknown): string => { +export const getErrorMessage = async (error: unknown): Promise => { let message: string; if (error instanceof Error) { diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index f012c3f358..b5bd978029 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -1,6 +1,4 @@ -import React from "react"; - -import { AuthForm } from "@/components/auth"; +import { AuthForm } from "@/components/auth/oss"; const SignIn = () => { return ; diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx index dfe04cadeb..8e525507b8 100644 --- a/app/(auth)/sign-up/page.tsx +++ b/app/(auth)/sign-up/page.tsx @@ -1,6 +1,4 @@ -import React from "react"; - -import { AuthForm } from "@/components/auth"; +import { AuthForm } from "@/components/auth/oss"; const SignUp = () => { return ; diff --git a/app/(prowler)/profile/page.tsx b/app/(prowler)/profile/page.tsx index 76c72a8800..0cfb4f8ab2 100644 --- a/app/(prowler)/profile/page.tsx +++ b/app/(prowler)/profile/page.tsx @@ -2,6 +2,7 @@ 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"; @@ -13,12 +14,17 @@ export default async function 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/app/(prowler)/providers/page.tsx b/app/(prowler)/providers/page.tsx index a10cd9a618..b999671859 100644 --- a/app/(prowler)/providers/page.tsx +++ b/app/(prowler)/providers/page.tsx @@ -1,7 +1,7 @@ import { Spacer } from "@nextui-org/react"; import { Suspense } from "react"; -import { getProviders } from "@/actions"; +import { getProviders } from "@/actions/providers"; import { FilterControls, filtersProviders } from "@/components/filters"; import { AddProvider } from "@/components/providers"; import { @@ -24,7 +24,7 @@ export default async function Providers({
- + @@ -43,7 +43,7 @@ const SSRDataTable = async ({ searchParams: SearchParamsProps; }) => { const page = parseInt(searchParams.page?.toString() || "1", 10); - const sort = searchParams.sort?.toString() || ""; + const sort = searchParams.sort?.toString() || "-inserted_at"; // Extract all filter parameters const filters = Object.fromEntries( diff --git a/app/(prowler)/users/page.tsx b/app/(prowler)/users/page.tsx index 0a2f240f57..34a887589f 100644 --- a/app/(prowler)/users/page.tsx +++ b/app/(prowler)/users/page.tsx @@ -3,7 +3,7 @@ import { redirect } from "next/navigation"; import { Suspense } from "react"; import { getUsers } from "@/actions/users"; -import { auth } from "@/auth.config"; +// import { auth } from "@/auth.config"; import { Header } from "@/components/ui"; import { AddUserModal, @@ -18,11 +18,6 @@ export default async function Users({ }: { searchParams: SearchParamsProps; }) { - const session = await auth(); - if (session?.user?.role !== "admin") { - redirect("/"); - } - const searchParamsKey = JSON.stringify(searchParams || {}); return ( diff --git a/app/api/providers/route.ts b/app/api/providers/route.ts deleted file mode 100644 index ef9b5995e2..0000000000 --- a/app/api/providers/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NextResponse } from "next/server"; - -import data from "../../../dataProviders.json"; - -export async function GET() { - // Simulate fetching data with a delay - await new Promise((resolve) => setTimeout(resolve, 2000)); - - return NextResponse.json({ providers: data }); -} diff --git a/auth.config.ts b/auth.config.ts index 55a81edcca..4639f8bf37 100644 --- a/auth.config.ts +++ b/auth.config.ts @@ -1,61 +1,68 @@ -import bcryptjs from "bcryptjs"; -import NextAuth, { type NextAuthConfig } from "next-auth"; +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 { userMockData } from "./lib"; +import { getToken, getUserByMe } from "./actions/auth"; -async function getUser(email: string, password: string): Promise { - // Check if the user exists in the userMockData array. - const user = userMockData.find((user) => user.email === email); - if (!user) return null; - - if (!bcryptjs.compareSync(password, user.password)) return null; - - return { - id: user.id, - tenantId: user.tenantId, - name: user.name, - companyName: user.companyName, - email: user.email, - role: user.role, - image: user.image, - }; +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: "TokenRefresh", + 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", }, - callbacks: { - authorized({ auth, request: { nextUrl } }) { - const isLoggedIn = !!auth?.user; - const isOnDashboard = nextUrl.pathname.startsWith("/"); - if (isOnDashboard) { - if (isLoggedIn) return true; - return false; // Redirect unauthenticated users to login page - } else if (isLoggedIn) { - return Response.redirect(new URL("/", nextUrl)); - } - return true; - }, - jwt({ token, user }) { - if (user) { - token.data = user; - } - return token; - }, - - session({ session, token }) { - session.user = token.data as any; - return session; - }, - }, providers: [ Credentials({ name: "credentials", @@ -67,21 +74,112 @@ export const authConfig = { const parsedCredentials = z .object({ email: z.string().email(), - password: z.string().min(6), + password: z.string().min(12), }) .safeParse(credentials); - if (!parsedCredentials.success) { - return null; - } - const { email, password } = parsedCredentials.data; + if (!parsedCredentials.success) return null; - const user = await getUser(email, password); - if (!user) return null; - return user; + 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/components/auth/AuthButton.tsx b/components/auth/AuthButton.tsx deleted file mode 100644 index 0eda81782a..0000000000 --- a/components/auth/AuthButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { Button, CircularProgress } from "@nextui-org/react"; -import React from "react"; -import { useFormStatus } from "react-dom"; - -export const AuthButton = ({ type }: { type: string }) => { - const { pending } = useFormStatus(); - - return ( - - ); -}; diff --git a/components/auth/AuthForm.tsx b/components/auth/AuthForm.tsx deleted file mode 100644 index b1bbcb866a..0000000000 --- a/components/auth/AuthForm.tsx +++ /dev/null @@ -1,252 +0,0 @@ -"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 { useEffect } from "react"; -import { useFormState } from "react-dom"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import { authenticate } from "@/actions"; -import { - Form, - FormControl, - FormField, - FormMessage, -} from "@/components/ui/form"; -import { authFormSchema } from "@/types"; - -import { NotificationIcon, ProwlerExtended } from "../icons"; -import { ThemeSwitch } from "../ThemeSwitch"; -import { CustomInput } from "../ui/custom"; -import { AuthButton } from "./AuthButton"; - -export const AuthForm = ({ type }: { type: string }) => { - const formSchema = authFormSchema(type); - const router = useRouter(); - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - email: "", - password: "", - }, - }); - - const [state, dispatch] = useFormState(authenticate, undefined); - - useEffect(() => { - if (state?.message === "Success") { - router.push("/"); - } - }, [state]); - - const onSubmit = async (data: z.infer) => { - // Do something with the form values - // this will be type-safe and validated - try { - // Sign-up logic will be here. - if (type === "sign-in") { - dispatch({ - email: data.email.toLowerCase(), - password: data.password, - }); - } - // if (type === "sign-up") { - // const newUser = await signUp(data); - // } - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - } - }; - - return ( -
- {/* Brand Logo and ThemeSwitch */} -
-
- - -
-
- - {/* Testimonial */} -
-

- - Open Cloud Security - -

-
- -
-

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

- -
- - {type === "sign-up" && ( - <> - - - - )} - - - - - {type === "sign-in" && ( -
- - Remember me - - - Forgot password? - -
- )} - {/* - {isConfirmVisible ? ( - - ) : ( - - )} - - } - label="Confirm Password" - name="confirmPassword" - placeholder="Confirm your password" - type={isConfirmVisible ? "text" : "password"} - variant="bordered" - /> */} - {type === "sign-up" && ( - ( - <> - - - field.onChange(e.target.checked ? "true" : "false") - } - > - I agree with the  - - Terms - -   and  - - Privacy Policy - - - - - - )} - /> - )} - - {state?.message === "Credentials error" && ( -
- -

Incorrect email or password

-
- )} - - - - - - {type === "sign-in" && ( - <> -
- -

OR

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

- Need to create an account?  - Sign Up -

- ) : ( -

- Already have an account?  - Log In -

- )} -
-
- ); -}; diff --git a/components/auth/index.ts b/components/auth/index.ts deleted file mode 100644 index 26161c473c..0000000000 --- a/components/auth/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./AuthButton"; -export * from "./AuthForm"; diff --git a/components/auth/oss/auth-form.tsx b/components/auth/oss/auth-form.tsx new file mode 100644 index 0000000000..451e043c0b --- /dev/null +++ b/components/auth/oss/auth-form.tsx @@ -0,0 +1,294 @@ +"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 { useEffect, useState } from "react"; +import { useFormState } from "react-dom"; +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 }: { type: string }) => { + const formSchema = authFormSchema(type); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: "", + password: "", + ...(type === "sign-up" && { + name: "", + company: "", + termsAndConditions: false, + confirmPassword: "", + }), + }, + }); + + const [state, dispatch] = useFormState(authenticate, undefined); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + useEffect(() => { + if (state?.message === "Success") { + router.push("/"); + } + }, [state]); + + const onSubmit = async (data: z.infer) => { + if (type === "sign-in") { + setIsLoading(true); + dispatch({ + email: data.email.toLowerCase(), + password: data.password, + }); + setIsLoading(false); + } + if (type === "sign-up") { + setIsLoading(true); + const newUser = await createNewUser(data); + setIsLoading(false); + + if (!newUser.errors) { + router.push("/sign-in"); + } + + if (newUser?.errors && newUser.errors.length > 0) { + 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/password": + form.setError("password", { + type: "server", + message: errorMessage, + }); + break; + default: + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + } else { + toast({ + title: "Success!", + description: "The user was registered successfully.", + }); + form.reset({ + name: "", + company: "", + email: "", + password: "", + termsAndConditions: false, + }); + } + } + }; + + 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" && ( + <> + + ( + <> + + field.onChange(e.target.checked)} + > + I agree with the  + + Terms + +   and  + + Privacy Policy + + + + + + )} + /> + + )} + + {state?.message === "Credentials error" && ( +
+ +

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/components/auth/oss/index.ts b/components/auth/oss/index.ts new file mode 100644 index 0000000000..82fc6feefa --- /dev/null +++ b/components/auth/oss/index.ts @@ -0,0 +1 @@ +export * from "./auth-form"; diff --git a/components/filters/data-filters.ts b/components/filters/data-filters.ts index 9aea8e9dd3..df1e01cea0 100644 --- a/components/filters/data-filters.ts +++ b/components/filters/data-filters.ts @@ -1,9 +1,4 @@ export const filtersProviders = [ - { - key: "provider__in", - labelCheckboxGroup: "Select a Provider", - values: ["aws", "gcp", "azure", "kubernetes"], - }, { key: "connected", labelCheckboxGroup: "Connection", diff --git a/components/icons/prowler/ProwlerIcons.tsx b/components/icons/prowler/ProwlerIcons.tsx index 0229c6e0b5..425788e88b 100644 --- a/components/icons/prowler/ProwlerIcons.tsx +++ b/components/icons/prowler/ProwlerIcons.tsx @@ -7,26 +7,27 @@ export const ProwlerExtended: React.FC = ({ width = 164, height, ...props -}) => ( - - - -); +}) => { + return ( + + + + ); +}; export const ProwlerShort: React.FC = ({ size, diff --git a/components/providers/CheckConnectionProvider.tsx b/components/providers/CheckConnectionProvider.tsx index 2fadafad04..d5bc9e727d 100644 --- a/components/providers/CheckConnectionProvider.tsx +++ b/components/providers/CheckConnectionProvider.tsx @@ -2,7 +2,7 @@ import { useRef } from "react"; -import { checkConnectionProvider } from "@/actions"; +import { checkConnectionProvider } from "@/actions/providers"; import { CustomButtonClientAction } from "../ui/custom"; import { useToast } from "../ui/toast"; diff --git a/components/providers/forms/add-form.tsx b/components/providers/forms/add-form.tsx index f30855d2d0..4c063c7aef 100644 --- a/components/providers/forms/add-form.tsx +++ b/components/providers/forms/add-form.tsx @@ -5,7 +5,7 @@ import { Dispatch, SetStateAction } from "react"; import { useForm } from "react-hook-form"; import * as z from "zod"; -import { addProvider } from "@/actions"; +import { addProvider } from "@/actions/providers"; import { SaveIcon } from "@/components/icons"; import { useToast } from "@/components/ui"; import { CustomButton, CustomInput } from "@/components/ui/custom"; diff --git a/components/providers/forms/delete-form.tsx b/components/providers/forms/delete-form.tsx index 434f453279..e08ac4ada5 100644 --- a/components/providers/forms/delete-form.tsx +++ b/components/providers/forms/delete-form.tsx @@ -5,7 +5,7 @@ import React, { Dispatch, SetStateAction } from "react"; import { useForm } from "react-hook-form"; import * as z from "zod"; -import { deleteProvider } from "@/actions"; +import { deleteProvider } from "@/actions/providers"; import { DeleteIcon } from "@/components/icons"; import { useToast } from "@/components/ui"; import { CustomButton } from "@/components/ui/custom"; diff --git a/components/providers/forms/edit-form.tsx b/components/providers/forms/edit-form.tsx index 669e4a9184..bae117b7da 100644 --- a/components/providers/forms/edit-form.tsx +++ b/components/providers/forms/edit-form.tsx @@ -5,7 +5,7 @@ import { Dispatch, SetStateAction } from "react"; import { useForm } from "react-hook-form"; import * as z from "zod"; -import { updateProvider } from "@/actions"; +import { updateProvider } from "@/actions/providers"; import { SaveIcon } from "@/components/icons"; import { useToast } from "@/components/ui"; import { CustomButton, CustomInput } from "@/components/ui/custom"; diff --git a/components/providers/table/columns-provider.tsx b/components/providers/table/columns-provider.tsx index fd190a811a..065b6d5863 100644 --- a/components/providers/table/columns-provider.tsx +++ b/components/providers/table/columns-provider.tsx @@ -42,7 +42,7 @@ export const ColumnsProvider: ColumnDef[] = [ { accessorKey: "uid", header: ({ column }) => ( - + ), cell: ({ row }) => { const { diff --git a/components/ui/custom/CustomInput.tsx b/components/ui/custom/CustomInput.tsx index 81a91316d0..26cc17cd9b 100644 --- a/components/ui/custom/CustomInput.tsx +++ b/components/ui/custom/CustomInput.tsx @@ -16,6 +16,7 @@ interface CustomInputProps { type?: string; placeholder?: string; password?: boolean; + confirmPassword?: boolean; isRequired?: boolean; isInvalid?: boolean; } @@ -28,24 +29,53 @@ export const CustomInput = ({ labelPlacement = "inside", placeholder, variant = "bordered", + confirmPassword = false, password = false, isRequired = true, isInvalid, }: CustomInputProps) => { - const [isVisible, setIsVisible] = useState(false); + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] = + useState(false); - const toggleVisibility = () => setIsVisible(!isVisible); + const inputLabel = confirmPassword + ? "Confirm Password" + : password + ? "Password" + : label; - const inputLabel = password ? "Password" : label; - const inputType = password ? (isVisible ? "text" : "password") : type; - const inputPlaceholder = password ? "Enter your password" : placeholder; - const inputIsRequired = password ? true : isRequired; + const inputPlaceholder = confirmPassword + ? "Confirm Password" + : password + ? "Password" + : placeholder; - const endContent = password && ( + const inputType = + password || confirmPassword + ? isPasswordVisible || isConfirmPasswordVisible + ? "text" + : "password" + : type; + const inputIsRequired = password || confirmPassword ? true : isRequired; + + const toggleVisibility = () => { + if (password) { + setIsPasswordVisible(!isPasswordVisible); + } else if (confirmPassword) { + setIsConfirmPasswordVisible(!isConfirmPasswordVisible); + } + }; + + const endContent = (password || confirmPassword) && ( ); @@ -58,6 +88,7 @@ export const CustomInput = ({ <> } + spinner={ + + } isLoading={isLoading} isIconOnly={isIconOnly} {...props} diff --git a/components/ui/sidebar/SidebarWrap.tsx b/components/ui/sidebar/SidebarWrap.tsx index 949cd59da0..72451eb3fe 100644 --- a/components/ui/sidebar/SidebarWrap.tsx +++ b/components/ui/sidebar/SidebarWrap.tsx @@ -6,10 +6,10 @@ import clsx from "clsx"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useSession } from "next-auth/react"; -import React, { useCallback } from "react"; +import React, { Suspense, useCallback } from "react"; import { useMediaQuery } from "usehooks-ts"; -import { logOut } from "@/actions"; +import { logOut } from "@/actions/auth"; import { useUIStore } from "@/store"; import { @@ -18,7 +18,7 @@ import { } from "../../icons/prowler/ProwlerIcons"; import { ThemeSwitch } from "../../ThemeSwitch"; import Sidebar from "./Sidebar"; -import { sectionItems, sectionItemsWithTeams } from "./SidebarItems"; +import { sectionItemsWithTeams } from "./SidebarItems"; import { UserAvatar } from "./UserAvatar"; export const SidebarWrap = () => { @@ -72,22 +72,20 @@ export const SidebarWrap = () => { - {" "} + Loading...

}> + +
diff --git a/dataProviders.json b/dataProviders.json deleted file mode 100644 index ab8a058620..0000000000 --- a/dataProviders.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "links": { - "first": "https://api.prowler.com/api/v1/providers?page%5Bnumber%5D=1", - "last": "https://api.prowler.com/api/v1/providers?page%5Bnumber%5D=1", - "next": null, - "prev": null - }, - "data": [ - { - "id": "5fd8f121-269e-4715-84cf-f92373f15dfa", - "type": "providers", - "attributes": { - "provider": "aws", - "provider_id": "1234567890", - "alias": "mock_aws_connected", - "status": "cancelled", - "resources": 101, - "connection": { - "connected": true, - "last_checked_at": "2024-07-17T09:55:14.191475Z" - }, - "scanner_args": { - "only_logs": true, - "excluded_checks": [ - "awslambda_function_no_secrets_in_code", - "cloudwatch_log_group_no_secrets_in_logs" - ], - "aws_retries_max_attempts": 5 - }, - "inserted_at": "2024-07-17T09:55:14.191475Z", - "updated_at": "2024-07-17T09:55:14.191475Z", - "created_by": { - "object": "user", - "id": "eea048ab-7cb3-47eb-9e5e-dce591ade41f" - } - } - }, - { - "id": "16aaeb4e-d3cd-4bb6-86f8-6c39cf93821e", - "type": "providers", - "attributes": { - "provider": "azure", - "provider_id": "1234567891", - "alias": "mock_aws_not_connected", - "status": "pending", - "resources": 222, - "connection": { - "connected": false, - "last_checked_at": "2024-07-17T09:55:18.987425Z" - }, - "scanner_args": { - "only_logs": true, - "excluded_checks": [ - "awslambda_function_no_secrets_in_code", - "cloudwatch_log_group_no_secrets_in_logs" - ], - "aws_retries_max_attempts": 5 - }, - "inserted_at": "2024-07-17T09:50:18.987425Z", - "updated_at": "2024-07-17T09:55:18.987425Z", - "created_by": { - "object": "user", - "id": "a8f5e964-5964-4aaf-9176-844e2c3b0716" - } - } - }, - { - "id": "63f16b03-7849-4054-b40b-300e331f46f0", - "type": "providers", - "attributes": { - "provider": "gcp", - "provider_id": "1234567895", - "alias": "mock_gcp", - "status": "completed", - "resources": 143, - "connection": { - "connected": true, - "last_checked_at": "2024-07-17T09:55:18.987425Z" - }, - "scanner_args": { - "only_logs": true, - "excluded_checks": [ - "apikeys_key_exists", - "cloudsql_instance_public_ip" - ], - "excluded_services": ["kms"] - }, - "inserted_at": "2024-07-17T09:50:18.987425Z", - "updated_at": "2024-07-17T09:55:18.987425Z", - "created_by": { - "object": "user", - "id": "eea048ab-7cb3-47eb-9e5e-dce591ade41f" - } - } - } - ], - "meta": { - "pagination": { - "page": 1, - "pages": 1, - "count": 3 - }, - "version": "v1" - } -} diff --git a/lib/index.ts b/lib/index.ts index 8edc14574b..38d384fb6e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,2 @@ export * from "./custom"; -export * from "./seed"; export * from "./utils"; diff --git a/lib/seed.ts b/lib/seed.ts deleted file mode 100644 index 1ff00ee60b..0000000000 --- a/lib/seed.ts +++ /dev/null @@ -1,25 +0,0 @@ -import bcryptjs from "bcryptjs"; -import { v4 as uuidv4 } from "uuid"; - -export const userMockData = [ - { - id: uuidv4(), // Generate a unique UUID. - tenantId: "12646005-9067-4d2a-a098-8bb378604362", - email: "admin@prowler.com", - name: "Admin Prowler", - companyName: "Prowler", - password: bcryptjs.hashSync("123123", 10), - role: "admin", - image: null, - }, - { - id: uuidv4(), // Generate a unique UUID. - tenantId: "12646005-9067-4d2a-a098-8bb378604362", - email: "user@prowler.com", - name: "User Prowler", - companyName: "Prowler", - password: bcryptjs.hashSync("123123", 10), - role: "user", - image: null, - }, -]; diff --git a/nextauth.d.ts b/nextauth.d.ts index 2fe2480ce5..d5b6f472f9 100644 --- a/nextauth.d.ts +++ b/nextauth.d.ts @@ -1,15 +1,23 @@ import { DefaultSession } from "next-auth"; declare module "next-auth" { - interface Session { + interface User extends NextAuthUser { + name: string; + email: string; + company?: string; + dateJoined: string; + } + + interface Session extends DefaultSession { user: { - id: string; - tenantId: string; - firstName: string; - companyName: string; + name: string; email: string; - role: string; - image?: string; + companyName?: string; + dateJoined: string; } & DefaultSession["user"]; + userId: string; + tenantId: string; + accessToken: string; + refreshToken: string; } } diff --git a/package-lock.json b/package-lock.json index 9d3fc5073c..1f8510886b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,8 @@ "date-fns": "^3.6.0", "framer-motion": "~11.1.1", "intl-messageformat": "^10.5.0", + "jose": "^5.9.3", + "jwt-decode": "^4.0.0", "lucide-react": "^0.417.0", "next": "^14.2.12", "next-auth": "^5.0.0-beta.20", @@ -9423,9 +9425,10 @@ } }, "node_modules/jose": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.7.0.tgz", - "integrity": "sha512-3P9qfTYDVnNn642LCAqIKbTGb9a1TBxZ9ti5zEVEr48aDdflgRjhspWFb6WM4PzAfFbGMJYC4+803v8riCRAKw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.3.tgz", + "integrity": "sha512-egLIoYSpcd+QUF+UHgobt5YzI2Pkw/H39ou9suW687MY6PmCwPmkNV/4TNjn1p2tX5xO3j0d0sq5hiYE24bSlg==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -9507,6 +9510,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 468ce86d58..67ac210f66 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "date-fns": "^3.6.0", "framer-motion": "~11.1.1", "intl-messageformat": "^10.5.0", + "jose": "^5.9.3", + "jwt-decode": "^4.0.0", "lucide-react": "^0.417.0", "next": "^14.2.12", "next-auth": "^5.0.0-beta.20", diff --git a/tailwind.config.js b/tailwind.config.js index 6d2ff06563..31bdbfd39e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -20,33 +20,34 @@ module.exports = { extend: { colors: { prowler: { - blue: { + theme: { midnight: "#030921", pale: "#f3fcff", - smoky: "#7b8390", - }, - grey: { - medium: "#353a4d", - light: "#868994", - }, - green: { - DEFAULT: "#9FD655", - medium: "#09BF3D", - }, - theme: { green: "#6af400", purple: "#5001d0", coral: "#ff5356", orange: "#f69000", yellow: "#ffdf16", }, - dark: { - title: "#E2E8F0", - text: "#94a3b8" /* primary default for dark mode */, + blue: { + 800: "#1e293bff", }, - light: { - title: "#1e293bff", - text: "#64748b" /* primary default for light mode */, + grey: { + medium: "#353a4d", + light: "#868994", + 600: "#64748b", + }, + green: { + DEFAULT: "#9FD655", + medium: "#09BF3D", + }, + black: { + DEFAULT: "#000", + 900: "#18181A", + }, + white: { + DEFAULT: "#FFF", + 900: "#18181A", }, }, system: { @@ -82,6 +83,7 @@ module.exports = { }, }, danger: "#E11D48", + action: "#353a4d", }, fontFamily: { sans: ["var(--font-sans)"], diff --git a/types/authFormSchema.ts b/types/authFormSchema.ts index 3cd577de69..6f3ab5a087 100644 --- a/types/authFormSchema.ts +++ b/types/authFormSchema.ts @@ -1,27 +1,43 @@ import { z } from "zod"; export const authFormSchema = (type: string) => - z.object({ - // Sign Up - companyName: type === "sign-in" ? z.string().optional() : z.string().min(3), - firstName: - type === "sign-in" - ? z.string().optional() - : z - .string() - .min(3, { - message: "The name must be at least 3 characters.", - }) - .max(20), - termsAndConditions: - type === "sign-in" - ? z.enum(["true"]).optional() - : z.enum(["true"], { - errorMap: () => ({ + z + .object({ + // Sign Up + company: + type === "sign-in" ? z.string().optional() : z.string().optional(), + name: + type === "sign-in" + ? z.string().optional() + : z + .string() + .min(3, { + message: "The name must be at least 3 characters.", + }) + .max(20), + confirmPassword: + type === "sign-in" + ? z.string().optional() + : z.string().min(12, { + message: "It must contain at least 12 characters.", + }), + termsAndConditions: + type === "sign-in" + ? z.boolean().optional() + : z.boolean().refine((value) => value === true, { message: "You must accept the terms and conditions.", }), - }), - // both - email: z.string().email(), - password: z.string().min(6), - }); + + // Fields for Sign In and Sign Up + email: z.string().email(), + password: z.string().min(12, { + message: "It must contain at least 12 characters.", + }), + }) + .refine( + (data) => type === "sign-in" || data.password === data.confirmPassword, + { + message: "The password must match", + path: ["confirmPassword"], + }, + ); diff --git a/types/components.ts b/types/components.ts index 28ffbe7355..02018469dc 100644 --- a/types/components.ts +++ b/types/components.ts @@ -29,6 +29,16 @@ export type NextUIColors = export interface SearchParamsProps { [key: string]: string | string[] | undefined; } + +export interface ApiError { + detail: string; + status: string; + source: { + pointer: string; + }; + code: string; +} + export interface ProviderProps { id: string; type: "providers"; diff --git a/types/users/index.ts b/types/users/index.ts new file mode 100644 index 0000000000..ddf77b4624 --- /dev/null +++ b/types/users/index.ts @@ -0,0 +1 @@ +export * from "./users"; diff --git a/types/users/users.ts b/types/users/users.ts new file mode 100644 index 0000000000..61a92ae70e --- /dev/null +++ b/types/users/users.ts @@ -0,0 +1,52 @@ +export interface UserAttributes { + name: string; + email: string; + company_name: string; + date_joined: string; +} + +export interface Membership { + type: string; + id: string; +} + +export interface MembershipMeta { + count: number; +} + +export interface UserRelationships { + memberships: { + meta: MembershipMeta; + data: Membership[]; + }; +} + +export interface UserData { + type: string; + id: string; + attributes: UserAttributes; + relationships: UserRelationships; +} + +export interface Meta { + version: string; +} + +export interface UserProps { + data: UserData; + meta: Meta; +} + +export interface TokenAttributes { + refreshToken: string; + accessToken: string; +} + +export interface TokenData { + type: string; + attributes: TokenAttributes; +} + +export interface SignInResponse { + data: TokenData; +}