Skip to content

Commit

Permalink
feat(#34): add login functionality with email and password for secure…
Browse files Browse the repository at this point in the history
… account access (#35)

- adding login form
- adding login screen
- adding screen access from not found (debugging purposes)
- adding login endpoint in api
  • Loading branch information
chriscoderdr authored Nov 3, 2024
1 parent 6da6685 commit d19a5b1
Show file tree
Hide file tree
Showing 9 changed files with 290 additions and 7 deletions.
48 changes: 47 additions & 1 deletion apps/api/src/controllers/driver-controller.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.' };
}
};
3 changes: 2 additions & 1 deletion apps/api/src/routes/driver-routes.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions apps/driver-app/app/+not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export default function NotFoundScreen() {
<Link href="/signup" style={styles.link}>
<Text>Go to SignUp!</Text>
</Link>
<Link href="/login" style={styles.link}>
<Text>Go to Login!</Text>
</Link>
<Link href="/token-display" style={styles.link}>
<Text>Test tokens!</Text>
</Link>
Expand Down
34 changes: 34 additions & 0 deletions apps/driver-app/app/login.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SafeAreaView style={{ flex: 1 }}>
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
}}
>
<KeyboardDismiss>
<View style={styles.container}>
<LoginForm />
</View>
</KeyboardDismiss>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
},
});
14 changes: 12 additions & 2 deletions apps/driver-app/src/api/models.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export interface ApiResponse {
message: string;
}

export interface DriverData {
name: string;
email: string;
Expand All @@ -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;
}


137 changes: 137 additions & 0 deletions apps/driver-app/src/components/login-form/index.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(undefined);
const [passwordError, setPasswordError] = useState<string | undefined>(
undefined
);
const [loginError, setLoginError] = useState<string | null>(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 (
<View style={styles.container}>
<Text style={styles.title}>Login</Text>

<View style={styles.inputContainer}>
<InputTextField
label="Email"
placeholder="[email protected]"
fullWidth
autoComplete="email"
autoCorrect={false}
autoCapitalize="none"
onChangeText={handleEmailChange}
errorText={emailError}
testID="email-input"
errorTestId="email-input-error"
/>
<InputTextField
label="Password"
placeholder="Enter your password"
fullWidth
autoCorrect={false}
autoCapitalize="none"
onSubmitEditing={handleSubmitEditing}
securedEntry
onChangeText={handlePasswordChange}
errorText={passwordError}
testID="password-input"
errorTestId="password-input-error"
/>
</View>

<View style={styles.buttonContainer}>
<View style={styles.feedbackContainer}>
{loginError && <Text style={styles.feedbackError}>{loginError}</Text>}
{isSuccess && (
<Text style={styles.feedbackSuccess}>Login successful!</Text>
)}
</View>
<RoundedButton
disabled={isButtonDisabled()}
text={isLoading ? 'Logging in...' : 'Login'}
onPress={handleLogin}
testID="login-button"
/>
</View>
</View>
);
};

export default LoginForm;
46 changes: 46 additions & 0 deletions apps/driver-app/src/components/login-form/styles.ts
Original file line number Diff line number Diff line change
@@ -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<ILoginFormStyles>({
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,
},
});
1 change: 0 additions & 1 deletion apps/driver-app/src/services/mqtt-client-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ class MQTTClientService {
// sound: 'mySoundFile.wav', // Provide ONLY the base filename
},
trigger: {
seconds: 2,
channelId: 'new-ride-request'
}
})
Expand Down
11 changes: 9 additions & 2 deletions apps/driver-app/src/store/slices/api-slice.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,8 +15,15 @@ export const apiSlice = createApi({
method: 'POST',
body: data
})
}),
loginDriver: builder.mutation<LoginResponse, LoginData>({
query: (data) => ({
url: '/drivers/login',
method: 'POST',
body: data
})
})
})
});

export const { useRegisterDriverMutation } = apiSlice;
export const { useRegisterDriverMutation, useLoginDriverMutation } = apiSlice;

0 comments on commit d19a5b1

Please sign in to comment.