diff --git a/app/auth/login/action.ts b/app/auth/login/action.ts new file mode 100644 index 0000000..8790ee9 --- /dev/null +++ b/app/auth/login/action.ts @@ -0,0 +1,22 @@ +'use server' +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' +import type { SignInSchema } from '@/auth/login/schema' +import { createClient } from '@/auth/supabase/server' + +export const signIn = async ({ + email, + password +}: SignInSchema): Promise => { + const supabase = createClient(cookies()) + + const { error } = await supabase.auth.signInWithPassword({ + email, + password + }) + if (error) { + throw error + } else { + return redirect('/') + } +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index 37e58b9..8fe4eef 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -1,5 +1,85 @@ -import { Heading } from '@chakra-ui/react' +'use client' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { + Box, + Flex, + FormLabel, + FormErrorMessage, + FormControl, + Input, + Heading, + useToast, + useBoolean +} from '@chakra-ui/react' +import { signIn } from '@/auth/login/action' +import { signInResolver, SignInSchema } from '@/auth/login/schema' +import { PrimaryButton } from '@/components/button' -export default function Login() { - return Login Page +export default function SignIn() { + const toast = useToast() + const [isLoading, setIsLoading] = useBoolean() + const [toastId, setToastId] = useState | undefined>( + undefined + ) + + const { + register, + handleSubmit, + formState: { errors } + } = useForm({ + resolver: signInResolver + }) + + const signInHandler = handleSubmit(async (data: SignInSchema) => { + try { + setIsLoading.on() + await signIn(data) + } catch (error) { + if (!toastId) { + const res = toast({ + title: "We're sorry, but you failed to sign in.", + description: error instanceof Error ? error.message : 'unknown error', + status: 'error', + duration: 5000, + isClosable: true, + position: 'top', + onCloseComplete() { + setToastId(undefined) + } + }) + setToastId(res) + } + } finally { + setIsLoading.off() + } + }) + + return ( + <> + Welcome Back! + Sign In + + + + Email address + + {errors.email && ( + {errors.email.message} + )} + + + Password + + {errors.password && ( + {errors.password.message} + )} + + + Sign In + + + + + ) } diff --git a/app/auth/login/schema.ts b/app/auth/login/schema.ts new file mode 100644 index 0000000..2f190c1 --- /dev/null +++ b/app/auth/login/schema.ts @@ -0,0 +1,12 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import * as z from 'zod' + +const signInSchema = z.object({ + email: z.string().email('This is not valid email address'), + password: z + .string() + .min(8, { message: 'Password must contain at least 8 character(s)' }) +}) + +export type SignInSchema = z.infer +export const signInResolver = zodResolver(signInSchema) diff --git a/app/auth/signup/email/action.ts b/app/auth/signup/email/action.ts index ac5c2f6..bf4c023 100644 --- a/app/auth/signup/email/action.ts +++ b/app/auth/signup/email/action.ts @@ -2,7 +2,7 @@ import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import type { SignUpSchema } from '@/auth/signup/email/schema' -import { createClient } from '@/auth/supabase' +import { createClient } from '@/auth/supabase/server' export const signUp = async ({ email, diff --git a/app/auth/signup/page.tsx b/app/auth/signup/page.tsx index 0302310..793b5ca 100644 --- a/app/auth/signup/page.tsx +++ b/app/auth/signup/page.tsx @@ -14,6 +14,10 @@ export default function SignUp() { + Already have an account? + + Sign in to your account? + ) } diff --git a/app/auth/supabase/middleware.ts b/app/auth/supabase/middleware.ts new file mode 100644 index 0000000..7c4cbda --- /dev/null +++ b/app/auth/supabase/middleware.ts @@ -0,0 +1,60 @@ +import { createServerClient, type CookieOptions } from '@supabase/ssr' +import { type NextRequest, NextResponse } from 'next/server' + +export const createClient = (request: NextRequest) => { + let response = NextResponse.next({ + request: { + headers: request.headers + } + }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return request.cookies.get(name)?.value + }, + set(name: string, value: string, options: CookieOptions) { + // If the cookie is updated, update the cookies for the request and response + request.cookies.set({ + name, + value, + ...options + }) + response = NextResponse.next({ + request: { + headers: request.headers + } + }) + response.cookies.set({ + name, + value, + ...options + }) + }, + remove(name: string, options: CookieOptions) { + // If the cookie is removed, update the cookies for the request and response + request.cookies.set({ + name, + value: '', + ...options + }) + response = NextResponse.next({ + request: { + headers: request.headers + } + }) + response.cookies.set({ + name, + value: '', + ...options + }) + } + } + } + ) + + return { supabase, response } +} diff --git a/app/auth/supabase.ts b/app/auth/supabase/server.ts similarity index 100% rename from app/auth/supabase.ts rename to app/auth/supabase/server.ts diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..33e07a7 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,24 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { createClient } from '@/auth/supabase/middleware' + +export async function middleware(request: NextRequest) { + const { supabase, response } = createClient(request) + + // Refresh session if expired - required for Server Components + // https://supabase.com/docs/guides/auth/auth-helpers/nextjs#managing-session-with-middleware + const { + error, + data: { session } + } = await supabase.auth.getSession() + + const loginUri = '/auth/login' + + if (request.nextUrl.pathname.startsWith(loginUri)) { + return response + } + if (error || !session) { + return NextResponse.redirect(new URL(loginUri, request.url)) + } + + return response +}