You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何将NextAuth V5/Auth.js登录错误信息持久化到前端?

问题描述

正在将应用从PHP迁移到Next.js,使用Auth.js并参考CodeWithAntonio的教程,采用mysql2而非Prisma的实现方式,对React/Next.js/Auth.js不熟悉。目前登录功能可正常工作,但当凭证不匹配时,无法将指定错误信息传递到前端。使用的是next-auth 5.0.0-beta.25,同时想了解是否必须使用react-hook-forms。


相关代码文件

log-in-form.tsx

'use client'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { LogInSchema } from '../../../../../../constants/validation-schemas'
import { logIn } from '../../../../../../actions/users/log-in'

export default function LogInForm() {
    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm<z.infer<typeof LogInSchema>>({
        resolver: zodResolver(LogInSchema),
    })

    async function onSubmit(credentials: z.infer<typeof LogInSchema>) {
        logIn(credentials)
    }

    return (
        <>
            <div id="log-in-container">
                <form id="log-in-form" onSubmit={handleSubmit(onSubmit)}>
                    <div className="label">
                        <label htmlFor="username">username</label>
                    </div>
                    <div className="field">
                        <input
                            {...register('username')}
                            type="text"
                            id="username"
                            maxLength={15}
                        />
                        {errors.username && (
                            <div className="error-message">
                                {errors.username.message}
                            </div>
                        )}
                    </div>
                    <div className="label">
                        <label htmlFor="password">password</label>
                    </div>
                    <div className="field">
                        <input
                            {...register('password')}
                            type="password"
                            id="password"
                            maxLength={16}
                        />
                        {errors.password && (
                            <div className="error-message">
                                {errors.password.message}
                            </div>
                        )}
                    </div>
                    <button type="submit" id="log-in-button">
                        log in
                    </button>
                    {errors.root && (
                        <div className="error-message">
                            {errors.root.message}
                        </div>
                    )}
                </form>
            </div>
        </>
    )
}

auth.ts

import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { LogInSchema } from './constants/validation-schemas'
import bcryptjs from 'bcryptjs'
import { getPasswordByUsername } from './actions/users/get-password-by-username'
import { getUserIdByUsername } from './actions/users/get-user-id-by-username'
import { getSuspensionStatusByUsername } from './actions/users/get-suspension-status-by-username'

export const { auth, handlers, signIn, signOut } = NextAuth({
    providers: [
        Credentials({
            async authorize(credentials) {
                const validatedFields = LogInSchema.safeParse(credentials)

                if (validatedFields.success) {
                    const { username, password } = validatedFields.data

                    const user = await getUserIdByUsername(username)

                    if (!user) {
                        return null
                    }

                    const suspended = await getSuspensionStatusByUsername(
                        username
                    )

                    if (suspended) {
                        return null
                    }

                    const storedPassword = await getPasswordByUsername(username)

                    const passwordsMatch = await bcryptjs.compare(
                        password,
                        storedPassword
                    )

                    if (passwordsMatch) {
                        return await getUserIdByUsername(username)
                    }

                    return null
                }

                return null
            },
        }),
    ],
    session: { strategy: 'jwt' },
    callbacks: {
        async session({ session, user, token }) {
            return session
        },
        async jwt({ token, user, account, profile, isNewUser }) {
            return token
        },
    },
})

log-in.ts(服务器操作)

'use server'
import * as z from 'zod'
import { LogInSchema } from '../../constants/validation-schemas'
import { signIn } from '../../auth'
import { DEFAULT_LOGIN_REDIRECT } from '../../constants/routes'
import { AuthError } from 'next-auth'

export async function logIn(values: z.infer<typeof LogInSchema>) {
    const validatedFields = LogInSchema.safeParse(values)

    const { username, password } = validatedFields.data

    try {
        await signIn('credentials', {
            username,
            password,
            redirectTo: DEFAULT_LOGIN_REDIRECT,
        })
    } catch (error) {
        if (error instanceof AuthError) {
            switch (error.type) {
                case 'CredentialsSignin':
                    return { error: 'Invalid credentials.' }
                default:
                    return { error: 'Something went wrong.' }
            }
        }
        throw error
    }
}

终端错误日志

[auth][error] CredentialsSignin: Read more at https://errors.authjs.dev#credentialssignin
    at Module.callback (webpack-internal:///(action-browser)/./node_modules/@auth/core/lib/actions/callback/index.js:246:23)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async AuthInternal (webpack-internal:///(action-browser)/./node_modules/@auth/core/lib/index.js:70:24)
    at async Auth (webpack-internal:///(action-browser)/./node_modules/@auth/core/index.js:130:34)
    at async signIn (webpack-internal:///(action-browser)/./node_modules/next-auth/lib/actions.js:53:17)
    at async logIn (webpack-internal:///(action-browser)/./actions/users/log-in.ts:23:9)

解决方案

1. 修复错误信息传递到前端的问题

当前核心问题是onSubmit函数未接收服务器操作返回的错误,也未将错误同步到react-hook-form的表单状态中,需做以下修改:

步骤1:更新log-in-form.tsx的onSubmit逻辑

首先在useForm解构中添加setError方法,用于手动设置表单错误:

const {
    register,
    handleSubmit,
    formState: { errors },
    setError, // 添加这个方法
} = useForm<z.infer<typeof LogInSchema>>({
    resolver: zodResolver(LogInSchema),
})

然后修改onSubmit函数,接收logIn的返回结果并设置错误:

async function onSubmit(credentials: z.infer<typeof LogInSchema>) {
    const result = await logIn(credentials)
    if (result?.error) {
        // 将错误设置为root级别的表单错误,对应页面中已有的errors.root渲染逻辑
        setError('root', { message: result.error })
    }
}

步骤2:(可选)区分不同错误类型

当前authorize函数所有失败场景都返回null,导致统一抛出CredentialsSignin错误。若需要区分“用户不存在”“账号封禁”“密码错误”等场景,可修改auth.ts的authorize函数抛出自定义错误:

async authorize(credentials) {
    const validatedFields = LogInSchema.safeParse(credentials)

    if (validatedFields.success) {
        const { username, password } = validatedFields.data

        const user = await getUserIdByUsername(username)

        if (!user) {
            throw new Error('用户不存在')
        }

        const suspended = await getSuspensionStatusByUsername(username)

        if (suspended) {
            throw new Error('账号已被封禁')
        }

        const storedPassword = await getPasswordByUsername(username)

        const passwordsMatch = await bcryptjs.compare(password, storedPassword)

        if (passwordsMatch) {
            return await getUserIdByUsername(username)
        }

        throw new Error('密码错误')
    }

    throw new Error('输入格式不正确')
}

然后在log-in.ts中捕获自定义错误信息:

try {
    await signIn('credentials', {
        username,
        password,
        redirectTo: DEFAULT_LOGIN_REDIRECT,
    })
} catch (error) {
    if (error instanceof AuthError) {
        const errorMessage = (error.cause as Error)?.message || '凭证无效'
        return { error: errorMessage }
    }
    throw error
}

2. 是否必须使用react-hook-form?

不是必须的。react-hook-form是一款提升表单开发效率的工具库,提供了状态管理、验证、提交处理等便捷能力,但你完全可以用原生HTML表单结合React的useState来实现登录功能,手动管理输入值和错误状态。

内容的提问来源于stack exchange,提问作者Zuno

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 04:02:33