Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Implement Self-Serve Account Deletion #1188

Merged
merged 4 commits into from
Jan 19, 2023
Merged
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
7 changes: 3 additions & 4 deletions client/components/build/Center/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,12 @@ const Header = () => {

const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);

const { mutateAsync: duplicateMutation } = useMutation<Resume, ServerError, DuplicateResumeParams>(duplicateResume);

const { mutateAsync: deleteMutation } = useMutation<void, ServerError, DeleteResumeParams>(deleteResume);

const resume = useAppSelector((state) => state.resume.present);
const { left, right } = useAppSelector((state) => state.build.sidebar);

const { mutateAsync: deleteMutation } = useMutation<void, ServerError, DeleteResumeParams>(deleteResume);
const { mutateAsync: duplicateMutation } = useMutation<Resume, ServerError, DuplicateResumeParams>(duplicateResume);

const name = useMemo(() => get(resume, 'name'), [resume]);

useEffect(() => {
Expand Down
15 changes: 11 additions & 4 deletions client/components/shared/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ import { useState } from 'react';

import { logout } from '@/store/auth/authSlice';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import getGravatarUrl from '@/utils/getGravatarUrl';

import styles from './Avatar.module.scss';

type Props = {
size?: number;
interactive?: boolean;
};

const Avatar: React.FC<Props> = ({ size = 64 }) => {
const Avatar: React.FC<Props> = ({ size = 64, interactive = true }) => {
const router = useRouter();

const { t } = useTranslation();
Expand All @@ -34,6 +36,11 @@ const Avatar: React.FC<Props> = ({ size = 64 }) => {
setAnchorEl(null);
};

const handleOpenProfile = () => {
dispatch(setModalState({ modal: 'auth.profile', state: { open: true } }));
handleClose();
};

const handleLogout = () => {
dispatch(logout());
handleClose();
Expand All @@ -43,7 +50,7 @@ const Avatar: React.FC<Props> = ({ size = 64 }) => {

return (
<>
<IconButton onClick={handleOpen}>
<IconButton onClick={handleOpen} disabled={!interactive}>
<Image
width={size}
height={size}
Expand All @@ -54,9 +61,9 @@ const Avatar: React.FC<Props> = ({ size = 64 }) => {
</IconButton>

<Menu anchorEl={anchorEl} onClose={handleClose} open={Boolean(anchorEl)}>
<MenuItem>
<MenuItem onClick={handleOpenProfile}>
<div>
<span className="text-xs opacity-50">{t<string>('common.avatar.menu.greeting')}</span>
<span className="text-xs opacity-50">{t<string>('common.avatar.menu.greeting')},</span>
<p>{user?.name}</p>
</div>
</MenuItem>
Expand Down
159 changes: 159 additions & 0 deletions client/modals/auth/UserProfileModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { joiResolver } from '@hookform/resolvers/joi';
import { CrisisAlert, ManageAccounts } from '@mui/icons-material';
import { Button, Divider, TextField } from '@mui/material';
import Joi from 'joi';
import { useRouter } from 'next/router';
import { Trans, useTranslation } from 'next-i18next';
import { useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useMutation } from 'react-query';

import Avatar from '@/components/shared/Avatar';
import BaseModal from '@/components/shared/BaseModal';
import { deleteAccount, updateProfile, UpdateProfileParams } from '@/services/auth';
import { ServerError } from '@/services/axios';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';

type FormData = {
name: string;
email: string;
};

const defaultState: FormData = {
name: '',
email: '',
};

const schema = Joi.object({
name: Joi.string().required(),
email: Joi.string()
.email({ tlds: { allow: false } })
.required(),
});

const UserProfileModal = () => {
const router = useRouter();

const { t } = useTranslation();

const dispatch = useAppDispatch();

const [deleteText, setDeleteText] = useState<string>('');
const isDeleteTextValid = useMemo(() => deleteText.toLowerCase() === 'delete', [deleteText]);

const user = useAppSelector((state) => state.auth.user);
const { open: isOpen } = useAppSelector((state) => state.modal['auth.profile']);

const { mutateAsync: deleteAccountMutation } = useMutation<void, ServerError>(deleteAccount);
const { mutateAsync: updateProfileMutation } = useMutation<void, ServerError, UpdateProfileParams>(updateProfile);

const { reset, getFieldState, control, handleSubmit } = useForm<FormData>({
defaultValues: defaultState,
resolver: joiResolver(schema),
});

useEffect(() => {
if (user && !getFieldState('name').isTouched && !getFieldState('email').isTouched) {
reset({ name: user.name, email: user.email });
}
}, [user]);

const handleClose = () => {
dispatch(setModalState({ modal: 'auth.profile', state: { open: false } }));
};

const handleUpdate = handleSubmit(async (data) => {
handleClose();
await updateProfileMutation({ name: data.name });
});

const handleDelete = async () => {
await deleteAccountMutation();
handleClose();

router.push('/');
};

return (
<BaseModal isOpen={isOpen} handleClose={handleClose} heading="Your Account" icon={<ManageAccounts />}>
<div className="grid gap-4">
<form className="grid gap-4 xl:w-2/3">
<div className="flex items-center gap-4">
<Avatar interactive={false} />

<div className="grid flex-1 gap-1.5">
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<TextField
autoFocus
label={t('modals.auth.profile.form.name.label')}
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>

<p className="pl-4 text-[10.25px] opacity-50">
<Trans t={t} i18nKey="modals.auth.profile.form.avatar.help-text">
You can update your profile picture on{' '}
<a href="https://gravatar.com/" target="_blank" rel="noreferrer">
Gravatar
</a>
</Trans>
</p>
</div>
</div>

<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
disabled
label={t('modals.auth.profile.form.email.label')}
error={!!fieldState.error}
helperText={t('modals.auth.profile.form.email.help-text')}
{...field}
/>
)}
/>

<div>
<Button onClick={handleUpdate}>{t('modals.auth.profile.actions.save')}</Button>
</div>
</form>

<div className="my-2">
<Divider />
</div>

<div className="flex items-center gap-2">
<CrisisAlert />
<h5 className="font-medium">{t('modals.auth.profile.delete-account.heading')}</h5>
</div>

<p className="text-xs opacity-75">{t('modals.auth.profile.delete-account.body', { keyword: 'delete' })}</p>

<div className="flex max-w-xs flex-col gap-4">
<TextField
value={deleteText}
placeholder="Type 'delete' to confirm"
onChange={(e) => setDeleteText(e.target.value)}
/>

<div>
<Button variant="contained" color="error" disabled={!isDeleteTextValid} onClick={handleDelete}>
{t('modals.auth.profile.delete-account.actions.delete')}
</Button>
</div>
</div>
</div>
</BaseModal>
);
};

export default UserProfileModal;
2 changes: 2 additions & 0 deletions client/modals/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ForgotPasswordModal from './auth/ForgotPasswordModal';
import LoginModal from './auth/LoginModal';
import RegisterModal from './auth/RegisterModal';
import ResetPasswordModal from './auth/ResetPasswordModal';
import UserProfileModal from './auth/UserProfileModal';
import AwardModal from './builder/sections/AwardModal';
import CertificateModal from './builder/sections/CertificateModal';
import CustomModal from './builder/sections/CustomModal';
Expand Down Expand Up @@ -49,6 +50,7 @@ const ModalWrapper: React.FC = () => {
<RegisterModal />
<ForgotPasswordModal />
<ResetPasswordModal />
<UserProfileModal />

{/* Dashboard */}
<CreateResumeModal />
Expand Down
25 changes: 25 additions & 0 deletions client/public/locales/en/modals.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@
}
},
"heading": "Reset your password"
},
"profile": {
"heading": "Your Account",
"form": {
"avatar": {
"help-text": "You can update your profile picture on <1>Gravatar</1>"
},
"name": {
"label": "Full Name"
},
"email": {
"label": "Email Address",
"help-text": "It is not possible to update your email address at the moment, please create a new account instead."
}
},
"delete-account": {
"heading": "Delete Account and Data",
"body": "To delete your account, your data and all your resumes, type \"{{keyword}}\" into the textbox and click on the button. Please note that this is an irreversible action and your data cannot be retrieved again.",
"actions": {
"delete": "Delete Account"
}
},
"actions": {
"save": "Save Changes"
}
}
},
"dashboard": {
Expand Down
1 change: 1 addition & 0 deletions client/public/robots.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# *
User-agent: *
Allow: /
Disallow: /*/*

# Host
Host: https://rxresu.me
Expand Down
26 changes: 25 additions & 1 deletion client/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { User } from '@reactive-resume/schema';
import { AxiosResponse } from 'axios';
import toast from 'react-hot-toast';

import { setAccessToken, setUser } from '@/store/auth/authSlice';
import { logout, setAccessToken, setUser } from '@/store/auth/authSlice';

import store from '../store';
import axios from './axios';
Expand Down Expand Up @@ -37,6 +37,10 @@ export type ResetPasswordParams = {
password: string;
};

export type UpdateProfileParams = {
name: string;
};

export const login = async (loginParams: LoginParams) => {
const {
data: { user, accessToken },
Expand Down Expand Up @@ -75,3 +79,23 @@ export const resetPassword = async (resetPasswordParams: ResetPasswordParams) =>

toast.success('Your password has been changed successfully, please login again.');
};

export const updateProfile = async (updateProfileParams: UpdateProfileParams) => {
const { data: user } = await axios.patch<User, AxiosResponse<User>, UpdateProfileParams>(
'/auth/update-profile',
updateProfileParams
);

store.dispatch(setUser(user));

toast.success('Your profile has been successfully updated.');
};

export const deleteAccount = async () => {
await axios.delete('/resume/all');
await axios.delete('/auth');

store.dispatch(logout());

toast.success('Your account has been deleted, hope to see you again soon.');
};
2 changes: 1 addition & 1 deletion client/store/build/buildSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const buildSlice = createSlice({
name: 'build',
initialState,
reducers: {
setTheme: (state, action: PayloadAction<SetThemePayload>) => {
setTheme: (state: BuildState, action: PayloadAction<SetThemePayload>) => {
const { theme } = action.payload;

state.theme = theme;
Expand Down
2 changes: 2 additions & 0 deletions client/store/modal/modalSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type ModalName =
| 'auth.register'
| 'auth.forgot'
| 'auth.reset'
| 'auth.profile'
| 'dashboard.create-resume'
| 'dashboard.import-external'
| 'dashboard.rename-resume'
Expand All @@ -24,6 +25,7 @@ const initialState: Record<ModalName, ModalState> = {
'auth.register': { open: false },
'auth.forgot': { open: false },
'auth.reset': { open: false },
'auth.profile': { open: false },
'dashboard.create-resume': { open: false },
'dashboard.import-external': { open: false },
'dashboard.rename-resume': { open: false },
Expand Down
10 changes: 9 additions & 1 deletion server/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, HttpCode, Post, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Patch, Post, UseGuards } from '@nestjs/common';

import { User } from '@/decorators/user.decorator';
import { User as UserEntity } from '@/users/entities/user.entity';
Expand All @@ -7,6 +7,7 @@ import { AuthService } from './auth.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { JwtAuthGuard } from './guards/jwt.guard';
import { LocalAuthGuard } from './guards/local.guard';

Expand Down Expand Up @@ -57,6 +58,13 @@ export class AuthController {
return this.authService.resetPassword(resetPasswordDto);
}

@HttpCode(200)
@UseGuards(JwtAuthGuard)
@Patch('update-profile')
updateProfile(@User('id') userId: number, @Body() updateProfileDto: UpdateProfileDto) {
return this.authService.updateProfile(userId, updateProfileDto);
}

@HttpCode(200)
@UseGuards(JwtAuthGuard)
@Delete()
Expand Down
Loading