diff --git a/autogpt_platform/frontend/src/app/login/actions.ts b/autogpt_platform/frontend/src/app/login/actions.ts index 7c3fb8125e27..ba696c094fd5 100644 --- a/autogpt_platform/frontend/src/app/login/actions.ts +++ b/autogpt_platform/frontend/src/app/login/actions.ts @@ -5,11 +5,7 @@ import { z } from "zod"; import * as Sentry from "@sentry/nextjs"; import getServerSupabase from "@/lib/supabase/getServerSupabase"; import BackendAPI from "@/lib/autogpt-server-api"; - -const loginFormSchema = z.object({ - email: z.string().email().min(2).max(64), - password: z.string().min(6).max(64), -}); +import { loginFormSchema, LoginProvider } from "@/types/auth"; export async function logout() { return await Sentry.withServerActionInstrumentation( @@ -25,7 +21,7 @@ export async function logout() { const { error } = await supabase.auth.signOut(); if (error) { - console.log("Error logging out", error); + console.error("Error logging out", error); return error.message; } @@ -47,18 +43,13 @@ export async function login(values: z.infer) { // We are sure that the values are of the correct type because zod validates the form const { data, error } = await supabase.auth.signInWithPassword(values); - await api.createUser(); - if (error) { - console.log("Error logging in", error); - if (error.status == 400) { - // Hence User is not present - redirect("/login"); - } - + console.error("Error logging in", error); return error.message; } + await api.createUser(); + if (data.session) { await supabase.auth.setSession(data.session); } @@ -68,38 +59,34 @@ export async function login(values: z.infer) { }); } -export async function signup(values: z.infer) { - "use server"; +export async function providerLogin(provider: LoginProvider) { return await Sentry.withServerActionInstrumentation( - "signup", + "providerLogin", {}, async () => { const supabase = getServerSupabase(); + const api = new BackendAPI(); if (!supabase) { redirect("/error"); } - // We are sure that the values are of the correct type because zod validates the form - const { data, error } = await supabase.auth.signUp(values); + const { error } = await supabase!.auth.signInWithOAuth({ + provider: provider, + options: { + redirectTo: + process.env.AUTH_CALLBACK_URL ?? + `http://localhost:3000/auth/callback`, + }, + }); if (error) { - console.log("Error signing up", error); - if (error.message.includes("P0001")) { - return "Please join our waitlist for your turn: https://agpt.co/waitlist"; - } - if (error.code?.includes("user_already_exists")) { - redirect("/login"); - } + console.error("Error logging in", error); return error.message; } - if (data.session) { - await supabase.auth.setSession(data.session); - } - console.log("Signed up"); - revalidatePath("/", "layout"); - redirect("/store/profile"); + await api.createUser(); + console.log("Logged in"); }, ); } diff --git a/autogpt_platform/frontend/src/app/login/page.tsx b/autogpt_platform/frontend/src/app/login/page.tsx index 969ac5dcb38f..6edaf9900641 100644 --- a/autogpt_platform/frontend/src/app/login/page.tsx +++ b/autogpt_platform/frontend/src/app/login/page.tsx @@ -1,10 +1,8 @@ "use client"; -import { login, signup } from "./actions"; -import { Button } from "@/components/ui/button"; +import { login, providerLogin } from "./actions"; import { Form, FormControl, - FormDescription, FormField, FormItem, FormLabel, @@ -14,40 +12,69 @@ import { useForm } from "react-hook-form"; import { Input } from "@/components/ui/input"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { PasswordInput } from "@/components/PasswordInput"; -import { FaGoogle, FaGithub, FaDiscord, FaSpinner } from "react-icons/fa"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { Checkbox } from "@/components/ui/checkbox"; import useSupabase from "@/hooks/useSupabase"; import Spinner from "@/components/Spinner"; -import { useBackendAPI } from "@/lib/autogpt-server-api/context"; - -const loginFormSchema = z.object({ - email: z.string().email().min(2).max(64), - password: z.string().min(6).max(64), - agreeToTerms: z.boolean().refine((value) => value === true, { - message: "You must agree to the Terms of Use and Privacy Policy", - }), -}); +import { + AuthCard, + AuthHeader, + AuthButton, + AuthFeedback, + AuthBottomText, + PasswordInput, +} from "@/components/auth"; +import { loginFormSchema } from "@/types/auth"; export default function LoginPage() { const { supabase, user, isUserLoading } = useSupabase(); const [feedback, setFeedback] = useState(null); const router = useRouter(); const [isLoading, setIsLoading] = useState(false); - const api = useBackendAPI(); const form = useForm>({ resolver: zodResolver(loginFormSchema), defaultValues: { email: "", password: "", - agreeToTerms: false, }, }); + // TODO: uncomment when we enable social login + // const onProviderLogin = useCallback(async ( + // provider: LoginProvider, + // ) => { + // setIsLoading(true); + // const error = await providerLogin(provider); + // setIsLoading(false); + // if (error) { + // setFeedback(error); + // return; + // } + // setFeedback(null); + // }, [supabase]); + + const onLogin = useCallback( + async (data: z.infer) => { + setIsLoading(true); + + if (!(await form.trigger())) { + setIsLoading(false); + return; + } + + const error = await login(data); + setIsLoading(false); + if (error) { + setFeedback(error); + return; + } + setFeedback(null); + }, + [form], + ); + if (user) { console.debug("User exists, redirecting to /"); router.push("/"); @@ -65,179 +92,60 @@ export default function LoginPage() { ); } - async function handleSignInWithProvider( - provider: "google" | "github" | "discord", - ) { - const { data, error } = await supabase!.auth.signInWithOAuth({ - provider: provider, - options: { - redirectTo: - process.env.AUTH_CALLBACK_URL ?? - `http://localhost:3000/auth/callback`, - }, - }); - - await api.createUser(); - - if (!error) { - setFeedback(null); - return; - } - setFeedback(error.message); - } - - const onLogin = async (data: z.infer) => { - setIsLoading(true); - const error = await login(data); - setIsLoading(false); - if (error) { - setFeedback(error); - return; - } - setFeedback(null); - }; - return ( -
-
-

Log in to your Account

- {/*
- - - -
*/} - - - ( - - Email - - - - - - )} - /> - ( - - Password - - - - - Password needs to be at least 6 characters long - - - - )} - /> - ( - - - - -
- - I agree to the{" "} - - Terms of Use - {" "} - and{" "} - - Privacy Policy - - - -
-
- )} - /> -
- - -
- -

{feedback}

- - - Forgot your password? - -
-
+ Login + + + + + + ); } diff --git a/autogpt_platform/frontend/src/app/reset_password/actions.ts b/autogpt_platform/frontend/src/app/reset_password/actions.ts new file mode 100644 index 000000000000..eebea08b979f --- /dev/null +++ b/autogpt_platform/frontend/src/app/reset_password/actions.ts @@ -0,0 +1,60 @@ +"use server"; +import getServerSupabase from "@/lib/supabase/getServerSupabase"; +import { redirect } from "next/navigation"; +import * as Sentry from "@sentry/nextjs"; +import { headers } from "next/headers"; + +export async function sendResetEmail(email: string) { + return await Sentry.withServerActionInstrumentation( + "sendResetEmail", + {}, + async () => { + const supabase = getServerSupabase(); + const headersList = headers(); + const host = headersList.get("host"); + const protocol = + process.env.NODE_ENV === "development" ? "http" : "https"; + const origin = `${protocol}://${host}`; + + if (!supabase) { + redirect("/error"); + } + + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${origin}/reset_password`, + }); + + if (error) { + console.error("Error sending reset email", error); + return error.message; + } + + console.log("Reset email sent"); + redirect("/reset_password"); + }, + ); +} + +export async function changePassword(password: string) { + return await Sentry.withServerActionInstrumentation( + "changePassword", + {}, + async () => { + const supabase = getServerSupabase(); + + if (!supabase) { + redirect("/error"); + } + + const { error } = await supabase.auth.updateUser({ password }); + + if (error) { + console.error("Error changing password", error); + return error.message; + } + + await supabase.auth.signOut(); + redirect("/login"); + }, + ); +} diff --git a/autogpt_platform/frontend/src/app/reset_password/page.tsx b/autogpt_platform/frontend/src/app/reset_password/page.tsx index b63f5a2ffd6c..36d1413bfef0 100644 --- a/autogpt_platform/frontend/src/app/reset_password/page.tsx +++ b/autogpt_platform/frontend/src/app/reset_password/page.tsx @@ -1,8 +1,15 @@ "use client"; -import { Button } from "@/components/ui/button"; +import { + AuthCard, + AuthHeader, + AuthButton, + AuthFeedback, + PasswordInput, +} from "@/components/auth"; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -10,205 +17,170 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import useSupabase from "@/hooks/useSupabase"; +import { sendEmailFormSchema, changePasswordFormSchema } from "@/types/auth"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useForm } from "react-hook-form"; -import { FaSpinner } from "react-icons/fa"; import { z } from "zod"; - -const emailFormSchema = z.object({ - email: z.string().email().min(2).max(64), -}); - -const resetPasswordFormSchema = z - .object({ - password: z.string().min(6).max(64), - confirmPassword: z.string().min(6).max(64), - }) - .refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"], - }); +import { changePassword, sendResetEmail } from "./actions"; +import Spinner from "@/components/Spinner"; export default function ResetPasswordPage() { const { supabase, user, isUserLoading } = useSupabase(); - const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [feedback, setFeedback] = useState(null); + const [isError, setIsError] = useState(false); + const [disabled, setDisabled] = useState(false); - const emailForm = useForm>({ - resolver: zodResolver(emailFormSchema), + const sendEmailForm = useForm>({ + resolver: zodResolver(sendEmailFormSchema), defaultValues: { email: "", }, }); - const resetPasswordForm = useForm>({ - resolver: zodResolver(resetPasswordFormSchema), + const changePasswordForm = useForm>({ + resolver: zodResolver(changePasswordFormSchema), defaultValues: { password: "", confirmPassword: "", }, }); - if (isUserLoading) { - return ( -
- -
- ); - } + const onSendEmail = useCallback( + async (data: z.infer) => { + setIsLoading(true); + setFeedback(null); - if (!supabase) { - return ( -
- User accounts are disabled because Supabase client is unavailable -
- ); - } - - async function onSendEmail(d: z.infer) { - setIsLoading(true); - setFeedback(null); + if (!(await sendEmailForm.trigger())) { + setIsLoading(false); + return; + } - if (!(await emailForm.trigger())) { + const error = await sendResetEmail(data.email); setIsLoading(false); - return; - } - - const { data, error } = await supabase!.auth.resetPasswordForEmail( - d.email, - { - redirectTo: `${window.location.origin}/reset_password`, - }, - ); - - if (error) { - setFeedback(error.message); - setIsLoading(false); - return; - } + if (error) { + setFeedback(error); + setIsError(true); + return; + } + setDisabled(true); + setFeedback( + "Password reset email sent if user exists. Please check your email.", + ); + setIsError(false); + }, + [sendEmailForm], + ); - setFeedback("Password reset email sent. Please check your email."); - setIsLoading(false); - } + const onChangePassword = useCallback( + async (data: z.infer) => { + setIsLoading(true); + setFeedback(null); - async function onResetPassword(d: z.infer) { - setIsLoading(true); - setFeedback(null); + if (!(await changePasswordForm.trigger())) { + setIsLoading(false); + return; + } - if (!(await resetPasswordForm.trigger())) { + const error = await changePassword(data.password); setIsLoading(false); - return; - } - - const { data, error } = await supabase!.auth.updateUser({ - password: d.password, - }); + if (error) { + setFeedback(error); + setIsError(true); + return; + } + setFeedback("Password changed successfully. Redirecting to login."); + setIsError(false); + }, + [changePasswordForm], + ); - if (error) { - setFeedback(error.message); - setIsLoading(false); - return; - } + if (isUserLoading) { + return ; + } - await supabase!.auth.signOut(); - router.push("/login"); + if (!supabase) { + return ( +
+ User accounts are disabled because Supabase client is unavailable +
+ ); } return ( -
-
-

Reset Password

- {user ? ( -
- - ( - - Password - - - - - - )} - /> - ( - - Confirm Password - - - - - - )} - /> - - - - ) : ( -
- - ( - - Email - - - - - - )} - /> - - {feedback ? ( -
- {feedback} -
- ) : null} - - - )} -
-
+ + Reset Password + {user ? ( +
+ + ( + + Password + + + + + + )} + /> + ( + + Confirm Password + + + + + Password needs to be at least 6 characters long + + + + )} + /> + onChangePassword(changePasswordForm.getValues())} + isLoading={isLoading} + type="submit" + > + Update password + + + + + ) : ( +
+ + ( + + Email + + + + + + )} + /> + onSendEmail(sendEmailForm.getValues())} + isLoading={isLoading} + disabled={disabled} + type="submit" + > + Send reset email + + + + + )} +
); } diff --git a/autogpt_platform/frontend/src/app/signup/actions.ts b/autogpt_platform/frontend/src/app/signup/actions.ts new file mode 100644 index 000000000000..0d0c3fb8a45a --- /dev/null +++ b/autogpt_platform/frontend/src/app/signup/actions.ts @@ -0,0 +1,44 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { z } from "zod"; +import * as Sentry from "@sentry/nextjs"; +import getServerSupabase from "@/lib/supabase/getServerSupabase"; +import { signupFormSchema } from "@/types/auth"; + +export async function signup(values: z.infer) { + "use server"; + return await Sentry.withServerActionInstrumentation( + "signup", + {}, + async () => { + const supabase = getServerSupabase(); + + if (!supabase) { + redirect("/error"); + } + + // We are sure that the values are of the correct type because zod validates the form + const { data, error } = await supabase.auth.signUp(values); + + if (error) { + console.error("Error signing up", error); + // FIXME: supabase doesn't return the correct error message for this case + if (error.message.includes("P0001")) { + return "Please join our waitlist for your turn: https://agpt.co/waitlist"; + } + if (error.code?.includes("user_already_exists")) { + redirect("/login"); + } + return error.message; + } + + if (data.session) { + await supabase.auth.setSession(data.session); + } + console.log("Signed up"); + revalidatePath("/", "layout"); + redirect("/store/profile"); + }, + ); +} diff --git a/autogpt_platform/frontend/src/app/signup/page.tsx b/autogpt_platform/frontend/src/app/signup/page.tsx new file mode 100644 index 000000000000..a0bbdeb66177 --- /dev/null +++ b/autogpt_platform/frontend/src/app/signup/page.tsx @@ -0,0 +1,219 @@ +"use client"; +import { signup } from "./actions"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useForm } from "react-hook-form"; +import { Input } from "@/components/ui/input"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Checkbox } from "@/components/ui/checkbox"; +import useSupabase from "@/hooks/useSupabase"; +import Spinner from "@/components/Spinner"; +import { + AuthCard, + AuthHeader, + AuthButton, + AuthFeedback, + AuthBottomText, + PasswordInput, +} from "@/components/auth"; +import { signupFormSchema } from "@/types/auth"; + +export default function SignupPage() { + const { supabase, user, isUserLoading } = useSupabase(); + const [feedback, setFeedback] = useState(null); + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [showWaitlistPrompt, setShowWaitlistPrompt] = useState(false); + + const form = useForm>({ + resolver: zodResolver(signupFormSchema), + defaultValues: { + email: "", + password: "", + confirmPassword: "", + agreeToTerms: false, + }, + }); + + const onSignup = useCallback( + async (data: z.infer) => { + setIsLoading(true); + + if (!(await form.trigger())) { + setIsLoading(false); + return; + } + + const error = await signup(data); + setIsLoading(false); + if (error) { + setShowWaitlistPrompt(true); + return; + } + setFeedback(null); + }, + [form], + ); + + if (user) { + console.debug("User exists, redirecting to /"); + router.push("/"); + } + + if (isUserLoading || user) { + return ; + } + + if (!supabase) { + return ( +
+ User accounts are disabled because Supabase client is unavailable +
+ ); + } + + return ( + + Create a new account +
+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Confirm Password + + + + + Password needs to be at least 6 characters long + + + + )} + /> + onSignup(form.getValues())} + isLoading={isLoading} + type="submit" + > + Sign up + + ( + + + + +
+ + + I agree to the + + + Terms of Use + + + and + + + Privacy Policy + + + +
+
+ )} + /> + + + + {showWaitlistPrompt && ( +
+ + The provided email may not be allowed to sign up. + +
+ + - AutoGPT Platform is currently in closed beta. You can join + + + the waitlist here. + +
+ + - Make sure you use the same email address you used to sign up for + the waitlist. + +
+ + - You can self host the platform, visit our + + + GitHub repository. + +
+ )} + +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/auth/AuthBottomText.tsx b/autogpt_platform/frontend/src/components/auth/AuthBottomText.tsx new file mode 100644 index 000000000000..7dcaecf48930 --- /dev/null +++ b/autogpt_platform/frontend/src/components/auth/AuthBottomText.tsx @@ -0,0 +1,37 @@ +import { cn } from "@/lib/utils"; +import Link from "next/link"; + +interface Props { + className?: string; + text: string; + linkText?: string; + href?: string; +} + +export default function AuthBottomText({ + className = "", + text, + linkText, + href = "", +}: Props) { + return ( +
+ + {text} + + {linkText && ( + + {linkText} + + )} +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/auth/AuthButton.tsx b/autogpt_platform/frontend/src/components/auth/AuthButton.tsx new file mode 100644 index 000000000000..be8c86f981c4 --- /dev/null +++ b/autogpt_platform/frontend/src/components/auth/AuthButton.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from "react"; +import { Button } from "../ui/button"; +import { FaSpinner } from "react-icons/fa"; + +interface Props { + children?: ReactNode; + onClick: () => void; + isLoading?: boolean; + disabled?: boolean; + type?: "button" | "submit" | "reset"; +} + +export default function AuthButton({ + children, + onClick, + isLoading = false, + disabled = false, + type = "button", +}: Props) { + return ( + + ); +} diff --git a/autogpt_platform/frontend/src/components/auth/AuthCard.tsx b/autogpt_platform/frontend/src/components/auth/AuthCard.tsx new file mode 100644 index 000000000000..f8b4c643d122 --- /dev/null +++ b/autogpt_platform/frontend/src/components/auth/AuthCard.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +export default function AuthCard({ children }: Props) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/autogpt_platform/frontend/src/components/auth/AuthFeedback.tsx b/autogpt_platform/frontend/src/components/auth/AuthFeedback.tsx new file mode 100644 index 000000000000..216a01b3660b --- /dev/null +++ b/autogpt_platform/frontend/src/components/auth/AuthFeedback.tsx @@ -0,0 +1,16 @@ +interface Props { + message?: string | null; + isError?: boolean; +} + +export default function AuthFeedback({ message = "", isError = false }: Props) { + return ( +
+ {isError ? ( +
{message}
+ ) : ( +
{message}
+ )} +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/auth/AuthHeader.tsx b/autogpt_platform/frontend/src/components/auth/AuthHeader.tsx new file mode 100644 index 000000000000..221a40b4611b --- /dev/null +++ b/autogpt_platform/frontend/src/components/auth/AuthHeader.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +export default function AuthHeader({ children }: Props) { + return ( +
+ {children} +
+ ); +} diff --git a/autogpt_platform/frontend/src/components/PasswordInput.tsx b/autogpt_platform/frontend/src/components/auth/PasswordInput.tsx similarity index 88% rename from autogpt_platform/frontend/src/components/PasswordInput.tsx rename to autogpt_platform/frontend/src/components/auth/PasswordInput.tsx index 0b27b970aa16..8bafe0641d1f 100644 --- a/autogpt_platform/frontend/src/components/PasswordInput.tsx +++ b/autogpt_platform/frontend/src/components/auth/PasswordInput.tsx @@ -16,6 +16,7 @@ const PasswordInput = forwardRef( type={showPassword ? "text" : "password"} className={cn("hide-password-toggle pr-10", className)} ref={ref} + title="password" {...props} />