From d19a5b144b816808d8af9aed7f75da79904fc5e0 Mon Sep 17 00:00:00 2001 From: Chris Gomez Date: Sun, 3 Nov 2024 18:39:12 +0800 Subject: [PATCH] feat(#34): add login functionality with email and password for secure account access (#35) - adding login form - adding login screen - adding screen access from not found (debugging purposes) - adding login endpoint in api --- apps/api/src/controllers/driver-controller.ts | 48 +++++- apps/api/src/routes/driver-routes.ts | 3 +- apps/driver-app/app/+not-found.tsx | 3 + apps/driver-app/app/login.tsx | 34 +++++ apps/driver-app/src/api/models.ts | 14 +- .../src/components/login-form/index.tsx | 137 ++++++++++++++++++ .../src/components/login-form/styles.ts | 46 ++++++ .../src/services/mqtt-client-service.ts | 1 - apps/driver-app/src/store/slices/api-slice.ts | 11 +- 9 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 apps/driver-app/app/login.tsx create mode 100644 apps/driver-app/src/components/login-form/index.tsx create mode 100644 apps/driver-app/src/components/login-form/styles.ts diff --git a/apps/api/src/controllers/driver-controller.ts b/apps/api/src/controllers/driver-controller.ts index 0077ac2..c4bdfdb 100644 --- a/apps/api/src/controllers/driver-controller.ts +++ b/apps/api/src/controllers/driver-controller.ts @@ -1,8 +1,12 @@ +import bcrypt from 'bcrypt'; import { Context } from 'koa'; import { Op } from 'sequelize'; import Driver from '../models/driver'; import logger from '../utils/logger'; -import { generateAccessToken, generateRefreshToken } from '../utils/token-utils'; +import { + generateAccessToken, + generateRefreshToken +} from '../utils/token-utils'; export const registerDriver = async (ctx: Context) => { const { name, email, phone, password } = ctx.request.body as { @@ -56,3 +60,45 @@ export const registerDriver = async (ctx: Context) => { ctx.body = { error: 'Server error. Please try again later.' }; } }; + +export const loginDriver = async (ctx: Context) => { + const { email, password } = ctx.request.body as { + email: string; + password: string; + }; + + if (!email || !password) { + ctx.status = 400; + ctx.body = { error: 'Email and password are required.' }; + return; + } + + try { + const driver = await Driver.findOne({ where: { email } }); + + if (!driver || !(await bcrypt.compare(password, driver.password))) { + ctx.status = 401; + ctx.body = { error: 'Invalid email or password.' }; + return; + } + + // Generate tokens + const accessToken = generateAccessToken(driver.dataValues.id, 'driver'); + const refreshToken = generateRefreshToken(); + + // Store the new refresh token in the database + await driver.update({ refreshToken }); + + ctx.status = 200; + ctx.body = { + message: 'Login successful.', + driverId: driver.dataValues.id, + accessToken, + refreshToken + }; + } catch (error) { + logger.error(error); + ctx.status = 500; + ctx.body = { error: 'Server error. Please try again later.' }; + } +}; diff --git a/apps/api/src/routes/driver-routes.ts b/apps/api/src/routes/driver-routes.ts index 1f63a5a..a30b599 100644 --- a/apps/api/src/routes/driver-routes.ts +++ b/apps/api/src/routes/driver-routes.ts @@ -1,8 +1,9 @@ import Router from '@koa/router'; -import { registerDriver } from '../controllers/driver-controller'; +import { loginDriver, registerDriver } from '../controllers/driver-controller'; const router = new Router(); router.post('/register', registerDriver); +router.post('/login', loginDriver); export default router; diff --git a/apps/driver-app/app/+not-found.tsx b/apps/driver-app/app/+not-found.tsx index bc7240e..cb0be7e 100644 --- a/apps/driver-app/app/+not-found.tsx +++ b/apps/driver-app/app/+not-found.tsx @@ -14,6 +14,9 @@ export default function NotFoundScreen() { Go to SignUp! + + Go to Login! + Test tokens! diff --git a/apps/driver-app/app/login.tsx b/apps/driver-app/app/login.tsx new file mode 100644 index 0000000..764ac55 --- /dev/null +++ b/apps/driver-app/app/login.tsx @@ -0,0 +1,34 @@ +import KeyboardDismiss from "@/src/components/keyboard-dismiss"; +import LoginForm from "@/src/components/login-form"; +import { KeyboardAvoidingView, Platform, StyleSheet, View } from "react-native"; +import { ScrollView } from "react-native-gesture-handler"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export default function Login() { + return ( + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/apps/driver-app/src/api/models.ts b/apps/driver-app/src/api/models.ts index 5f64be7..ae83305 100644 --- a/apps/driver-app/src/api/models.ts +++ b/apps/driver-app/src/api/models.ts @@ -1,3 +1,7 @@ +export interface ApiResponse { + message: string; +} + export interface DriverData { name: string; email: string; @@ -11,8 +15,14 @@ export interface RegisterResponse extends ApiResponse { refreshToken: string; } -export interface ApiResponse { - message: string; +export interface LoginData { + email: string; + password: string; +} + +export interface LoginResponse extends ApiResponse { + accessToken: string; + refreshToken: string; } diff --git a/apps/driver-app/src/components/login-form/index.tsx b/apps/driver-app/src/components/login-form/index.tsx new file mode 100644 index 0000000..e9dfac9 --- /dev/null +++ b/apps/driver-app/src/components/login-form/index.tsx @@ -0,0 +1,137 @@ +import InputTextField from '@/src/components/input-text-field'; +import RoundedButton from '@/src/components/rounded-button'; +import { useAppDispatch } from '@/src/hooks/use-app-dispatch'; +import { useLoginDriverMutation } from '@/src/store/slices/api-slice'; +import { setTokens } from '@/src/store/slices/auth-slice'; +import React, { useState } from 'react'; +import { Keyboard, Text, View } from 'react-native'; +import { styles } from './styles'; + +const LoginForm: React.FC = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const [emailError, setEmailError] = useState(undefined); + const [passwordError, setPasswordError] = useState( + undefined + ); + const [loginError, setLoginError] = useState(null); + + const dispatch = useAppDispatch(); + const [login, { isLoading, isError, error, isSuccess }] = + useLoginDriverMutation(); + + const isValidEmail = (email: string) => + /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + + const handleLogin = async () => { + let isValid = true; + + if (!isValidEmail(email)) { + setEmailError('Please enter a valid email address'); + isValid = false; + } else { + setEmailError(undefined); + } + + if (password.length < 8) { + setPasswordError('Password must be at least 8 characters'); + isValid = false; + } else { + setPasswordError(undefined); + } + + if (isValid) { + setLoginError(null); + try { + const result = await login({ email, password }).unwrap(); + dispatch( + setTokens({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + }) + ); + } catch (err: any) { + const errorMessage = + err?.data?.error || 'Login failed. Please try again.'; + setLoginError(errorMessage); + console.error('Login failed:', errorMessage); + } + } + }; + + const handleEmailChange = (text: string) => { + setEmail(text); + setEmailError( + !isValidEmail(text) ? 'Please enter a valid email address' : undefined + ); + }; + + const handlePasswordChange = (text: string) => { + setPassword(text); + setPasswordError( + text.length < 8 ? 'Password must be at least 8 characters' : undefined + ); + }; + + const handleSubmitEditing = () => { + Keyboard.dismiss(); + }; + + const isButtonDisabled = () => { + return ( + isLoading || Boolean(emailError || passwordError) || !email || !password + ); + }; + + return ( + + Login + + + + + + + + + {loginError && {loginError}} + {isSuccess && ( + Login successful! + )} + + + + + ); +}; + +export default LoginForm; diff --git a/apps/driver-app/src/components/login-form/styles.ts b/apps/driver-app/src/components/login-form/styles.ts new file mode 100644 index 0000000..5a11a17 --- /dev/null +++ b/apps/driver-app/src/components/login-form/styles.ts @@ -0,0 +1,46 @@ +import { StyleSheet, TextStyle, ViewStyle } from "react-native"; + +interface ILoginFormStyles { + container: ViewStyle; + title: TextStyle; + inputContainer: ViewStyle; + buttonContainer: ViewStyle; + feedbackContainer: ViewStyle; + feedbackSuccess: TextStyle; + feedbackError: TextStyle; +} + +export const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + paddingVertical: 24, + justifyContent: "space-between", + alignItems: "stretch", + flex: 1, + }, + title: { + fontFamily: "Poppins-Bold", + fontSize: 30, + lineHeight: 30 * 1.3, + color: "#000000", + marginBottom: 14, + textAlign: "center", + }, + inputContainer: { + gap: 22, + }, + buttonContainer: { + marginTop: 14, + }, + feedbackContainer: { + minHeight: 34, + }, + feedbackSuccess: { + color: "green", + marginVertical: 8, + }, + feedbackError: { + color: "red", + marginVertical: 8, + }, +}); diff --git a/apps/driver-app/src/services/mqtt-client-service.ts b/apps/driver-app/src/services/mqtt-client-service.ts index e1305f3..43e0461 100644 --- a/apps/driver-app/src/services/mqtt-client-service.ts +++ b/apps/driver-app/src/services/mqtt-client-service.ts @@ -89,7 +89,6 @@ class MQTTClientService { // sound: 'mySoundFile.wav', // Provide ONLY the base filename }, trigger: { - seconds: 2, channelId: 'new-ride-request' } }) diff --git a/apps/driver-app/src/store/slices/api-slice.ts b/apps/driver-app/src/store/slices/api-slice.ts index 9624591..ace1464 100644 --- a/apps/driver-app/src/store/slices/api-slice.ts +++ b/apps/driver-app/src/store/slices/api-slice.ts @@ -1,4 +1,4 @@ -import { DriverData, RegisterResponse } from '@/src/api/models'; +import { DriverData, LoginData, LoginResponse, RegisterResponse } from '@/src/api/models'; import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; const API_BASE_URL = process.env.EXPO_PUBLIC_MORRO_API_BASE_URL; @@ -15,8 +15,15 @@ export const apiSlice = createApi({ method: 'POST', body: data }) + }), + loginDriver: builder.mutation({ + query: (data) => ({ + url: '/drivers/login', + method: 'POST', + body: data + }) }) }) }); -export const { useRegisterDriverMutation } = apiSlice; +export const { useRegisterDriverMutation, useLoginDriverMutation } = apiSlice;