Improves error messages in auth (#1120)

This commit is contained in:
Mihovil Ilakovac 2023-04-11 16:49:26 +02:00 committed by GitHub
parent 28fb8310e2
commit ee5272e6de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 118 additions and 63 deletions

View File

@ -1,5 +1,5 @@
{{={= =}=}}
import { useState, FormEvent, useEffect, useCallback } from 'react'
import { useState, FormEvent, useEffect } from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import { createTheme } from '@stitches/react'
@ -22,6 +22,11 @@ import config from '../../config.js'
import { styled } from '../../stitches.config'
import { State, CustomizationOptions } from './types'
type ErrorMessage = {
title: string;
description?: string;
};
const logoStyle = {
height: '3rem'
}
@ -216,13 +221,13 @@ function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
state: State;
} & CustomizationOptions) {
const isLogin = state === "login";
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<ErrorMessage | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
{=# isAnyPasswordBasedAuthEnabled =}
const history = useHistory();
const onErrorHandler = (error) => {
setErrorMessage(error.message)
setErrorMessage({ title: error.message, description: error.data?.data?.message })
};
{=/ isAnyPasswordBasedAuthEnabled =}
{=# isUsernameAndPasswordAuthEnabled =}
@ -358,7 +363,9 @@ function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
<HeaderText>{title}</HeaderText>
</div>
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
{errorMessage && (<ErrorMessage>
{errorMessage.title}{errorMessage.description && ': '}{errorMessage.description}
</ErrorMessage>)}
{successMessage && <SuccessMessage>{successMessage}</SuccessMessage>}
{(state === 'login' || state === 'signup') && loginSignupForm}
{=# isEmailAuthEnabled =}
@ -388,7 +395,14 @@ function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
export default Auth;
{=# isEmailAuthEnabled =}
const ForgotPasswordForm = ({ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }) => {
const ForgotPasswordForm = (
{ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }: {
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
setErrorMessage: (errorMessage: ErrorMessage | null) => void;
setSuccessMessage: (successMessage: string | null) => void;
},
) => {
const [email, setEmail] = useState('')
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
@ -401,7 +415,7 @@ const ForgotPasswordForm = ({ isLoading, setIsLoading, setErrorMessage, setSucce
setSuccessMessage('Check your email for a password reset link.')
setEmail('')
} catch (error) {
setErrorMessage(error.message)
setErrorMessage({ title: error.message, description: error.data?.data?.message })
} finally {
setIsLoading(false)
}
@ -428,7 +442,14 @@ const ForgotPasswordForm = ({ isLoading, setIsLoading, setErrorMessage, setSucce
)
}
const ResetPasswordForm = ({ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }) => {
const ResetPasswordForm = (
{ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }: {
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
setErrorMessage: (errorMessage: ErrorMessage | null) => void;
setSuccessMessage: (successMessage: string | null) => void;
},
) => {
const location = useLocation()
const token = new URLSearchParams(location.search).get('token')
const [password, setPassword] = useState('')
@ -438,12 +459,12 @@ const ResetPasswordForm = ({ isLoading, setIsLoading, setErrorMessage, setSucces
event.preventDefault()
if (!token) {
setErrorMessage('The token is missing from the URL. Please check the link you received in your email.')
setErrorMessage({ title: 'The token is missing from the URL. Please check the link you received in your email.' })
return
}
if (!password || password !== passwordConfirmation) {
setErrorMessage("Passwords don't match!")
setErrorMessage({ title: `Passwords don't match!` })
return
}
@ -456,7 +477,7 @@ const ResetPasswordForm = ({ isLoading, setIsLoading, setErrorMessage, setSucces
setPassword('')
setPasswordConfirmation('')
} catch (error) {
setErrorMessage(error.message)
setErrorMessage({ title: error.message, description: error.data?.data?.message })
} finally {
setIsLoading(false)
}
@ -493,7 +514,14 @@ const ResetPasswordForm = ({ isLoading, setIsLoading, setErrorMessage, setSucces
)
}
const VerifyEmailForm = ({ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }) => {
const VerifyEmailForm = (
{ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }: {
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
setErrorMessage: (errorMessage: ErrorMessage | null) => void;
setSuccessMessage: (successMessage: string | null) => void;
},
) => {
const location = useLocation()
const token = new URLSearchParams(location.search).get('token')
@ -509,7 +537,7 @@ const VerifyEmailForm = ({ isLoading, setIsLoading, setErrorMessage, setSuccessM
await verifyEmail({ token })
setSuccessMessage('Your email has been verified. You can now log in.')
} catch (error) {
setErrorMessage(error.message)
setErrorMessage({ title: error.message, description: error.data?.data?.message })
} finally {
setIsLoading(false)
}

View File

@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import { verifyPassword } from "../../../core/auth.js";
import { findUserBy, createAuthToken, ensureValidEmailAndPassword } from "../../utils.js";
import { findUserBy, createAuthToken, ensureValidEmailAndPassword, throwInvalidCredentialsError } from "../../utils.js";
export function getLoginRoute({
allowUnverifiedLogin,
@ -11,25 +11,25 @@ export function getLoginRoute({
req: Request<{ email: string; password: string; }>,
res: Response,
): Promise<Response<{ token: string } | undefined>> {
const args = req.body || {};
ensureValidEmailAndPassword(args);
const args = req.body || {}
ensureValidEmailAndPassword(args)
args.email = args.email.toLowerCase();
args.email = args.email.toLowerCase()
const user = await findUserBy<'email'>({ email: args.email });
const user = await findUserBy<'email'>({ email: args.email })
if (!user) {
return res.status(401).send();
throwInvalidCredentialsError()
}
if (!user.isEmailVerified && !allowUnverifiedLogin) {
return res.status(401).send();
throwInvalidCredentialsError()
}
try {
await verifyPassword(user.password, args.password);
} catch(e) {
return res.status(401).send();
throwInvalidCredentialsError()
}
const token = await createAuthToken(user);
const token = await createAuthToken(user)
return res.json({ token })
};

View File

@ -2,20 +2,20 @@
import { verifyPassword } from '../../../core/auth.js'
import { handleRejection } from '../../../utils.js'
import { findUserBy, createAuthToken } from '../../utils.js'
import { findUserBy, createAuthToken, throwInvalidCredentialsError } from '../../utils.js'
export default handleRejection(async (req, res) => {
const args = req.body || {}
const user = await findUserBy<'username'>({ username: args.username })
if (!user) {
return res.status(401).send()
throwInvalidCredentialsError()
}
try {
await verifyPassword(user.password, args.password)
} catch(e) {
return res.status(401).send()
throwInvalidCredentialsError()
}
// Username & password valid - generate token.

View File

@ -7,5 +7,5 @@ export default handleRejection(async (req, res) => {
await createUser(userFields)
return res.send()
return res.json({ success: true })
})

View File

@ -34,7 +34,7 @@ export async function createUser(data: Prisma.{= userEntityUpper =}CreateInput):
try {
return await prisma.{= userEntityLower =}.create({ data })
} catch (e) {
rethrowError(e);
rethrowPossiblePrismaError(e);
}
}
@ -42,7 +42,7 @@ export async function deleteUser(user: {= userEntityUpper =}): Promise<{= userEn
try {
return await prisma.{= userEntityLower =}.delete({ where: { id: user.id } })
} catch (e) {
rethrowError(e);
rethrowPossiblePrismaError(e);
}
}
@ -74,7 +74,7 @@ export async function updateUserEmailVerification(userId: {= userEntityUpper =}I
data: { isEmailVerified: true },
})
} catch (e) {
rethrowError(e);
rethrowPossiblePrismaError(e);
}
}
@ -85,7 +85,7 @@ export async function updateUserPassword(userId: {= userEntityUpper =}Id, passwo
data: { password },
})
} catch (e) {
rethrowError(e);
rethrowPossiblePrismaError(e);
}
}
@ -135,7 +135,7 @@ async function sendEmailAndLogTimestamp(
data: { [field]: new Date() },
})
} catch (e) {
rethrowError(e);
rethrowPossiblePrismaError(e);
}
emailSender.send(content).catch((e) => {
console.error(`Failed to send email for ${field}`, e);
@ -200,18 +200,26 @@ export function ensureValidPassword(args: unknown): void {
function validate(args: unknown, validators: { validates: string, message: string, validator: (value: unknown) => boolean }[]): void {
for (const { validates, message, validator } of validators) {
if (!validator(args[validates])) {
throw new HttpError(422, `Validation failed: ${message}`, { message, field: validates })
throwValidationError(message);
}
}
}
{=/ isEmailAuthEnabled =}
function rethrowError(e: unknown): void {
export function throwInvalidCredentialsError(message?: string): void {
throw new HttpError(401, 'Invalid credentials', { message })
}
function rethrowPossiblePrismaError(e: unknown): void {
if (e instanceof AuthError) {
throw new HttpError(422, 'Validation failed', { message: e.message })
throwValidationError(e.message);
} else if (isPrismaError(e)) {
throw prismaErrorToHttpError(e)
} else {
throw new HttpError(500)
}
}
function throwValidationError(message: string): void {
throw new HttpError(422, 'Validation failed', { message })
}

View File

@ -7,6 +7,7 @@ import { randomInt } from 'node:crypto'
import prisma from '../dbClient.js'
import { handleRejection } from '../utils.js'
import config from '../config.js'
import { throwInvalidCredentialsError } from '../auth/utils.js'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
@ -33,7 +34,7 @@ const auth = handleRejection(async (req, res, next) => {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
return res.status(401).send()
throwInvalidCredentialsError()
} else {
throw error
}
@ -41,7 +42,7 @@ const auth = handleRejection(async (req, res, next) => {
const user = await prisma.{= userEntityLower =}.findUnique({ where: { id: userIdFromToken } })
if (!user) {
return res.status(401).send()
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
@ -52,7 +53,7 @@ const auth = handleRejection(async (req, res, next) => {
req.user = userView
} else {
return res.status(401).send()
throwInvalidCredentialsError()
}
next()

View File

@ -1,11 +1,12 @@
{{={= =}=}}
import { serialize as superjsonSerialize } from 'superjson'
import { handleRejection } from '../../utils.js'
import { throwInvalidCredentialsError } from '../../auth/utils.js'
export default handleRejection(async (req, res) => {
if (req.{= userEntityLower =}) {
return res.json(superjsonSerialize(req.{= userEntityLower =}))
} else {
return res.status(401).send()
throwInvalidCredentialsError()
}
})

View File

@ -32,7 +32,7 @@ export const prismaErrorToHttpError = (e) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
message: `user with the same ${e.meta.target.join(', ')} already exists`,
target: e.meta.target
})
} else {

View File

@ -235,7 +235,7 @@
"file",
"server/src/utils.js"
],
"9f0afaa88132ee2f05e0acababf1a84d60a83aa6e0ccbd3028b2982500b29459"
"300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec"
],
[
[

View File

@ -32,7 +32,7 @@ export const prismaErrorToHttpError = (e) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
message: `user with the same ${e.meta.target.join(', ')} already exists`,
target: e.meta.target
})
} else {

View File

@ -242,7 +242,7 @@
"file",
"server/src/utils.js"
],
"9f0afaa88132ee2f05e0acababf1a84d60a83aa6e0ccbd3028b2982500b29459"
"300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec"
],
[
[

View File

@ -32,7 +32,7 @@ export const prismaErrorToHttpError = (e) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
message: `user with the same ${e.meta.target.join(', ')} already exists`,
target: e.meta.target
})
} else {

View File

@ -179,7 +179,7 @@
"file",
"server/src/auth/utils.ts"
],
"e5b365c0914f8da5dfeb721db11bb241b97ce5f82b59537786747bdb6521bf93"
"cc5c7e899b9e4389999748f19177f8a392098279f19d99875d09986334d173c8"
],
[
[
@ -207,7 +207,7 @@
"file",
"server/src/core/auth.js"
],
"da2adb670df6bb76d9df157a44056c7f13bf16a2eb5b78fa90dbaa8d98674689"
"0ea7ff5b8576c4bd455328a56d60a038c1107eaa2e20cba28437cdfc630ce571"
],
[
[
@ -403,7 +403,7 @@
"file",
"server/src/routes/auth/me.js"
],
"b122d0dfbe6b9ef6599a1848b15da291d2d17005fcfdd965f48fc1362a0aaad0"
"9a9cb533bb94af63caf448f73a0d0fef8902c8f8d1af411bed2570a32da2fab9"
],
[
[
@ -466,7 +466,7 @@
"file",
"server/src/utils.js"
],
"9f0afaa88132ee2f05e0acababf1a84d60a83aa6e0ccbd3028b2982500b29459"
"300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec"
],
[
[
@ -592,7 +592,7 @@
"file",
"web-app/src/auth/forms/Auth.tsx"
],
"6e74c3affea9fdc6c71d94fa2875298b4e5bdcf71b89b63320d0a52d81c18652"
"8879b1f60150d66a478b5d9f849d4d021359db5b5c255d57994a0ed7f497d70e"
],
[
[

View File

@ -28,7 +28,7 @@ export async function createUser(data: Prisma.UserCreateInput): Promise<User> {
try {
return await prisma.user.create({ data })
} catch (e) {
rethrowError(e);
rethrowPossiblePrismaError(e);
}
}
@ -36,7 +36,7 @@ export async function deleteUser(user: User): Promise<User> {
try {
return await prisma.user.delete({ where: { id: user.id } })
} catch (e) {
rethrowError(e);
rethrowPossiblePrismaError(e);
}
}
@ -61,12 +61,20 @@ export async function doFakeWork() {
}
function rethrowError(e: unknown): void {
export function throwInvalidCredentialsError(message?: string): void {
throw new HttpError(401, 'Invalid credentials', { message })
}
function rethrowPossiblePrismaError(e: unknown): void {
if (e instanceof AuthError) {
throw new HttpError(422, 'Validation failed', { message: e.message })
throwValidationError(e.message);
} else if (isPrismaError(e)) {
throw prismaErrorToHttpError(e)
} else {
throw new HttpError(500)
}
}
function throwValidationError(message: string): void {
throw new HttpError(422, 'Validation failed', { message })
}

View File

@ -6,6 +6,7 @@ import { randomInt } from 'node:crypto'
import prisma from '../dbClient.js'
import { handleRejection } from '../utils.js'
import config from '../config.js'
import { throwInvalidCredentialsError } from '../auth/utils.js'
const jwtSign = util.promisify(jwt.sign)
const jwtVerify = util.promisify(jwt.verify)
@ -32,7 +33,7 @@ const auth = handleRejection(async (req, res, next) => {
userIdFromToken = (await verify(token)).id
} catch (error) {
if (['TokenExpiredError', 'JsonWebTokenError', 'NotBeforeError'].includes(error.name)) {
return res.status(401).send()
throwInvalidCredentialsError()
} else {
throw error
}
@ -40,7 +41,7 @@ const auth = handleRejection(async (req, res, next) => {
const user = await prisma.user.findUnique({ where: { id: userIdFromToken } })
if (!user) {
return res.status(401).send()
throwInvalidCredentialsError()
}
// TODO: This logic must match the type in types/index.ts (if we remove the
@ -51,7 +52,7 @@ const auth = handleRejection(async (req, res, next) => {
req.user = userView
} else {
return res.status(401).send()
throwInvalidCredentialsError()
}
next()

View File

@ -1,10 +1,11 @@
import { serialize as superjsonSerialize } from 'superjson'
import { handleRejection } from '../../utils.js'
import { throwInvalidCredentialsError } from '../../auth/utils.js'
export default handleRejection(async (req, res) => {
if (req.user) {
return res.json(superjsonSerialize(req.user))
} else {
return res.status(401).send()
throwInvalidCredentialsError()
}
})

View File

@ -32,7 +32,7 @@ export const prismaErrorToHttpError = (e) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
message: `user with the same ${e.meta.target.join(', ')} already exists`,
target: e.meta.target
})
} else {

View File

@ -1,4 +1,4 @@
import { useState, FormEvent, useEffect, useCallback } from 'react'
import { useState, FormEvent, useEffect } from 'react'
import { useHistory, useLocation } from 'react-router-dom'
import { createTheme } from '@stitches/react'
@ -9,6 +9,11 @@ import config from '../../config.js'
import { styled } from '../../stitches.config'
import { State, CustomizationOptions } from './types'
type ErrorMessage = {
title: string;
description?: string;
};
const logoStyle = {
height: '3rem'
}
@ -198,7 +203,7 @@ function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
state: State;
} & CustomizationOptions) {
const isLogin = state === "login";
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<ErrorMessage | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
@ -239,7 +244,9 @@ function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
<HeaderText>{title}</HeaderText>
</div>
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
{errorMessage && (<ErrorMessage>
{errorMessage.title}{errorMessage.description && ': '}{errorMessage.description}
</ErrorMessage>)}
{successMessage && <SuccessMessage>{successMessage}</SuccessMessage>}
{(state === 'login' || state === 'signup') && loginSignupForm}
</Container>

View File

@ -256,7 +256,7 @@
"file",
"server/src/utils.js"
],
"9f0afaa88132ee2f05e0acababf1a84d60a83aa6e0ccbd3028b2982500b29459"
"300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec"
],
[
[

View File

@ -32,7 +32,7 @@ export const prismaErrorToHttpError = (e) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
message: `user with the same ${e.meta.target.join(', ')} already exists`,
target: e.meta.target
})
} else {

View File

@ -242,7 +242,7 @@
"file",
"server/src/utils.js"
],
"9f0afaa88132ee2f05e0acababf1a84d60a83aa6e0ccbd3028b2982500b29459"
"300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec"
],
[
[

View File

@ -32,7 +32,7 @@ export const prismaErrorToHttpError = (e) => {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') {
return new HttpError(422, 'Save failed', {
message: `A record with the same ${e.meta.target.join(', ')} already exists.`,
message: `user with the same ${e.meta.target.join(', ')} already exists`,
target: e.meta.target
})
} else {