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/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 2c278292f..c3f8eca37 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -29,6 +29,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 = { @@ -50,8 +55,11 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { }); const isSubmitting = form.formState.isSubmitting; + const hasTwoFactorAuthentication = user.twoFactorEnabled; const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); + const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = + trpc.profile.deleteAccount.useMutation(); const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { try { @@ -133,7 +141,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { /> - 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..0e8ec6afc 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'; diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index df5132aff..d6d4284b4 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -1,4 +1,7 @@ import { prisma } from '@documenso/prisma'; +import { DocumentStatus } from '@documenso/prisma/client'; + +import { deletedAccountServiceAccount } from './service-accounts/deleted-account'; export type DeleteUserOptions = { email: string; @@ -17,6 +20,22 @@ export const deleteUser = async ({ email }: DeleteUserOptions) => { throw new Error(`User with email ${email} not found`); } + const serviceAccount = await deletedAccountServiceAccount(); + + // TODO: Send out cancellations for all pending docs + await prisma.document.updateMany({ + where: { + userId: user.id, + status: { + in: [DocumentStatus.PENDING, DocumentStatus.COMPLETED], + }, + }, + data: { + userId: serviceAccount.id, + deletedAt: new Date(), + }, + }); + return await prisma.user.delete({ where: { id: user.id, 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 new file mode 100644 index 000000000..d001bc4ae --- /dev/null +++ b/packages/prisma/migrations/20240205120648_create_delete_account/migration.sql @@ -0,0 +1,30 @@ +-- Create deleted@documenso.com +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM "public"."User" WHERE "email" = 'deleted-account@documenso.com') THEN + INSERT INTO + "public"."User" ( + "email", + "emailVerified", + "password", + "createdAt", + "updatedAt", + "lastSignedIn", + "roles", + "identityProvider", + "twoFactorEnabled" + ) + VALUES + ( + 'deleted-account@documenso.com', + NOW(), + NULL, + NOW(), + NOW(), + NOW(), + ARRAY['USER'::TEXT]::"public"."Role" [], + CAST('GOOGLE'::TEXT AS "public"."IdentityProvider"), + FALSE + ); + END IF; +END $$ 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/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index bceee020a..2f636d87d 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; +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'; @@ -155,4 +156,23 @@ export const profileRouter = router({ }); } }), + + deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { + try { + const user = ctx.user; + + 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, + }); + } + }), }); diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index 5fc3fc1bb..add486332 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',