account settings actions, account deletion left

This commit is contained in:
m5r
2022-05-14 14:43:45 +02:00
parent 98b89ae0f7
commit 48b3604116
17 changed files with 314 additions and 239 deletions

View File

@ -0,0 +1,126 @@
import { type ActionFunction, json, redirect } from "@remix-run/node";
import { badRequest } from "remix-utils";
import { z } from "zod";
import SecurePassword from "secure-password";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
import { hashPassword, requireLoggedIn, verifyPassword } from "~/utils/auth.server";
import { type FormError, validate } from "~/utils/validation.server";
import { destroySession, getSession } from "~/utils/session.server";
import deleteUserQueue from "~/queues/delete-user-data.server";
const action: ActionFunction = async ({ request }) => {
const formData = Object.fromEntries(await request.formData());
if (!formData._action) {
const errorMessage = "POST /settings without any _action";
logger.error(errorMessage);
return badRequest({ errorMessage });
}
switch (formData._action as Action) {
case "deleteUser":
return deleteUser(request);
case "changePassword":
return changePassword(request, formData);
case "updateUser":
return updateUser(request, formData);
default:
const errorMessage = `POST /settings with an invalid _action=${formData._action}`;
logger.error(errorMessage);
return badRequest({ errorMessage });
}
};
export default action;
async function deleteUser(request: Request) {
const { id } = await requireLoggedIn(request);
await db.user.update({
where: { id },
data: { hashedPassword: "pending deletion" },
});
await deleteUserQueue.add(`delete user ${id}`, { userId: id });
return redirect("/", {
headers: {
"Set-Cookie": await destroySession(await getSession(request)),
},
});
}
type ChangePasswordFailureActionData = { errors: FormError<typeof validations.changePassword>; submitted?: never };
type ChangePasswordSuccessfulActionData = { errors?: never; submitted: true };
export type ChangePasswordActionData = {
changePassword: ChangePasswordFailureActionData | ChangePasswordSuccessfulActionData;
};
async function changePassword(request: Request, formData: unknown) {
const validation = validate(validations.changePassword, formData);
if (validation.errors) {
return json<ChangePasswordActionData>({
changePassword: { errors: validation.errors },
});
}
const { id } = await requireLoggedIn(request);
const user = await db.user.findUnique({ where: { id } });
const { currentPassword, newPassword } = validation.data;
const verificationResult = await verifyPassword(user!.hashedPassword!, currentPassword);
if ([SecurePassword.INVALID, SecurePassword.INVALID_UNRECOGNIZED_HASH, false].includes(verificationResult)) {
return json<ChangePasswordActionData>({
changePassword: { errors: { currentPassword: "Current password is incorrect" } },
});
}
const hashedPassword = await hashPassword(newPassword.trim());
await db.user.update({
where: { id: user!.id },
data: { hashedPassword },
});
return json<ChangePasswordActionData>({
changePassword: { submitted: true },
});
}
type UpdateUserFailureActionData = { errors: FormError<typeof validations.updateUser>; submitted?: never };
type UpdateUserSuccessfulActionData = { errors?: never; submitted: true };
export type UpdateUserActionData = {
updateUser: UpdateUserFailureActionData | UpdateUserSuccessfulActionData;
};
async function updateUser(request: Request, formData: unknown) {
const validation = validate(validations.updateUser, formData);
if (validation.errors) {
return json<UpdateUserActionData>({
updateUser: { errors: validation.errors },
});
}
const user = await requireLoggedIn(request);
const { email, fullName } = validation.data;
await db.user.update({
where: { id: user.id },
data: { email, fullName },
});
return json<UpdateUserActionData>({
updateUser: { submitted: true },
});
}
type Action = "deleteUser" | "updateUser" | "changePassword";
const validations = {
deleteUser: null,
changePassword: z.object({
currentPassword: z.string(),
newPassword: z.string().min(10).max(100),
}),
updateUser: z.object({
fullName: z.string(),
email: z.string(),
}),
} as const;

View File

@ -1,4 +1,5 @@
import { useRef, useState } from "react";
import { useFetcher, useSubmit, useTransition } from "@remix-run/react";
import clsx from "clsx";
import Button from "../button";
@ -6,9 +7,14 @@ import SettingsSection from "../settings-section";
import Modal, { ModalTitle } from "~/features/core/components/modal";
export default function DangerZone() {
const [isDeletingUser, setIsDeletingUser] = useState(false);
const transition = useTransition();
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "deleteUser";
const isDeletingUser = isCurrentFormTransition && transition.state === "submitting";
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
const fetcher = useFetcher();
const submit = useSubmit();
// TODO
const closeModal = () => {
if (isDeletingUser) {
@ -17,10 +23,6 @@ export default function DangerZone() {
setIsConfirmationModalOpen(false);
};
const onConfirm = () => {
setIsDeletingUser(true);
// return deleteUserMutation(); // TODO
};
return (
<SettingsSection className="border border-red-300">
@ -63,7 +65,6 @@ export default function DangerZone() {
"bg-red-600 hover:bg-red-700": !isDeletingUser,
},
)}
onClick={onConfirm}
disabled={isDeletingUser}
>
Delete my account

View File

@ -1,47 +1,48 @@
import type { FunctionComponent } from "react";
import { useActionData, useTransition } from "@remix-run/react";
import { Form, useActionData, useTransition } from "@remix-run/react";
import Alert from "../../../core/components/alert";
import type { UpdateUserActionData } from "~/features/settings/actions/account";
import useSession from "~/features/core/hooks/use-session";
import Alert from "~/features/core/components/alert";
import Button from "../button";
import SettingsSection from "../settings-section";
import useSession from "~/features/core/hooks/use-session";
const ProfileInformations: FunctionComponent = () => {
const user = useSession();
const transition = useTransition();
const actionData = useActionData();
const actionData = useActionData<UpdateUserActionData>()?.updateUser;
const isSubmitting = transition.state === "submitting";
const isSuccess = actionData?.submitted === true;
const error = actionData?.error;
const isError = !!error;
const onSubmit = async () => {
// await updateUserMutation({ email, fullName }); // TODO
};
const errors = actionData?.errors;
const topErrorMessage = errors?.general;
const isError = typeof topErrorMessage !== "undefined";
const isSuccess = actionData?.submitted;
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "updateUser";
const isSubmitting = isCurrentFormTransition && transition.state === "submitting";
console.log("isSuccess", isSuccess, actionData);
return (
<form onSubmit={onSubmit}>
<Form method="post">
<SettingsSection
footer={
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button variant="default" type="submit" isDisabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
Save
</Button>
</div>
}
>
{isError ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={error} variant="error" />
<Alert title="Oops, there was an issue" message={topErrorMessage} variant="error" />
</div>
) : null}
{isSuccess ? (
{isSuccess && (
<div className="mb-8">
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
) : null}
)}
<div className="col-span-3 sm:col-span-2">
<label htmlFor="fullName" className="block text-sm font-medium leading-5 text-gray-700">
Full name
@ -75,8 +76,10 @@ const ProfileInformations: FunctionComponent = () => {
/>
</div>
</div>
<input type="hidden" name="_action" value="updateUser" />
</SettingsSection>
</form>
</Form>
);
};

View File

@ -1,37 +1,36 @@
import type { FunctionComponent } from "react";
import { Form, useActionData, useTransition } from "@remix-run/react";
import type { ChangePasswordActionData } from "~/features/settings/actions/account";
import Alert from "~/features/core/components/alert";
import LabeledTextField from "~/features/core/components/labeled-text-field";
import Button from "../button";
import SettingsSection from "../settings-section";
import { useActionData, useTransition } from "@remix-run/react";
const UpdatePassword: FunctionComponent = () => {
const transition = useTransition();
const actionData = useActionData();
const actionData = useActionData<ChangePasswordActionData>()?.changePassword;
const isSubmitting = transition.state === "submitting";
const isSuccess = actionData?.submitted === true;
const error = actionData?.error;
const isError = !!error;
const onSubmit = async () => {
// await changePasswordMutation({ currentPassword, newPassword }); // TODO
};
const topErrorMessage = actionData?.errors?.general;
const isError = typeof topErrorMessage !== "undefined";
const isSuccess = actionData?.submitted;
const isCurrentFormTransition = transition.submission?.formData.get("_action") === "changePassword";
const isSubmitting = isCurrentFormTransition && transition.state === "submitting";
return (
<form onSubmit={onSubmit}>
<Form method="post">
<SettingsSection
footer={
<div className="px-4 py-3 bg-gray-50 text-right text-sm font-medium sm:px-6">
<Button variant="default" type="submit" isDisabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
Save
</Button>
</div>
}
>
{isError ? (
<div className="mb-8">
<Alert title="Oops, there was an issue" message={error} variant="error" />
<Alert title="Oops, there was an issue" message={topErrorMessage} variant="error" />
</div>
) : null}
@ -40,45 +39,28 @@ const UpdatePassword: FunctionComponent = () => {
<Alert title="Saved successfully" message="Your changes have been saved." variant="success" />
</div>
) : null}
<div>
<label
htmlFor="currentPassword"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>Current password</div>
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="currentPassword"
name="currentPassword"
type="password"
tabIndex={3}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
required
/>
</div>
</div>
<div>
<label
htmlFor="newPassword"
className="flex justify-between text-sm font-medium leading-5 text-gray-700"
>
<div>New password</div>
</label>
<div className="mt-1 rounded-md shadow-sm">
<input
id="newPassword"
name="newPassword"
type="password"
tabIndex={4}
className="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-primary focus:border-primary-300 transition duration-150 ease-in-out sm:text-sm sm:leading-5"
required
/>
</div>
</div>
<LabeledTextField
name="currentPassword"
label="Current password"
type="password"
tabIndex={3}
error={actionData?.errors?.currentPassword}
disabled={isSubmitting}
/>
<LabeledTextField
name="newPassword"
label="New password"
type="password"
tabIndex={4}
error={actionData?.errors?.newPassword}
disabled={isSubmitting}
/>
<input type="hidden" name="_action" value="changePassword" />
</SettingsSection>
</form>
</Form>
);
};

View File

@ -5,8 +5,7 @@ type Props = {
variant: Variant;
onClick?: MouseEventHandler;
isDisabled?: boolean;
type: ButtonHTMLAttributes<HTMLButtonElement>["type"];
};
} & ButtonHTMLAttributes<HTMLButtonElement>;
const Button: FunctionComponent<PropsWithChildren<Props>> = ({ children, type, variant, onClick, isDisabled }) => {
return (