+ {notificationMessage && (
+
+ )}
{jobsOnPage &&
jobsOnPage.map((job: Job) =>
)}
- {jobsOnPage.length > 0 &&
}
+ {jobsOnPage.length > 0 && (
+
+ )}
{jobsOnPage.length === 0 && (
No results. Please modify your search and try again.
@@ -67,6 +85,9 @@ const Search: React.SFC = (props: SearchProps) => {
const mapStateToProps = (state: RootState) => ({
currentJobs: state.application.currentJobs,
currentPage: state.application.currentPage,
+ notificationMessage: state.application.notificationMessage,
+ notificationType: state.application.notificationType,
+ totalPages: state.application.totalPages,
});
export default connect(mapStateToProps)(Search);
diff --git a/src/client/pages/Signup.tsx b/src/client/pages/Signup.tsx
new file mode 100644
index 0000000..d989acd
--- /dev/null
+++ b/src/client/pages/Signup.tsx
@@ -0,0 +1,147 @@
+import * as React from "react";
+import { connect } from "react-redux";
+import { Redirect } from "react-router-dom";
+
+import Button from "../components/Button";
+import Copyright from "../components/Copyright";
+import Notification from "../components/Notification";
+import Input from "../components/Input";
+
+import {
+ setConfirmPassword,
+ setEmail,
+ setName,
+ setPassword,
+} from "../redux/actions/user";
+import { signup } from "../redux/thunks";
+
+import { RootState } from "../types";
+
+export interface SignupProps {
+ confirmPassword: string;
+ email: string;
+ notificationMessage: string;
+ handleConfirmPasswordChange: (confirmPassword: string) => void;
+ handleEmailChange: (email: string) => void;
+ handleNameChange: (name: string) => void;
+ handlePasswordChange: (password: string) => void;
+ handleSignup: () => void;
+ isLoggedIn: boolean;
+ name: string;
+ password: string;
+}
+
+const Signup: React.SFC = (props: SignupProps) => {
+ const {
+ confirmPassword,
+ email,
+ notificationMessage,
+ handleConfirmPasswordChange,
+ handleEmailChange,
+ handleNameChange,
+ handlePasswordChange,
+ handleSignup,
+ isLoggedIn,
+ name,
+ password,
+ } = props;
+
+ if (isLoggedIn) {
+ return ;
+ } else {
+ return (
+
+ );
+ }
+};
+
+const mapStateToProps = (state: RootState) => ({
+ confirmPassword: state.user.confirmPassword,
+ email: state.user.email,
+ notificationMessage: state.application.notificationMessage,
+ isLoggedIn: state.user.isLoggedIn,
+ name: state.user.name,
+ password: state.user.password,
+});
+
+const mapDispatchToProps = (dispatch) => ({
+ handleConfirmPasswordChange: (confirmPassword: string) =>
+ dispatch(setConfirmPassword(confirmPassword)),
+ handleEmailChange: (email: string) => dispatch(setEmail(email)),
+ handleNameChange: (name: string) => dispatch(setName(name)),
+ handlePasswordChange: (password: string) => dispatch(setPassword(password)),
+ handleSignup: () => dispatch(signup()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Signup);
diff --git a/src/client/redux/actionTypes.ts b/src/client/redux/actionTypes.ts
index d61c794..b4e535c 100644
--- a/src/client/redux/actionTypes.ts
+++ b/src/client/redux/actionTypes.ts
@@ -1,3 +1,4 @@
+// * Application
export const SET_CURRENT_JOBS = "SET_CURRENT_JOBS";
export const SET_CURRENT_PAGE = "SET_CURRENT_PAGE";
export const SET_FULL_TIME = "SET_FULL_TIME";
@@ -7,3 +8,24 @@ export const SET_JOBS_FETCHED_AT = "SET_JOBS_FETCHED_AT";
export const SET_LOCATION_SEARCH = "SET_LOCATION_SEARCH";
export const SET_SEARCH_VALUE = "SET_SEARCH_VALUE";
export const SET_TOTAL_PAGES = "SET_TOTAL_PAGES";
+
+// * User
+export const SET_CONFIRM_PASSWORD = "SET_CONFIRM_PASSWORD";
+export const SET_EDIT_EMAIL = "SET_EDIT_EMAIL";
+export const SET_EDIT_NAME = "SET_EDIT_NAME";
+export const SET_EMAIL = "SET_EMAIL";
+export const SET_IS_DELETING_PROFILE = "SET_IS_DELETING_PROFILE";
+export const SET_IS_EDITING_PROFILE = "SET_IS_EDITING_PROFILE";
+export const SET_IS_LOGGED_IN = "SET_IS_LOGGED_IN";
+export const SET_IS_RESETTING_PASSWORD = "SET_IS_RESETTING_PASSWORD";
+export const SET_IS_VIEWING_SAVED_JOBS = "SET_IS_VIEWING_SAVED_JOBS";
+export const SET_NAME = "SET_NAME";
+export const SET_NOTIFICATION_MESSAGE = "SET_NOTIFICATION_MESSAGE";
+export const SET_NOTIFICATION_TYPE = "SET_NOTIFICATION_TYPE";
+export const SET_PASSWORD = "SET_PASSWORD";
+export const SET_RESET_CONFIRM_NEW_PASSWORD = "SET_RESET_CONFIRM_NEW_PASSWORD";
+export const SET_RESET_CURRENT_PASSWORD = "SET_RESET_CURRENT_PASSWORD";
+export const SET_RESET_NEW_PASSWORD = "SET_RESET_NEW_PASSWORD";
+export const SET_SAVED_JOBS = "SET_SAVED_JOBS";
+export const SET_SAVED_JOBS_CURRENT_PAGE = "SET_SAVED_JOBS_CURRENT_PAGE";
+export const SET_SAVED_JOBS_TOTAL_PAGES = "SET_SAVED_JOBS_TOTAL_PAGES";
diff --git a/src/client/redux/actions/application.ts b/src/client/redux/actions/application.ts
index 2e0e009..dd309dc 100644
--- a/src/client/redux/actions/application.ts
+++ b/src/client/redux/actions/application.ts
@@ -6,11 +6,13 @@ import {
SET_CURRENT_JOBS,
SET_CURRENT_PAGE,
SET_LOCATION_SEARCH,
+ SET_NOTIFICATION_MESSAGE,
+ SET_NOTIFICATION_TYPE,
SET_SEARCH_VALUE,
SET_TOTAL_PAGES,
} from "../actionTypes";
-import { ApplicationAction, Job } from "../../types";
+import { ApplicationAction, Job, NotificationType } from "../../types";
export const setCurrentJobs = (currentJobs: Job[]): ApplicationAction => ({
type: SET_CURRENT_JOBS,
@@ -49,6 +51,20 @@ export const setLocationSearch = (
payload: { locationSearch },
});
+export const setNotificationMessage = (
+ notificationMessage: string
+): ApplicationAction => ({
+ type: SET_NOTIFICATION_MESSAGE,
+ payload: { notificationMessage },
+});
+
+export const setNotificationType = (
+ notificationType: NotificationType
+): ApplicationAction => ({
+ type: SET_NOTIFICATION_TYPE,
+ payload: { notificationType },
+});
+
export const setSearchValue = (searchValue: string): ApplicationAction => ({
type: SET_SEARCH_VALUE,
payload: { searchValue },
diff --git a/src/client/redux/actions/user.ts b/src/client/redux/actions/user.ts
new file mode 100644
index 0000000..705bc21
--- /dev/null
+++ b/src/client/redux/actions/user.ts
@@ -0,0 +1,120 @@
+import {
+ SET_CONFIRM_PASSWORD,
+ SET_EDIT_EMAIL,
+ SET_EDIT_NAME,
+ SET_EMAIL,
+ SET_IS_DELETING_PROFILE,
+ SET_IS_EDITING_PROFILE,
+ SET_IS_LOGGED_IN,
+ SET_IS_RESETTING_PASSWORD,
+ SET_IS_VIEWING_SAVED_JOBS,
+ SET_NAME,
+ SET_PASSWORD,
+ SET_RESET_CONFIRM_NEW_PASSWORD,
+ SET_RESET_CURRENT_PASSWORD,
+ SET_RESET_NEW_PASSWORD,
+ SET_SAVED_JOBS,
+ SET_SAVED_JOBS_CURRENT_PAGE,
+ SET_SAVED_JOBS_TOTAL_PAGES,
+} from "../actionTypes";
+
+import { Job, UserAction } from "../../types";
+
+export const setConfirmPassword = (confirmPassword: string): UserAction => ({
+ type: SET_CONFIRM_PASSWORD,
+ payload: { confirmPassword },
+});
+
+export const setEditEmail = (editEmail: string): UserAction => ({
+ type: SET_EDIT_EMAIL,
+ payload: { editEmail },
+});
+
+export const setEditName = (editName: string): UserAction => ({
+ type: SET_EDIT_NAME,
+ payload: { editName },
+});
+
+export const setEmail = (email: string): UserAction => ({
+ type: SET_EMAIL,
+ payload: { email },
+});
+
+export const setIsDeletingProfile = (
+ isDeletingProfile: boolean
+): UserAction => ({
+ type: SET_IS_DELETING_PROFILE,
+ payload: { isDeletingProfile },
+});
+
+export const setIsEditingProfile = (isEditingProfile: boolean): UserAction => ({
+ type: SET_IS_EDITING_PROFILE,
+ payload: { isEditingProfile },
+});
+
+export const setIsLoggedIn = (isLoggedIn: boolean): UserAction => ({
+ type: SET_IS_LOGGED_IN,
+ payload: { isLoggedIn },
+});
+
+export const setIsResettingPassword = (
+ isResettingPassword: boolean
+): UserAction => ({
+ type: SET_IS_RESETTING_PASSWORD,
+ payload: { isResettingPassword },
+});
+
+export const setIsViewingSavedJobs = (
+ isViewingSavedJobs: boolean
+): UserAction => ({
+ type: SET_IS_VIEWING_SAVED_JOBS,
+ payload: { isViewingSavedJobs },
+});
+
+export const setName = (name: string): UserAction => ({
+ type: SET_NAME,
+ payload: { name },
+});
+
+export const setPassword = (password: string): UserAction => ({
+ type: SET_PASSWORD,
+ payload: { password },
+});
+
+export const setResetConfirmNewPassword = (
+ resetConfirmNewPassword: string
+): UserAction => ({
+ type: SET_RESET_CONFIRM_NEW_PASSWORD,
+ payload: { resetConfirmNewPassword },
+});
+
+export const setResetCurrentPassword = (
+ resetCurrentPassword: string
+): UserAction => ({
+ type: SET_RESET_CURRENT_PASSWORD,
+ payload: { resetCurrentPassword },
+});
+
+export const setResetNewPassword = (resetNewPassword: string): UserAction => ({
+ type: SET_RESET_NEW_PASSWORD,
+ payload: { resetNewPassword },
+});
+
+export const setSavedJobs = (savedJobs: Job[]): UserAction => ({
+ type: SET_SAVED_JOBS,
+ payload: { savedJobs },
+});
+
+export const setSavedJobsCurrentPage = (
+ savedJobsCurrentPage: number
+): UserAction => ({
+ type: SET_SAVED_JOBS_CURRENT_PAGE,
+ payload: { savedJobsCurrentPage },
+});
+
+export const setSavedJobsTotalPages = (
+ savedJobsTotalPages: number
+): UserAction => ({
+ type: SET_SAVED_JOBS_TOTAL_PAGES,
+ payload: { savedJobsTotalPages },
+});
diff --git a/src/client/redux/reducers/application.ts b/src/client/redux/reducers/application.ts
index e604e28..c9b050b 100644
--- a/src/client/redux/reducers/application.ts
+++ b/src/client/redux/reducers/application.ts
@@ -6,6 +6,8 @@ import {
SET_JOBS,
SET_JOBS_FETCHED_AT,
SET_LOCATION_SEARCH,
+ SET_NOTIFICATION_MESSAGE,
+ SET_NOTIFICATION_TYPE,
SET_SEARCH_VALUE,
SET_TOTAL_PAGES,
} from "../actionTypes";
@@ -20,6 +22,8 @@ export const initialState: ApplicationState = {
jobs: [],
jobsFetchedAt: null,
locationSearch: "",
+ notificationMessage: "",
+ notificationType: "info",
searchValue: "",
totalPages: 1,
};
@@ -28,33 +32,27 @@ const reducer = (
state = initialState,
action: ApplicationAction
): ApplicationState => {
+ let key: string;
+ let value;
+
+ if (action && action.payload) {
+ key = Object.keys(action.payload)[0];
+ value = action.payload[key];
+ }
+
switch (action.type) {
- case SET_CURRENT_JOBS: {
- return { ...state, currentJobs: action.payload.currentJobs };
- }
- case SET_CURRENT_PAGE: {
- return { ...state, currentPage: action.payload.currentPage };
- }
- case SET_FULL_TIME: {
- return { ...state, fullTime: action.payload.fullTime };
- }
- case SET_IS_LOADING: {
- return { ...state, isLoading: action.payload.isLoading };
- }
- case SET_JOBS: {
- return { ...state, jobs: action.payload.jobs };
- }
- case SET_JOBS_FETCHED_AT: {
- return { ...state, jobsFetchedAt: action.payload.jobsFetchedAt };
- }
- case SET_LOCATION_SEARCH: {
- return { ...state, locationSearch: action.payload.locationSearch };
- }
- case SET_SEARCH_VALUE: {
- return { ...state, searchValue: action.payload.searchValue };
- }
+ case SET_CURRENT_JOBS:
+ case SET_CURRENT_PAGE:
+ case SET_FULL_TIME:
+ case SET_IS_LOADING:
+ case SET_JOBS:
+ case SET_JOBS_FETCHED_AT:
+ case SET_LOCATION_SEARCH:
+ case SET_NOTIFICATION_MESSAGE:
+ case SET_NOTIFICATION_TYPE:
+ case SET_SEARCH_VALUE:
case SET_TOTAL_PAGES: {
- return { ...state, totalPages: action.payload.totalPages };
+ return { ...state, [key]: value };
}
default:
return state;
diff --git a/src/client/redux/reducers/index.ts b/src/client/redux/reducers/index.ts
index d64376e..d703133 100644
--- a/src/client/redux/reducers/index.ts
+++ b/src/client/redux/reducers/index.ts
@@ -1,6 +1,9 @@
import { combineReducers } from "redux";
+
import application from "./application";
+import user from "./user";
export default combineReducers({
application,
+ user,
});
diff --git a/src/client/redux/reducers/user.ts b/src/client/redux/reducers/user.ts
new file mode 100644
index 0000000..ff527b6
--- /dev/null
+++ b/src/client/redux/reducers/user.ts
@@ -0,0 +1,77 @@
+import {
+ SET_CONFIRM_PASSWORD,
+ SET_EDIT_EMAIL,
+ SET_EDIT_NAME,
+ SET_EMAIL,
+ SET_IS_DELETING_PROFILE,
+ SET_IS_EDITING_PROFILE,
+ SET_IS_LOGGED_IN,
+ SET_IS_RESETTING_PASSWORD,
+ SET_IS_VIEWING_SAVED_JOBS,
+ SET_NAME,
+ SET_PASSWORD,
+ SET_RESET_CONFIRM_NEW_PASSWORD,
+ SET_RESET_CURRENT_PASSWORD,
+ SET_RESET_NEW_PASSWORD,
+ SET_SAVED_JOBS,
+ SET_SAVED_JOBS_CURRENT_PAGE,
+ SET_SAVED_JOBS_TOTAL_PAGES,
+} from "../actionTypes";
+
+import { UserAction, UserState } from "../../types";
+
+export const initialState: UserState = {
+ confirmPassword: "",
+ editEmail: "",
+ editName: "",
+ email: "",
+ isDeletingProfile: false,
+ isEditingProfile: false,
+ isLoggedIn: false,
+ isResettingPassword: false,
+ isViewingSavedJobs: false,
+ name: "",
+ password: "",
+ resetConfirmNewPassword: "",
+ resetCurrentPassword: "",
+ resetNewPassword: "",
+ savedJobs: [],
+ savedJobsCurrentPage: 1,
+ savedJobsTotalPages: 1,
+};
+
+const reducer = (state = initialState, action: UserAction): UserState => {
+ let key: string;
+ let value;
+
+ if (action && action.payload) {
+ key = Object.keys(action.payload)[0];
+ value = action.payload[key];
+ }
+
+ switch (action.type) {
+ case SET_CONFIRM_PASSWORD:
+ case SET_EDIT_EMAIL:
+ case SET_EDIT_NAME:
+ case SET_EMAIL:
+ case SET_IS_DELETING_PROFILE:
+ case SET_IS_EDITING_PROFILE:
+ case SET_IS_LOGGED_IN:
+ case SET_IS_RESETTING_PASSWORD:
+ case SET_IS_VIEWING_SAVED_JOBS:
+ case SET_NAME:
+ case SET_PASSWORD:
+ case SET_RESET_CONFIRM_NEW_PASSWORD:
+ case SET_RESET_CURRENT_PASSWORD:
+ case SET_RESET_NEW_PASSWORD:
+ case SET_SAVED_JOBS:
+ case SET_SAVED_JOBS_CURRENT_PAGE:
+ case SET_SAVED_JOBS_TOTAL_PAGES: {
+ return { ...state, [key]: value };
+ }
+ default:
+ return state;
+ }
+};
+
+export default reducer;
diff --git a/src/client/redux/thunks.ts b/src/client/redux/thunks.ts
index b0269d6..ce24d10 100644
--- a/src/client/redux/thunks.ts
+++ b/src/client/redux/thunks.ts
@@ -1,19 +1,57 @@
+import endOfToday from "date-fns/endOfToday";
+import isWithinInterval from "date-fns/isWithinInterval";
+import startOfToday from "date-fns/startOfToday";
+
import {
- setJobs,
- setJobsFetchedAt,
setCurrentJobs,
- setIsLoading,
setCurrentPage,
- setTotalPages,
+ setIsLoading,
+ setJobs,
+ setJobsFetchedAt,
setSearchValue,
+ setTotalPages,
+ setNotificationMessage,
+ setNotificationType,
} from "./actions/application";
-import { getData, unique } from "../util";
+import {
+ setConfirmPassword,
+ setEditEmail,
+ setEditName,
+ setEmail,
+ setIsDeletingProfile,
+ setIsEditingProfile,
+ setIsLoggedIn,
+ setIsViewingSavedJobs,
+ setIsResettingPassword,
+ setName,
+ setPassword,
+ setResetConfirmNewPassword,
+ setResetCurrentPassword,
+ setResetNewPassword,
+ setSavedJobs,
+ setSavedJobsCurrentPage,
+ setSavedJobsTotalPages,
+} from "./actions/user";
+import { fetchServerData, unique } from "../util";
-import { AppThunk, Job, LocationOption, RootState } from "../types";
+import {
+ AddSavedJobResponse,
+ AppThunk,
+ DeleteProfileResponse,
+ EditProfileResponse,
+ Job,
+ LocationOption,
+ LoginResponse,
+ RemoveSavedJobResponse,
+ ResetPasswordResponse,
+ RootState,
+ ServerResponseUser,
+ SignupResponse,
+} from "../types";
export const getJobs = (): AppThunk => async (dispatch) => {
try {
- const jobs: Job[] = await getData("/jobs");
+ const jobs: Job[] = await fetchServerData("/jobs", "GET");
dispatch(setJobs(jobs));
dispatch(setJobsFetchedAt(new Date().toString()));
@@ -59,7 +97,7 @@ export const searchJobs = (
)}&description=${encodeURI(search)}&location=${encodeURI(
location.value
)}`;
- const data = await getData(url);
+ const data = await fetchServerData(url, "GET");
jobs.push(...data);
})
);
@@ -68,7 +106,7 @@ export const searchJobs = (
const url = `/jobs/search?full_time=${encodeURI(
fullTime.toString()
)}&description=${encodeURI(search)}`;
- const data = await getData(url);
+ const data = await fetchServerData(url, "GET");
jobs.push(...data);
}
@@ -84,6 +122,394 @@ export const searchJobs = (
dispatch(setIsLoading(false));
};
-export const pagination = (pageNumber: number): AppThunk => (dispach) => {
- dispach(setCurrentPage(pageNumber));
+export const pagination = (pageNumber: number): AppThunk => (dispatch) => {
+ dispatch(setCurrentPage(pageNumber));
+};
+
+export const logIn = (): AppThunk => async (dispatch, getState) => {
+ dispatch(setIsLoading(true));
+ dispatch(setNotificationMessage(""));
+
+ const { user } = getState();
+ const { email, password } = user;
+
+ const response: LoginResponse = await fetchServerData(
+ "/user/login",
+ "POST",
+ JSON.stringify({ email, password })
+ );
+
+ if (response.error) {
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(response.error));
+ dispatch(setIsLoading(false));
+ return;
+ }
+
+ dispatch(setIsLoggedIn(true));
+ dispatch(setEmail(response.email));
+ dispatch(setName(response.name));
+ dispatch(setSavedJobs(response.savedJobs));
+
+ dispatch(setIsLoading(false));
+};
+
+export const signup = (): AppThunk => async (dispatch, getState) => {
+ dispatch(setIsLoading(true));
+ dispatch(setNotificationMessage(""));
+
+ const { user } = getState();
+ const { confirmPassword, email, name, password } = user;
+
+ if (confirmPassword !== password) {
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage("Passwords do not match."));
+ dispatch(setIsLoading(false));
+ return;
+ }
+
+ const response: SignupResponse = await fetchServerData(
+ "/user",
+ "POST",
+ JSON.stringify({ confirmPassword, email, name, password })
+ );
+
+ if (response.error) {
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(response.error));
+ dispatch(setIsLoading(false));
+ return;
+ }
+
+ dispatch(setIsLoggedIn(true));
+ dispatch(setEmail(response.email));
+ dispatch(setName(response.name));
+ dispatch(setPassword(""));
+ dispatch(setConfirmPassword(""));
+ dispatch(setSavedJobs(response.savedJobs));
+
+ dispatch(setIsLoading(false));
+};
+
+export const initializeApplication = (): AppThunk => async (
+ dispatch,
+ getState
+) => {
+ dispatch(setIsLoading(true));
+ dispatch(setNotificationType("info"));
+ dispatch(setNotificationMessage(""));
+ const state: RootState = getState();
+ const { jobsFetchedAt } = state.application;
+ // * Establish Job Data
+ if (jobsFetchedAt) {
+ const isWithinToday = isWithinInterval(new Date(jobsFetchedAt), {
+ start: startOfToday(),
+ end: endOfToday(),
+ });
+
+ if (!isWithinToday) {
+ dispatch(getJobs());
+ }
+ } else {
+ dispatch(getJobs());
+ }
+
+ // * Establish User Authentication
+ dispatch(checkAuthentication());
+};
+
+export const checkAuthentication = (): AppThunk => async (dispatch) => {
+ try {
+ const response = await fetch("/user/me");
+ if (response.status === 200) {
+ const user: ServerResponseUser = await response.json();
+ dispatch(setName(user.name));
+ dispatch(setEmail(user.email));
+ dispatch(setSavedJobs(user.savedJobs));
+ dispatch(setIsLoggedIn(true));
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error);
+ }
+ dispatch(setIsLoading(false));
+};
+
+export const logOut = (): AppThunk => async (dispatch) => {
+ dispatch(setIsLoading(true));
+ const response = await fetchServerData("/user/logout", "POST");
+
+ if (response.error) {
+ console.error(response.error);
+ dispatch(setNotificationType("error"));
+ dispatch(
+ setNotificationMessage(
+ "Error when attempting to log out. Please try again or contact the developer."
+ )
+ );
+ return;
+ }
+
+ dispatch(setConfirmPassword(""));
+ dispatch(setEmail(""));
+ dispatch(setNotificationMessage(""));
+ dispatch(setName(""));
+ dispatch(setPassword(""));
+ dispatch(setSavedJobs([]));
+ dispatch(setIsLoggedIn(false));
+
+ dispatch(setIsLoading(false));
+};
+
+export const logOutAll = (): AppThunk => async (dispatch) => {
+ dispatch(setIsLoading(true));
+ const response = await fetchServerData("/user/logout/all", "POST");
+
+ if (response.error) {
+ console.error(response.error);
+ dispatch(setNotificationType("error"));
+ dispatch(
+ setNotificationMessage(
+ "Error when attempting to log out. Please try again or contact the developer."
+ )
+ );
+ return;
+ }
+
+ dispatch(setConfirmPassword(""));
+ dispatch(setEmail(""));
+ dispatch(setNotificationMessage(""));
+ dispatch(setName(""));
+ dispatch(setPassword(""));
+ dispatch(setSavedJobs([]));
+ dispatch(setIsLoggedIn(false));
+
+ dispatch(setIsLoading(false));
+};
+
+export const resetPassword = (): AppThunk => async (dispatch, getState) => {
+ dispatch(setIsLoading(true));
+ const state: RootState = getState();
+
+ const {
+ resetConfirmNewPassword,
+ resetCurrentPassword,
+ resetNewPassword,
+ } = state.user;
+
+ if (resetConfirmNewPassword !== resetNewPassword) {
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage("Passwords do not match."));
+ dispatch(setIsLoading(false));
+ return;
+ }
+
+ try {
+ const response: ResetPasswordResponse = await fetchServerData(
+ "/user/me",
+ "PATCH",
+ JSON.stringify({
+ currentPassword: resetCurrentPassword,
+ newPassword: resetNewPassword,
+ })
+ );
+
+ if (response.error) {
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(response.error));
+ dispatch(setIsLoading(false));
+ return;
+ }
+
+ dispatch(setNotificationType("info"));
+ dispatch(setNotificationMessage("Password reset successfully."));
+ dispatch(setResetConfirmNewPassword(""));
+ dispatch(setResetCurrentPassword(""));
+ dispatch(setResetNewPassword(""));
+ dispatch(setIsResettingPassword(false));
+ dispatch(setIsLoading(false));
+ } catch (error) {
+ console.error(error);
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(error));
+ dispatch(setIsLoading(false));
+ }
+};
+
+export const cancelResetPassword = (): AppThunk => (dispatch) => {
+ dispatch(setResetConfirmNewPassword(""));
+ dispatch(setResetCurrentPassword(""));
+ dispatch(setResetNewPassword(""));
+ dispatch(setNotificationMessage(""));
+ dispatch(setIsResettingPassword(false));
+};
+
+export const clickEditProfile = (): AppThunk => (dispatch, getState) => {
+ const state: RootState = getState();
+
+ const { email, name } = state.user;
+
+ dispatch(setNotificationMessage(""));
+ dispatch(setEditEmail(email));
+ dispatch(setEditName(name));
+ dispatch(setIsEditingProfile(true));
+};
+
+export const cancelEditProfile = (): AppThunk => (dispatch) => {
+ dispatch(setEditEmail(""));
+ dispatch(setEditName(""));
+ dispatch(setNotificationMessage(""));
+ dispatch(setIsEditingProfile(false));
+};
+
+export const editProfile = (): AppThunk => async (dispatch, getState) => {
+ dispatch(setIsLoading(true));
+ const state: RootState = getState();
+
+ const { editEmail, editName } = state.user;
+ try {
+ const response: EditProfileResponse = await fetchServerData(
+ "/user/me",
+ "PATCH",
+ JSON.stringify({ email: editEmail, name: editName })
+ );
+
+ if (response.error) {
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(response.error));
+ dispatch(setIsLoading(false));
+ return;
+ }
+
+ dispatch(setNotificationType("info"));
+ dispatch(
+ setNotificationMessage("Profile information updated successfully.")
+ );
+ dispatch(setEditEmail(""));
+ dispatch(setEditName(""));
+ dispatch(setEmail(response.email));
+ dispatch(setName(response.name));
+ dispatch(setIsEditingProfile(false));
+ dispatch(setIsLoading(false));
+ } catch (error) {
+ console.error(error);
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(error));
+ dispatch(setIsLoading(false));
+ }
+};
+
+export const cancelDeleteProfile = (): AppThunk => (dispatch) => {
+ dispatch(setNotificationMessage(""));
+ dispatch(setIsDeletingProfile(false));
+};
+
+export const clickDeleteProfile = (): AppThunk => (dispatch) => {
+ dispatch(setNotificationType("warning"));
+ dispatch(
+ setNotificationMessage(
+ "Are you sure you would like to delete your profile? This can not be reversed."
+ )
+ );
+ dispatch(setIsDeletingProfile(true));
+};
+
+export const deleteProfile = (): AppThunk => async (dispatch) => {
+ dispatch(setIsLoading(true));
+
+ try {
+ const response: DeleteProfileResponse = await fetchServerData(
+ "/user/me",
+ "DELETE"
+ );
+
+ if (response.error) {
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(response.error));
+ dispatch(setIsLoading(false));
+ return;
+ }
+
+ dispatch(setNotificationType("info"));
+ dispatch(setNotificationMessage("Profile deleted successfully."));
+ dispatch(setEmail(""));
+ dispatch(setName(""));
+ dispatch(setSavedJobs([]));
+ dispatch(setIsDeletingProfile(false));
+ dispatch(setIsLoggedIn(false));
+ dispatch(setIsLoading(false));
+ } catch (error) {
+ console.error(error);
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(error));
+ dispatch(setIsLoading(false));
+ }
+};
+
+export const addSavedJob = (job: Job): AppThunk => async (dispatch) => {
+ dispatch(setIsLoading(true));
+ try {
+ const response: AddSavedJobResponse = await fetchServerData(
+ "/user/savedJobs",
+ "PATCH",
+ JSON.stringify({ method: "ADD", job })
+ );
+
+ if (response.error) {
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(response.error));
+ dispatch(setIsLoading(false));
+ return;
+ }
+
+ const { savedJobs } = response;
+
+ dispatch(setSavedJobs(savedJobs));
+ dispatch(setSavedJobsCurrentPage(1));
+ dispatch(setSavedJobsTotalPages(Math.ceil(savedJobs.length / 5)));
+ dispatch(setNotificationType("info"));
+ dispatch(setNotificationMessage("Job saved successfully."));
+ dispatch(setIsLoading(false));
+ } catch (error) {
+ console.error(error);
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(error));
+ dispatch(setIsLoading(false));
+ }
+};
+
+export const removeSavedJob = (job: Job): AppThunk => async (dispatch) => {
+ dispatch(setIsLoading(true));
+ try {
+ const response: RemoveSavedJobResponse = await fetchServerData(
+ "/user/savedJobs",
+ "PATCH",
+ JSON.stringify({ method: "REMOVE", job })
+ );
+
+ if (response.error) {
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(response.error));
+ dispatch(setIsLoading(false));
+ return;
+ }
+
+ const { savedJobs } = response;
+
+ dispatch(setSavedJobs(savedJobs));
+ dispatch(setSavedJobsCurrentPage(1));
+ dispatch(setSavedJobsTotalPages(Math.ceil(savedJobs.length / 5)));
+ dispatch(setNotificationType("info"));
+ dispatch(setNotificationMessage("Job removed successfully."));
+ dispatch(setIsLoading(false));
+ } catch (error) {
+ console.error(error);
+ dispatch(setNotificationType("error"));
+ dispatch(setNotificationMessage(error));
+ dispatch(setIsLoading(false));
+ }
+};
+
+export const clickViewSavedJobs = (): AppThunk => (dispatch) => {
+ dispatch(setIsViewingSavedJobs(true));
};
diff --git a/src/client/types.ts b/src/client/types.ts
index 9d72604..10c3972 100644
--- a/src/client/types.ts
+++ b/src/client/types.ts
@@ -1,6 +1,8 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
+export type AddSavedJobResponse = ServerResponseError & ServerResponseUser;
+
export interface ApplicationAction {
type: string;
// eslint-disable-next-line
@@ -15,6 +17,8 @@ export interface ApplicationState {
jobs: Job[];
jobsFetchedAt: string;
locationSearch: string;
+ notificationMessage: string;
+ notificationType: NotificationType;
searchValue: string;
totalPages: number;
}
@@ -26,6 +30,83 @@ export type AppThunk = ThunkAction<
Action
>;
+export type ButtonStyle = "primary" | "secondary" | "danger";
+
+export type ButtonType = "button" | "reset" | "submit";
+
+export type DeleteProfileResponse = ServerResponseError & ServerResponseUser;
+
+export type EditProfileResponse = ServerResponseError & ServerResponseUser;
+
+export type InputAutoComplete =
+ | "off"
+ | "on"
+ | "name"
+ | "email"
+ | "username"
+ | "new-password"
+ | "current-password"
+ | "one-time-code"
+ | "organization-title"
+ | "organization"
+ | "street-address"
+ | "address-line1"
+ | "address-line2"
+ | "address-line3"
+ | "address-level4"
+ | "address-level3"
+ | "address-level2"
+ | "address-level1"
+ | "country"
+ | "country-name"
+ | "postal-code"
+ | "cc-name"
+ | "cc-given-name"
+ | "cc-additional-name"
+ | "cc-number"
+ | "cc-exp"
+ | "cc-exp-month"
+ | "cc-exp-year"
+ | "cc-csc"
+ | "cc-type"
+ | "transaction-currency"
+ | "transaction-amount"
+ | "language"
+ | "bday"
+ | "bday-day"
+ | "bday-month"
+ | "bday-year"
+ | "sex"
+ | "tel"
+ | "tel-extension"
+ | "impp"
+ | "url"
+ | "photo";
+
+export type InputType =
+ | "button"
+ | "checkbox"
+ | "color"
+ | "date"
+ | "datetime-local"
+ | "email"
+ | "file"
+ | "hidden"
+ | "image"
+ | "month"
+ | "number"
+ | "password"
+ | "radio"
+ | "range"
+ | "reset"
+ | "search"
+ | "submit"
+ | "tel"
+ | "text"
+ | "time"
+ | "url"
+ | "week";
+
export interface Job {
company: string;
company_logo: string;
@@ -48,10 +129,77 @@ export interface LocationOption {
value: string;
}
+export type LoginResponse = ServerResponseError & ServerResponseUser;
+
+export type NotificationType = "error" | "info" | "warning";
+
export type PaginationNavigationType = "left" | "right";
+export type RemoveSavedJobResponse = ServerResponseError & ServerResponseUser;
+
+export type RequestMethod = "DELETE" | "GET" | "PATCH" | "POST";
+
+export type ResetPasswordResponse = ServerResponseError & ServerResponseUser;
+
export type RootState = {
application: ApplicationState;
+ user: UserState;
};
export type SearchType = "description" | "location";
+
+export interface ServerResponseError {
+ error: string;
+}
+
+export interface ServerResponseUser {
+ createdAt: string;
+ email: string;
+ name: string;
+ savedJobs: Job[];
+ updatedAt: string;
+ __v: number;
+ _id: string;
+}
+
+export type SignupResponse = SignupResponseError & SignupResponseSuccess;
+
+export interface SignupResponseError {
+ error: string;
+}
+
+export interface SignupResponseSuccess {
+ createdAt: string;
+ email: string;
+ name: string;
+ savedJobs: Job[];
+ updatedAt: string;
+ __v: number;
+ _id: string;
+}
+
+export interface UserAction {
+ type: string;
+ // eslint-disable-next-line
+ payload: any;
+}
+
+export interface UserState {
+ confirmPassword: string;
+ editEmail: string;
+ editName: string;
+ email: string;
+ isDeletingProfile: boolean;
+ isEditingProfile: boolean;
+ isLoggedIn: false;
+ isResettingPassword: boolean;
+ isViewingSavedJobs: boolean;
+ name: string;
+ password: string;
+ resetConfirmNewPassword: string;
+ resetCurrentPassword: string;
+ resetNewPassword: string;
+ savedJobs: Job[];
+ savedJobsCurrentPage: number;
+ savedJobsTotalPages: number;
+}
diff --git a/src/client/util.ts b/src/client/util.ts
index 16d1265..6c89c27 100644
--- a/src/client/util.ts
+++ b/src/client/util.ts
@@ -1,11 +1,17 @@
-import { Job } from "./types";
+import { RequestMethod } from "./types";
-export const getData = async (url: string): Promise => {
+export const fetchServerData = async (
+ url: string,
+ method: RequestMethod,
+ body?: string
+ // eslint-disable-next-line
+): Promise => {
const response = await fetch(url, {
+ body: body ? body : undefined,
headers: { "Content-Type": "application/json" },
- method: "GET",
+ method,
});
- const data: Job[] = await response.json();
+ const data = await response.json();
return data;
};
@@ -36,6 +42,7 @@ export const validURL = (str: string): boolean => {
* Loads the state of the application from localStorage if present.
* @returns {object}
*/
+// eslint-disable-next-line
export const loadState = (): any => {
try {
const serializedState = localStorage.getItem("state");
diff --git a/src/server/app.ts b/src/server/app.ts
index 45c0b9e..3293fa3 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -1,6 +1,8 @@
import chalk from "chalk";
+import cookieParser from "cookie-parser";
import cors, { CorsOptions } from "cors";
import express, { Request, Response } from "express";
+import mongoose from "mongoose";
import morgan from "morgan";
import path from "path";
@@ -23,11 +25,27 @@ class App {
}
private initializeMiddlewares(): void {
+ if (!process.env.MONGODB_URL) throw new Error("No MOONGODB_URL");
+
+ mongoose.connect(process.env.MONGODB_URL, {
+ useNewUrlParser: true,
+ useCreateIndex: true,
+ useFindAndModify: false,
+ useUnifiedTopology: true,
+ });
+
+ this.app.use(cookieParser());
this.app.use(express.json());
- this.app.use(morgan("dev"));
+
+ if (process.env.NODE_ENV !== "test") {
+ this.app.use(morgan("dev"));
+ }
+
const whitelistDomains = [
"http://localhost:3000",
"http://localhost:8080",
+ "https://gh-jobs.herokuapp.com",
+ "https://www.githubjobs.io",
undefined,
];
diff --git a/src/server/assets/handshake.jpg b/src/server/assets/handshake.jpg
new file mode 100644
index 0000000..9b46ae7
Binary files /dev/null and b/src/server/assets/handshake.jpg differ
diff --git a/src/server/assets/welcome.html b/src/server/assets/welcome.html
new file mode 100644
index 0000000..8aec773
--- /dev/null
+++ b/src/server/assets/welcome.html
@@ -0,0 +1,548 @@
+
+
+
+ Welcome!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Welcome aboard!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Welcome to GH Jobs.
+
+
+
+
+
+
+ We're really excited you've decided to give
+ GH Jobs a try. You can login to your account with your
+ email address.
+
+
+
+
+
+
+
+
+
+
+
+ Thanks,
+ The GH Jobs Team
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/server/controllers/assets.ts b/src/server/controllers/assets.ts
index 04f1da6..1fbe7a9 100644
--- a/src/server/controllers/assets.ts
+++ b/src/server/controllers/assets.ts
@@ -15,6 +15,7 @@ class AssetsController {
"favicon.ico",
"favicon-16x16.png",
"favicon-32x32.png",
+ "handshake.jpg",
"site.webmanifest",
];
diff --git a/src/server/controllers/user.ts b/src/server/controllers/user.ts
new file mode 100644
index 0000000..61234ec
--- /dev/null
+++ b/src/server/controllers/user.ts
@@ -0,0 +1,317 @@
+import bcrypt from "bcryptjs";
+import express, { Request, Response, Router } from "express";
+import sgMail from "@sendgrid/mail";
+import validator from "validator";
+
+import auth from "../middleware/auth";
+
+import User from "../models/User";
+
+import {
+ AuthenticatedRequest,
+ EditSavedJobsMethod,
+ Job,
+ Token,
+ UserDocument,
+} from "../types";
+
+/**
+ * User Controller.
+ */
+class UserController {
+ public router: Router = express.Router();
+
+ constructor() {
+ this.initializeRoutes();
+ }
+
+ public initializeRoutes(): void {
+ this.router.post(
+ "/user",
+ async (req: Request, res: Response): Promise => {
+ try {
+ const existingUser = await User.findOne({ email: req.body.email });
+
+ if (existingUser) {
+ return res.status(400).send({
+ error:
+ "A user with that email address already exists. Please try logging in instead.",
+ });
+ }
+
+ if (req.body.confirmPassword !== req.body.password) {
+ return res.status(400).send({ error: "Passwords do not match." });
+ }
+
+ const newUser = new User({
+ email: req.body.email,
+ name: req.body.name,
+ password: req.body.password,
+ savedJobs: [],
+ });
+
+ const token = await newUser.generateAuthToken();
+
+ // * Set a Cookie with that token
+ res.cookie("ghjobs", token, {
+ maxAge: 60 * 60 * 1000, // 1 hour
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production", // * localhost isn't https
+ sameSite: true,
+ });
+
+ await newUser.save();
+
+ // * Send "Welcome" email
+ if (process.env.NODE_ENV !== "test") {
+ sgMail.setApiKey(process.env.SENDGRID_API_KEY);
+ const msg = {
+ to: req.body.email,
+ from: "support@githubjobs.io",
+ subject: "Welcome to GH Jobs!",
+ text: `Welcome aboard! Welcome to GH Jobs. We're really excited you've decided to give GH Jobs a try. You can login to your account with your email address. Thanks, The GH Jobs Team`,
+ html: ` Welcome! Welcome to GH Jobs.
We're really excited you've decided to give GH Jobs a try. You can login to your account with your email address.
Thanks, The GH Jobs Team
`,
+ };
+ sgMail.send(msg);
+ }
+
+ return res.status(201).send(newUser);
+ } catch (error) {
+ if (process.env.NODE_ENV !== "test") {
+ console.error(error);
+ }
+
+ if (error.errors.email) {
+ return res.status(400).send({ error: error.errors.email.message });
+ }
+
+ if (error.errors.password) {
+ return res
+ .status(400)
+ .send({ error: error.errors.password.message });
+ }
+
+ return res.status(400).send({ error });
+ }
+ }
+ );
+
+ this.router.get(
+ "/user/me",
+ auth,
+ async (req: AuthenticatedRequest, res: Response): Promise => {
+ try {
+ return res.send(req.user);
+ } catch (error) {
+ if (process.env.NODE_ENV !== "test") {
+ console.error(error);
+ }
+ return res.status(500).send({ error });
+ }
+ }
+ );
+
+ this.router.post(
+ "/user/login",
+ async (
+ req: express.Request,
+ res: express.Response
+ ): Promise => {
+ try {
+ if (!validator.isEmail(req.body.email)) {
+ return res.status(400).send({ error: "Invalid email" });
+ }
+
+ const user: UserDocument = await User.findByCredentials(
+ req.body.email,
+ req.body.password
+ );
+
+ if (!user) {
+ return res.status(401).send({ error: "Invalid credentials." });
+ }
+
+ const token = await user.generateAuthToken();
+ // * Set a Cookie with that token
+ res.cookie("ghjobs", token, {
+ maxAge: 60 * 60 * 1000, // 1 hour
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production", // * localhost isn't https
+ sameSite: true,
+ });
+
+ return res.send(user);
+ } catch (error) {
+ if (process.env.NODE_ENV !== "test") {
+ console.error(error);
+ }
+ return res.status(500).send({});
+ }
+ }
+ );
+
+ this.router.post(
+ "/user/logout",
+ auth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ req.user.tokens = req.user.tokens.filter(
+ (token: Token) => token.token !== req.token
+ );
+ await req.user.save();
+
+ res.clearCookie("ghjobs");
+
+ return res.send({});
+ } catch (error) {
+ if (process.env.NODE_ENV !== "test") {
+ console.error(error);
+ }
+ return res.status(500).send({ error });
+ }
+ }
+ );
+
+ this.router.post(
+ "/user/logout/all",
+ auth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ req.user.tokens = [];
+ await req.user.save();
+
+ res.clearCookie("ghjobs");
+
+ return res.send({});
+ } catch (error) {
+ if (process.env.NODE_ENV !== "test") {
+ console.error(error);
+ }
+ return res.status(500).send({ error });
+ }
+ }
+ );
+
+ this.router.patch(
+ "/user/savedJobs",
+ auth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const method: EditSavedJobsMethod = req.body.method;
+ const job: Job = req.body.job;
+ const currentSavedJobs = req.user.savedJobs;
+ let newJobs;
+
+ if (method !== "ADD" && method !== "REMOVE") {
+ // * Request is incorrect - error
+ // ! Should never happen
+ return res.status(400).send({ error: "Invalid request." });
+ }
+
+ if (method === "ADD") {
+ // * User is attempting to add a saved job
+ newJobs = [...currentSavedJobs, job];
+ } else if (method === "REMOVE") {
+ // * User is attempting to remove a saved job
+ newJobs = currentSavedJobs.filter(
+ (savedJob: Job) => savedJob.id !== job.id
+ );
+ }
+ req.user.savedJobs = newJobs;
+ await req.user.save();
+ return res.send(req.user);
+ } catch (error) {
+ if (process.env.NODE_ENV !== "test") {
+ console.error(error);
+ }
+
+ return res.status(500).send({ error });
+ }
+ }
+ );
+
+ this.router.patch(
+ "/user/me",
+ auth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ if (req.body.email || req.body.name) {
+ // * New Email / New Name
+ const newEmail = req.body.email;
+ const newName = req.body.name;
+
+ if (!newEmail || !validator.isEmail(newEmail)) {
+ return res.status(400).send({ error: "Invalid email." });
+ }
+
+ if (!newName || validator.isEmpty(newName)) {
+ return res.status(400).send({ error: "Invalid name." });
+ }
+
+ req.user.email = newEmail;
+ req.user.name = newName;
+ await req.user.save();
+
+ return res.send(req.user);
+ }
+
+ const { currentPassword, newPassword } = req.body;
+
+ // * Check if currrentPassword matches password in DB
+ const isMatch = await bcrypt.compare(
+ currentPassword,
+ req.user.password
+ );
+ if (!isMatch) {
+ return res.status(401).send({ error: "Invalid credentials." });
+ }
+
+ // * Set newPassword
+ req.user.password = newPassword;
+ await req.user.save();
+
+ // * Send User as respoonse
+ return res.send(req.user);
+ } catch (error) {
+ if (error.errors.password) {
+ // * Min Length Validation Error
+ if (error.errors.password.kind === "minlength") {
+ return res.status(400).send({
+ error: "Password must be a minimum of 7 characters.",
+ });
+ }
+ // * Password Validation Error
+ return res
+ .status(400)
+ .send({ error: error.errors.password.message });
+ }
+
+ if (process.env.NODE_ENV !== "test") {
+ console.error(error);
+ }
+
+ return res.status(500).send({ error });
+ }
+ }
+ );
+
+ this.router.delete(
+ "/user/me",
+ auth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ res.clearCookie("ghjobs");
+ await req.user.remove();
+ res.send(req.user);
+ } catch (error) {
+ if (process.env.NODE_ENV !== "test") {
+ console.error(error);
+ }
+ res.status(500).send({ error });
+ }
+ }
+ );
+ }
+}
+
+export default UserController;
diff --git a/src/server/index.ts b/src/server/index.ts
index 665ded5..c51e891 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -1,17 +1,46 @@
+import chalk from "chalk";
+
import App from "./app";
import AssetsController from "./controllers/assets";
import JobController from "./controllers/job";
import ScriptsController from "./controllers/scripts";
+import UserController from "./controllers/user";
+
+import { checkIfMongoDBIsRunning } from "./util";
/**
* Main Server Application.
*/
const main = async (): Promise => {
try {
+ const isRunning = await checkIfMongoDBIsRunning();
+
+ if (!isRunning) {
+ console.error(chalk.red("ERROR: Could not connect to MongoDB URL"));
+ console.log("");
+ console.error(
+ `Attempted to connect to: ${chalk.red(process.env.MONGODB_URL)}`
+ );
+ console.log("");
+ console.warn(
+ "If this is a local MongoDB instance, please ensure you have started MongoDB on your machine."
+ );
+ console.warn(
+ "If this is a remote MongoDB instance, please double check the value for MONGODB_URL in `/.env-cmdrc.json`."
+ );
+ console.log("");
+ return process.exit(1);
+ }
+
if (!process.env.PORT) throw new Error("No PORT");
const app = new App(
- [new AssetsController(), new JobController(), new ScriptsController()],
+ [
+ new AssetsController(),
+ new JobController(),
+ new ScriptsController(),
+ new UserController(),
+ ],
process.env.PORT
);
diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts
new file mode 100644
index 0000000..a037202
--- /dev/null
+++ b/src/server/middleware/auth.ts
@@ -0,0 +1,49 @@
+/* eslint-disable no-underscore-dangle */
+import { NextFunction, Request, Response } from "express";
+import jwt from "jsonwebtoken";
+import User from "../models/User";
+import { UserDocument } from "../types";
+
+export interface UserRequest extends Request {
+ user?: UserDocument;
+}
+
+const auth = async (
+ req: UserRequest,
+ res: Response,
+ next: NextFunction
+): Promise => {
+ try {
+ const tokenFromCookie = req.cookies.ghjobs;
+ // *Check if Cookie exists
+ if (tokenFromCookie) {
+ // *Verify the jwt value
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const decoded: any = jwt.verify(tokenFromCookie, process.env.JWT_SECRET);
+ const user: UserDocument = await User.findOne({
+ _id: decoded._id,
+ "tokens.token": tokenFromCookie,
+ });
+
+ if (!user) {
+ throw new Error(
+ `No user found in database. { _id: ${decoded._id}, tokens.token: ${tokenFromCookie}, path: ${req.originalUrl} }`
+ );
+ }
+
+ // * User is authenticated
+ req.user = user;
+ next();
+ } else {
+ // * User is not authenticated correctly
+ res.status(401).send({ error: "Please authenticate." });
+ }
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error(error);
+
+ res.status(500).send({ error });
+ }
+};
+
+export default auth;
diff --git a/src/server/models/User.ts b/src/server/models/User.ts
new file mode 100644
index 0000000..ff42a23
--- /dev/null
+++ b/src/server/models/User.ts
@@ -0,0 +1,161 @@
+/* eslint-disable func-names */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-this-alias */
+import bcrypt from "bcryptjs";
+import jwt from "jsonwebtoken";
+import mongoose from "mongoose";
+import validator from "validator";
+import { UserDocument, UserModel } from "../types";
+
+const userSchema = new mongoose.Schema(
+ {
+ // * Email, Password, etc
+ email: {
+ lowercase: true,
+ required: [true, "Email is required."],
+ trim: true,
+ type: String,
+ unique: true,
+ validate: (value: any): boolean => {
+ if (!validator.isEmail(value)) {
+ throw new Error("Email is invalid.");
+ }
+ return true;
+ },
+ },
+ name: {
+ required: false,
+ trim: true,
+ type: String,
+ },
+ password: {
+ minlength: [7, "Password must be a minimum of 7 characters."],
+ required: [true, "Password is required."],
+ trim: true,
+ type: String,
+ validate: (value: any): boolean => {
+ // * Password should contain:
+ // * 1. At least 1 uppercase letter
+ // * 2. At least 1 lowercase letter
+ // * 3. At least 1 letter
+ // * 4. At least 1 number
+ // * 5. At least 1 special character
+
+ if (value.toLowerCase().includes("password")) {
+ throw new Error(`Password can't contain the string "password".`);
+ }
+
+ if (validator.isLowercase(value)) {
+ throw new Error(
+ "Password should contain at least 1 uppercase letter."
+ );
+ }
+
+ if (validator.isUppercase(value)) {
+ throw new Error(
+ "Password should contain at least 1 lowercase letter."
+ );
+ }
+
+ if (validator.isNumeric(value)) {
+ throw new Error(
+ "Password must contain at least 1 uppercase letter and 1 lowercase letter."
+ );
+ }
+
+ // eslint-disable-next-line no-restricted-globals
+ if (value.split("").every((char: unknown) => isNaN(Number(char)))) {
+ throw new Error("Password should contain at least 1 number.");
+ }
+
+ if (validator.isAlphanumeric(value)) {
+ throw new Error(
+ "Password should contain at least 1 special character."
+ );
+ }
+
+ return true;
+ },
+ },
+ savedJobs: [
+ {
+ company: String,
+ company_logo: String,
+ company_url: String,
+ created_at: String,
+ description: String,
+ how_to_apply: String,
+ id: String,
+ location: String,
+ title: String,
+ type: { type: String },
+ url: String,
+ },
+ ],
+ tokens: [
+ {
+ token: {
+ required: true,
+ type: String,
+ },
+ },
+ ],
+ },
+ { timestamps: true }
+);
+
+function contentToJSON(): void {
+ const userObj = this.toObject();
+
+ delete userObj.password;
+ delete userObj.tokens;
+
+ return userObj;
+}
+
+userSchema.methods.toJSON = contentToJSON;
+
+userSchema.methods.generateAuthToken = async function (): Promise {
+ const user = this;
+ const token = jwt.sign({ _id: user.id.toString() }, process.env.JWT_SECRET);
+
+ user.tokens = user.tokens.concat({ token });
+
+ await user.save();
+
+ return token;
+};
+
+userSchema.statics.findByCredentials = async (
+ email: string,
+ password: string
+): Promise => {
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ const user: UserDocument = await User.findOne({ email });
+
+ if (!user) {
+ return null;
+ }
+
+ const isMatch = await bcrypt.compare(password, user.password);
+
+ if (!isMatch) {
+ return null;
+ }
+
+ return user;
+};
+
+userSchema.pre("save", async function (next): Promise {
+ const user: any = this;
+
+ if (user.isModified("password")) {
+ user.password = await bcrypt.hash(user.password, 8);
+ }
+
+ next();
+});
+
+const User = mongoose.model("User", userSchema);
+
+export default User;
diff --git a/src/server/types.ts b/src/server/types.ts
index 176207f..25aff27 100644
--- a/src/server/types.ts
+++ b/src/server/types.ts
@@ -1,9 +1,17 @@
-import { Router } from "express";
+import { Request, Router } from "express";
+import { Document, Model } from "mongoose";
+
+export interface AuthenticatedRequest extends Request {
+ token: string;
+ user: UserDocument;
+}
export type Controller = {
router: Router;
};
+export type EditSavedJobsMethod = "ADD" | "REMOVE";
+
export interface Job {
company: string;
company_logo: string;
@@ -19,3 +27,22 @@ export interface Job {
}
export type JobType = "Contract" | "Full Time";
+
+export interface Token {
+ _id: string;
+ token: string;
+}
+
+export interface UserDocument extends Document {
+ _id: string;
+ email: string;
+ generateAuthToken(): Promise;
+ password: string;
+ name: string;
+ savedJobs: Job[];
+ tokens: Token[];
+}
+
+export interface UserModel extends Model {
+ findByCredentials(email: string, password: string): Promise;
+}
diff --git a/src/server/util.ts b/src/server/util.ts
index c3181ff..3fc26f0 100644
--- a/src/server/util.ts
+++ b/src/server/util.ts
@@ -20,8 +20,11 @@ export const checkIfMongoDBIsRunning = async (): Promise =>
export const createSearchURL = (
page: number,
+ // eslint-disable-next-line
description: string | any,
+ // eslint-disable-next-line
full_time: string | any,
+ // eslint-disable-next-line
location: string | any
): string => {
let url = `https://jobs.github.com/positions.json?page=${page}&`;