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
+
+
+
+ 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.
+
+
+
+
+
+ {isDeletingAccount ? 'Deleting account...' : 'Delete 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) => {
/>
-
+
{isSubmitting ? 'Updating profile...' : 'Update profile'}
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',