From f652ca9b73b5eddd38d0f7828602f2430bbdb2c4 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sat, 20 Jan 2024 19:07:47 +0000 Subject: [PATCH 01/13] feat: account deletion confirmation dialog --- apps/web/src/components/forms/profile.tsx | 60 ++++++++++++++++++++++- packages/ui/primitives/alert.tsx | 3 +- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 0ce5c7f3d..78da0f636 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -10,7 +10,20 @@ import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@documenso/ui/primitives/alert-dialog'; import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent, CardFooter } from '@documenso/ui/primitives/card'; import { Form, FormControl, @@ -133,10 +146,55 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { /> - + +
+ + + + Delete your account and all its contents, including completed documents. This action is + irreversible and will cancel your subscription, so proceed with caution. + + + + + + + + + Delete Account + + Documenso will delete{' '} + all of your documents, along with all of + your completed documents, signatures, and all other resources belonging to your + Account. + + + + + Cancel + + Delete Account + + + + + + +
); }; + +export function AlertDestructive() { + return ( + + + This action is not reversible. Please be certain. + + + ); +} diff --git a/packages/ui/primitives/alert.tsx b/packages/ui/primitives/alert.tsx index 190f7781d..5409152b7 100644 --- a/packages/ui/primitives/alert.tsx +++ b/packages/ui/primitives/alert.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; -import { VariantProps, cva } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; import { cn } from '../lib/utils'; From a3e560899a82521c1edbfa75e0d259f3f12d9ac1 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sat, 20 Jan 2024 23:30:56 +0000 Subject: [PATCH 02/13] feat: delete user from db and unsubscribe from stripe --- .devcontainer/devcontainer.json | 12 +++- apps/web/src/components/forms/profile.tsx | 58 +++++++++++++++---- .../ee/server-only/stripe/delete-customer.ts | 10 ++++ packages/trpc/react/index.tsx | 2 +- packages/trpc/server/auth-router/schema.ts | 6 ++ packages/trpc/server/profile-router/router.ts | 25 ++++++++ 6 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 packages/ee/server-only/stripe/delete-customer.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 60b385403..3471f4f88 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,7 +10,13 @@ "ghcr.io/devcontainers/features/node:1": {} }, "onCreateCommand": "./.devcontainer/on-create.sh", - "forwardPorts": [3000, 54320, 9000, 2500, 1100], + "forwardPorts": [ + 3000, + 54320, + 9000, + 2500, + 1100 + ], "customizations": { "vscode": { "extensions": [ @@ -25,8 +31,8 @@ "GitHub.copilot", "GitHub.vscode-pull-request-github", "Prisma.prisma", - "VisualStudioExptTeam.vscodeintellicode", + "VisualStudioExptTeam.vscodeintellicode" ] } } -} +} \ No newline at end of file diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 78da0f636..b36df4e7f 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; +import { signOut } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -65,6 +66,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const isSubmitting = form.formState.isSubmitting; const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); + const { mutateAsync: deleteAccount } = trpc.profile.deleteAccount.useMutation(); const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { try { @@ -98,6 +100,39 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } }; + const onDeleteAccount = async () => { + try { + await deleteAccount(); + + await signOut({ callbackUrl: '/' }); + + toast({ + title: 'Account deleted', + description: 'Your account has been deleted successfully.', + duration: 5000, + }); + + // logout after deleting account + + router.push('/'); + } catch (err) { + if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { + toast({ + title: 'An error occurred', + description: err.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to delete your account. Please try again later.', + }); + } + } + }; + return (
{ all of your documents, along with all of your completed documents, signatures, and all other resources belonging to your Account. - + + + This action is not reversible. Please be certain. + + Cancel - + Delete Account @@ -189,12 +231,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { ); }; -export function AlertDestructive() { - return ( - - - This action is not reversible. Please be certain. - - - ); -} +// Cal.com Delete User TRPC = https://github.com/calcom/cal.com/blob/main/packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts#L11 +// https://github.com/calcom/cal.com/blob/main/packages/features/users/lib/userDeletionService.ts#L7 +// delete stripe: https://github.com/calcom/cal.com/blob/main/packages/app-store/stripepayment/lib/customer.ts#L72 diff --git a/packages/ee/server-only/stripe/delete-customer.ts b/packages/ee/server-only/stripe/delete-customer.ts new file mode 100644 index 000000000..16120de68 --- /dev/null +++ b/packages/ee/server-only/stripe/delete-customer.ts @@ -0,0 +1,10 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; +import type { User } from '@documenso/prisma/client'; + +export const deleteStripeCustomer = async (user: User) => { + if (!user.customerId) { + return null; + } + + return await stripe.customers.del(user.customerId); +}; diff --git a/packages/trpc/react/index.tsx b/packages/trpc/react/index.tsx index 85161d0e8..ce80ba267 100644 --- a/packages/trpc/react/index.tsx +++ b/packages/trpc/react/index.tsx @@ -9,7 +9,7 @@ import SuperJSON from 'superjson'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; -import { AppRouter } from '../server/router'; +import type { AppRouter } from '../server/router'; export const trpc = createTRPCReact({ unstable_overrides: { diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index cc969c679..f342c25fb 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -10,3 +10,9 @@ export const ZSignUpMutationSchema = z.object({ export type TSignUpMutationSchema = z.infer; export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true }); + +export const ZDeleteAccountMutationSchema = z.object({ + email: z.string().email(), +}); + +export type TDeleteAccountMutationSchema = z.infer; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 4dcf4ca93..cf5fdbf94 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,5 +1,7 @@ import { TRPCError } from '@trpc/server'; +import { deleteStripeCustomer } from '@documenso/ee/server-only/stripe/delete-customer'; +import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { resetPassword } from '@documenso/lib/server-only/user/reset-password'; @@ -133,4 +135,27 @@ export const profileRouter = router({ }); } }), + + deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { + try { + const user = ctx.user; + + const deletedUser = await deleteStripeCustomer(user); + + console.log(deletedUser); + + return await deleteUser(user); + } catch (err) { + let message = 'We were unable to delete your account. Please try again.'; + + if (err instanceof Error) { + message = err.message; + } + + throw new TRPCError({ + code: 'BAD_REQUEST', + message, + }); + } + }), }); From 7762b1db6593e80af92bc854989828f53d2545a6 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sun, 21 Jan 2024 09:47:50 +0000 Subject: [PATCH 03/13] feat: add loading to button --- apps/web/src/components/forms/profile.tsx | 63 +++++++++++------------ 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index b36df4e7f..7e274ff8e 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -12,19 +12,17 @@ import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@documenso/ui/primitives/alert-dialog'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent, CardFooter } from '@documenso/ui/primitives/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; import { Form, FormControl, @@ -66,7 +64,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const isSubmitting = form.formState.isSubmitting; const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); - const { mutateAsync: deleteAccount } = trpc.profile.deleteAccount.useMutation(); + const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = + trpc.profile.deleteAccount.useMutation(); const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { try { @@ -194,14 +193,14 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { irreversible and will cancel your subscription, so proceed with caution. - - + + - - - - Delete Account - + + + + Delete Account + Documenso will delete{' '} all of your documents, along with all of your completed documents, signatures, and all other resources belonging to your @@ -211,26 +210,22 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { This action is not reversible. Please be certain. - - - - Cancel - + + + + + + ); }; - -// Cal.com Delete User TRPC = https://github.com/calcom/cal.com/blob/main/packages/trpc/server/routers/loggedInViewer/deleteMe.handler.ts#L11 -// https://github.com/calcom/cal.com/blob/main/packages/features/users/lib/userDeletionService.ts#L7 -// delete stripe: https://github.com/calcom/cal.com/blob/main/packages/app-store/stripepayment/lib/customer.ts#L72 From 9e433af1126c2d0f7665d0cb827cd16ffe9fff91 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sun, 21 Jan 2024 15:38:32 +0000 Subject: [PATCH 04/13] feat: require 2fa code before account is deleted --- apps/web/src/components/forms/profile.tsx | 123 +++++++++++++----- packages/lib/server-only/2fa/setup-2fa.ts | 2 +- packages/lib/server-only/2fa/validate-2fa.ts | 2 +- .../lib/server-only/2fa/verify-2fa-token.ts | 3 +- packages/ui/primitives/button.tsx | 3 +- 5 files changed, 98 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 7e274ff8e..575a81d46 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -7,6 +7,7 @@ import { signOut } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa'; import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; @@ -41,6 +42,11 @@ export const ZProfileFormSchema = z.object({ signature: z.string().min(1, 'Signature Pad cannot be empty'), }); +export const ZTwoFactorAuthTokenSchema = z.object({ + token: z.string(), +}); + +export type TTwoFactorAuthTokenSchema = z.infer; export type TProfileFormSchema = z.infer; export type ProfileFormProps = { @@ -61,7 +67,15 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { resolver: zodResolver(ZProfileFormSchema), }); + const deleteAccountTwoFactorTokenForm = useForm({ + defaultValues: { + token: '', + }, + resolver: zodResolver(ZTwoFactorAuthTokenSchema), + }); + const isSubmitting = form.formState.isSubmitting; + const hasTwoFactorAuthentication = user.twoFactorEnabled; const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = @@ -101,9 +115,20 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const onDeleteAccount = async () => { try { - await deleteAccount(); + const { token } = deleteAccountTwoFactorTokenForm.getValues(); - await signOut({ callbackUrl: '/' }); + if (!token) { + throw new Error('Please enter your Two Factor Authentication token.'); + } + + await validateTwoFactorAuthentication({ + totpCode: token, + user, + }).catch(() => { + throw new Error('We were unable to validate your Two Factor Authentication token.'); + }); + + await deleteAccount(); toast({ title: 'Account deleted', @@ -111,9 +136,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { duration: 5000, }); - // logout after deleting account - - router.push('/'); + await signOut({ callbackUrl: '/' }); } catch (err) { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({ @@ -126,6 +149,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { title: 'An unknown error occurred', variant: 'destructive', description: + err.message ?? 'We encountered an unknown error while attempting to delete your account. Please try again later.', }); } @@ -193,36 +217,73 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { irreversible and will cancel your subscription, so proceed with caution. - - - - - - - Delete Account - - Documenso will delete{' '} - all of your documents, along with all of - your completed documents, signatures, and all other resources belonging to your - Account. - +
+ { + console.log('delete account'); + })} + > + + + + + + + Delete Account + + Documenso will delete{' '} + all of your documents, along with all + of your completed documents, signatures, and all other resources belonging + to your Account. + + + + This action is not reversible. Please be certain. - - - - - - - + + {hasTwoFactorAuthentication && ( +
+ ( + + + Two Factor Authentication Token + + + + + + + )} + /> +
+ )} + + + + + +
+ +
diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts index 30ddf0ec3..a60b0934b 100644 --- a/packages/lib/server-only/2fa/setup-2fa.ts +++ b/packages/lib/server-only/2fa/setup-2fa.ts @@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { prisma } from '@documenso/prisma'; -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; import { symmetricEncrypt } from '../../universal/crypto'; diff --git a/packages/lib/server-only/2fa/validate-2fa.ts b/packages/lib/server-only/2fa/validate-2fa.ts index 7fc76a8bb..33141c325 100644 --- a/packages/lib/server-only/2fa/validate-2fa.ts +++ b/packages/lib/server-only/2fa/validate-2fa.ts @@ -1,4 +1,4 @@ -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { ErrorCode } from '../../next-auth/error-codes'; import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; diff --git a/packages/lib/server-only/2fa/verify-2fa-token.ts b/packages/lib/server-only/2fa/verify-2fa-token.ts index fa9159517..3c410bd58 100644 --- a/packages/lib/server-only/2fa/verify-2fa-token.ts +++ b/packages/lib/server-only/2fa/verify-2fa-token.ts @@ -1,7 +1,7 @@ import { base32 } from '@scure/base'; import { TOTPController } from 'oslo/otp'; -import { User } from '@documenso/prisma/client'; +import type { User } from '@documenso/prisma/client'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; import { symmetricDecrypt } from '../../universal/crypto'; @@ -17,6 +17,7 @@ export const verifyTwoFactorAuthenticationToken = async ({ user, totpCode, }: VerifyTwoFactorAuthenticationTokenOptions) => { + // TODO: This is undefined and I can't figure out why. const key = DOCUMENSO_ENCRYPTION_KEY; if (!user.twoFactorSecret) { diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index 5754b35a5..68ecb6eb0 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -13,7 +13,8 @@ const buttonVariants = cva( variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + destructive: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive', outline: 'border border-input hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', From 014c09bd910a407f974dff880e1c35e10e02ece8 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sun, 28 Jan 2024 18:43:20 +0000 Subject: [PATCH 05/13] fix: account deletion error for users without two factor authentication --- apps/web/src/components/forms/profile.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 575a81d46..80c33dd60 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -113,10 +113,26 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } }; - const onDeleteAccount = async () => { + const onDeleteAccount = async (hasTwoFactorAuthentication: boolean) => { try { + if (!hasTwoFactorAuthentication) { + await deleteAccount(); + + toast({ + title: 'Account deleted', + description: 'Your account has been deleted successfully.', + duration: 5000, + }); + + await signOut({ callbackUrl: '/' }); + + return; + } + const { token } = deleteAccountTwoFactorTokenForm.getValues(); + console.log(token); + if (!token) { throw new Error('Please enter your Two Factor Authentication token.'); } @@ -273,7 +289,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { + diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index df5132aff..13ab038d4 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -13,10 +13,32 @@ export const deleteUser = async ({ email }: DeleteUserOptions) => { }, }); + const defaultDeleteUser = await prisma.user.findFirst({ + where: { + email: 'deleted@documenso.com', + }, + }); + if (!user) { throw new Error(`User with email ${email} not found`); } + if (!defaultDeleteUser) { + throw new Error(`Default delete account not found`); + } + + await prisma.document.updateMany({ + where: { + userId: user.id, + status: { + in: ['PENDING', 'COMPLETED'], + }, + }, + data: { + userId: defaultDeleteUser.id, + }, + }); + return await prisma.user.delete({ where: { id: user.id, diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index cf5fdbf94..2cadfd574 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -139,10 +139,7 @@ export const profileRouter = router({ deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { try { const user = ctx.user; - - const deletedUser = await deleteStripeCustomer(user); - - console.log(deletedUser); + await deleteStripeCustomer(user); return await deleteUser(user); } catch (err) { From 30752815e77138c269ccc8f901575268e2df4f86 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Mon, 5 Feb 2024 13:06:36 +0000 Subject: [PATCH 08/13] feat: soft-delete transfered documents --- packages/lib/server-only/user/delete-user.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 13ab038d4..352f5c9e9 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -36,6 +36,7 @@ export const deleteUser = async ({ email }: DeleteUserOptions) => { }, data: { userId: defaultDeleteUser.id, + deletedAt: new Date(), }, }); From cab875f68a70512d669622c215cecfd789878403 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 14 Feb 2024 13:20:40 +0000 Subject: [PATCH 09/13] fix: update create delete user sql script --- .../migration.sql | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql b/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql index 72727eadb..bfb9c2c83 100644 --- a/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql +++ b/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql @@ -1,25 +1,30 @@ -- Create deleted@documenso.com -INSERT INTO - "public"."User" ( - "email", - "emailVerified", - "password", - "createdAt", - "updatedAt", - "lastSignedIn", - "roles", - "identityProvider", - "twoFactorEnabled" - ) -VALUES - ( - 'deleted@documenso.com', - '2024-02-05 11:58:39.668 UTC', - NULL, - '2024-02-05 11:58:39.670 UTC', - '2024-02-05 11:58:39.670 UTC', - '2024-02-05 11:58:39.670 UTC', - ARRAY['USER'::TEXT]::"public"."Role" [], - CAST('GOOGLE'::TEXT AS "public"."IdentityProvider"), - FALSE - ) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM "public"."User" WHERE "email" = 'deleted@documenso.com') THEN + INSERT INTO + "public"."User" ( + "email", + "emailVerified", + "password", + "createdAt", + "updatedAt", + "lastSignedIn", + "roles", + "identityProvider", + "twoFactorEnabled" + ) + VALUES + ( + 'deleted@documenso.com', + NOW(), + NULL, + NOW(), + NOW(), + NOW(), + ARRAY['USER'::TEXT]::"public"."Role" [], + CAST('GOOGLE'::TEXT AS "public"."IdentityProvider"), + FALSE + ); + END IF; +END $$ From c680cfc24f54eece6a4a362bdc0d1c1ef60fa8ae Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Wed, 14 Feb 2024 14:52:18 +0000 Subject: [PATCH 10/13] chore: update pr based on review --- apps/web/src/components/forms/profile.tsx | 4 +--- packages/ee/server-only/stripe/delete-customer.ts | 10 ---------- packages/lib/server-only/user/delete-user.ts | 2 +- packages/trpc/server/auth-router/schema.ts | 6 ------ packages/trpc/server/profile-router/router.ts | 6 ++---- 5 files changed, 4 insertions(+), 24 deletions(-) delete mode 100644 packages/ee/server-only/stripe/delete-customer.ts diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 7a4bbdb77..a44e70940 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -124,9 +124,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { duration: 5000, }); - await signOut({ callbackUrl: '/' }); - - return; + return await signOut({ callbackUrl: '/' }); } const { token } = deleteAccountTwoFactorTokenForm.getValues(); diff --git a/packages/ee/server-only/stripe/delete-customer.ts b/packages/ee/server-only/stripe/delete-customer.ts deleted file mode 100644 index 16120de68..000000000 --- a/packages/ee/server-only/stripe/delete-customer.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { stripe } from '@documenso/lib/server-only/stripe'; -import type { User } from '@documenso/prisma/client'; - -export const deleteStripeCustomer = async (user: User) => { - if (!user.customerId) { - return null; - } - - return await stripe.customers.del(user.customerId); -}; diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 352f5c9e9..02d811b12 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -4,7 +4,7 @@ export type DeleteUserOptions = { email: string; }; -export const deleteUser = async ({ email }: DeleteUserOptions) => { +export const deletedServiceAccount = async ({ email }: DeleteUserOptions) => { const user = await prisma.user.findFirst({ where: { email: { diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index 49826d7ad..dbe42a25c 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -26,9 +26,3 @@ export const ZSignUpMutationSchema = z.object({ export type TSignUpMutationSchema = z.infer; export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true }); - -export const ZDeleteAccountMutationSchema = z.object({ - email: z.string().email(), -}); - -export type TDeleteAccountMutationSchema = z.infer; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 552057bdd..56a6eea29 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,7 +1,6 @@ import { TRPCError } from '@trpc/server'; -import { deleteStripeCustomer } from '@documenso/ee/server-only/stripe/delete-customer'; -import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; +import { deletedServiceAccount } from '@documenso/lib/server-only/user/delete-user'; import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; @@ -161,9 +160,8 @@ export const profileRouter = router({ deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { try { const user = ctx.user; - await deleteStripeCustomer(user); - return await deleteUser(user); + return await deletedServiceAccount(user); } catch (err) { let message = 'We were unable to delete your account. Please try again.'; From fddd860d15a69a804493babc70120f9ccc2d6499 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Thu, 15 Feb 2024 11:33:43 +0000 Subject: [PATCH 11/13] chore: code refactor to avoid repetitions --- apps/web/src/components/forms/profile.tsx | 32 +++++++++----------- packages/lib/server-only/user/delete-user.ts | 3 +- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index a44e70940..23861c9fc 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -113,18 +113,22 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } }; + const deleteAccoutAndSignOut = async () => { + await deleteAccount(); + + toast({ + title: 'Account deleted', + description: 'Your account has been deleted successfully.', + duration: 5000, + }); + + return await signOut({ callbackUrl: '/' }); + }; + const onDeleteAccount = async (hasTwoFactorAuthentication: boolean) => { try { if (!hasTwoFactorAuthentication) { - await deleteAccount(); - - toast({ - title: 'Account deleted', - description: 'Your account has been deleted successfully.', - duration: 5000, - }); - - return await signOut({ callbackUrl: '/' }); + return await deleteAccoutAndSignOut(); } const { token } = deleteAccountTwoFactorTokenForm.getValues(); @@ -140,15 +144,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { throw new Error('We were unable to validate your Two Factor Authentication token.'); }); - await deleteAccount(); - - toast({ - title: 'Account deleted', - description: 'Your account has been deleted successfully.', - duration: 5000, - }); - - await signOut({ callbackUrl: '/' }); + await deleteAccoutAndSignOut(); } catch (err) { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({ diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 02d811b12..65a74ac42 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -1,4 +1,5 @@ import { prisma } from '@documenso/prisma'; +import { DocumentStatus } from '@documenso/prisma/client'; export type DeleteUserOptions = { email: string; @@ -31,7 +32,7 @@ export const deletedServiceAccount = async ({ email }: DeleteUserOptions) => { where: { userId: user.id, status: { - in: ['PENDING', 'COMPLETED'], + in: [DocumentStatus.PENDING, DocumentStatus.COMPLETED], }, }, data: { From f98567ea87852a77040488982f2d10ecf0dfc243 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sat, 17 Feb 2024 07:34:21 +0000 Subject: [PATCH 12/13] feat: request usee to disable 2fa before deleting account --- apps/web/src/components/forms/profile.tsx | 138 ++++++------------ .../lib/server-only/2fa/verify-2fa-token.ts | 1 - 2 files changed, 45 insertions(+), 94 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 23861c9fc..8a7e2ff3f 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -7,7 +7,6 @@ import { signOut } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; -import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa'; import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; @@ -67,13 +66,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { resolver: zodResolver(ZProfileFormSchema), }); - const deleteAccountTwoFactorTokenForm = useForm({ - defaultValues: { - token: '', - }, - resolver: zodResolver(ZTwoFactorAuthTokenSchema), - }); - const isSubmitting = form.formState.isSubmitting; const hasTwoFactorAuthentication = user.twoFactorEnabled; @@ -113,38 +105,17 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } }; - const deleteAccoutAndSignOut = async () => { - await deleteAccount(); - - toast({ - title: 'Account deleted', - description: 'Your account has been deleted successfully.', - duration: 5000, - }); - - return await signOut({ callbackUrl: '/' }); - }; - - const onDeleteAccount = async (hasTwoFactorAuthentication: boolean) => { + const onDeleteAccount = async () => { try { - if (!hasTwoFactorAuthentication) { - return await deleteAccoutAndSignOut(); - } + await deleteAccount(); - const { token } = deleteAccountTwoFactorTokenForm.getValues(); - - if (!token) { - throw new Error('Please enter your Two Factor Authentication token.'); - } - - await validateTwoFactorAuthentication({ - totpCode: token, - user, - }).catch(() => { - throw new Error('We were unable to validate your Two Factor Authentication token.'); + toast({ + title: 'Account deleted', + description: 'Your account has been deleted successfully.', + duration: 5000, }); - await deleteAccoutAndSignOut(); + return await signOut({ callbackUrl: '/' }); } catch (err) { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({ @@ -225,66 +196,47 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { irreversible and will cancel your subscription, so proceed with caution. -
- { - console.log('delete account'); - })} - > - - - - - - - Delete Account - - Documenso will delete{' '} - all of your documents, along with all - of your completed documents, signatures, and all other resources belonging - to your Account. - - + + + + + + + Delete Account + + Documenso will delete{' '} + all of your documents, along with all of + your completed documents, signatures, and all other resources belonging to your + Account. + + - - - This action is not reversible. Please be certain. - - + + + This action is not reversible. Please be certain. + + - {hasTwoFactorAuthentication && ( -
- ( - - - Two Factor Authentication Token - - - - - - - )} - /> -
- )} + {hasTwoFactorAuthentication && ( + + + Disable Two Factor Authentication before deleting your account. + + + )} - - - -
-
- - + + + +
+
diff --git a/packages/lib/server-only/2fa/verify-2fa-token.ts b/packages/lib/server-only/2fa/verify-2fa-token.ts index 3c410bd58..0e8ec6afc 100644 --- a/packages/lib/server-only/2fa/verify-2fa-token.ts +++ b/packages/lib/server-only/2fa/verify-2fa-token.ts @@ -17,7 +17,6 @@ export const verifyTwoFactorAuthenticationToken = async ({ user, totpCode, }: VerifyTwoFactorAuthenticationTokenOptions) => { - // TODO: This is undefined and I can't figure out why. const key = DOCUMENSO_ENCRYPTION_KEY; if (!user.twoFactorSecret) { From 9cf72e144255cff4ec0396089e9498cd74b39399 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sun, 25 Feb 2024 11:12:18 +0000 Subject: [PATCH 13/13] chore: tidy code and extract alert-dialog --- .../profile/delete-account-dialog.tsx | 124 ++++++++++++++++++ .../app/(dashboard)/settings/profile/page.tsx | 6 +- apps/web/src/components/forms/profile.tsx | 95 -------------- packages/lib/server-only/user/delete-user.ts | 17 +-- .../user/service-accounts/deleted-account.ts | 17 +++ .../migration.sql | 4 +- packages/trpc/server/profile-router/router.ts | 4 +- 7 files changed, 156 insertions(+), 111 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx create mode 100644 packages/lib/server-only/user/service-accounts/deleted-account.ts diff --git a/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx new file mode 100644 index 000000000..933b37f31 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/profile/delete-account-dialog.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { signOut } from 'next-auth/react'; + +import type { User } from '@documenso/prisma/client'; +import { TRPCClientError } from '@documenso/trpc/client'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type DeleteAccountDialogProps = { + className?: string; + user: User; +}; + +export const DeleteAccountDialog = ({ className, user }: DeleteAccountDialogProps) => { + const { toast } = useToast(); + + const hasTwoFactorAuthentication = user.twoFactorEnabled; + + const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = + trpc.profile.deleteAccount.useMutation(); + + const onDeleteAccount = async () => { + try { + await deleteAccount(); + + toast({ + title: 'Account deleted', + description: 'Your account has been deleted successfully.', + duration: 5000, + }); + + return await signOut({ callbackUrl: '/' }); + } catch (err) { + if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { + toast({ + title: 'An error occurred', + description: err.message, + variant: 'destructive', + }); + } else { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + err.message ?? + 'We encountered an unknown error while attempting to delete your account. Please try again later.', + }); + } + } + }; + + return ( +
+ +
+ Delete Account + + Delete your account and all its contents, including completed documents. This action is + irreversible and will cancel your subscription, so proceed with caution. + +
+ +
+ + + + + + + Delete Account + + + + This action is not reversible. Please be certain. + + + + {hasTwoFactorAuthentication && ( + + + Disable Two Factor Authentication before deleting your account. + + + )} + + + Documenso will delete all of your documents + , along with all of your completed documents, signatures, and all other resources + belonging to your Account. + + + + + + + + +
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx index 2890eb5d5..11cfc8515 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -5,6 +5,8 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get- import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; import { ProfileForm } from '~/components/forms/profile'; +import { DeleteAccountDialog } from './delete-account-dialog'; + export const metadata: Metadata = { title: 'Profile', }; @@ -16,7 +18,9 @@ export default async function ProfileSettingsPage() {
- + + +
); } diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 8a7e2ff3f..c3f8eca37 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -3,7 +3,6 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { signOut } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -11,18 +10,7 @@ import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; -import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent, CardFooter } from '@documenso/ui/primitives/card'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@documenso/ui/primitives/dialog'; import { Form, FormControl, @@ -105,36 +93,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { } }; - const onDeleteAccount = async () => { - try { - await deleteAccount(); - - toast({ - title: 'Account deleted', - description: 'Your account has been deleted successfully.', - duration: 5000, - }); - - return await signOut({ callbackUrl: '/' }); - } catch (err) { - if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { - toast({ - title: 'An error occurred', - description: err.message, - variant: 'destructive', - }); - } else { - toast({ - title: 'An unknown error occurred', - variant: 'destructive', - description: - err.message ?? - 'We encountered an unknown error while attempting to delete your account. Please try again later.', - }); - } - } - }; - return (
{ {isSubmitting ? 'Updating profile...' : 'Update profile'}
- -
- - - - Delete your account and all its contents, including completed documents. This action is - irreversible and will cancel your subscription, so proceed with caution. - - - - - - - - - Delete Account - - Documenso will delete{' '} - all of your documents, along with all of - your completed documents, signatures, and all other resources belonging to your - Account. - - - - - - This action is not reversible. Please be certain. - - - - {hasTwoFactorAuthentication && ( - - - Disable Two Factor Authentication before deleting your account. - - - )} - - - - - - - - -
); }; diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 65a74ac42..d6d4284b4 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -1,11 +1,13 @@ import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@documenso/prisma/client'; +import { deletedAccountServiceAccount } from './service-accounts/deleted-account'; + export type DeleteUserOptions = { email: string; }; -export const deletedServiceAccount = async ({ email }: DeleteUserOptions) => { +export const deleteUser = async ({ email }: DeleteUserOptions) => { const user = await prisma.user.findFirst({ where: { email: { @@ -14,20 +16,13 @@ export const deletedServiceAccount = async ({ email }: DeleteUserOptions) => { }, }); - const defaultDeleteUser = await prisma.user.findFirst({ - where: { - email: 'deleted@documenso.com', - }, - }); - if (!user) { throw new Error(`User with email ${email} not found`); } - if (!defaultDeleteUser) { - throw new Error(`Default delete account not found`); - } + const serviceAccount = await deletedAccountServiceAccount(); + // TODO: Send out cancellations for all pending docs await prisma.document.updateMany({ where: { userId: user.id, @@ -36,7 +31,7 @@ export const deletedServiceAccount = async ({ email }: DeleteUserOptions) => { }, }, data: { - userId: defaultDeleteUser.id, + userId: serviceAccount.id, deletedAt: new Date(), }, }); diff --git a/packages/lib/server-only/user/service-accounts/deleted-account.ts b/packages/lib/server-only/user/service-accounts/deleted-account.ts new file mode 100644 index 000000000..6bfd6d25f --- /dev/null +++ b/packages/lib/server-only/user/service-accounts/deleted-account.ts @@ -0,0 +1,17 @@ +import { prisma } from '@documenso/prisma'; + +export const deletedAccountServiceAccount = async () => { + const serviceAccount = await prisma.user.findFirst({ + where: { + email: 'deleted-account@documenso.com', + }, + }); + + if (!serviceAccount) { + throw new Error( + 'Deleted account service account not found, have you ran the appropriate migrations?', + ); + } + + return serviceAccount; +}; diff --git a/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql b/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql index bfb9c2c83..d001bc4ae 100644 --- a/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql +++ b/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql @@ -1,7 +1,7 @@ -- Create deleted@documenso.com DO $$ BEGIN - IF NOT EXISTS (SELECT 1 FROM "public"."User" WHERE "email" = 'deleted@documenso.com') THEN + IF NOT EXISTS (SELECT 1 FROM "public"."User" WHERE "email" = 'deleted-account@documenso.com') THEN INSERT INTO "public"."User" ( "email", @@ -16,7 +16,7 @@ BEGIN ) VALUES ( - 'deleted@documenso.com', + 'deleted-account@documenso.com', NOW(), NULL, NOW(), diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 56a6eea29..2f636d87d 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; -import { deletedServiceAccount } from '@documenso/lib/server-only/user/delete-user'; +import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; @@ -161,7 +161,7 @@ export const profileRouter = router({ try { const user = ctx.user; - return await deletedServiceAccount(user); + return await deleteUser(user); } catch (err) { let message = 'We were unable to delete your account. Please try again.';