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("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([ + 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("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(null); @@ -19,14 +22,17 @@ export default function DangerZone() { }; const onConfirm = () => { setIsDeletingUser(true); - // user.deleteUser(); + return deleteUserMutation(); }; return (
-
-

Once you delete your account, all of its data will be permanently deleted.

+
+

+ Once you delete your account, all of its data will be permanently deleted and any ongoing + subscription will be cancelled. +

) : null} - {isSubmitSuccessful ? ( + {!isSubmitting && isSubmitSuccessful && !errorMessage ? (
@@ -70,6 +61,25 @@ const UpdatePassword: FunctionComponent = () => {
+
+ +
+ +
+
+
-
- -
- -
-
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 }); + } +});