refactor(frontend): Auth pages update (#9124)

There are UX and design issues with current auth pages; `login`,
`signup` and `reset_password` (including change password).

### Changes 🏗️


![auth](https://github.com/user-attachments/assets/56dfbae3-5c12-4324-a29a-846d091d9501)
*Missing `s` on the login's password error is fixed.

Important changes in bold.

#### All auth pages
- **Split `/login` into `/signup`**
- UI Redesign that adheres to Figma designs
- General code cleanup and improvements
- Fix feedback: it's now shown when needed and clear (e.g. "~~String~~
Password must be...")
- All action functions use `Sentry.withServerActionInstrumentation`
- `PasswordInput` "eye button" shows password only when mouse button is
hold and doesn't capture tab

#### Login page
- **Removed agree to terms checkbox** (it's only on signup now)
- Move provider login function to `actions.ts`

#### Signup page
- **Requires to type password twice**
- Shows waitlist information on *any* database error

#### Reset password page
- **Password update requires to type password twice**
- **When request to send email is processed then the feedback is:
Password reset email sent if user exists. Please check your email.**
- Email sent feedback is black, error is red
- Move send email and update password functions to `actions.ts`
- Disable button when email is sent

#### Other
- Update zod schema objects and move them to `types/auth`
- Move `components/PasswordInput.tsx` to `/components/auth`
- Make common UI elements separate components in `components/auth`
- Update `yarn.lock` (supabase packages)
- Remove redundant letter in `client.ts`
- Don't log error when user auth is missing in `useSupabase`; user is
simply not logged in

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Form feedback:
    - [x] Login works
    - [x] Signup works
    - [x] Reset email works
    - [x] Change password works
  - [x] Login works
  - [x] Signup works
  - [x] Reset email is sent
  - [x] Reset email logs user in and redirects to `/reset_password`
  - [x] Change password works
  - [x] Logout works
  - [x] All links across auth pages work

Note: OAuth login providers are disabled and so untested.

<details>
  <summary>Example test plan</summary>
  
  - [ ] Create from scratch and execute an agent with at least 3 blocks
- [ ] Import an agent from file upload, and confirm it executes
correctly
  - [ ] Upload agent to marketplace
- [ ] Import an agent from marketplace and confirm it executes correctly
  - [ ] Edit an agent from monitor, and confirm it executes correctly
</details>

#### For configuration changes:
- [ ] `.env.example` is updated or already compatible with my changes
- [ ] `docker-compose.yml` is updated or already compatible with my
changes
- [ ] I have included a list of my configuration changes in the PR
description (under **Changes**)

<details>
  <summary>Examples of configuration changes</summary>

  - Changing ports
  - Adding new services that need to communicate with each other
  - Secrets or environment variable changes
  - New or infrastructure changes such as databases
</details>

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
pull/9157/head^2
Krzysztof Czerwinski 2024-12-30 18:23:02 +00:00 committed by GitHub
parent 763284e3a3
commit 15af2f410b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 804 additions and 416 deletions

View File

@ -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<typeof loginFormSchema>) {
// 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<typeof loginFormSchema>) {
});
}
export async function signup(values: z.infer<typeof loginFormSchema>) {
"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");
},
);
}

View File

@ -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<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const api = useBackendAPI();
const form = useForm<z.infer<typeof loginFormSchema>>({
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<typeof loginFormSchema>) => {
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,84 +92,19 @@ 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<typeof loginFormSchema>) => {
setIsLoading(true);
const error = await login(data);
setIsLoading(false);
if (error) {
setFeedback(error);
return;
}
setFeedback(null);
};
return (
<div className="flex h-[80vh] items-center justify-center">
<div className="w-full max-w-md space-y-6 rounded-lg p-8 shadow-md">
<h1 className="text-lg font-medium">Log in to your Account </h1>
{/* <div className="mb-6 space-y-2">
<Button
className="w-full"
onClick={() => handleSignInWithProvider("google")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaGoogle className="mr-2 h-4 w-4" />
Sign in with Google
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("github")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaGithub className="mr-2 h-4 w-4" />
Sign in with GitHub
</Button>
<Button
className="w-full"
onClick={() => handleSignInWithProvider("discord")}
variant="outline"
type="button"
disabled={isLoading}
>
<FaDiscord className="mr-2 h-4 w-4" />
Sign in with Discord
</Button>
</div> */}
<AuthCard>
<AuthHeader>Login to your account</AuthHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onLogin)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="user@email.com" {...field} />
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -152,92 +114,38 @@ export default function LoginPage() {
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="password" {...field} />
</FormControl>
<FormDescription>
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
I agree to the{" "}
<FormItem className="mb-6">
<FormLabel className="flex w-full items-center justify-between">
<span>Password</span>
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="underline"
href="/reset_password"
className="text-sm font-normal leading-normal text-black underline"
>
Terms of Use
</Link>{" "}
and{" "}
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
className="underline"
>
Privacy Policy
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<div className="mb-6 mt-8 flex w-full space-x-4">
<Button
className="flex w-full justify-center"
type="submit"
disabled={isLoading}
onClick={async () => {
setIsLoading(true);
const values = form.getValues();
const result = await login(values);
if (result) {
setFeedback(result);
}
setIsLoading(false);
}}
>
{isLoading ? <FaSpinner className="animate-spin" /> : "Log in"}
</Button>
<Button
className="flex w-full justify-center"
type="button"
disabled={isLoading}
onClick={async () => {
setIsLoading(true);
const values = form.getValues();
const result = await signup(values);
if (result) {
setFeedback(result);
}
setIsLoading(false);
}}
>
{isLoading ? <FaSpinner className="animate-spin" /> : "Sign up"}
</Button>
</div>
</form>
<p className="text-sm text-red-500">{feedback}</p>
</Form>
<Link href="/reset_password" className="text-sm">
Forgot your password?
</Link>
</div>
</div>
</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<AuthButton
onClick={() => onLogin(form.getValues())}
isLoading={isLoading}
type="submit"
>
Login
</AuthButton>
</form>
<AuthFeedback message={feedback} isError={true} />
</Form>
<AuthBottomText
text="Don't have an account?"
linkText="Sign up"
href="/signup"
/>
</AuthCard>
);
}

View File

@ -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");
},
);
}

View File

@ -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,54 +17,87 @@ 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<string | null>(null);
const [isError, setIsError] = useState(false);
const [disabled, setDisabled] = useState(false);
const emailForm = useForm<z.infer<typeof emailFormSchema>>({
resolver: zodResolver(emailFormSchema),
const sendEmailForm = useForm<z.infer<typeof sendEmailFormSchema>>({
resolver: zodResolver(sendEmailFormSchema),
defaultValues: {
email: "",
},
});
const resetPasswordForm = useForm<z.infer<typeof resetPasswordFormSchema>>({
resolver: zodResolver(resetPasswordFormSchema),
const changePasswordForm = useForm<z.infer<typeof changePasswordFormSchema>>({
resolver: zodResolver(changePasswordFormSchema),
defaultValues: {
password: "",
confirmPassword: "",
},
});
if (isUserLoading) {
return (
<div className="flex h-[80vh] items-center justify-center">
<FaSpinner className="mr-2 h-16 w-16 animate-spin" />
</div>
const onSendEmail = useCallback(
async (data: z.infer<typeof sendEmailFormSchema>) => {
setIsLoading(true);
setFeedback(null);
if (!(await sendEmailForm.trigger())) {
setIsLoading(false);
return;
}
const error = await sendResetEmail(data.email);
setIsLoading(false);
if (error) {
setFeedback(error);
setIsError(true);
return;
}
setDisabled(true);
setFeedback(
"Password reset email sent if user exists. Please check your email.",
);
setIsError(false);
},
[sendEmailForm],
);
const onChangePassword = useCallback(
async (data: z.infer<typeof changePasswordFormSchema>) => {
setIsLoading(true);
setFeedback(null);
if (!(await changePasswordForm.trigger())) {
setIsLoading(false);
return;
}
const error = await changePassword(data.password);
setIsLoading(false);
if (error) {
setFeedback(error);
setIsError(true);
return;
}
setFeedback("Password changed successfully. Redirecting to login.");
setIsError(false);
},
[changePasswordForm],
);
if (isUserLoading) {
return <Spinner />;
}
if (!supabase) {
@ -68,147 +108,79 @@ export default function ResetPasswordPage() {
);
}
async function onSendEmail(d: z.infer<typeof emailFormSchema>) {
setIsLoading(true);
setFeedback(null);
if (!(await emailForm.trigger())) {
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;
}
setFeedback("Password reset email sent. Please check your email.");
setIsLoading(false);
}
async function onResetPassword(d: z.infer<typeof resetPasswordFormSchema>) {
setIsLoading(true);
setFeedback(null);
if (!(await resetPasswordForm.trigger())) {
setIsLoading(false);
return;
}
const { data, error } = await supabase!.auth.updateUser({
password: d.password,
});
if (error) {
setFeedback(error.message);
setIsLoading(false);
return;
}
await supabase!.auth.signOut();
router.push("/login");
}
return (
<div className="flex h-full flex-col items-center justify-center">
<div className="w-full max-w-md">
<h1 className="text-center text-3xl font-bold">Reset Password</h1>
<AuthCard>
<AuthHeader>Reset Password</AuthHeader>
{user ? (
<form
onSubmit={resetPasswordForm.handleSubmit(onResetPassword)}
className="mt-6 space-y-6"
>
<Form {...resetPasswordForm}>
<form onSubmit={changePasswordForm.handleSubmit(onChangePassword)}>
<Form {...changePasswordForm}>
<FormField
control={resetPasswordForm.control}
control={changePasswordForm.control}
name="password"
render={({ field }) => (
<FormItem className="mb-4">
<FormItem className="mb-6">
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="password"
{...field}
/>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={resetPasswordForm.control}
control={changePasswordForm.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="mb">
<FormItem className="mb-6">
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="password"
{...field}
/>
<PasswordInput {...field} />
</FormControl>
<FormDescription className="text-sm font-normal leading-tight text-slate-500">
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
<AuthButton
onClick={() => onChangePassword(changePasswordForm.getValues())}
isLoading={isLoading}
type="submit"
className="w-full"
disabled={isLoading}
onClick={() => onResetPassword(resetPasswordForm.getValues())}
>
{isLoading ? <FaSpinner className="mr-2 animate-spin" /> : null}
Reset Password
</Button>
Update password
</AuthButton>
<AuthFeedback message={feedback} isError={isError} />
</Form>
</form>
) : (
<form
onSubmit={emailForm.handleSubmit(onSendEmail)}
className="mt-6 space-y-6"
>
<Form {...emailForm}>
<form onSubmit={sendEmailForm.handleSubmit(onSendEmail)}>
<Form {...sendEmailForm}>
<FormField
control={emailForm.control}
control={sendEmailForm.control}
name="email"
render={({ field }) => (
<FormItem className="mb-4">
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="user@email.com" {...field} />
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
<AuthButton
onClick={() => onSendEmail(sendEmailForm.getValues())}
isLoading={isLoading}
disabled={disabled}
type="submit"
className="w-full"
disabled={isLoading}
onClick={() => onSendEmail(emailForm.getValues())}
>
{isLoading ? <FaSpinner className="mr-2 animate-spin" /> : null}
Send Reset Email
</Button>
{feedback ? (
<div className="text-center text-sm text-red-500">
{feedback}
</div>
) : null}
Send reset email
</AuthButton>
<AuthFeedback message={feedback} isError={isError} />
</Form>
</form>
)}
</div>
</div>
</AuthCard>
);
}

View File

@ -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<typeof signupFormSchema>) {
"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");
},
);
}

View File

@ -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<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [showWaitlistPrompt, setShowWaitlistPrompt] = useState(false);
const form = useForm<z.infer<typeof signupFormSchema>>({
resolver: zodResolver(signupFormSchema),
defaultValues: {
email: "",
password: "",
confirmPassword: "",
agreeToTerms: false,
},
});
const onSignup = useCallback(
async (data: z.infer<typeof signupFormSchema>) => {
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 <Spinner />;
}
if (!supabase) {
return (
<div>
User accounts are disabled because Supabase client is unavailable
</div>
);
}
return (
<AuthCard>
<AuthHeader>Create a new account</AuthHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSignup)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="mb-6">
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormDescription className="text-sm font-normal leading-tight text-slate-500">
Password needs to be at least 6 characters long
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AuthButton
onClick={() => onSignup(form.getValues())}
isLoading={isLoading}
type="submit"
>
Sign up
</AuthButton>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="mt-6 flex flex-row items-start -space-y-1 space-x-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="">
<FormLabel>
<span className="mr-1 text-sm font-normal leading-normal text-slate-950">
I agree to the
</span>
<Link
href="https://auto-gpt.notion.site/Terms-of-Use-11400ef5bece80d0b087d7831c5fd6bf"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
Terms of Use
</Link>
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
and
</span>
<Link
href="https://www.notion.so/auto-gpt/Privacy-Policy-ab11c9c20dbd4de1a15dcffe84d77984"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
Privacy Policy
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</form>
<AuthFeedback message={feedback} isError={true} />
</Form>
{showWaitlistPrompt && (
<div>
<span className="mr-1 text-sm font-normal leading-normal text-red-500">
The provided email may not be allowed to sign up.
</span>
<br />
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
- AutoGPT Platform is currently in closed beta. You can join
</span>
<Link
href="https://agpt.co/waitlist"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
the waitlist here.
</Link>
<br />
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
- Make sure you use the same email address you used to sign up for
the waitlist.
</span>
<br />
<span className="mx-1 text-sm font-normal leading-normal text-slate-950">
- You can self host the platform, visit our
</span>
<Link
href="https://agpt.co/waitlist"
className="text-sm font-normal leading-normal text-slate-950 underline"
>
GitHub repository.
</Link>
</div>
)}
<AuthBottomText
text="Already a member?"
linkText="Log in"
href="/login"
/>
</AuthCard>
);
}

View File

@ -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 (
<div
className={cn(
className,
"mt-8 inline-flex w-full items-center justify-center",
)}
>
<span className="text-sm font-medium leading-normal text-slate-950">
{text}
</span>
{linkText && (
<Link
href={href}
className="ml-1 text-sm font-medium leading-normal text-slate-950 underline"
>
{linkText}
</Link>
)}
</div>
);
}

View File

@ -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 (
<Button
className="mt-2 w-full self-stretch rounded-md bg-slate-900 px-4 py-2"
type={type}
disabled={isLoading || disabled}
onClick={onClick}
>
{isLoading ? (
<FaSpinner className="animate-spin" />
) : (
<div className="text-sm font-medium leading-normal text-slate-50">
{children}
</div>
)}
</Button>
);
}

View File

@ -0,0 +1,15 @@
import { ReactNode } from "react";
interface Props {
children: ReactNode;
}
export default function AuthCard({ children }: Props) {
return (
<div className="flex h-[80vh] w-[32rem] items-center justify-center">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-md">
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
interface Props {
message?: string | null;
isError?: boolean;
}
export default function AuthFeedback({ message = "", isError = false }: Props) {
return (
<div className="mt-4 text-center text-sm font-medium leading-normal">
{isError ? (
<div className="text-red-500">{message}</div>
) : (
<div className="text-slate-950">{message}</div>
)}
</div>
);
}

View File

@ -0,0 +1,13 @@
import { ReactNode } from "react";
interface Props {
children: ReactNode;
}
export default function AuthHeader({ children }: Props) {
return (
<div className="mb-8 text-2xl font-semibold leading-normal text-slate-950">
{children}
</div>
);
}

View File

@ -16,6 +16,7 @@ const PasswordInput = forwardRef<HTMLInputElement, InputProps>(
type={showPassword ? "text" : "password"}
className={cn("hide-password-toggle pr-10", className)}
ref={ref}
title="password"
{...props}
/>
<Button
@ -23,8 +24,11 @@ const PasswordInput = forwardRef<HTMLInputElement, InputProps>(
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword((prev) => !prev)}
onMouseDown={() => setShowPassword(true)}
onMouseUp={() => setShowPassword(false)}
onMouseLeave={() => setShowPassword(false)}
disabled={disabled}
tabIndex={-1}
>
{showPassword && !disabled ? (
<EyeIcon className="h-4 w-4" aria-hidden="true" />

View File

@ -0,0 +1,15 @@
import AuthBottomText from "./AuthBottomText";
import AuthButton from "./AuthButton";
import AuthCard from "./AuthCard";
import AuthFeedback from "./AuthFeedback";
import AuthHeader from "./AuthHeader";
import { PasswordInput } from "./PasswordInput";
export {
AuthBottomText,
AuthButton,
AuthCard,
AuthFeedback,
AuthHeader,
PasswordInput,
};

View File

@ -28,7 +28,11 @@ export default function useSupabase() {
const response = await supabase.auth.getUser();
if (response.error) {
// Display error only if it's not about missing auth session (user is not logged in)
if (response.error.message !== "Auth session missing!") {
console.error("Error fetching user", response.error);
}
setUser(null);
} else {
setUser(response.data.user);
}

View File

@ -7,27 +7,22 @@ export class LoginPage {
console.log("Attempting login with:", { email, password }); // Debug log
// Fill email
const emailInput = this.page.getByPlaceholder("user@email.com");
const emailInput = this.page.getByPlaceholder("m@example.com");
await emailInput.waitFor({ state: "visible" });
await emailInput.fill(email);
// Fill password
const passwordInput = this.page.getByPlaceholder("password");
const passwordInput = this.page.getByTitle("Password");
await passwordInput.waitFor({ state: "visible" });
await passwordInput.fill(password);
// Check terms
const termsCheckbox = this.page.getByLabel("I agree to the Terms of Use");
await termsCheckbox.waitFor({ state: "visible" });
await termsCheckbox.click();
// TODO: This is a workaround to wait for the page to load after filling the email and password
const emailInput2 = this.page.getByPlaceholder("user@email.com");
const emailInput2 = this.page.getByPlaceholder("m@example.com");
await emailInput2.waitFor({ state: "visible" });
await emailInput2.fill(email);
// Fill password
const passwordInput2 = this.page.getByPlaceholder("password");
const passwordInput2 = this.page.getByTitle("Password");
await passwordInput2.waitFor({ state: "visible" });
await passwordInput2.fill(password);

View File

@ -0,0 +1,63 @@
import { z } from "zod";
export type LoginProvider = "google" | "github" | "discord";
export const loginFormSchema = z.object({
email: z
.string()
.email()
.max(128, "Email must contain at most 128 characters")
.trim(),
password: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
});
export const signupFormSchema = z
.object({
email: z
.string()
.email()
.max(128, "Email must contain at most 128 characters")
.trim(),
password: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
confirmPassword: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
agreeToTerms: z.boolean().refine((value) => value === true, {
message: "You must agree to the Terms of Use and Privacy Policy",
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
export const sendEmailFormSchema = z.object({
email: z
.string()
.email()
.max(128, "Email must contain at most 128 characters")
.trim(),
});
export const changePasswordFormSchema = z
.object({
password: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
confirmPassword: z
.string()
.min(6, "Password must contain at least 6 characters")
.max(64, "Password must contain at most 64 characters"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});

View File

@ -3257,17 +3257,17 @@
resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-8.4.7.tgz#c308f6a883999bd35e87826738ab8a76515932b5"
integrity sha512-99rgLEjf7iwfSEmdqlHkSG3AyLcK0sfExcr0jnc6rLiAkBhzuIsvcHjjUwkR210SOCgXqBPW0ZA6uhnuyppHLw==
"@supabase/auth-js@2.67.1":
version "2.67.1"
resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.1.tgz#b72217136df61d645dcfb7b12c7db8cbb7875a4c"
integrity sha512-1SRZG9VkLFz4rtiyEc1l49tMq9jTYu4wJt3pMQEWi7yshZFIBdVH1o5sshk1plQd5LY6GcrPIpCydM2gGDxchA==
"@supabase/auth-js@2.67.3":
version "2.67.3"
resolved "https://registry.yarnpkg.com/@supabase/auth-js/-/auth-js-2.67.3.tgz#a1f5eb22440b0cdbf87fe2ecae662a8dd8bb2028"
integrity sha512-NJDaW8yXs49xMvWVOkSIr8j46jf+tYHV0wHhrwOaLLMZSFO4g6kKAf+MfzQ2RaD06OCUkUHIzctLAxjTgEVpzw==
dependencies:
"@supabase/node-fetch" "^2.6.14"
"@supabase/functions-js@2.4.3":
version "2.4.3"
resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.4.3.tgz#ac1c696d3a1ebe00f60d5cea69b208078678ef8b"
integrity sha512-sOLXy+mWRyu4LLv1onYydq+10mNRQ4rzqQxNhbrKLTLTcdcmS9hbWif0bGz/NavmiQfPs4ZcmQJp4WqOXlR4AQ==
"@supabase/functions-js@2.4.4":
version "2.4.4"
resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.4.4.tgz#45fcd94d546bdfa66d01f93a796ca0304ec154b8"
integrity sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==
dependencies:
"@supabase/node-fetch" "^2.6.14"
@ -3311,12 +3311,12 @@
"@supabase/node-fetch" "^2.6.14"
"@supabase/supabase-js@^2.47.8":
version "2.47.8"
resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.47.8.tgz#6471a356b694e14170a00e6582bdbd0126944ec6"
integrity sha512-2GjK8/PrGJYDVBcjqGyM2irBLMQXvvkJLbS8VFPlym2uuNz+pPMnwLbNf5njkknUTy3PamjgIRoADpuPPPA6oA==
version "2.47.10"
resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.47.10.tgz#310ce81dc734116f9445dbce7f9341ae1c24d834"
integrity sha512-vJfPF820Ho5WILYHfKiBykDQ1SB9odTHrRZ0JxHfuLMC8GRvv21YLkUZQK7/rSVCkLvD6/ZwMWaOAfdUd//guw==
dependencies:
"@supabase/auth-js" "2.67.1"
"@supabase/functions-js" "2.4.3"
"@supabase/auth-js" "2.67.3"
"@supabase/functions-js" "2.4.4"
"@supabase/node-fetch" "2.6.15"
"@supabase/postgrest-js" "1.17.7"
"@supabase/realtime-js" "2.11.2"