diff --git a/client/modules/User/actions.ts b/client/modules/User/actions.ts index b34c2bd1b4..40f6697e7f 100644 --- a/client/modules/User/actions.ts +++ b/client/modules/User/actions.ts @@ -10,6 +10,7 @@ import { showToast, setToastText } from '../IDE/actions/toast'; import type { CreateApiKeyRequestBody, CreateUserRequestBody, + DeleteAccountRequestBody, Error, PublicUser, PublicUserOrError, @@ -506,3 +507,33 @@ export function setUserCookieConsent( }); }; } + +/** + * - Method: `DELETE` + * - Endpoint: `/account` + * - Authenticated: `true` + * - Id: `UserController.deleteAccount` + * + * Description: + * - Permanently delete the authenticated user's account and all their data. + * - Returns the error message from the server on failure, or redirects to `/` on success. + */ +export function deleteAccount(formValues: DeleteAccountRequestBody) { + return (dispatch: Dispatch) => + new Promise((resolve) => { + apiClient + .delete('/account', { data: formValues }) + .then(() => { + dispatch({ type: ActionTypes.UNAUTH_USER }); + dispatch({ type: ActionTypes.RESET_PROJECT }); + dispatch({ type: ActionTypes.CLEAR_CONSOLE }); + browserHistory.push('/'); + resolve(); + }) + .catch((error) => { + const message = + error.response?.data?.message || 'Error deleting account.'; + resolve(message); + }); + }); +} diff --git a/client/modules/User/components/DangerZone.tsx b/client/modules/User/components/DangerZone.tsx new file mode 100644 index 0000000000..c14114be44 --- /dev/null +++ b/client/modules/User/components/DangerZone.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import type { ThunkDispatch } from 'redux-thunk'; +import type { AnyAction } from 'redux'; +import { Button, ButtonKinds, ButtonTypes } from '../../../common/Button'; +import { deleteAccount } from '../actions'; +import { RootState } from '../../../reducers'; + +export function DangerZone() { + const { t } = useTranslation(); + const dispatch = useDispatch>(); + const hasPassword = useSelector( + (state: RootState) => + state.user.github === undefined && state.user.google === undefined + ); + + const [isConfirming, setIsConfirming] = useState(false); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleDelete = async (event: React.FormEvent) => { + event.preventDefault(); + setError(''); + setIsSubmitting(true); + const result = await dispatch( + deleteAccount(hasPassword ? { password } : {}) + ); + setIsSubmitting(false); + if (result) { + setError(result as string); + } + }; + + const header = ( + +

{t('DangerZone.Title')}

+

+ {t('DangerZone.DeleteAccountDescription')} +

+
+ ); + + if (!isConfirming) { + return ( +
+ {header} +
+ +
+
+ ); + } + + return ( +
+ {header} +
+ {hasPassword && ( +

+ + setPassword(e.target.value)} + /> +

+ )} + {error && ( +

+ {error} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/client/modules/User/pages/AccountView.tsx b/client/modules/User/pages/AccountView.tsx index 8c32071888..3912bd1b46 100644 --- a/client/modules/User/pages/AccountView.tsx +++ b/client/modules/User/pages/AccountView.tsx @@ -11,6 +11,7 @@ import { SocialAuthServices } from '../components/SocialAuthButton'; import { APIKeyForm } from '../components/APIKeyForm'; +import { DangerZone } from '../components/DangerZone'; import Nav from '../../IDE/components/Header/Nav'; import ErrorModal from '../../IDE/components/ErrorModal'; import { hideErrorModal } from '../../IDE/actions/ide'; @@ -106,13 +107,19 @@ export function AccountView() { + )} - {!accessTokensUIEnabled && } + {!accessTokensUIEnabled && ( + + + + + )} ); diff --git a/client/styles/components/_account.scss b/client/styles/components/_account.scss index 469be83497..e0eafba68a 100644 --- a/client/styles/components/_account.scss +++ b/client/styles/components/_account.scss @@ -47,3 +47,31 @@ .account__social-stack > * { margin-right: #{math.div(15, $base-font-size)}rem; } + +.account__inline-label { + font-size: #{math.div(12, $base-font-size)}rem; + margin-top: #{math.div(10, $base-font-size)}rem; + margin-bottom: #{math.div(7, $base-font-size)}rem; + display: block; + @include themify() { + color: getThemifyVariable('form-secondary-title-color'); + } +} + +.account__danger-zone { + padding-bottom: #{math.div(40, $base-font-size)}rem; +} + +.account__action-stack { + display: flex; + justify-content: flex-start; + gap: #{math.div(15, $base-font-size)}rem; + @media (max-width: 770px) { + flex-direction: column; + align-items: stretch; + + button { + width: 100% !important; + } + } +} diff --git a/common/types/index.ts b/common/types/index.ts index b45c30b26e..13f8831746 100644 --- a/common/types/index.ts +++ b/common/types/index.ts @@ -26,6 +26,7 @@ export type { ResetOrUpdatePasswordRequestParams, UpdatePasswordRequestBody, CreateUserRequestBody, + DeleteAccountRequestBody, DuplicateUserCheckQuery, VerifyEmailQuery } from '../../server/types/user'; diff --git a/server/controllers/aws.controller.js b/server/controllers/aws.controller.js index f321c40ffa..2479796fa2 100644 --- a/server/controllers/aws.controller.js +++ b/server/controllers/aws.controller.js @@ -181,6 +181,26 @@ export async function moveObjectToUserInS3(url, userId) { return `${s3Bucket}${userId}/${newFilename}`; } +export async function deleteAllObjectsForUser(userId) { + try { + const params = { + Bucket: process.env.S3_BUCKET, + Prefix: `${userId}/` + }; + const data = await s3Client.send(new ListObjectsCommand(params)); + const keys = (data.Contents || []).map((object) => object.Key); + if (keys.length > 0) { + await deleteObjectsFromS3(keys); + } + } catch (error) { + if (error instanceof TypeError) { + return; + } + console.error('Error deleting all S3 objects for user: ', error); + throw error; + } +} + export async function listObjectsInS3ForUser(userId) { try { let assets = []; diff --git a/server/controllers/user.controller/authManagement.ts b/server/controllers/user.controller/authManagement.ts index 19f3cf870c..6dc8098737 100644 --- a/server/controllers/user.controller/authManagement.ts +++ b/server/controllers/user.controller/authManagement.ts @@ -1,7 +1,10 @@ import { RequestHandler } from 'express'; +import Project from '../../models/project'; +import Collection from '../../models/collection'; import { User } from '../../models/user'; import { saveUser, generateToken, userResponse } from './helpers'; import { + DeleteAccountRequestBody, GenericResponseBody, PublicUserOrErrorOrGeneric, UnlinkThirdPartyResponseBody, @@ -13,6 +16,7 @@ import { } from '../../types'; import { mailerService } from '../../utils/mail'; import { renderResetPassword, renderEmailConfirmation } from '../../views/mail'; +import { deleteAllObjectsForUser } from '../aws.controller'; /** * - Method: `POST` @@ -258,3 +262,70 @@ export const unlinkGoogle: RequestHandler< message: 'You must be logged in to complete this action.' }); }; + +/** + * - Method: `DELETE` + * - Endpoint: `/account` + * - Authenticated: `true` + * - Id: `UserController.deleteAccount` + * + * Description: + * - Permanently delete the authenticated user's account, all their projects + * (including S3 assets) and all their collections. + * - Users with a password must supply it in the request body for confirmation. + */ +export const deleteAccount: RequestHandler< + {}, + GenericResponseBody, + DeleteAccountRequestBody +> = async (req, res) => { + try { + const user = await User.findById(req.user!.id); + if (!user) { + res.status(404).json({ success: false, message: 'User not found.' }); + return; + } + + if (user.password) { + if (!req.body.password) { + res + .status(401) + .json({ success: false, message: 'Password is required.' }); + return; + } + const isMatch = await user.comparePassword(req.body.password); + if (!isMatch) { + res.status(401).json({ success: false, message: 'Invalid password.' }); + return; + } + } + + user.github = undefined; + user.google = undefined; + user.tokens = user.tokens.filter( + (token) => token.kind !== 'github' && token.kind !== 'google' + ); + + try { + await deleteAllObjectsForUser(user._id.toString()); + } catch (err) { + console.error('Failed to delete S3 assets during account deletion', err); + } + + await Project.deleteMany({ user: user._id }).exec(); + await Collection.deleteMany({ owner: user._id }).exec(); + + req.logout((logoutErr) => { + if (logoutErr) { + console.error('Error during logout on account deletion', logoutErr); + } + (req as any).session?.destroy(() => {}); + }); + + await user.deleteOne(); + + res.json({ success: true, message: 'Account successfully deleted.' }); + } catch (err) { + res.status(500).json({ success: false, message: 'Internal server error.' }); + } +}; diff --git a/server/routes/user.routes.ts b/server/routes/user.routes.ts index a61a42e11b..f4adfce929 100644 --- a/server/routes/user.routes.ts +++ b/server/routes/user.routes.ts @@ -45,6 +45,8 @@ router.get('/reset-password/:token', UserController.validateResetPasswordToken); router.post('/reset-password/:token', UserController.updatePassword); // PUT /account (updating username, email or password while logged in) router.put('/account', isAuthenticated, UserController.updateSettings); +// DELETE /account (delete user account) +router.delete('/account', isAuthenticated, UserController.deleteAccount); // DELETE /auth/github router.delete('/auth/github', UserController.unlinkGithub); // DELETE /auth/google diff --git a/server/types/user.ts b/server/types/user.ts index 1f180fee37..d2a596683b 100644 --- a/server/types/user.ts +++ b/server/types/user.ts @@ -130,6 +130,10 @@ export interface CreateUserRequestBody { email: string; password: string; } +/** userController.deleteAccount - Request */ +export interface DeleteAccountRequestBody { + password?: string; +} /** userController.duplicateUserCheck - Query */ export interface DuplicateUserCheckQuery { // eslint-disable-next-line camelcase diff --git a/translations/locales/en-US/translations.json b/translations/locales/en-US/translations.json index 7529a1d9ff..f319d4e260 100644 --- a/translations/locales/en-US/translations.json +++ b/translations/locales/en-US/translations.json @@ -721,5 +721,16 @@ "Label": "Private" }, "Changed": "'{{projectName}}' is now {{newVisibility}}..." + }, + "DangerZone": { + "Title": "Danger Zone", + "DeleteAccount": "Delete Account", + "DeleteAccountDescription": "Permanently delete your account and all associated sketches, collections, and assets. This action cannot be undone.", + "PasswordLabel": "Confirm your password", + "PasswordARIA": "Password", + "Confirm": "Permanently Delete Account", + "Cancel": "Cancel", + "InvalidPassword": "Invalid password.", + "PasswordRequired": "Password is required." } }