From d142376ef980332611b219cb08c59e405cb0e780 Mon Sep 17 00:00:00 2001 From: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com> Date: Sat, 29 Jul 2023 00:09:43 +0700 Subject: [PATCH] feat: I can delete my account easily (#977) * Add support for account deletion Co-authored-by: v1b3m * Add more fixes Co-authored-by: Benjamin Mayanja * Add more fixes Co-authored-by: v1b3m --------- Co-authored-by: v1b3m --- front/src/generated/graphql.tsx | 38 +++++ .../profile/components/DeleteModal.tsx | 106 ++++++++++++++ .../profile/components/DeleteWorkspace.tsx | 133 +++++++----------- front/src/modules/users/queries/update.ts | 8 ++ server/src/ability/ability.factory.ts | 2 +- .../ability/handlers/user.ability-handler.ts | 9 +- server/src/core/user/user.resolver.ts | 14 +- server/src/core/user/user.service.ts | 85 +++++++++++ 8 files changed, 306 insertions(+), 89 deletions(-) create mode 100644 front/src/modules/settings/profile/components/DeleteModal.tsx diff --git a/front/src/generated/graphql.tsx b/front/src/generated/graphql.tsx index 95320bf5db..bd73d6b236 100644 --- a/front/src/generated/graphql.tsx +++ b/front/src/generated/graphql.tsx @@ -894,6 +894,7 @@ export type Mutation = { deleteManyCompany: AffectedRows; deleteManyPerson: AffectedRows; deleteManyPipelineProgress: AffectedRows; + deleteUserAccount: User; deleteWorkspaceMember: WorkspaceMember; renewToken: AuthTokens; signUp: LoginToken; @@ -2588,6 +2589,11 @@ export type RemoveProfilePictureMutationVariables = Exact<{ export type RemoveProfilePictureMutation = { __typename?: 'Mutation', updateUser: { __typename?: 'User', id: string, avatarUrl?: string | null } }; +export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; + + +export type DeleteUserAccountMutation = { __typename?: 'Mutation', deleteUserAccount: { __typename?: 'User', id: string } }; + export type GetViewFieldsQueryVariables = Exact<{ where?: InputMaybe; }>; @@ -4780,6 +4786,38 @@ export function useRemoveProfilePictureMutation(baseOptions?: Apollo.MutationHoo export type RemoveProfilePictureMutationHookResult = ReturnType; export type RemoveProfilePictureMutationResult = Apollo.MutationResult; export type RemoveProfilePictureMutationOptions = Apollo.BaseMutationOptions; +export const DeleteUserAccountDocument = gql` + mutation DeleteUserAccount { + deleteUserAccount { + id + } +} + `; +export type DeleteUserAccountMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteUserAccountMutation__ + * + * To run a mutation, you first call `useDeleteUserAccountMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteUserAccountMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteUserAccountMutation, { data, loading, error }] = useDeleteUserAccountMutation({ + * variables: { + * }, + * }); + */ +export function useDeleteUserAccountMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteUserAccountDocument, options); + } +export type DeleteUserAccountMutationHookResult = ReturnType; +export type DeleteUserAccountMutationResult = Apollo.MutationResult; +export type DeleteUserAccountMutationOptions = Apollo.BaseMutationOptions; export const GetViewFieldsDocument = gql` query GetViewFields($where: ViewFieldWhereInput) { viewFields: findManyViewField(where: $where) { diff --git a/front/src/modules/settings/profile/components/DeleteModal.tsx b/front/src/modules/settings/profile/components/DeleteModal.tsx new file mode 100644 index 0000000000..f37d5ce45e --- /dev/null +++ b/front/src/modules/settings/profile/components/DeleteModal.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import styled from '@emotion/styled'; +import { AnimatePresence, LayoutGroup } from 'framer-motion'; +import { useRecoilValue } from 'recoil'; + +import { currentUserState } from '@/auth/states/currentUserState'; +import { Button, ButtonVariant } from '@/ui/button/components/Button'; +import { TextInput } from '@/ui/input/components/TextInput'; +import { Modal } from '@/ui/modal/components/Modal'; +import { debounce } from '~/utils/debounce'; + +interface DeleteModalProps { + isOpen: boolean; + title: string; + subtitle: string; + setIsOpen: (val: boolean) => void; + handleConfirmDelete: () => void; + deleteButtonText?: string; +} + +const StyledTitle = styled.div` + font-size: ${({ theme }) => theme.font.size.lg}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; +`; + +const StyledModal = styled(Modal)` + color: ${({ theme }) => theme.font.color.primary}; + > * + * { + margin-top: ${({ theme }) => theme.spacing(8)}; + } +`; + +const StyledCenteredButton = styled(Button)` + justify-content: center; +`; + +export const StyledDeleteButton = styled(StyledCenteredButton)` + border-color: ${({ theme }) => theme.color.red20}; + color: ${({ theme }) => theme.color.red}; + font-size: ${({ theme }) => theme.font.size.md}; + line-height: ${({ theme }) => theme.text.lineHeight.lg}; +`; + +export function DeleteModal({ + isOpen = false, + title, + subtitle, + setIsOpen, + handleConfirmDelete, + deleteButtonText = 'Delete', +}: DeleteModalProps) { + const [email, setEmail] = useState(''); + const [isValidEmail, setIsValidEmail] = useState(true); + const currentUser = useRecoilValue(currentUserState); + const userEmail = currentUser?.email; + + const handleEmailChange = (val: string) => { + setEmail(val); + isEmailMatchingUserEmail(val, userEmail); + }; + + const isEmailMatchingUserEmail = debounce( + (email1?: string, email2?: string) => { + setIsValidEmail(Boolean(email1 && email2 && email1 === email2)); + }, + 250, + ); + + const errorMessage = + email && !isValidEmail ? 'email provided is not correct' : ''; + + return ( + + + + {title} +
{subtitle}
+ + + setIsOpen(false)} + variant={ButtonVariant.Secondary} + title="Cancel" + fullWidth + style={{ + marginTop: 10, + }} + /> +
+
+
+ ); +} diff --git a/front/src/modules/settings/profile/components/DeleteWorkspace.tsx b/front/src/modules/settings/profile/components/DeleteWorkspace.tsx index 13a635ef16..3e6598beed 100644 --- a/front/src/modules/settings/profile/components/DeleteWorkspace.tsx +++ b/front/src/modules/settings/profile/components/DeleteWorkspace.tsx @@ -1,51 +1,25 @@ -import { useState } from 'react'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import styled from '@emotion/styled'; -import { AnimatePresence, LayoutGroup } from 'framer-motion'; -import { useRecoilValue } from 'recoil'; import { useAuth } from '@/auth/hooks/useAuth'; -import { currentUserState } from '@/auth/states/currentUserState'; import { AppPath } from '@/types/AppPath'; -import { Button, ButtonVariant } from '@/ui/button/components/Button'; -import { TextInput } from '@/ui/input/components/TextInput'; -import { Modal } from '@/ui/modal/components/Modal'; +import { ButtonVariant } from '@/ui/button/components/Button'; import { SubSectionTitle } from '@/ui/title/components/SubSectionTitle'; -import { useDeleteCurrentWorkspaceMutation } from '~/generated/graphql'; -import { debounce } from '~/utils/debounce'; +import { + useDeleteCurrentWorkspaceMutation, + useDeleteUserAccountMutation, +} from '~/generated/graphql'; -const StyledCenteredButton = styled(Button)` - justify-content: center; -`; - -const StyledDeleteButton = styled(StyledCenteredButton)` - border-color: ${({ theme }) => theme.color.red20}; - color: ${({ theme }) => theme.color.red}; - font-size: ${({ theme }) => theme.font.size.md}; - line-height: ${({ theme }) => theme.text.lineHeight.lg}; -`; - -const StyledTitle = styled.div` - font-size: ${({ theme }) => theme.font.size.lg}; - font-weight: ${({ theme }) => theme.font.weight.semiBold}; -`; - -const StyledModal = styled(Modal)` - color: ${({ theme }) => theme.font.color.primary}; - > * + * { - margin-top: ${({ theme }) => theme.spacing(8)}; - } -`; +import { DeleteModal, StyledDeleteButton } from './DeleteModal'; export function DeleteWorkspace() { - const [isOpen, setIsOpen] = useState(false); - const [isValidEmail, setIsValidEmail] = useState(true); - const [email, setEmail] = useState(''); - const currentUser = useRecoilValue(currentUserState); - const userEmail = currentUser?.email; + const [isDeleteWorkSpaceModalOpen, setIsDeleteWorkSpaceModalOpen] = + useState(false); + const [isDeleteAccountModalOpen, setIsDeleteAccountModalOpen] = + useState(false); const [deleteCurrentWorkspace] = useDeleteCurrentWorkspaceMutation(); + const [deleteUserAccount] = useDeleteUserAccountMutation(); const { signOut } = useAuth(); const navigate = useNavigate(); @@ -59,20 +33,15 @@ export function DeleteWorkspace() { handleLogout(); }; - const isEmailMatchingUserEmail = debounce( - (email1?: string, email2?: string) => { - setIsValidEmail(Boolean(email1 && email2 && email1 === email2)); - }, - 250, - ); - - const handleEmailChange = (val: string) => { - setEmail(val); - isEmailMatchingUserEmail(val, userEmail); + const deleteAccount = async () => { + await deleteUserAccount(); + handleLogout(); }; - const errorMessage = - email && !isValidEmail ? 'email provided is not correct' : ''; + const subtitle = ( + type: 'workspace' | 'account', + ) => `This action cannot be undone. This will permanently delete your + entire ${type}. Please type in your email to confirm.`; return ( <> @@ -81,46 +50,38 @@ export function DeleteWorkspace() { description="Delete your whole workspace" /> setIsOpen(!isOpen)} + onClick={() => setIsDeleteWorkSpaceModalOpen(true)} variant={ButtonVariant.Secondary} title="Delete workspace" /> - - - - Workspace Deletion -
- This action cannot be undone. This will permanently delete your - entire workspace. Please type in your email to confirm. -
- - - setIsOpen(false)} - variant={ButtonVariant.Secondary} - title="Cancel" - fullWidth - style={{ - marginTop: 10, - }} - /> -
-
-
+ + setIsDeleteAccountModalOpen(true)} + variant={ButtonVariant.Secondary} + title="Delete account" + /> + + + + ); } diff --git a/front/src/modules/users/queries/update.ts b/front/src/modules/users/queries/update.ts index 9d3a56ed37..0bb595ad62 100644 --- a/front/src/modules/users/queries/update.ts +++ b/front/src/modules/users/queries/update.ts @@ -42,3 +42,11 @@ export const REMOVE_PROFILE_PICTURE = gql` } } `; + +export const DELETE_USER_ACCOUNT = gql` + mutation DeleteUserAccount { + deleteUserAccount { + id + } + } +`; diff --git a/server/src/ability/ability.factory.ts b/server/src/ability/ability.factory.ts index bd6ef07381..32fa03f127 100644 --- a/server/src/ability/ability.factory.ts +++ b/server/src/ability/ability.factory.ts @@ -59,7 +59,7 @@ export class AbilityFactory { }, }); can(AbilityAction.Update, 'User', { id: user.id }); - cannot(AbilityAction.Delete, 'User'); + can(AbilityAction.Delete, 'User', { id: user.id }); // Workspace can(AbilityAction.Read, 'Workspace'); diff --git a/server/src/ability/handlers/user.ability-handler.ts b/server/src/ability/handlers/user.ability-handler.ts index d18995f6fe..69bee102e5 100644 --- a/server/src/ability/handlers/user.ability-handler.ts +++ b/server/src/ability/handlers/user.ability-handler.ts @@ -65,6 +65,7 @@ export class UpdateUserAbilityHandler implements IAbilityHandler { async handle(ability: AppAbility, context: ExecutionContext) { const gqlContext = GqlExecutionContext.create(context); const args = gqlContext.getArgs(); + // TODO: Confirm if this is correct const user = await this.prismaService.client.user.findFirst({ where: args.where, }); @@ -92,8 +93,14 @@ export class DeleteUserAbilityHandler implements IAbilityHandler { async handle(ability: AppAbility, context: ExecutionContext) { const gqlContext = GqlExecutionContext.create(context); const args = gqlContext.getArgs(); + + // obtain the auth user from the context + const reqUser = gqlContext.getContext().req.user; + + // FIXME: When `args.where` is undefined(which it is in almost all the cases I've tested), + // this query will return the first user entry in the DB, which is most likely not the current user const user = await this.prismaService.client.user.findFirst({ - where: args.where, + where: { ...args.where, id: reqUser.user.id }, }); assert(user, '', NotFoundException); diff --git a/server/src/core/user/user.resolver.ts b/server/src/core/user/user.resolver.ts index d5169a0d18..9b39446d0c 100644 --- a/server/src/core/user/user.resolver.ts +++ b/server/src/core/user/user.resolver.ts @@ -9,7 +9,7 @@ import { import { UseFilters, UseGuards } from '@nestjs/common'; import { accessibleBy } from '@casl/prisma'; -import { Prisma } from '@prisma/client'; +import { Prisma, Workspace } from '@prisma/client'; import { FileUpload, GraphQLUpload } from 'graphql-upload'; import { FileFolder } from 'src/core/file/interfaces/file-folder.interface'; @@ -25,6 +25,7 @@ import { import { AbilityGuard } from 'src/guards/ability.guard'; import { CheckAbilities } from 'src/decorators/check-abilities.decorator'; import { + DeleteUserAbilityHandler, ReadUserAbilityHandler, UpdateUserAbilityHandler, } from 'src/ability/handlers/user.ability-handler'; @@ -35,6 +36,7 @@ import { assert } from 'src/utils/assert'; import { UpdateOneUserArgs } from 'src/core/@generated/user/update-one-user.args'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; import { FileUploadService } from 'src/core/file/services/file-upload.service'; +import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator'; import { UserService } from './user.service'; @@ -147,4 +149,14 @@ export class UserResolver { return paths[0]; } + + @Mutation(() => User) + @UseGuards(AbilityGuard) + @CheckAbilities(DeleteUserAbilityHandler) + async deleteUserAccount( + @AuthUser() { id: userId }: User, + @AuthWorkspace() { id: workspaceId }: Workspace, + ) { + return this.userService.deleteUser({ userId, workspaceId }); + } } diff --git a/server/src/core/user/user.service.ts b/server/src/core/user/user.service.ts index cfcf47970b..0250b0329b 100644 --- a/server/src/core/user/user.service.ts +++ b/server/src/core/user/user.service.ts @@ -91,4 +91,89 @@ export class UserService { return user as Prisma.UserGetPayload; } + + async deleteUser({ + workspaceId, + userId, + }: { + workspaceId: string; + userId: string; + }) { + const { + workspaceMember, + company, + comment, + attachment, + refreshToken, + activity, + activityTarget, + } = this.prismaService.client; + const user = await this.findUnique({ + where: { + id: userId, + }, + select: { + id: true, + }, + }); + assert(user, 'User not found'); + + const workspace = await this.workspaceService.findUnique({ + where: { id: workspaceId }, + select: { id: true }, + }); + assert(workspace, 'Workspace not found'); + + const workSpaceMembers = await workspaceMember.findMany({ + where: { + workspaceId, + }, + }); + + const isLastMember = + workSpaceMembers.length === 1 && workSpaceMembers[0].userId === userId; + + if (isLastMember) { + // Delete entire workspace + await this.workspaceService.deleteWorkspace({ + workspaceId, + userId, + select: { id: true }, + }); + } else { + const where = { authorId: userId }; + const activities = await activity.findMany({ + where, + }); + + await this.prismaService.client.$transaction([ + workspaceMember.deleteMany({ + where: { userId }, + }), + company.deleteMany({ + where: { accountOwnerId: userId }, + }), + comment.deleteMany({ + where, + }), + attachment.deleteMany({ + where, + }), + refreshToken.deleteMany({ + where: { userId }, + }), + ...activities.map(({ id: activityId }) => + activityTarget.deleteMany({ + where: { activityId }, + }), + ), + activity.deleteMany({ + where, + }), + this.delete({ where: { id: userId } }), + ]); + } + + return user; + } }