Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(builder): Add Supabase Auth, session and sign in UI #7655

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions rnd/autogpt_builder/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
AGPT_SERVER_URL=http://localhost:8000/api

## Supabase credentials
## YOU ONLY NEED THEM IF YOU WANT TO USE SUPABASE USER AUTHENTICATION
## If you're using self-hosted version then you most likely don't need to set this
# NEXT_PUBLIC_SUPABASE_URL=your-project-url
# NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Comment on lines +6 to +7
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible to default these to our instance safely

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so


## OAuth Callback URL
## This should be {domain}/auth/callback
## Only used if you're using Supabase and OAuth
AUTH_CALLBACK_URL=http://localhost:3000/auth/callback
3 changes: 3 additions & 0 deletions rnd/autogpt_builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@supabase/ssr": "^0.4.0",
"@supabase/supabase-js": "^2.45.0",
"ajv": "^8.17.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
Expand All @@ -32,6 +34,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18",
"react-hook-form": "^7.52.1",
"react-icons": "^5.2.1",
"react-markdown": "^9.0.1",
"react-modal": "^3.16.1",
"reactflow": "^11.11.4",
Expand Down
34 changes: 34 additions & 0 deletions rnd/autogpt_builder/src/app/auth/auth-code-error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import { useEffect, useState } from 'react';

export default function AuthErrorPage() {
const [errorType, setErrorType] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [errorDescription, setErrorDescription] = useState<string | null>(null);

useEffect(() => {
// This code only runs on the client side
if (typeof window !== 'undefined') {
const hash = window.location.hash.substring(1); // Remove the leading '#'
const params = new URLSearchParams(hash);

setErrorType(params.get('error'));
setErrorCode(params.get('error_code'));
setErrorDescription(params.get('error_description')?.replace(/\+/g, ' ') ?? null); // Replace '+' with space
}
}, []);

if (!errorType && !errorCode && !errorDescription) {
return <div>Loading...</div>;
}

return (
<div>
<h1>Authentication Error</h1>
{errorType && <p>Error Type: {errorType}</p>}
{errorCode && <p>Error Code: {errorCode}</p>}
{errorDescription && <p>Error Description: {errorDescription}</p>}
</div>
);
}
36 changes: 36 additions & 0 deletions rnd/autogpt_builder/src/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server'
import { createServerClient } from '@/lib/supabase/server'

// Handle the callback to complete the user session login
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
// if "next" is in param, use it as the redirect URL
const next = searchParams.get('next') ?? '/profile'

if (code) {
const supabase = createServerClient()

if (!supabase) {
return NextResponse.redirect(`${origin}/error`)
}

const { data, error } = await supabase.auth.exchangeCodeForSession(code)
// data.session?.refresh_token is available if you need to store it for later use
if (!error) {
const forwardedHost = request.headers.get('x-forwarded-host') // original origin before load balancer
const isLocalEnv = process.env.NODE_ENV === 'development'
if (isLocalEnv) {
// we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host
return NextResponse.redirect(`${origin}${next}`)
} else if (forwardedHost) {
return NextResponse.redirect(`https://${forwardedHost}${next}`)
} else {
return NextResponse.redirect(`${origin}${next}`)
}
}
}

// return the user to an error page with instructions
return NextResponse.redirect(`${origin}/auth/auth-code-error`)
}
33 changes: 33 additions & 0 deletions rnd/autogpt_builder/src/app/auth/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { type EmailOtpType } from '@supabase/supabase-js'
import { type NextRequest } from 'next/server'

import { redirect } from 'next/navigation'
import { createServerClient } from '@/lib/supabase/server'

// Email confirmation route
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
const next = searchParams.get('next') ?? '/'

if (token_hash && type) {
const supabase = createServerClient()

if (!supabase) {
redirect('/error')
}

const { error } = await supabase.auth.verifyOtp({
type,
token_hash,
})
if (!error) {
// redirect user to specified redirect URL or root of app
redirect(next)
}
}

// redirect the user to an error page with some instructions
redirect('/error')
}
3 changes: 3 additions & 0 deletions rnd/autogpt_builder/src/app/error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function ErrorPage() {
return <p>Sorry, something went wrong</p>
}
6 changes: 3 additions & 3 deletions rnd/autogpt_builder/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import React from 'react';
import type { Metadata } from "next";
import { Inter } from "next/font/google";

import "./globals.css";

import { Providers } from "@/app/providers";
import {NavBar} from "@/components/NavBar";
import {cn} from "@/lib/utils";

import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
title: "NextGen AutoGPT",
description: "Your one stop shop to creating AI Agents",
};

export default function RootLayout({
children,
}: Readonly<{
Expand Down
54 changes: 54 additions & 0 deletions rnd/autogpt_builder/src/app/login/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createServerClient } from '@/lib/supabase/server'
import { z } from 'zod'

const loginFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
})

export async function login(values: z.infer<typeof loginFormSchema>) {
const supabase = createServerClient()

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.signInWithPassword(values)

if (error) {
return error.message
}

if (data.session) {
await supabase.auth.setSession(data.session);
}

revalidatePath('/', 'layout')
redirect('/profile')
}

export async function signup(values: z.infer<typeof loginFormSchema>) {
const supabase = createServerClient()

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) {
return error.message
}

if (data.session) {
await supabase.auth.setSession(data.session);
}

revalidatePath('/', 'layout')
redirect('/profile')
}
168 changes: 168 additions & 0 deletions rnd/autogpt_builder/src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"use client";
import useUser from '@/hooks/useUser';
import { login, signup } from './actions'
import { Button } from '@/components/ui/button';
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 { PasswordInput } from '@/components/PasswordInput';
import { FaGoogle, FaGithub, FaDiscord, FaSpinner } from "react-icons/fa";
import { useState } from 'react';
import { useSupabase } from '@/components/SupabaseProvider';
import { useRouter } from 'next/navigation';

const loginFormSchema = z.object({
email: z.string().email().min(2).max(64),
password: z.string().min(6).max(64),
})

export default function LoginPage() {
const { supabase, isLoading: isSupabaseLoading } = useSupabase();
const { user, isLoading: isUserLoading } = useUser();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);

const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
email: "",
password: "",
},
})

if (user) {
console.log('User exists, redirecting to profile')
router.push('/profile')
}

if (isUserLoading || isSupabaseLoading || user) {
return (
<div className="flex justify-center items-center h-[80vh]">
<FaSpinner className="mr-2 h-16 w-16 animate-spin"/>
</div>
);
}

if (!supabase) {
return <div>User accounts are disabled because Supabase client is unavailable</div>
}

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`,
// Get Google provider_refresh_token
// queryParams: {
// access_type: 'offline',
// prompt: 'consent',
// },
},
})

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)
}

const onSignup = async (data: z.infer<typeof loginFormSchema>) => {
if (await form.trigger()) {
setIsLoading(true)
const error = await signup(data)
setIsLoading(false)
if (error) {
setFeedback(error)
return
}
setFeedback(null)
}
}

return (
<div className="flex items-center justify-center h-[80vh]">
<div className="w-full max-w-md p-8 rounded-lg shadow-md space-y-6">
<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>
<Form {...form}>
<form onSubmit={form.handleSubmit(onLogin)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className='mb-4'>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
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>
)}
/>
<div className='flex w-full space-x-4 mt-6 mb-6'>
<Button className='w-1/2 flex justify-center' type="submit" disabled={isLoading}>
Log in
</Button>
<Button
className='w-1/2 flex justify-center'
variant='outline'
type="button"
onClick={form.handleSubmit(onSignup)}
disabled={isLoading}
>
Sign up
</Button>
</div>
</form>
<p className='text-red-500 text-sm'>{feedback}</p>
<p className='text-primary text-center text-sm'>
By continuing you agree to everything
</p>
</Form>
</div>
</div>
)
}
Loading
Loading