Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions client/modules/User/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { showToast, setToastText } from '../IDE/actions/toast';
import type {
CreateApiKeyRequestBody,
CreateUserRequestBody,
DeleteAccountRequestBody,
Error,
PublicUser,
PublicUserOrError,
Expand Down Expand Up @@ -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<void | string>((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);
});
});
}
113 changes: 113 additions & 0 deletions client/modules/User/components/DangerZone.tsx
Original file line number Diff line number Diff line change
@@ -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<ThunkDispatch<RootState, unknown, AnyAction>>();
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 = (
<React.Fragment>
<h2 className="form-container__divider">{t('DangerZone.Title')}</h2>
<p className="account__social-text">
{t('DangerZone.DeleteAccountDescription')}
</p>
</React.Fragment>
);

if (!isConfirming) {
return (
<div className="account__danger-zone">
{header}
<div className="account__social-stack">
<Button
kind={ButtonKinds.PRIMARY}
onClick={() => setIsConfirming(true)}
>
{t('DangerZone.DeleteAccount')}
</Button>
</div>
</div>
);
}

return (
<div className="account__danger-zone">
{header}
<form className="form" onSubmit={handleDelete}>
{hasPassword && (
<p className="form__field">
<label
htmlFor="danger-zone-password"
className="account__inline-label"
>
{t('DangerZone.PasswordLabel')}
</label>
<input
className="form__input"
aria-label={t('DangerZone.PasswordARIA')}
id="danger-zone-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</p>
)}
{error && (
<p className="form-error" aria-live="polite">
{error}
</p>
)}
<div className="account__action-stack">
<Button
kind={ButtonKinds.SECONDARY}
type={ButtonTypes.BUTTON}
onClick={() => {
setIsConfirming(false);
setPassword('');
setError('');
}}
disabled={isSubmitting}
>
{t('DangerZone.Cancel')}
</Button>
<Button
kind={ButtonKinds.PRIMARY}
type={ButtonTypes.SUBMIT}
disabled={isSubmitting || (hasPassword && password === '')}
>
{t('DangerZone.Confirm')}
</Button>
</div>
</form>
</div>
);
}
9 changes: 8 additions & 1 deletion client/modules/User/pages/AccountView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -106,13 +107,19 @@ export function AccountView() {
</TabList>
<TabPanel>
<SocialLoginPanel />
<DangerZone />
</TabPanel>
<TabPanel>
<APIKeyForm />
</TabPanel>
</Tabs>
)}
{!accessTokensUIEnabled && <SocialLoginPanel />}
{!accessTokensUIEnabled && (
<React.Fragment>
<SocialLoginPanel />
<DangerZone />
</React.Fragment>
)}
</main>
</div>
);
Expand Down
28 changes: 28 additions & 0 deletions client/styles/components/_account.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
1 change: 1 addition & 0 deletions common/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type {
ResetOrUpdatePasswordRequestParams,
UpdatePasswordRequestBody,
CreateUserRequestBody,
DeleteAccountRequestBody,
DuplicateUserCheckQuery,
VerifyEmailQuery
} from '../../server/types/user';
Expand Down
89 changes: 89 additions & 0 deletions server/controllers/user.controller/authManagement.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,6 +16,7 @@ import {
} from '../../types';
import { mailerService } from '../../utils/mail';
import { renderResetPassword, renderEmailConfirmation } from '../../views/mail';
import { deleteObjectsFromS3, getObjectKey } from '../aws.controller';

/**
* - Method: `POST`
Expand Down Expand Up @@ -258,3 +262,88 @@ 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'
);

const projects = await Project.find({ user: user._id }).exec();

const s3Keys = projects.flatMap((project: any) =>
(project.files as any[])
.filter(
(file: any) =>
file.url &&
(file.url.includes(process.env.S3_BUCKET_URL_BASE || '') ||
file.url.includes(process.env.S3_BUCKET || ''))
)
.map((file: any) => getObjectKey(file.url))
);

if (s3Keys.length > 0) {
try {
await deleteObjectsFromS3(s3Keys);
} 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.' });
}
};
2 changes: 2 additions & 0 deletions server/routes/user.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions server/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions translations/locales/en-US/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}