From b5abeb51b5c847ab480f81e340143eb093f54d86 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Tue, 17 Feb 2026 21:43:10 -1000 Subject: [PATCH] Fix form errors. Closes #213 --- apps/api/src/endpoints/trpc/auth/login.ts | 13 ++--- .../src/endpoints/trpc/auth/updatePassword.ts | 2 +- apps/api/src/index.ts | 2 +- apps/api/src/models/User.ts | 2 - apps/api/src/trpc/index.ts | 27 +++++++++- apps/mobile/src/app/(tabs)/fertilizers.tsx | 3 +- apps/mobile/src/app/(tabs)/index.tsx | 3 +- apps/mobile/src/app/(tabs)/settings.tsx | 28 ++++++----- apps/mobile/src/app/create-account.tsx | 41 +++++++-------- apps/mobile/src/app/edit-password.tsx | 41 +++++++-------- apps/mobile/src/app/edit-profile.tsx | 50 ++++++------------- apps/mobile/src/app/forgot-password.tsx | 24 ++++----- apps/mobile/src/app/login.tsx | 28 ++++------- apps/mobile/src/app/plants/add-edit.tsx | 3 +- apps/mobile/src/app/reset-password.tsx | 31 ++++++------ .../src/components/AddEditChoreModal.tsx | 5 +- apps/mobile/src/components/InputError.tsx | 23 +++++++++ apps/mobile/src/components/TextInput.tsx | 38 ++++++++++++++ 18 files changed, 203 insertions(+), 161 deletions(-) create mode 100644 apps/mobile/src/components/InputError.tsx create mode 100644 apps/mobile/src/components/TextInput.tsx diff --git a/apps/api/src/endpoints/trpc/auth/login.ts b/apps/api/src/endpoints/trpc/auth/login.ts index bffefb2..1008711 100644 --- a/apps/api/src/endpoints/trpc/auth/login.ts +++ b/apps/api/src/endpoints/trpc/auth/login.ts @@ -17,19 +17,12 @@ export const login = publicProcedure .mutation(async ({ input }) => { const user = await User.findOne({ email: input.email.toLowerCase().trim() }) - if (!user) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'Invalid email or password', - }) - } - - const isValidPassword = await bcrypt.compare(input.password, user.password) + const isValidPassword = user && await bcrypt.compare(input.password, user.password) - if (!isValidPassword) { + if (!user || !isValidPassword) { throw new TRPCError({ code: 'UNAUTHORIZED', - message: 'Invalid email or password', + message: 'Incorrect email or password', }) } diff --git a/apps/api/src/endpoints/trpc/auth/updatePassword.ts b/apps/api/src/endpoints/trpc/auth/updatePassword.ts index fd96a41..edc9442 100644 --- a/apps/api/src/endpoints/trpc/auth/updatePassword.ts +++ b/apps/api/src/endpoints/trpc/auth/updatePassword.ts @@ -24,7 +24,7 @@ export const updatePassword = authProcedure if (!isValidPassword) { throw new TRPCError({ - code: 'UNAUTHORIZED', + code: 'BAD_REQUEST', message: 'Current password is incorrect', }) } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4ab5ab2..f657309 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -46,7 +46,7 @@ app.use( router: trpcRouter, createContext: ({ req }) => ({ req }), onError: ({ error, path }) => { - console.error(`❌ [TRPC Error on ${path}]`, error) + console.error(`❌ [TRPC Error on ${path}]`, error, error.cause) }, }) ) diff --git a/apps/api/src/models/User.ts b/apps/api/src/models/User.ts index 079fa36..5b72fd7 100644 --- a/apps/api/src/models/User.ts +++ b/apps/api/src/models/User.ts @@ -41,8 +41,6 @@ export const userSchema = new mongoose.Schema({ phone: { type: String, required: false, - unique: true, - sparse: true, lowercase: true, trim: true, match: [/^[0-9]{10}$/, 'Please fill a valid phone number'], // Phone validation diff --git a/apps/api/src/trpc/index.ts b/apps/api/src/trpc/index.ts index 038004d..36c9a69 100644 --- a/apps/api/src/trpc/index.ts +++ b/apps/api/src/trpc/index.ts @@ -1,9 +1,11 @@ import { initTRPC } from '@trpc/server' import superjson from 'superjson' import type { IncomingMessage } from 'http' +import { MongoServerError } from 'mongodb' +import type { Document } from 'mongoose' +import { ZodError } from 'zod' import type { IUser } from '../models/User' -import type { Document } from 'mongoose' export interface Context { req?: IncomingMessage, @@ -18,6 +20,29 @@ export interface Context { export const tRPCContext = initTRPC .context() .create({ + errorFormatter: ({ error, shape }) => { + let message = shape.message + let fieldErrors = null + + if (error.cause instanceof ZodError && error.code === 'BAD_REQUEST') { + message = error.cause.issues.map(issue => issue.message).join(', ') + fieldErrors = error.cause.issues + } + + if (error.cause instanceof MongoServerError) { + // Squelch database errors from being logged to the client + message = 'An internal error has occurred' + } + + return { + ...shape, + message, + data: { + ...shape.data, + fieldErrors, + }, + } + }, transformer: superjson, }) diff --git a/apps/mobile/src/app/(tabs)/fertilizers.tsx b/apps/mobile/src/app/(tabs)/fertilizers.tsx index 405c57b..adb50fe 100644 --- a/apps/mobile/src/app/(tabs)/fertilizers.tsx +++ b/apps/mobile/src/app/(tabs)/fertilizers.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Modal, ScrollView, StyleSheet, Switch, Text, TextInput, TouchableOpacity, View } from 'react-native' +import { Modal, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native' import { useLayoutEffect } from 'react' import { useLocalSearchParams, useNavigation } from 'expo-router' import { keepPreviousData } from '@tanstack/react-query' @@ -12,6 +12,7 @@ import { LoadingSkeleton, LoadingSkeletonLine } from '../../components/LoadingSk import { ScreenTitle } from '../../components/ScreenTitle' import { ScreenWrapper } from '../../components/ScreenWrapper' import { SwipeToDelete } from '../../components/SwipeToDelete' +import { TextInput } from '../../components/TextInput' import { useAlert } from '../../contexts/AlertContext' diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 8eac396..f1e8222 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { Text, View, ScrollView, TouchableOpacity, TextInput, Modal, Switch, StyleSheet } from 'react-native' +import { Text, View, ScrollView, TouchableOpacity, Modal, Switch, StyleSheet } from 'react-native' import { useNavigation, useRouter } from 'expo-router' import { keepPreviousData } from '@tanstack/react-query' @@ -13,6 +13,7 @@ import { ScreenTitle } from '../../components/ScreenTitle' import { ScreenWrapper } from '../../components/ScreenWrapper' import { SnoozeChoreModal } from '../../components/SnoozeChoreModal' import { SwipeToDelete } from '../../components/SwipeToDelete' +import { TextInput } from '../../components/TextInput' import { useAlert } from '../../contexts/AlertContext' diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 40c5c08..7b82771 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -141,20 +141,22 @@ export function SettingsScreen() { style: 'destructive', onPress: () => { // Second confirmation prompt - alert( - 'Just double checking...', - 'You want to PERMANENTLY delete your account?\n\nWe\'re sorry to see you go but we wish you the best!', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Yes, Delete', - style: 'destructive', - onPress: () => { - deleteMeMutation.mutate() + setTimeout(() => { + alert( + 'Just double checking...', + 'You want to PERMANENTLY delete your account?\n\nWe\'re sorry to see you go but we wish you the best!', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Yes, Delete', + style: 'destructive', + onPress: () => { + deleteMeMutation.mutate() + }, }, - }, - ] - ) + ] + ) + }, 1000) }, }, ] diff --git a/apps/mobile/src/app/create-account.tsx b/apps/mobile/src/app/create-account.tsx index 92e61b1..7b1d5be 100644 --- a/apps/mobile/src/app/create-account.tsx +++ b/apps/mobile/src/app/create-account.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react' -import { ActivityIndicator, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native' +import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native' import * as Localization from 'expo-localization' import { useRouter } from 'expo-router' import { ScreenWrapper } from '../components/ScreenWrapper' +import { TextInput } from '../components/TextInput' import { useAlert } from '../contexts/AlertContext' import { useAuth } from '../contexts/AuthContext' @@ -34,6 +35,17 @@ export default function CreateAccountScreen() { router.replace('/(tabs)') }, onError: (error) => { + if ( + error.shape?.data?.fieldErrors?.find(error => error.path.includes('name')) + || error.shape?.data?.fieldErrors?.find(error => error.path.includes('email')) + || error.shape?.data?.fieldErrors?.find(error => error.path.includes('password')) + || error.shape?.data?.fieldErrors?.find(error => error.path.includes('confirmPassword')) + ) { + // Error is handled by input below, do not show alert + return + } + + // Unable to handle error on input, show alert alert('Account Creation Failed', error.message || 'Failed to create account') }, }) @@ -41,21 +53,13 @@ export default function CreateAccountScreen() { const handleCreateAccount = () => { if (!name.trim() || !email.trim() || !password.trim()) { alert('Error', 'Please fill in all fields') - return - } - - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - alert('Error', 'Please enter a valid email address') - return - } - if (password.length < 6) { - alert('Error', 'Password must be at least 6 characters') return } if (password !== confirmPassword) { alert('Error', 'Passwords do not match') + return } @@ -74,39 +78,39 @@ export default function CreateAccountScreen() { Sign up for Plannting error.path.includes('name'))} /> error.path.includes('email'))} /> error.path.includes('password'))} /> error.path.includes('confirmPassword'))} /> { + if ( + error.shape?.data?.fieldErrors?.find(error => error.path.includes('currentPassword')) + || error.shape?.data?.fieldErrors?.find(error => error.path.includes('newPassword')) + || error.shape?.data?.fieldErrors?.find(error => error.path.includes('confirmPassword')) + ) { + // Error is handled by input below, do not show alert + return + } + + // Unable to handle error on input, show alert alert('Error', error.message || 'Failed to update password') }, }) @@ -35,21 +46,19 @@ export default function EditPasswordScreen() { const handleSubmit = () => { if (!currentPassword.trim()) { alert('Error', 'Please enter your current password') + return } if (!newPassword.trim()) { alert('Error', 'Please enter a new password') - return - } - if (newPassword.length < 6) { - alert('Error', 'Password must be at least 6 characters') return } if (newPassword !== confirmPassword) { alert('Error', 'New password and confirm password do not match') + return } @@ -68,40 +77,34 @@ export default function EditPasswordScreen() { Current Password error.path.includes('currentPassword'))} /> New Password error.path.includes('newPassword'))} /> Confirm New Password error.path.includes('confirmPassword'))} /> - {updatePasswordMutation.error && ( - - {updatePasswordMutation.error.message} - - )} - { + if ( + error.shape?.data?.fieldErrors?.find(error => error.path.includes('name')) + || error.shape?.data?.fieldErrors?.find(error => error.path.includes('email')) + || error.shape?.data?.fieldErrors?.find(error => error.path.includes('phone')) + ) { + // Error is handled by input below, do not show alert + return + } + + // Unable to handle error on input, show alert alert('Error', error.message || 'Failed to update profile') }, }) const handleSubmit = () => { - if (!name.trim()) { - alert('Error', 'Name is required') - return - } - - if (!email.trim()) { - alert('Error', 'Email is required') - return - } - - // Validate email format - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { - alert('Error', 'Please enter a valid email address') - return - } - updateProfileMutation.mutate({ name: name.trim(), email: email.trim(), @@ -86,27 +81,26 @@ export default function EditProfileScreen() { Name error.path.includes('name'))} /> Email error.path.includes('email'))} /> Phone (optional) { @@ -117,14 +111,9 @@ export default function EditProfileScreen() { }} keyboardType='phone-pad' maxLength={10} + fieldError={updateProfileMutation.error?.shape?.data?.fieldErrors?.find(error => error.path.includes('phone'))} /> - {updateProfileMutation.error && ( - - {updateProfileMutation.error.message} - - )} - { + if (error.shape?.data?.fieldErrors?.find(error => error.path.includes('email'))) { + // Error is handled by input below, do not show alert + return + } + + // Unable to handle error on input, show alert alert('Error', error.message || 'Failed to send password reset email') }, }) @@ -31,11 +38,7 @@ export default function ForgotPasswordScreen() { const handleSubmit = () => { if (!email.trim()) { alert('Error', 'Please enter your email address') - return - } - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - alert('Error', 'Please enter a valid email address') return } @@ -53,7 +56,6 @@ export default function ForgotPasswordScreen() { error.path.includes('email'))} /> { await login(data.token, data.user) + router.replace('/(tabs)') }, onError: (error) => { + if (error.shape?.data?.fieldErrors?.find(error => error.path.includes('email')) || error.shape?.data?.fieldErrors?.find(error => error.path.includes('password'))) { + // Error is handled by input below, do not show alert + return + } + + // Unable to handle error on input, show alert alert('Login Failed', error.message || 'Invalid email or password') }, }) const handleLogin = () => { - if (!email.trim() || !password.trim()) { - alert('Error', 'Please enter both email and password') - return - } - loginMutation.mutate({ email: email.trim(), password, @@ -55,22 +58,22 @@ export default function LoginScreen() { Login to your account error.path.includes('email'))} /> error.path.includes('password'))} /> { + if ( + error.shape?.data?.fieldErrors?.find(error => error.path.includes('email')) + || error.shape?.data?.fieldErrors?.find(error => error.path.includes('code')) + || error.shape?.data?.fieldErrors?.find(error => error.path.includes('password')) + || error.shape?.data?.fieldErrors?.find(error => error.path.includes('confirmPassword')) + ) { + // Error is handled by input below, do not show alert + return + } + + // Unable to handle error on input, show alert alert('Error', error.message || 'Failed to reset password') }, }) @@ -112,7 +124,6 @@ export default function ResetPasswordScreen() { error.path.includes('email'))} /> setCode(text.replace(/[^0-9A-Fa-f]/g, '').toUpperCase().slice(0, 6))} @@ -131,6 +142,7 @@ export default function ResetPasswordScreen() { autoCapitalize="characters" maxLength={6} autoFocus={!!emailParam} + fieldError={resetPasswordMutation.error?.shape?.data?.fieldErrors?.find(error => error.path.includes('code'))} /> error.path.includes('password'))} /> error.path.includes('confirmPassword'))} /> Amount * { diff --git a/apps/mobile/src/components/InputError.tsx b/apps/mobile/src/components/InputError.tsx new file mode 100644 index 0000000..8b1e9c0 --- /dev/null +++ b/apps/mobile/src/components/InputError.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { StyleSheet, Text, TextProps } from 'react-native' + +import { styles } from '../styles' + +export const InputError = ({ error, ...props }: TextProps & { error?: string }) => { + return ( + + {error} + + ) +} + +const localStyles = StyleSheet.create({ + errorText: { + marginTop: 4, + marginBottom: 0, + marginLeft: 8, + }, +}) diff --git a/apps/mobile/src/components/TextInput.tsx b/apps/mobile/src/components/TextInput.tsx new file mode 100644 index 0000000..8bb8587 --- /dev/null +++ b/apps/mobile/src/components/TextInput.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { type StyleProp, StyleSheet, TextInput as RNTextInput, TextInputProps as RNTextInputProps, View, type ViewStyle } from 'react-native' + +import type { TrpcRouter } from '../trpc' + +import { InputError } from './InputError' + +export type TextInputProps = RNTextInputProps & { + containerStyle?: StyleProp + fieldError?: NonNullable['data']['fieldErrors']>[number], +} + +export const TextInput = ({ fieldError, ...props }: TextInputProps) => { + return ( + + + + {fieldError && } + + ) +} + +const localStyles = StyleSheet.create({ + container: { + marginBottom: 16, + }, + input: { + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + padding: 12, + fontSize: 16, + }, +})