diff --git a/.env.template b/.env.template new file mode 100644 index 0000000000..35ac3b9b58 --- /dev/null +++ b/.env.template @@ -0,0 +1,2 @@ +# openssl rand -base64 32 +AUTH_SECRET=your-secret-key diff --git a/actions/auth.ts b/actions/auth.ts new file mode 100644 index 0000000000..3c93021a12 --- /dev/null +++ b/actions/auth.ts @@ -0,0 +1,52 @@ +"use server"; + +import { AuthError } from "next-auth"; + +import { signIn, signOut } from "@/auth.config"; +// import { authFormSchema } from "@/types"; + +// const formSchema = authFormSchema("sign-in"); + +const defaultValues = { + email: "", + password: "", +}; + +// Fix TS types. +export async function authenticate(prevState: any, formData: any) { + 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/index.ts b/actions/index.ts index 5532383f5f..0085735bb3 100644 --- a/actions/index.ts +++ b/actions/index.ts @@ -1 +1,2 @@ +export * from "./auth"; export * from "./providers"; diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000000..a9fdc8c394 --- /dev/null +++ b/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/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000000..f012c3f358 --- /dev/null +++ b/app/(auth)/sign-in/page.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +import { AuthForm } from "@/components/auth"; + +const SignIn = () => { + return ; +}; + +export default SignIn; diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx new file mode 100644 index 0000000000..dfe04cadeb --- /dev/null +++ b/app/(auth)/sign-up/page.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +import { AuthForm } from "@/components/auth"; + +const SignUp = () => { + return ; +}; + +export default SignUp; diff --git a/app/(prowler)/profile/page.tsx b/app/(prowler)/profile/page.tsx new file mode 100644 index 0000000000..76c72a8800 --- /dev/null +++ b/app/(prowler)/profile/page.tsx @@ -0,0 +1,24 @@ +import { Spacer } from "@nextui-org/react"; +import { redirect } from "next/navigation"; +import React from "react"; + +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"); + } + + return ( + <> +
+ + +
{JSON.stringify(session.user, null, 2)}
+ + ); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000000..316cf49c14 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth.config"; + +export const { GET, POST } = handlers; diff --git a/app/providers.tsx b/app/providers.tsx index 294f0647a6..942bce43dc 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -2,6 +2,7 @@ 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"; @@ -15,8 +16,10 @@ export function Providers({ children, themeProps }: ProvidersProps) { const router = useRouter(); return ( - - {children} - + + + {children} + + ); } diff --git a/auth.config.ts b/auth.config.ts new file mode 100644 index 0000000000..93d8387346 --- /dev/null +++ b/auth.config.ts @@ -0,0 +1,86 @@ +import bcryptjs from "bcryptjs"; +import NextAuth, { type NextAuthConfig } from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import { z } from "zod"; + +import { userMockData } from "./lib"; + +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, + name: user.name, + companyName: user.companyName, + email: user.email, + role: user.role, + image: user.image, + }; +} + +export const authConfig = { + session: { + strategy: "jwt", + }, + 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", + 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(6), + }) + .safeParse(credentials); + + if (!parsedCredentials.success) { + return null; + } + const { email, password } = parsedCredentials.data; + + const user = await getUser(email, password); + if (!user) return null; + return user; + }, + }), + ], +} satisfies NextAuthConfig; + +export const { signIn, signOut, auth, handlers } = NextAuth(authConfig); diff --git a/components/auth/AuthButton.tsx b/components/auth/AuthButton.tsx new file mode 100644 index 0000000000..0eda81782a --- /dev/null +++ b/components/auth/AuthButton.tsx @@ -0,0 +1,21 @@ +"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 new file mode 100644 index 0000000000..bef65115d0 --- /dev/null +++ b/components/auth/AuthForm.tsx @@ -0,0 +1,251 @@ +"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) { + 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 new file mode 100644 index 0000000000..26161c473c --- /dev/null +++ b/components/auth/index.ts @@ -0,0 +1,2 @@ +export * from "./AuthButton"; +export * from "./AuthForm"; diff --git a/components/providers/ButtonAddProvider.tsx b/components/providers/ButtonAddProvider.tsx index d4b82e1064..30f537ceae 100644 --- a/components/providers/ButtonAddProvider.tsx +++ b/components/providers/ButtonAddProvider.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button } from "@nextui-org/react"; +import { Button, CircularProgress } from "@nextui-org/react"; import React from "react"; import { useFormStatus } from "react-dom"; @@ -8,7 +8,7 @@ export const ButtonAddProvider = () => { const { pending } = useFormStatus(); return ( ); }; diff --git a/components/ui/custom/CustomInput.tsx b/components/ui/custom/CustomInput.tsx new file mode 100644 index 0000000000..8a876eec62 --- /dev/null +++ b/components/ui/custom/CustomInput.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { Icon } from "@iconify/react"; +import { Input } from "@nextui-org/react"; +import React, { useState } from "react"; +import { Control, FieldPath } from "react-hook-form"; +import { z } from "zod"; + +import { FormControl, FormField, FormMessage } from "@/components/ui/form"; +import { authFormSchema } from "@/types"; + +const formSchema = authFormSchema("sign-up"); + +type CustomInputProps = { + control: Control>; + isRequired?: boolean; +} & ( + | { + password?: false; + name: FieldPath>; + label: string; + type: "text" | "email"; + placeholder: string; + } + | { + password: true; + name?: never; + label?: never; + type?: never; + placeholder?: never; + } +); + +export const CustomInput = ({ + control, + name, + type, + label, + placeholder, + password = false, + isRequired = true, +}: CustomInputProps) => { + const [isVisible, setIsVisible] = useState(false); + + const toggleVisibility = () => setIsVisible(!isVisible); + + const inputName = password ? "password" : name!; + 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 endContent = password && ( + + ); + + return ( + >} + render={({ field }) => ( + <> + + + + + + )} + /> + ); +}; diff --git a/components/ui/custom/index.ts b/components/ui/custom/index.ts index 00732c48c8..126110ade6 100644 --- a/components/ui/custom/index.ts +++ b/components/ui/custom/index.ts @@ -1 +1,2 @@ export * from "./CustomBox"; +export * from "./CustomInput"; diff --git a/components/ui/form/Form.tsx b/components/ui/form/Form.tsx new file mode 100644 index 0000000000..0e6296cfd4 --- /dev/null +++ b/components/ui/form/Form.tsx @@ -0,0 +1,186 @@ +"use client"; + +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import * as React from "react"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "@/lib/utils"; + +import { Label } from "./Label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +
- + + {" "} + { )} +