diff --git a/.env-cmdrc.json b/.env-cmdrc.json deleted file mode 100644 index 82dd8bd..0000000 --- a/.env-cmdrc.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "production": { - "APP_NAME": "gh-jobs", - "MONGODB_URL": "mongodb://localhost:27017/gh-jobs", - "NODE_ENV": "production", - "PORT": 3000 - }, - "development": { - "APP_NAME": "gh-jobs", - "MONGODB_URL": "mongodb://localhost:27017/gh-jobs", - "NODE_ENV": "development", - "PORT": 3000 - }, - "test": { - "APP_NAME": "gh-jobs", - "MONGODB_URL": "mongodb://localhost:27017/gh-jobs", - "NODE_ENV": "test", - "PORT": 3000 - } -} diff --git a/.gitignore b/.gitignore index e2b5fd1..d2d0211 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ build/ dist/ cypress/screenshots/ -cypress/videos/ \ No newline at end of file +cypress/videos/ +.env-cmdrc.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 498d1e8..31a5863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2020-07-23 + +### πŸ¦Έβ€β™‚οΈ User Profiles and Saved Jobs + +### Added + +- Ability to create a new profile +- Ability to login +- Ability to reset your password +- Ability to edit profile information +- Ability to save your favorite jobs to your profile +- `` component +- `Login` page +- `Signup` page +- `SavedJobs` page +- ` + ); +}; + +export default Button; diff --git a/src/client/components/Input.tsx b/src/client/components/Input.tsx index fb5b7e0..dccf075 100644 --- a/src/client/components/Input.tsx +++ b/src/client/components/Input.tsx @@ -1,16 +1,33 @@ import * as React from "react"; +import { InputAutoComplete, InputType } from "../types"; + export interface InputProps { + autoComplete?: InputAutoComplete; + disabled?: boolean; icon?: string; id: string; label: string; - onChange: (e: React.ChangeEvent) => void; + onChange?: (e: React.ChangeEvent) => void; placeholder?: string; + required?: boolean; + type?: InputType; value: string; } const Input: React.SFC = (props: InputProps) => { - const { icon, id, label, onChange, placeholder, value } = props; + const { + autoComplete, + disabled, + icon, + id, + label, + onChange, + placeholder, + required, + type, + value, + } = props; return (
@@ -22,15 +39,16 @@ const Input: React.SFC = (props: InputProps) => { )}
- - {/* {" "} */} ); }; diff --git a/src/client/components/JobCard.tsx b/src/client/components/JobCard.tsx index 22e20af..4cc1103 100644 --- a/src/client/components/JobCard.tsx +++ b/src/client/components/JobCard.tsx @@ -1,19 +1,37 @@ import * as React from "react"; +import { connect } from "react-redux"; import formatDistanceToNow from "date-fns/formatDistanceToNow"; import { Link } from "react-router-dom"; -import { Job } from "../types"; +import { addSavedJob, removeSavedJob } from "../redux/thunks"; + +import { Job, RootState } from "../types"; export interface JobCardProps { + handleAddSavedJob: (job: Job) => void; + handleRemoveSavedJob: (job: Job) => void; + isLoggedIn: boolean; job: Job; + savedJobs: Job[]; } const JobCard: React.SFC = (props: JobCardProps) => { - const { job } = props; + const { + handleAddSavedJob, + handleRemoveSavedJob, + isLoggedIn, + job, + savedJobs, + } = props; const handleImageError = () => { // TODO - Should set the image to a fallback/just display the div with the not found text // alert("IMAGE ERROR - CREATE FUNCTIONALITY"); }; + + const jobIsSaved = savedJobs + ? savedJobs.findIndex((savedJob: Job) => savedJob.id === job.id) >= 0 + : false; + return (
@@ -34,7 +52,7 @@ const JobCard: React.SFC = (props: JobCardProps) => {

{job.company}

- +

{job.title}

{job.type === "Full Time" && ( @@ -44,19 +62,52 @@ const JobCard: React.SFC = (props: JobCardProps) => {
-
- public -

{job.location}

+
+ {isLoggedIn && ( + + )}
-
- access_time -

- {formatDistanceToNow(new Date(job.created_at), { addSuffix: true })} -

+
+
+ public +

{job.location}

+
+
+ access_time +

+ {formatDistanceToNow(new Date(job.created_at), { + addSuffix: true, + })} +

+
); }; -export default JobCard; +const mapStateToProps = (state: RootState) => ({ + isLoggedIn: state.user.isLoggedIn, + savedJobs: state.user.savedJobs, +}); + +const mapDispatchToProps = (dispatch) => ({ + handleAddSavedJob: (job: Job) => dispatch(addSavedJob(job)), + handleRemoveSavedJob: (job: Job) => dispatch(removeSavedJob(job)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(JobCard); diff --git a/src/client/components/Navigation.tsx b/src/client/components/Navigation.tsx new file mode 100644 index 0000000..c52ab34 --- /dev/null +++ b/src/client/components/Navigation.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import { Link, useLocation } from "react-router-dom"; + +import Header from "./Header"; +import { RootState } from "../types"; + +export interface NavigationProps { + isLoggedIn: boolean; +} + +const Navigation: React.SFC = (props: NavigationProps) => { + const { isLoggedIn } = props; + const { pathname } = useLocation(); + + return ( + + ); +}; + +const mapStateToProps = (state: RootState) => ({ + isLoggedIn: state.user.isLoggedIn, +}); + +export default connect(mapStateToProps)(Navigation); diff --git a/src/client/components/Notification.tsx b/src/client/components/Notification.tsx new file mode 100644 index 0000000..80e34f2 --- /dev/null +++ b/src/client/components/Notification.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; +import { connect } from "react-redux"; + +import { + setNotificationMessage, + setNotificationType, +} from "../redux/actions/application"; + +import { NotificationType } from "../types"; + +export interface NotificationProps { + handleResetNotification: () => void; + message: string; + type: NotificationType; +} + +const Notification: React.SFC = ( + props: NotificationProps +) => { + const { handleResetNotification, message, type } = props; + + React.useEffect(() => { + if (message && type === "info") { + setTimeout(() => { + handleResetNotification(); + }, 5000); + } + }, [message]); + + return ( +
+ {type} + {message} +
+ ); +}; + +const mapDispatchToProps = (dispatch) => ({ + handleResetNotification: () => { + dispatch(setNotificationMessage("")); + dispatch(setNotificationType("info")); + }, +}); + +export default connect(null, mapDispatchToProps)(Notification); diff --git a/src/client/components/Pagination.tsx b/src/client/components/Pagination.tsx index dd40dc1..63161f0 100644 --- a/src/client/components/Pagination.tsx +++ b/src/client/components/Pagination.tsx @@ -1,12 +1,9 @@ import * as React from "react"; -import { connect } from "react-redux"; import PaginationItem from "./PaginationItem"; import PaginationMore from "./PaginationMore"; import PaginationNavigation from "./PaginationNavigation"; -import { RootState } from "../types"; - export interface PaginationProps { currentPage: number; totalPages: number; @@ -58,17 +55,20 @@ const Pagination: React.SFC = (props: PaginationProps) => { return ( ); }; -const mapStateToProps = (state: RootState) => ({ - currentPage: state.application.currentPage, - totalPages: state.application.totalPages, -}); - -export default connect(mapStateToProps)(Pagination); +export default Pagination; diff --git a/src/client/components/PaginationNavigation.tsx b/src/client/components/PaginationNavigation.tsx index 2c2ebd0..ea93908 100644 --- a/src/client/components/PaginationNavigation.tsx +++ b/src/client/components/PaginationNavigation.tsx @@ -3,7 +3,7 @@ import { connect } from "react-redux"; import { setCurrentPage } from "../redux/actions/application"; -import { PaginationNavigationType, RootState } from "../types"; +import { PaginationNavigationType } from "../types"; export interface PaginationNavigationProps { currentPage: number; @@ -53,11 +53,6 @@ const PaginationNavigation: React.SFC = ( ); }; -const mapStateToProps = (state: RootState) => ({ - currentPage: state.application.currentPage, - totalPages: state.application.totalPages, -}); - const mapDispatchToProps = (dispatch) => ({ handleDecrement: (currentPage: number) => dispatch(setCurrentPage(currentPage - 1)), @@ -65,7 +60,4 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(setCurrentPage(currentPage + 1)), }); -export default connect( - mapStateToProps, - mapDispatchToProps -)(PaginationNavigation); +export default connect(null, mapDispatchToProps)(PaginationNavigation); diff --git a/src/client/components/ProfileDelete.tsx b/src/client/components/ProfileDelete.tsx new file mode 100644 index 0000000..55335d4 --- /dev/null +++ b/src/client/components/ProfileDelete.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; +import { connect } from "react-redux"; + +import Button from "./Button"; + +import { cancelDeleteProfile, deleteProfile } from "../redux/thunks"; + +export interface ProfileDeleteProps { + handleCancelDeleteProfile: () => void; + handleDeleteProfile: () => void; +} + +const ProfileDelete: React.SFC = ( + props: ProfileDeleteProps +) => { + const { handleCancelDeleteProfile, handleDeleteProfile } = props; + return ( + <> +
+
+ + ); +}; + +const mapDispatchToProps = (dispatch) => ({ + handleCancelDeleteProfile: () => dispatch(cancelDeleteProfile()), + handleDeleteProfile: () => dispatch(deleteProfile()), +}); + +export default connect(null, mapDispatchToProps)(ProfileDelete); diff --git a/src/client/components/ProfileDisplay.tsx b/src/client/components/ProfileDisplay.tsx new file mode 100644 index 0000000..0b4bd99 --- /dev/null +++ b/src/client/components/ProfileDisplay.tsx @@ -0,0 +1,142 @@ +import * as React from "react"; +import { connect } from "react-redux"; + +import Button from "./Button"; +import Input from "./Input"; + +import { setNotificationMessage } from "../redux/actions/application"; +import { setIsResettingPassword } from "../redux/actions/user"; +import { + clickEditProfile, + clickDeleteProfile, + clickViewSavedJobs, + logOut, + logOutAll, +} from "../redux/thunks"; + +import { Job, RootState } from "../types"; + +export interface ProfileDisplayProps { + email: string; + handleClearFormError: () => void; + handleClickDeleteProfile: () => void; + handleClickEditProfile: () => void; + handleClickViewSavedJobs: () => void; + handleLogOut: () => void; + handleLogOutAll: () => void; + handleSetIsResettingPassword: (isResettingPassword: boolean) => void; + name: string; + savedJobs: Job[]; +} + +const ProfileDisplay: React.SFC = ( + props: ProfileDisplayProps +) => { + const { + email, + handleClearFormError, + handleClickDeleteProfile, + handleClickEditProfile, + handleClickViewSavedJobs, + handleLogOut, + handleLogOutAll, + handleSetIsResettingPassword, + name, + savedJobs, + } = props; + return ( + <> + + + + +
+
+ +
+
+ +
+
+ + ); +}; + +const mapStateToProps = (state: RootState) => ({ + email: state.user.email, + name: state.user.name, + savedJobs: state.user.savedJobs, +}); + +const mapDispatchToProps = (dispatch) => ({ + handleClearFormError: () => dispatch(setNotificationMessage("")), + handleClickDeleteProfile: () => dispatch(clickDeleteProfile()), + handleClickEditProfile: () => dispatch(clickEditProfile()), + handleClickViewSavedJobs: () => dispatch(clickViewSavedJobs()), + handleLogOut: () => dispatch(logOut()), + handleLogOutAll: () => dispatch(logOutAll()), + handleSetIsResettingPassword: (isResettingPassword: boolean) => + dispatch(setIsResettingPassword(isResettingPassword)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ProfileDisplay); diff --git a/src/client/components/ProfileEdit.tsx b/src/client/components/ProfileEdit.tsx new file mode 100644 index 0000000..eeb2ae9 --- /dev/null +++ b/src/client/components/ProfileEdit.tsx @@ -0,0 +1,93 @@ +import * as React from "react"; +import { connect } from "react-redux"; + +import Button from "./Button"; +import Input from "./Input"; + +import { setEditEmail, setEditName } from "../redux/actions/user"; +import { cancelEditProfile, editProfile } from "../redux/thunks"; + +import { RootState } from "../types"; + +export interface ProfileEditProps { + editEmail: string; + editName: string; + email: string; + handleEditProfile: () => void; + handleCancelEditProfile: () => void; + handleSetEditEmail: (editEmail: string) => void; + handleSetEditName: (editName: string) => void; + name: string; +} + +const ProfileEdit: React.SFC = (props: ProfileEditProps) => { + const { + editEmail, + editName, + email, + handleCancelEditProfile, + handleEditProfile, + handleSetEditEmail, + handleSetEditName, + name, + } = props; + return ( + <> + handleSetEditName(e.target.value)} + type="text" + value={editName} + /> + + handleSetEditEmail(e.target.value)} + type="email" + value={editEmail} + /> + +
+
+ + ); +}; + +const mapStateToProps = (state: RootState) => ({ + editEmail: state.user.editEmail, + editName: state.user.editName, + email: state.user.email, + name: state.user.name, +}); + +const mapDispatchToProps = (dispatch) => ({ + handleCancelEditProfile: () => dispatch(cancelEditProfile()), + handleEditProfile: () => dispatch(editProfile()), + handleSetEditEmail: (editEmail: string) => dispatch(setEditEmail(editEmail)), + handleSetEditName: (editName: string) => dispatch(setEditName(editName)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ProfileEdit); diff --git a/src/client/components/ProfileReset.tsx b/src/client/components/ProfileReset.tsx new file mode 100644 index 0000000..6bbe2fd --- /dev/null +++ b/src/client/components/ProfileReset.tsx @@ -0,0 +1,114 @@ +import * as React from "react"; +import { connect } from "react-redux"; + +import Button from "./Button"; +import Input from "./Input"; + +import { + setResetConfirmNewPassword, + setResetCurrentPassword, + setResetNewPassword, +} from "../redux/actions/user"; +import { cancelResetPassword, resetPassword } from "../redux/thunks"; + +import { RootState } from "../types"; + +export interface ProfileResetProps { + handleCancelResetPassword: () => void; + handleResetPassword: () => void; + handleSetResetConfirmNewPassword: (resetConfirmNewPassword: string) => void; + handleSetResetCurrentPassword: (resetCurrentPassword: string) => void; + handleSetResetNewPassword: (resetNewPassword: string) => void; + resetConfirmNewPassword: string; + resetCurrentPassword: string; + resetNewPassword: string; +} + +const ProfileReset: React.SFC = ( + props: ProfileResetProps +) => { + const { + handleCancelResetPassword, + handleResetPassword, + handleSetResetConfirmNewPassword, + handleSetResetCurrentPassword, + handleSetResetNewPassword, + resetConfirmNewPassword, + resetCurrentPassword, + resetNewPassword, + } = props; + return ( + <> + handleSetResetCurrentPassword(e.target.value)} + type="password" + value={resetCurrentPassword} + /> + + handleSetResetNewPassword(e.target.value)} + type="password" + value={resetNewPassword} + /> + + handleSetResetConfirmNewPassword(e.target.value)} + type="password" + value={resetConfirmNewPassword} + /> + +
+
+ + ); +}; + +const mapStateToProps = (state: RootState) => ({ + resetConfirmNewPassword: state.user.resetConfirmNewPassword, + resetCurrentPassword: state.user.resetCurrentPassword, + resetNewPassword: state.user.resetNewPassword, +}); + +const mapDispatchToProps = (dispatch) => ({ + handleCancelResetPassword: () => dispatch(cancelResetPassword()), + handleResetPassword: () => dispatch(resetPassword()), + handleSetResetConfirmNewPassword: (resetConfirmNewPassword: string) => + dispatch(setResetConfirmNewPassword(resetConfirmNewPassword)), + handleSetResetCurrentPassword: (resetCurrentPassword: string) => + dispatch(setResetCurrentPassword(resetCurrentPassword)), + handleSetResetNewPassword: (resetNewPassword: string) => + dispatch(setResetNewPassword(resetNewPassword)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ProfileReset); diff --git a/src/client/components/ProfileSavedJobs.tsx b/src/client/components/ProfileSavedJobs.tsx new file mode 100644 index 0000000..c0b51fe --- /dev/null +++ b/src/client/components/ProfileSavedJobs.tsx @@ -0,0 +1,65 @@ +import * as React from "react"; +import { connect } from "react-redux"; + +import JobCard from "./JobCard"; +import Notification from "./Notification"; +import Pagination from "./Pagination"; + +import { Job, NotificationType, RootState } from "../types"; + +export interface ProfileSavedJobsProps { + notificationMessage: string; + notificationType: NotificationType; + savedJobs: Job[]; + savedJobsCurrentPage: number; + savedJobsTotalPages: number; +} + +const ProfileSavedJobs: React.SFC = ( + props: ProfileSavedJobsProps +) => { + const { + notificationMessage, + notificationType, + savedJobs, + savedJobsCurrentPage, + savedJobsTotalPages, + } = props; + + const jobsOnPage = savedJobs.slice( + savedJobsCurrentPage * 5 - 5, + savedJobsCurrentPage * 5 + ); + return ( + <> +
+ {notificationMessage && ( + + )} + {jobsOnPage && + jobsOnPage.map((job: Job) => )} + {jobsOnPage.length > 0 && ( + + )} + {jobsOnPage.length === 0 && ( +

+ No results. Please modify your search and try again. +

+ )} +
+ + ); +}; + +const mapStateToProps = (state: RootState) => ({ + notificationMessage: state.application.notificationMessage, + notificationType: state.application.notificationType, + savedJobs: state.user.savedJobs, + savedJobsCurrentPage: state.user.savedJobsCurrentPage, + savedJobsTotalPages: state.user.savedJobsTotalPages, +}); + +export default connect(mapStateToProps)(ProfileSavedJobs); diff --git a/src/client/components/SearchInput.tsx b/src/client/components/SearchInput.tsx index e364bcf..aeb646f 100644 --- a/src/client/components/SearchInput.tsx +++ b/src/client/components/SearchInput.tsx @@ -1,6 +1,8 @@ import * as React from "react"; import { connect } from "react-redux"; +import Button from "./Button"; + import { searchJobs } from "../redux/thunks"; import { LocationOption, RootState } from "../types"; @@ -38,9 +40,12 @@ const SearchInput: React.SFC = (props: SearchInputProps) => { value={search} />
- +
diff --git a/src/client/index.css b/src/client/index.css index 633c36c..8e42ab4 100644 --- a/src/client/index.css +++ b/src/client/index.css @@ -137,32 +137,11 @@ input::placeholder { margin-left: -1px; } -.search__button { - background-color: rgba(27, 108, 205, 1); - border: 3px solid rgba(255, 255, 255, 1); - border-bottom-right-radius: 0.25rem; - border-top-right-radius: 0.25rem; - color: #fff; - cursor: pointer; - display: inline-block; - font-weight: 400; - line-height: 1.5; - padding: 0.375rem 3rem; - position: relative; - text-align: center; - transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, - border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - user-select: none; - vertical-align: middle; - z-index: 2; -} - .flex { display: flex; } .jobcard__container { - align-items: center; background-color: #fff; box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.05); border-radius: 4px; @@ -178,6 +157,47 @@ input::placeholder { text-decoration: none; } +.jobcard__actions { + display: flex; + flex-direction: row; + justify-content: flex-end; +} + +.jobcard__save__selected, +.jobcard__save__deselected, +.details__save__selected, +.details__save__deselected { + background: transparent; + border: none; + color: #b9bdcf; + margin: 0; + padding: 0; +} + +.jobcard__save__selected, +.details__save__selected { + color: #1e86ff; +} + +.jobcard__save__selected:hover, +.jobcard__save__deselected:hover, +.details__save__selected:hover, +.details__save__deselected:hover { + color: #1e86ff; + cursor: pointer; +} + +.details__save__selected, +.details__save__deselected { + margin-left: 15px; +} + +.jobcard__info { + align-self: flex-end; + display: flex; + flex-direction: column; +} + .jobcard__logo__not-found, .details__logo__not-found { align-items: center; @@ -251,8 +271,9 @@ input::placeholder { } .jobcard__container__right { - align-self: flex-end; display: flex; + flex-direction: column; + justify-content: space-between; } .jobcard__location, @@ -266,7 +287,20 @@ input::placeholder { .jobcard__created i { color: #b9bdcf; font-size: 15px; - margin-right: 7.5px; + margin-right: 5px; +} + +.jobcard__created { + margin-top: 15px; +} + +.jobcard__created p, +.jobcard__location p { + margin: 0; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .jobcard__location p, @@ -281,10 +315,13 @@ input::placeholder { .details__created { align-items: center; display: flex; - margin-left: 28.5px; } .jobcard__created, +.jobcard__location { + justify-content: flex-end; +} + .details__created i { color: #b9bdcf; font-size: 15px; @@ -312,6 +349,10 @@ input::placeholder { width: 75%; } +.jobs__container.saved { + width: 100%; +} + .checkbox__container { display: block; color: #334680; @@ -445,7 +486,9 @@ input::placeholder { width: 75%; } -.details__side__link { +.details__side__link, +.login__action__create, +.navigation__link { align-items: center; color: #1e86ff; display: flex; @@ -457,16 +500,24 @@ input::placeholder { text-decoration: none; } -.details__side__link i { +.details__side__link { font-size: 14px; margin-right: 15px; } -.details__side__link:hover span { +.login__action__create i { + font-size: 18px; + margin-right: 10px; +} + +.details__side__link:hover span, +.login__action__create:hover span, +.navigation__link:hover span { text-decoration: underline; } -.details__side__link:hover i { +.details__side__link:hover i, +.login__action__create:hover i { text-decoration: none; } @@ -510,15 +561,20 @@ input::placeholder { .details__container__title__inner { align-items: flex-start; display: flex; - flex-direction: row; + flex-direction: column; } .details__title__fulltime { margin-bottom: 0; - margin-left: 17px; margin-top: 0; } +.details__container__actions { + display: flex; + flex-direction: row; + margin-top: 15px; +} + .details__created { align-items: start; margin-left: 0; @@ -579,64 +635,6 @@ input::placeholder { line-height: 24px; } -@media only screen and (max-width: 600px) { - #app { - max-width: 100%; - padding-left: 10px; - padding-right: 10px; - } - - .search__container__outer { - padding: 35px 5%; - } - - [placeholder] { - text-overflow: ellipsis; - } - - .search__button { - padding: 0.375rem 2rem; - } - - .search__container, - .jobcard__container, - .details__container, - .details__container__title__inner { - flex-direction: column; - } - - .options-panel__container, - .jobs__container, - .details__side__container, - .details__main__container { - width: 100%; - } - - .input__container { - max-width: 100%; - } - - .jobcard__location p, - .jobcard__created p { - margin-bottom: 0; - margin-top: 0; - } - - .jobcard__container { - margin-bottom: 13px; - margin-top: 13px; - } - - .details__title { - margin-top: 36px; - } - - .details__title__fulltime { - margin-left: 0; - margin-top: 4px; - } -} - .hidden { display: none; } @@ -811,3 +809,250 @@ input::placeholder { display: flex; width: 100%; } + +#navigation { + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +#login-page, +#signup-page, +#profile-page { + align-items: center; + display: flex; + flex-direction: column; +} + +#login-page > form, +#signup-page > form, +#profile-page > form { + max-width: 444px; + width: 50%; +} + +#profile-page > form.saved { + max-width: 800px; + width: 100%; +} + +#login-page > .input__container, +#signup-page > .input__container, +#profile-page > .input__container { + display: flex; + flex-direction: column; + margin: 0; + max-width: 100%; + width: 100%; +} + +.login__container__title, +.signup__container__title, +.profile__container__title { + align-items: center; + display: flex; + flex-direction: column; +} + +.login__container__title > h1, +.signup__container__title > h1, +.profile__container__title > h1 { + color: #282538; + font-family: Poppins; + font-style: normal; + font-weight: 200; + font-size: 24px; + line-height: 36px; + margin: 0; +} + +.avatar { + align-items: center; + background: #1e86ff; + border-radius: 50%; + display: flex; + height: 40px; + justify-content: center; + margin: 8px; + width: 40px; +} + +.avatar > i { + color: #fff; +} + +#login-page > form > .input__container, +#signup-page > form > .input__container, +#profile-page > form > .input__container { + max-width: 100%; +} + +.login__container__actions, +.signup__container__actions, +.profile__container__actions { + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.signup__container__actions { + justify-content: center; +} + +.profile__container__actions { + margin-bottom: 25px; + margin-top: 25px; +} + +.button__primary, +.button__secondary, +.button__danger { + border: 3px solid rgba(255, 255, 255, 1); + border-bottom-right-radius: 0.25rem; + border-top-right-radius: 0.25rem; + color: #fff; + cursor: pointer; + display: inline-block; + font-weight: 400; + line-height: 1.5; + padding: 0.375rem 3rem; + position: relative; + text-align: center; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + user-select: none; + vertical-align: middle; + z-index: 2; +} + +.button__primary { + background-color: rgba(27, 108, 205, 1); +} + +.button__secondary { + background-color: #b9bdcf; +} + +.button__danger { + background-color: rgba(205, 27, 27, 1); +} + +.notification__container { + align-items: center; + border-radius: 4px; + display: flex; + flex-direction: row; + justify-content: flex-start; + padding: 15px; + margin-top: 25px; +} + +.notification__container > span { + margin-left: 10px; +} + +.notification__container.error { + background-color: #f8d7da; + color: #721c24; +} + +.notification__container.info { + background-color: #d1ecf1; + color: #0c5460; +} + +.notification__container.warning { + background-color: #fff3cd; + color: #856404; +} + +@media only screen and (max-width: 800px) { + .search__container { + flex-direction: column; + } + + .options-panel__container, + .jobs__container { + width: 100%; + } +} + +@media only screen and (max-width: 600px) { + #app { + max-width: 100%; + padding-left: 10px; + padding-right: 10px; + } + + .search__container__outer { + padding: 35px 5%; + } + + [placeholder] { + text-overflow: ellipsis; + } + + #search-submit { + padding: 0.375rem 2rem; + } + + .search__container, + .details__container, + .login__container__actions, + .signup__container__actions, + .profile__container__actions { + flex-direction: column; + } + + .profile__container__actions { + align-items: normal; + } + + .options-panel__container, + .jobs__container, + .details__side__container, + .details__main__container, + #login-page > form, + #signup-page > form, + #profile-page > form { + width: 100%; + } + + .input__container { + max-width: 100%; + } + + .jobcard__location p, + .jobcard__created p { + margin-bottom: 0; + margin-top: 0; + } + + .jobcard__container { + margin-bottom: 13px; + margin-top: 13px; + } + + .details__title { + margin-top: 36px; + } + + #log-in, + #sign-up { + margin-top: 25px; + } + + .details__title__fulltime { + margin-left: 0; + margin-top: 4px; + } +} + +@media only screen and (max-width: 450px) { + .jobcard__location p, + .jobcard__created p { + max-width: 65px; + } +} diff --git a/src/client/pages/Details.tsx b/src/client/pages/Details.tsx index 35add74..075c447 100644 --- a/src/client/pages/Details.tsx +++ b/src/client/pages/Details.tsx @@ -4,14 +4,32 @@ import formatDistanceToNow from "date-fns/formatDistanceToNow"; import { useParams, Link } from "react-router-dom"; import Copyright from "../components/Copyright"; -import { Job, RootState } from "../types"; +import Notification from "../components/Notification"; + +import { addSavedJob, removeSavedJob } from "../redux/thunks"; + +import { Job, NotificationType, RootState } from "../types"; interface DetailsProps { + handleAddSavedJob: (job: Job) => void; + handleRemoveSavedJob: (job: Job) => void; + isLoggedIn: boolean; jobs: Job[]; + notificationMessage: string; + notificationType: NotificationType; + savedJobs: Job[]; } const Details: React.SFC = (props: DetailsProps) => { - const { jobs } = props; + const { + handleAddSavedJob, + handleRemoveSavedJob, + isLoggedIn, + jobs, + notificationMessage, + notificationType, + savedJobs, + } = props; const { id } = useParams(); const [data, setData] = React.useState(null); const [applyLink, setApplyLink] = React.useState(""); @@ -35,6 +53,11 @@ const Details: React.SFC = (props: DetailsProps) => { setData(job); }, []); + const jobIsSaved = + savedJobs && data + ? savedJobs.findIndex((savedJob: Job) => savedJob.id === data.id) >= 0 + : false; + return ( <>
@@ -64,14 +87,48 @@ const Details: React.SFC = (props: DetailsProps) => {
+ {notificationMessage && ( + + )} {data && ( <>

{data.title}

- {data.type === "Full Time" && ( -

Full Time

- )} +
+ {data.type === "Full Time" && ( +

+ Full Time +

+ )} + {isLoggedIn && ( + + )} +
@@ -134,7 +191,16 @@ const Details: React.SFC = (props: DetailsProps) => { }; const mapStateToProps = (state: RootState) => ({ + isLoggedIn: state.user.isLoggedIn, jobs: state.application.jobs, + notificationMessage: state.application.notificationMessage, + notificationType: state.application.notificationType, + savedJobs: state.user.savedJobs, +}); + +const mapDispatchToProps = (dispatch) => ({ + handleAddSavedJob: (job: Job) => dispatch(addSavedJob(job)), + handleRemoveSavedJob: (job: Job) => dispatch(removeSavedJob(job)), }); -export default connect(mapStateToProps)(Details); +export default connect(mapStateToProps, mapDispatchToProps)(Details); diff --git a/src/client/pages/Login.tsx b/src/client/pages/Login.tsx new file mode 100644 index 0000000..cbbfa4a --- /dev/null +++ b/src/client/pages/Login.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import { Link, 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 { setEmail, setPassword } from "../redux/actions/user"; +import { logIn } from "../redux/thunks"; + +import { RootState } from "../types"; + +export interface LoginProps { + email: string; + notificationMessage: string; + handleEmailChange: (email: string) => void; + handleLogIn: () => void; + handlePasswordChange: (password: string) => void; + isLoggedIn: boolean; + password: string; +} + +const Login: React.SFC = (props: LoginProps) => { + const { + email, + notificationMessage, + handleEmailChange, + handleLogIn, + handlePasswordChange, + isLoggedIn, + password, + } = props; + + if (isLoggedIn) { + return ; + } else { + return ( +
+
{ + e.preventDefault(); + handleLogIn(); + }} + > +
+ + lock + +

Login

+
+ + {notificationMessage && ( + + )} + + handleEmailChange(e.target.value)} + placeholder="example@email.com" + required + type="email" + value={email} + /> + handlePasswordChange(e.target.value)} + required + type="password" + value={password} + /> + +
+ + account_circle + Create an account + +
+ + +
+ ); + } +}; + +const mapStateToProps = (state: RootState) => ({ + email: state.user.email, + notificationMessage: state.application.notificationMessage, + isLoggedIn: state.user.isLoggedIn, + password: state.user.password, +}); + +const mapDispatchToProps = (dispatch) => ({ + handleEmailChange: (email: string) => dispatch(setEmail(email)), + handleLogIn: () => dispatch(logIn()), + handlePasswordChange: (password: string) => dispatch(setPassword(password)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Login); diff --git a/src/client/pages/Profile.tsx b/src/client/pages/Profile.tsx new file mode 100644 index 0000000..5488a8d --- /dev/null +++ b/src/client/pages/Profile.tsx @@ -0,0 +1,95 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import { Redirect } from "react-router-dom"; + +import Notification from "../components/Notification"; +import ProfileDelete from "../components/ProfileDelete"; +import ProfileDisplay from "../components/ProfileDisplay"; +import ProfileEdit from "../components/ProfileEdit"; +import ProfileReset from "../components/ProfileReset"; +import ProfileSavedJobs from "../components/ProfileSavedJobs"; + +import { NotificationType, RootState } from "../types"; + +export interface ProfileProps { + isDeletingProfile: boolean; + isEditingProfile: boolean; + isLoggedIn: boolean; + isResettingPassword: boolean; + isViewingSavedJobs: boolean; + notificationMessage: string; + notificationType: NotificationType; +} + +const Profile: React.SFC = (props: ProfileProps) => { + const { + isDeletingProfile, + isEditingProfile, + isLoggedIn, + isResettingPassword, + isViewingSavedJobs, + notificationMessage, + notificationType, + } = props; + + let heading = "Profile"; + + if (isResettingPassword) { + heading = "Reset Password"; + } else if (isEditingProfile) { + heading = "Edit Profile"; + } else if (isDeletingProfile) { + heading = "Delete Profile"; + } else if (isViewingSavedJobs) { + heading = "Saved Jobs"; + } + + if (!isLoggedIn) { + return ; + } else { + return ( +
+
+
+ + account_circle + +

{heading}

+
+ + {notificationMessage && ( + + )} + + {isResettingPassword && } + + {isEditingProfile && } + + {isDeletingProfile && } + + {isViewingSavedJobs && } + + {!isResettingPassword && + !isEditingProfile && + !isDeletingProfile && + !isViewingSavedJobs && } + +
+ ); + } +}; + +const mapStateToProps = (state: RootState) => ({ + isDeletingProfile: state.user.isDeletingProfile, + isEditingProfile: state.user.isEditingProfile, + isLoggedIn: state.user.isLoggedIn, + isResettingPassword: state.user.isResettingPassword, + isViewingSavedJobs: state.user.isViewingSavedJobs, + notificationMessage: state.application.notificationMessage, + notificationType: state.application.notificationType, +}); + +export default connect(mapStateToProps)(Profile); diff --git a/src/client/pages/Search.tsx b/src/client/pages/Search.tsx index 6bb1d02..57f03e8 100644 --- a/src/client/pages/Search.tsx +++ b/src/client/pages/Search.tsx @@ -1,21 +1,31 @@ import * as React from "react"; import { connect } from "react-redux"; -import SearchInput from "../components/SearchInput"; +import Copyright from "../components/Copyright"; import JobCard from "../components/JobCard"; +import Notification from "../components/Notification"; import OptionsPanel from "../components/OptionsPanel"; -import Copyright from "../components/Copyright"; import Pagination from "../components/Pagination"; +import SearchInput from "../components/SearchInput"; -import { Job, LocationOption, RootState } from "../types"; +import { Job, LocationOption, NotificationType, RootState } from "../types"; export interface SearchProps { currentJobs: Job[]; currentPage: number; + notificationMessage: string; + notificationType: NotificationType; + totalPages: number; } const Search: React.SFC = (props: SearchProps) => { - const { currentJobs, currentPage } = props; + const { + currentJobs, + currentPage, + notificationMessage, + notificationType, + totalPages, + } = props; const jobsOnPage = currentJobs.slice(currentPage * 5 - 5, currentPage * 5); @@ -49,9 +59,17 @@ const Search: React.SFC = (props: SearchProps) => {
+ {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 ( +

+
{ + e.preventDefault(); + handleSignup(); + }} + > +
+ + lock + +

Create Account

+
+ + {notificationMessage && ( + + )} + + handleNameChange(e.target.value)} + placeholder="John Smith" + required + type="text" + value={name} + /> + handleEmailChange(e.target.value)} + placeholder="example@email.com" + required + type="email" + value={email} + /> + handlePasswordChange(e.target.value)} + required + type="password" + value={password} + /> + handleConfirmPasswordChange(e.target.value)} + required + type="password" + value={confirmPassword} + /> + +
+
+ + +
+ ); + } +}; + +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. +
+
+ + + + +
+ + Login + +
+
+
+ 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 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.
Login
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}&`;