Skip to content

Commit

Permalink
Merge pull request #559 from codeforboston/frontend-issue-403-change_…
Browse files Browse the repository at this point in the history
…password

add validation routes, forgot password, reset password components
  • Loading branch information
Andrewy-gh authored May 22, 2024
2 parents 5f2c389 + 1bdb522 commit 7480d1e
Show file tree
Hide file tree
Showing 10 changed files with 390 additions and 4 deletions.
16 changes: 16 additions & 0 deletions backend/app/controllers/users/passwords_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ def create
super
end

def update
skip_authorization
super
end

def validate_reset_token
skip_authorization
@user = User.with_reset_password_token(params[:reset_password_token])

if @user
head :ok
else
head :unprocessable_entity
end
end

private

def respond_with(resource, _opts = {})
Expand Down
2 changes: 1 addition & 1 deletion backend/config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,5 @@

# Uncomment if you wish to allow Action Cable access from any origin.
# config.action_cable.disable_request_forgery_protection = true
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
config.action_mailer.default_url_options = { host: 'localhost', port: 3001 }
end
8 changes: 8 additions & 0 deletions backend/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
resources :surveys
resource :assignments_surveyors

devise_scope :user do
namespace :users do
resource :password, only: [] do
get 'validate_reset_token', on: :collection
end
end
end

devise_for :users, controllers: {
sessions: 'users/sessions',
passwords: 'users/passwords'
Expand Down
10 changes: 9 additions & 1 deletion frontend/front/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import {

import AdminContainer from "./pages/Admin/AdminContainer";
import Box from "@mui/material/Box";
import ForgotPassword from "./features/login/ForgotPassword";
import Login from "./features/login/Login";
import { ProtectedRoute } from "./routing/ProtectedRoute";
import PublicContainer from "./pages/Public/PublicContainer";
import ResetPassword from "./features/login/ResetPassword";
import ResetPasswordError from "./features/login/ResetPasswordError";
import SurveyorContainer from "./pages/Surveyor/SurveyorContainer";
import { Metadata } from "./pages/Metadata";

Expand Down Expand Up @@ -50,7 +53,12 @@ function App() {
}
/>
<Route path={`${routes.LOGIN_ROUTE}`} element={<Login />} />

<Route path="/users/forgot" element={<ForgotPassword />} />
<Route path="/users/password/edit" element={<ResetPassword />} />
<Route
path="/users/password/error"
element={<ResetPasswordError />}
/>
<Route path="/metadata" element={<Metadata />} />
</Routes>
</BrowserRouter>
Expand Down
35 changes: 35 additions & 0 deletions frontend/front/src/api/apiSlice.js
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,38 @@ export const apiSlice = createApi({
body: { user: { email, password } },
}),
}),
requestPasswordReset: builder.mutation({
query: (email) => ({
url: "/users/password",
method: "POST",
body: { user: { email } },
}),
}),
validateResetToken: builder.mutation({
query: (resetPasswordToken) => ({
url: `/users/password/validate_reset_token?reset_password_token=${resetPasswordToken}`,
}),
}),
resetPassword: builder.mutation({
query: ({ resetPasswordToken, password }) => ({
url: "/users/password/",
method: "PUT",
body: {
user: {
reset_password_token: resetPasswordToken,
password,
},
},
}),
transformResponse: (response) => {
// Response always 200 success. If failed, id is null
if (!response?.id) {
return { success: false };
} else {
return { success: true };
}
},
}),
}),
});

Expand All @@ -435,6 +467,9 @@ export const {
useLoginUserMutation,
useLogoutUserMutation,
useCreateUserMutation,
useRequestPasswordResetMutation,
useValidateResetTokenMutation,
useResetPasswordMutation,

// Survey
useDeleteSurveyMutation,
Expand Down
95 changes: 95 additions & 0 deletions frontend/front/src/features/login/ForgotPassword.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useState } from "react";
import { Avatar, Box, Grid, Paper, TextField, Typography } from "@mui/material";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { LoadingButton } from "@mui/lab";
import { useRequestPasswordResetMutation } from "../../api/apiSlice";
import { isEmailValid } from "../../util/stringUtils";
import TaskAltIcon from "@mui/icons-material/TaskAlt";

const avatarStyle = { backgroundColor: "#1bbd7e" };
const btnstyle = { margin: "10px 0" };
const paperStyle = {
padding: 40,
display: "flex",
flexDirection: "column",
width: 280,
margin: "20px auto",
};
const errorStyles = {
color: "rgb(239 68 68 / 1)",
fontSize: "0.875rem",
lineHeight: "1.25rem",
};

export default function ForgotPassword() {
const [email, setEmail] = useState("");
const [error, setError] = useState(null);
const [isSubmitted, setIsSubmitted] = useState(false);

const [requestPasswordReset, { isLoading }] =
useRequestPasswordResetMutation();

const handleSubmit = async () => {
const formattedEmail = email.trim().toLowerCase();
if (!isEmailValid(formattedEmail)) {
setError("Please enter a valid email");
return;
}
await requestPasswordReset(formattedEmail);
setIsSubmitted(true);
};

return (
<Box sx={{ marginTop: 20 }}>
<Paper elevation={5} style={paperStyle}>
<Grid align="center">
<Avatar style={avatarStyle}>
<LockOutlinedIcon />
</Avatar>
<h2>Forgot Password</h2>
</Grid>
<TextField
placeholder="Enter Email"
type="text"
style={btnstyle}
name="text"
fullWidth
label="Email"
variant="standard"
value={email}
disabled={isSubmitted}
onChange={({ target }) => {
setError(null);
setEmail(target.value);
}}
/>
{error && <span style={errorStyles}>{error}</span>}
<LoadingButton
loading={isLoading}
disabled={isSubmitted}
type="submit"
color="primary"
variant="contained"
style={{
btnstyle,
borderRadius: "20px",
marginTop: "20px",
}}
fullWidth
onClick={handleSubmit}
>
Submit
</LoadingButton>
{isSubmitted && (
<Box sx={{ mt: 3, display: "flex" }}>
<TaskAltIcon fontSize="large" style={{ color: "#1bbd7e" }} />
<Typography align="center">
If an account has been set up using this email address, you'll
receive an email shortly.
</Typography>
</Box>
)}
</Paper>
</Box>
);
}
11 changes: 9 additions & 2 deletions frontend/front/src/features/login/Login.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@ import {
} from "@mui/material";
import { ROLE_ADMIN, ROLE_SURVEYOR } from "../../features/login/loginUtils";
import React, { useMemo } from "react";

import FormControlLabel from "@mui/material/FormControlLabel";
import { LoadingButton } from "@mui/lab";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { Navigate } from "react-router-dom";
import { Link, Navigate } from "react-router-dom";
import { selectCurrentUser } from "../../features/login/loginSlice";
import { useForm } from "react-hook-form";
import { useLoginUserMutation } from "../../api/apiSlice";
Expand Down Expand Up @@ -150,6 +149,14 @@ const Login = () => {
Log in
</LoadingButton>
</form>
<Box sx={{ mt: 2 }}>
<Link
to="/users/forgot"
style={{ textDecoration: "none", color: "inherit" }}
>
Forgot Password?
</Link>
</Box>
</Paper>
</Box>
);
Expand Down
146 changes: 146 additions & 0 deletions frontend/front/src/features/login/ResetPassword.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { useState, useEffect } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { Avatar, Box, Grid, Paper, TextField, Typography } from "@mui/material";
import Loader from "../../components/Loader";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { LoadingButton } from "@mui/lab";
import {
useValidateResetTokenMutation,
useResetPasswordMutation,
} from "../../api/apiSlice";
import { isPasswordValid } from "../../util/stringUtils";

const avatarStyle = { backgroundColor: "#1bbd7e" };
const btnstyle = { margin: "10px 0" };
const paperStyle = {
padding: 40,
display: "flex",
flexDirection: "column",
width: 280,
margin: "20px auto",
};
const errorStyles = {
color: "rgb(239 68 68 / 1)",
fontSize: "0.875rem",
lineHeight: "1.25rem",
};

const ResetPasswordSuccess = () => {
return (
<Box sx={{ mt: 3 }}>
<Typography align="center">
Password has been successfully reset.
</Typography>
<Link to="/login" style={{ textDecoration: "none", color: "inherit" }}>
<Box
sx={{
mt: 3,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: ".3125rem",
}}
>
<Typography>Go to Login</Typography>
<ArrowForwardIcon />
</Box>
</Link>
</Box>
);
};

export default function ResetPassword() {
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const [searchParams] = useSearchParams();
const resetPasswordToken = searchParams.get("reset_password_token");

const [
validateResetToken,
{ isLoading: isValidationLoading, isError: isValidationError },
] = useValidateResetTokenMutation();

const [resetPassword, { isLoading: isResetLoading }] =
useResetPasswordMutation();

useEffect(() => {
if (resetPasswordToken) {
validateResetToken(resetPasswordToken);
}
}, [resetPasswordToken, validateResetToken]);

const handleSubmit = async () => {
const newPassword = isPasswordValid(password);
if (newPassword?.error) {
setError(newPassword.message);
return;
}
const res = await resetPassword({
resetPasswordToken,
password,
}).unwrap();
if (!res?.success) {
return navigate("/users/password/error");
}
setIsSubmitted(true);
};

if (isValidationLoading) {
return <Loader />;
}

if (!resetPasswordToken || isValidationError) {
return navigate("/users/password/error");
}

return (
<Box sx={{ mt: 20 }}>
<Paper elevation={5} style={paperStyle}>
<Grid align="center">
<Avatar style={avatarStyle}>
<LockOutlinedIcon />
</Avatar>
<h2>Reset password</h2>
</Grid>
<TextField
id="user-new-password"
placeholder="Enter New Password"
type="password"
style={btnstyle}
name="password"
fullWidth
label="New Password"
variant="standard"
value={password}
required
disabled={isSubmitted}
onChange={({ target }) => {
setError(null);
setPassword(target.value);
}}
/>
{error && <span style={errorStyles}>{error}</span>}
<LoadingButton
loading={isResetLoading}
type="submit"
color="primary"
variant="contained"
style={{
btnstyle,
borderRadius: "20px",
marginTop: "20px",
}}
fullWidth
disabled={isSubmitted}
onClick={handleSubmit}
>
Reset Password
</LoadingButton>
{isSubmitted && <ResetPasswordSuccess />}
</Paper>
</Box>
);
}
Loading

0 comments on commit 7480d1e

Please sign in to comment.