Create a login screen in Next.js to enable login with email address/password

  • nextjs
    nextjs
  • authjs
    authjs
Published on 2024/02/27

Introduction

Up to the previous article, we used Auth.js to determine whether the session for an incoming request was logged in or not, and used Next.js middleware to configure which screens could be accessed in each state.
This time, we will implement the login process and verify that we can access screens that are only available when logged in.

https://shinagawa-web.com/en/blogs/nextjs-middleware-auth-access-control

Goal for this article

We will create a login input form and, when login succeeds, redirect to the settings screen and display the session information. When the user logs out, they will be taken back to the login screen.

Image from Gyazo

We will also implement validation at login time so that if the password is not entered, or if the entered email address/password is incorrect, the user will be notified.

Login with email address/password

This can be implemented using the Auth.js Credentials Provider.

https://authjs.dev/getting-started/providers/credentials

Implementation flow

There are more parts to implement than before, and the amount of code is accordingly larger. However, since no new libraries are introduced, the code should be relatively easy to understand.

  • ① Create a Zod schema for login
  • ② Create processing to access the database using the email address as the key
  • ③ Create authentication processing
  • ④ Create a server action for login and integrate the authentication processing
  • ⑤ Create the login form (including validation)
  • ⑥ Integrate the login form into the login screen
  • ⑦ Implement logout processing on the settings screen

① Create a Zod schema for login

First, we will create a Zod schema for login.
It is similar to the schema for account creation, but we change two points:

  • No validation for name input
  • Password must be at least 1 character (because password strength is enforced at account creation time)

Add the following to index.ts under /schema.

index.ts
+ export const LoginSchema = z.object({
+   email: z.string().email({
+     message: 'Please enter your email address',
+   }),
+   password: z.string().min(1, {
+     message: 'Please enter your password',
+   }),
+ })

② Create processing to access the database using the email address as the key

We will create processing that queries the database for user information based on the email address.
Since this will be used in other places later as common processing, we will implement it as a standalone function.

Create a user.ts file under /data.

user.ts
import db from '@/lib/db'

export const getUserByEmail = async (email: string) => {
  try {
    const user = await db.user.findUnique({ where: { email } })

    return user
  } catch {
    return null
  }
}

③ Create authentication processing

We will perform authentication using the schema definition and database query processing created in ① and ②.

auth.config.ts
+ import bcryptjs from 'bcryptjs'
import type { NextAuthConfig } from 'next-auth'
+ import Credentials from 'next-auth/providers/credentials'
import github from 'next-auth/providers/github'
+ import { getUserByEmail } from './data/user'
+ import { LoginSchema } from './schema'

- export default { providers: [github] } satisfies NextAuthConfig
+ export default {
+   providers: [
+     github,
+     Credentials({
+       async authorize(credentials) {
+         const validatedFields = LoginSchema.safeParse(credentials)
+
+         if (!validatedFields.success) {
+           return null
+         }

+         const { email, password } = validatedFields.data
+         const user = await getUserByEmail(email)
+         if (!user || !user.password) {
+           return null
+         }
+         const passwordMatch = await bcryptjs.compare(password, user.password)
+         if (passwordMatch) {
+           return user
+         }
+         return null
+       },
+     }),
+   ],
+ } satisfies NextAuthConfig

Explanation of the code:

+     Credentials({
+       async authorize(credentials) {
+         const validatedFields = LoginSchema.safeParse(credentials)
+
+         if (!validatedFields.success) {
+           return null
+         }

The credentials object contains the email address and password entered by the user at login. We run schema validation, and if there is a problem, we terminate the process.

+         const { email, password } = validatedFields.data
+         const user = await getUserByEmail(email)
+         if (!user || !user.password) {
+           return null
+         }

We access the database using the email address entered by the user, and if the account is not registered or no password is set, we terminate the process.

+         const passwordMatch = await bcryptjs.compare(password, user.password)
+         if (passwordMatch) {
+           return user
+         }

After retrieving the hashed password from the database, we hash the password entered by the user and compare them; if they match, we return the user information and treat it as a successful completion.

④ Create a server action for login and integrate the authentication processing

We will integrate the authentication processing created above into a server action.

Create a login.ts file under /actions.

login.ts
'use server'
import { AuthError } from 'next-auth'
import { z } from 'zod'
import { signIn } from '@/auth'
import { DEFAULT_LOGIN_REDIRECT } from '@/route'
import { LoginSchema } from '@/schema'

export const login = async (values: z.infer<typeof LoginSchema>) => {
  const validatedFields = LoginSchema.safeParse(values)

  if (!validatedFields.success) {
    return { error: 'Please correct the input' }
  }

  const { email, password } = validatedFields.data
  try {
    await signIn('credentials', {
      email,
      password,
      redirectTo: DEFAULT_LOGIN_REDIRECT,
    })
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return {
            error: 'Email address or password is incorrect.',
          }
        default:
          return { error: 'An error occurred。' }
      }
    }

    throw error
  }
}

Explanation of the code:

    await signIn('credentials', {
      email,
      password,
      redirectTo: DEFAULT_LOGIN_REDIRECT,
    })

When the signIn function is executed, the authorize we defined earlier is called.
We set DEFAULT_LOGIN_REDIRECT (/settings) as the redirect destination when the process completes successfully.

⑤ Create the login form (including validation)

Now that the login processing has been implemented, we will prepare an input form for logging in and wire it up to the login processing.

Create a login-form.tsx file under /component/auth.

login-form.tsx
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useState, useTransition } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { login } from '@/actions/login'
import { LoginSchema } from '@/schema'
import { FormError } from '../form-error'
import { Button } from '../ui/button'
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '../ui/form'
import { Input } from '../ui/input'
import { CardWrapper } from './card-wrapper'

export const LoginForm = () => {
  const [error, setError] = useState<string | undefined>('')
  const [isPending, setTransition] = useTransition()
  const form = useForm<z.infer<typeof LoginSchema>>({
    resolver: zodResolver(LoginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  })
  const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
    setError('')
    setTransition(async () => {
      try {
        const response = await login(values)
        if (response && response.error) {
          setError(response.error)
        }
      } catch (e) {
        setError('An error occurred')
      }
    })
  }
  return (
    <CardWrapper
      headerLabel="Login with Email and Password"
      buttonLabel="Don't have an account? Click here"
      buttonHref="/auth/register"
      showSocial
    >
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
          <div className="space-y-4">
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email Address</FormLabel>
                  <FormControl>
                    <Input
                      {...field}
                      disabled={isPending}
                      placeholder="nextjs@example.com"
                      type="email"
                      autoComplete="off"
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <FormField
              control={form.control}
              name="password"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Password</FormLabel>
                  <FormControl>
                    <Input
                      {...field}
                      placeholder="******"
                      type="password"
                      disabled={isPending}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>
          <FormError message={error} />
          <Button type="submit" className="w-full" disabled={isPending}>
            Login
          </Button>
        </form>
      </Form>
    </CardWrapper>
  )
}

This is almost the same structure as the account creation form, so the detailed explanation is omitted.
One difference is that, while the account creation form returned a message on success, in the case of login we redirect to the settings screen on success, so there is no need to display a success message.
Therefore, we removed the component that displays a success message and the state management for that message.

⑥ Integrate the login form into the login screen

Integrate the created login form into the page.

page.tsx
import { LoginForm } from '@/components/auth/login-form'

const LoginPage = () => {
  return <LoginForm />
}

export default LoginPage

⑦ Implement logout processing on the settings screen

For operation verification, we will implement logout processing on the settings screen.

page.tsx
+ import { auth, signOut } from '@/auth'
+ import { Button } from '@/components/ui/button'

- const SettingsPage = () => {
-   return <div>Settings Screen</div>

+ const SettingsPage = async () => {
+   const session = await auth()
+   if (!session) return null
+   const onSubmit = async () => {
+     'use server'
+     await signOut()
+   }
+   return (
+     <div>
+       Settings Screen
+       <pre>{JSON.stringify(session, null, 2)}</pre>
+       <form action={onSubmit}>
+         <Button type="submit" className="w-full" variant="secondary">
+           Logout
+         </Button>
+       </form>
+     </div>
+   )
}

export default SettingsPage

Explanation of the code:

+   const session = await auth()
+   if (!session) return null

((※ Partially omitted))

+       <pre>{JSON.stringify(session, null, 2)}</pre>

With await auth(), Auth.js allows you to obtain the session on the server side. We then display the obtained session information on the settings screen.

+   const onSubmit = async () => {
+     'use server'
+     await signOut()
+   }

This is the processing for logging out.

Operation check

After implementing all the code, we will verify that the implemented parts behave as expected.

① When the password is not entered, a validation error is returned
② When the password is incorrect, the login server action returns an error
③ When the correct email address/password is entered, you are redirected to the settings screen, can log in, and can confirm the session information
④ When you log out, you are redirected to the login screen (Next.js middleware feature)
⑤ When you access the settings screen while not logged in, you are redirected to the login screen (Next.js middleware feature)

Items ④ and ⑤ are features implemented in the previous article.

Image from Gyazo

Conclusion

With this article, we have completed the series of processes from account creation to login and logout.
However, there are still several points to consider regarding authentication.

  • Checking whether the email address exists
  • Password reset
  • Two-factor authentication

These features are essential when implementing authentication with email address/password. In future articles, we will also cover how to implement these.
Even when using libraries like Auth.js, implementing email/password authentication involves a fair number of considerations and can be quite involved. For systems in the PoC phase where you want to spend as little time as possible on authentication, it may be better to use OAuth-based authentication with providers such as Google or Facebook.
Auth.js also supports integrating OAuth-based authentication, and we will cover that in future articles as well.

https://shinagawa-web.com/en/blogs/nextjs-email-verification-implementation-sending-email

https://shinagawa-web.com/en/blogs/nextjs-password-reset-email-sending

https://shinagawa-web.com/en/blogs/nextjs-two-factor-authentication

https://shinagawa-web.com/en/blogs/nextjs-github-google-oauth

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form