From c9b657e44c59c58eadbfc0212fe1a04ecfb7bba2 Mon Sep 17 00:00:00 2001 From: m5r <mokht@rmi.al> Date: Sat, 25 Sep 2021 07:09:20 +0800 Subject: [PATCH] implement update user, update password and delete account --- app/auth/mutations/change-password.ts | 24 ------- app/auth/mutations/logout.ts | 2 +- app/auth/validations.ts | 7 +- app/settings/api/queue/delete-user-data.ts | 72 +++++++++++++++++++ app/settings/api/queue/notify-email-change.ts | 28 ++++++++ app/settings/components/danger-zone.tsx | 12 +++- .../components/profile-informations.tsx | 14 ++-- app/settings/components/update-password.tsx | 69 ++++++++---------- app/settings/mutations/change-password.ts | 36 ++++++++++ app/settings/mutations/delete-user.ts | 14 ++++ app/settings/mutations/update-user.ts | 25 +++++++ 11 files changed, 220 insertions(+), 83 deletions(-) delete mode 100644 app/auth/mutations/change-password.ts create mode 100644 app/settings/api/queue/delete-user-data.ts create mode 100644 app/settings/api/queue/notify-email-change.ts create mode 100644 app/settings/mutations/change-password.ts create mode 100644 app/settings/mutations/delete-user.ts create mode 100644 app/settings/mutations/update-user.ts diff --git a/app/auth/mutations/change-password.ts b/app/auth/mutations/change-password.ts deleted file mode 100644 index 6c2cc95..0000000 --- a/app/auth/mutations/change-password.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NotFoundError, SecurePassword, resolver } from "blitz"; - -import db from "../../../db"; -import { authenticateUser } from "./login"; -import { ChangePassword } from "../validations"; - -export default resolver.pipe( - resolver.zod(ChangePassword), - resolver.authorize(), - async ({ currentPassword, newPassword }, ctx) => { - const user = await db.user.findFirst({ where: { id: ctx.session.userId! } }); - if (!user) throw new NotFoundError(); - - await authenticateUser(user.email, currentPassword); - - const hashedPassword = await SecurePassword.hash(newPassword.trim()); - await db.user.update({ - where: { id: user.id }, - data: { hashedPassword }, - }); - - return true; - }, -); diff --git a/app/auth/mutations/logout.ts b/app/auth/mutations/logout.ts index ac3a9af..1622650 100644 --- a/app/auth/mutations/logout.ts +++ b/app/auth/mutations/logout.ts @@ -1,4 +1,4 @@ -import { Ctx } from "blitz"; +import type { Ctx } from "blitz"; export default async function logout(_ = null, ctx: Ctx) { return await ctx.session.$revoke(); diff --git a/app/auth/validations.ts b/app/auth/validations.ts index e220688..34c66f5 100644 --- a/app/auth/validations.ts +++ b/app/auth/validations.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -const password = z.string().min(10).max(100); +export const password = z.string().min(10).max(100); export const Signup = z.object({ email: z.string().email(), @@ -26,8 +26,3 @@ export const ResetPassword = z message: "Passwords don't match", path: ["passwordConfirmation"], // set the path of the error }); - -export const ChangePassword = z.object({ - currentPassword: z.string(), - newPassword: password, -}); diff --git a/app/settings/api/queue/delete-user-data.ts b/app/settings/api/queue/delete-user-data.ts new file mode 100644 index 0000000..26f3171 --- /dev/null +++ b/app/settings/api/queue/delete-user-data.ts @@ -0,0 +1,72 @@ +import { Queue } from "quirrel/blitz"; + +import db, { MembershipRole } from "../../../../db"; +import appLogger from "../../../../integrations/logger"; + +const logger = appLogger.child({ queue: "delete-user-data" }); + +type Payload = { + userId: string; +}; + +const deleteUserData = Queue<Payload>("api/queue/delete-user-data", async ({ userId }) => { + const user = await db.user.findFirst({ + where: { id: userId }, + include: { + memberships: { + include: { + organization: { + include: { memberships: { include: { user: true } } }, + }, + }, + }, + }, + }); + if (!user) { + return; + } + + switch (user.memberships[0]!.role) { + case MembershipRole.OWNER: { + const organization = user.memberships[0]!.organization; + const where = { organizationId: organization.id }; + await Promise.all<unknown>([ + db.notificationSubscription.deleteMany({ where }), + db.phoneCall.deleteMany({ where }), + db.message.deleteMany({ where }), + db.processingPhoneNumber.deleteMany({ where }), + ]); + await db.phoneNumber.deleteMany({ where }); + + const orgMembers = organization.memberships + .map((membership) => membership.user!) + .filter((user) => user !== null); + await Promise.all( + orgMembers.map((member) => + Promise.all([ + db.token.deleteMany({ where: { userId: member.id } }), + db.session.deleteMany({ where: { userId: member.id } }), + db.membership.deleteMany({ where: { userId: member.id } }), + db.user.delete({ where: { id: member.id } }), + ]), + ), + ); + await db.organization.delete({ where: { id: organization.id } }); + break; + } + case MembershipRole.USER: { + await Promise.all([ + db.token.deleteMany({ where: { userId: user.id } }), + db.session.deleteMany({ where: { userId: user.id } }), + db.user.delete({ where: { id: user.id } }), + db.membership.deleteMany({ where: { userId: user.id } }), + ]); + break; + } + case MembershipRole.ADMIN: + // nothing to do here? + break; + } +}); + +export default deleteUserData; diff --git a/app/settings/api/queue/notify-email-change.ts b/app/settings/api/queue/notify-email-change.ts new file mode 100644 index 0000000..18aa3f7 --- /dev/null +++ b/app/settings/api/queue/notify-email-change.ts @@ -0,0 +1,28 @@ +import { Queue } from "quirrel/blitz"; + +import appLogger from "../../../../integrations/logger"; +import { sendEmail } from "../../../../integrations/ses"; + +const logger = appLogger.child({ queue: "notify-email-change" }); + +type Payload = { + oldEmail: string; + newEmail: string; +}; + +const notifyEmailChangeQueue = Queue<Payload>("api/queue/notify-email-change", async ({ oldEmail, newEmail }) => { + await Promise.all([ + sendEmail({ + recipients: [oldEmail], + subject: "", + body: "", + }), + sendEmail({ + recipients: [newEmail], + subject: "", + body: "", + }), + ]); +}); + +export default notifyEmailChangeQueue; diff --git a/app/settings/components/danger-zone.tsx b/app/settings/components/danger-zone.tsx index d59c59f..694b9de 100644 --- a/app/settings/components/danger-zone.tsx +++ b/app/settings/components/danger-zone.tsx @@ -1,11 +1,14 @@ import { useRef, useState } from "react"; +import { useMutation } from "blitz"; import clsx from "clsx"; import Button from "./button"; import SettingsSection from "./settings-section"; import Modal, { ModalTitle } from "./modal"; +import deleteUser from "../mutations/delete-user"; export default function DangerZone() { + const deleteUserMutation = useMutation(deleteUser)[0]; const [isDeletingUser, setIsDeletingUser] = useState(false); const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false); const modalCancelButtonRef = useRef<HTMLButtonElement>(null); @@ -19,14 +22,17 @@ export default function DangerZone() { }; const onConfirm = () => { setIsDeletingUser(true); - // user.deleteUser(); + return deleteUserMutation(); }; return ( <SettingsSection title="Danger Zone" description="Highway to the Danger Zone 𝅘𝅥𝅮"> <div className="shadow border border-red-300 sm:rounded-md sm:overflow-hidden"> - <div className="flex justify-between items-center flex-row px-4 py-5 bg-white sm:p-6"> - <p>Once you delete your account, all of its data will be permanently deleted.</p> + <div className="flex justify-between items-center flex-row px-4 py-5 space-x-2 bg-white sm:p-6"> + <p> + Once you delete your account, all of its data will be permanently deleted and any ongoing + subscription will be cancelled. + </p> <span className="text-base font-medium"> <Button variant="error" type="button" onClick={() => setIsConfirmationModalOpen(true)}> diff --git a/app/settings/components/profile-informations.tsx b/app/settings/components/profile-informations.tsx index 6dbbf3e..039ee2c 100644 --- a/app/settings/components/profile-informations.tsx +++ b/app/settings/components/profile-informations.tsx @@ -1,8 +1,9 @@ import type { FunctionComponent } from "react"; import { useEffect, useState } from "react"; -import { useRouter } from "blitz"; +import { useMutation } from "blitz"; import { useForm } from "react-hook-form"; +import updateUser from "../mutations/update-user"; import Alert from "./alert"; import Button from "./button"; import SettingsSection from "./settings-section"; @@ -19,7 +20,7 @@ const logger = appLogger.child({ module: "profile-settings" }); const ProfileInformations: FunctionComponent = () => { const { user } = useCurrentUser(); - const router = useRouter(); + const updateUserMutation = useMutation(updateUser)[0]; const { register, handleSubmit, @@ -39,16 +40,9 @@ const ProfileInformations: FunctionComponent = () => { } try { - // TODO - // await updateUser({ email, data: { name } }); + await updateUserMutation({ email, name }); } catch (error: any) { logger.error(error.response, "error updating user infos"); - - if (error.response.status === 401) { - logger.error("session expired, redirecting to sign in page"); - return router.push("/auth/sign-in"); - } - setErrorMessage(error.response.data.errorMessage); } }); diff --git a/app/settings/components/update-password.tsx b/app/settings/components/update-password.tsx index 48ca2de..98a5dd7 100644 --- a/app/settings/components/update-password.tsx +++ b/app/settings/components/update-password.tsx @@ -1,6 +1,6 @@ import type { FunctionComponent } from "react"; import { useState } from "react"; -import { useRouter } from "blitz"; +import { useMutation } from "blitz"; import { useForm } from "react-hook-form"; import Alert from "./alert"; @@ -8,16 +8,17 @@ import Button from "./button"; import SettingsSection from "./settings-section"; import appLogger from "../../../integrations/logger"; +import changePassword from "../mutations/change-password"; const logger = appLogger.child({ module: "update-password" }); type Form = { + currentPassword: string; newPassword: string; - newPasswordConfirmation: string; }; const UpdatePassword: FunctionComponent = () => { - const router = useRouter(); + const changePasswordMutation = useMutation(changePassword)[0]; const { register, handleSubmit, @@ -25,28 +26,18 @@ const UpdatePassword: FunctionComponent = () => { } = useForm<Form>(); const [errorMessage, setErrorMessage] = useState(""); - const onSubmit = handleSubmit(async ({ newPassword, newPasswordConfirmation }) => { + const onSubmit = handleSubmit(async ({ currentPassword, newPassword }) => { if (isSubmitting) { return; } - if (newPassword !== newPasswordConfirmation) { - setErrorMessage("New passwords don't match"); - return; - } + setErrorMessage(""); try { - // TODO - // await customer.updateUser({ password: newPassword }); + await changePasswordMutation({ currentPassword, newPassword }); } catch (error: any) { - logger.error(error.response, "error updating user infos"); - - if (error.response.status === 401) { - logger.error("session expired, redirecting to sign in page"); - return router.push("/auth/sign-in"); - } - - setErrorMessage(error.response.data.errorMessage); + logger.error(error, "error updating user infos"); + setErrorMessage(error.message); } }); @@ -62,7 +53,7 @@ const UpdatePassword: FunctionComponent = () => { </div> ) : null} - {isSubmitSuccessful ? ( + {!isSubmitting && isSubmitSuccessful && !errorMessage ? ( <div className="mb-8"> <Alert title="Saved successfully" message="Your changes have been saved." variant="success" /> </div> @@ -70,6 +61,25 @@ const UpdatePassword: FunctionComponent = () => { <div className="shadow sm:rounded-md sm:overflow-hidden"> <div className="px-4 py-5 bg-white space-y-6 sm:p-6"> + <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" + 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" + {...register("currentPassword")} + required + /> + </div> + </div> + <div> <label htmlFor="newPassword" @@ -81,28 +91,9 @@ const UpdatePassword: FunctionComponent = () => { <input id="newPassword" 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" - {...register("newPassword")} - required - /> - </div> - </div> - - <div> - <label - htmlFor="newPasswordConfirmation" - className="flex justify-between text-sm font-medium leading-5 text-gray-700" - > - <div>Confirm new password</div> - </label> - <div className="mt-1 rounded-md shadow-sm"> - <input - id="newPasswordConfirmation" - 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" - {...register("newPasswordConfirmation")} + {...register("newPassword")} required /> </div> diff --git a/app/settings/mutations/change-password.ts b/app/settings/mutations/change-password.ts new file mode 100644 index 0000000..7354d84 --- /dev/null +++ b/app/settings/mutations/change-password.ts @@ -0,0 +1,36 @@ +import { AuthenticationError, NotFoundError, resolver, SecurePassword } from "blitz"; +import { z } from "zod"; + +import db from "../../../db"; +import { authenticateUser } from "../../auth/mutations/login"; +import { password } from "../../auth/validations"; + +const Body = z.object({ + currentPassword: z.string(), + newPassword: password, +}); + +export default resolver.pipe( + resolver.zod(Body), + resolver.authorize(), + async ({ currentPassword, newPassword }, ctx) => { + const user = await db.user.findFirst({ where: { id: ctx.session.userId! } }); + if (!user) throw new NotFoundError(); + + try { + await authenticateUser(user.email, currentPassword); + } catch (error) { + if (error instanceof AuthenticationError) { + throw new Error("Current password is incorrect"); + } + + throw error; + } + + const hashedPassword = await SecurePassword.hash(newPassword.trim()); + await db.user.update({ + where: { id: user.id }, + data: { hashedPassword }, + }); + }, +); diff --git a/app/settings/mutations/delete-user.ts b/app/settings/mutations/delete-user.ts new file mode 100644 index 0000000..a41b799 --- /dev/null +++ b/app/settings/mutations/delete-user.ts @@ -0,0 +1,14 @@ +import { NotFoundError, resolver } from "blitz"; + +import db from "../../../db"; +import logout from "../../auth/mutations/logout"; +import deleteUserData from "../api/queue/delete-user-data"; + +export default resolver.pipe(resolver.authorize(), async (_ = null, ctx) => { + const user = await db.user.findFirst({ where: { id: ctx.session.userId! } }); + if (!user) throw new NotFoundError(); + + await db.user.update({ where: { id: user.id }, data: { hashedPassword: "pending deletion" } }); + await deleteUserData.enqueue({ userId: user.id }); + await logout(null, ctx); +}); diff --git a/app/settings/mutations/update-user.ts b/app/settings/mutations/update-user.ts new file mode 100644 index 0000000..0d267cd --- /dev/null +++ b/app/settings/mutations/update-user.ts @@ -0,0 +1,25 @@ +import { NotFoundError, resolver } from "blitz"; +import { z } from "zod"; + +import db from "../../../db"; +import notifyEmailChangeQueue from "../api/queue/notify-email-change"; + +const Body = z.object({ + email: z.string().email(), + name: z.string(), +}); + +export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ email, name }, ctx) => { + const user = await db.user.findFirst({ where: { id: ctx.session.userId! } }); + if (!user) throw new NotFoundError(); + + const oldEmail = user.email; + await db.user.update({ + where: { id: user.id }, + data: { email, name }, + }); + + if (oldEmail !== email) { + // await notifyEmailChangeQueue.enqueue({ newEmail: email, oldEmail: user.email }); + } +});