[Feature] Implement Self-Serve Account Deletion

This commit is contained in:
Amruth Pillai
2023-01-19 00:11:15 +01:00
parent 5024c19f87
commit ff101dbfac
14 changed files with 235 additions and 14 deletions

View File

@ -53,13 +53,12 @@ const Header = () => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null); 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 resume = useAppSelector((state) => state.resume.present);
const { left, right } = useAppSelector((state) => state.build.sidebar); 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]); const name = useMemo(() => get(resume, 'name'), [resume]);
useEffect(() => { useEffect(() => {

View File

@ -6,15 +6,17 @@ import { useState } from 'react';
import { logout } from '@/store/auth/authSlice'; import { logout } from '@/store/auth/authSlice';
import { useAppDispatch, useAppSelector } from '@/store/hooks'; import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { setModalState } from '@/store/modal/modalSlice';
import getGravatarUrl from '@/utils/getGravatarUrl'; import getGravatarUrl from '@/utils/getGravatarUrl';
import styles from './Avatar.module.scss'; import styles from './Avatar.module.scss';
type Props = { type Props = {
size?: number; size?: number;
interactive?: boolean;
}; };
const Avatar: React.FC<Props> = ({ size = 64 }) => { const Avatar: React.FC<Props> = ({ size = 64, interactive = true }) => {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
@ -34,6 +36,11 @@ const Avatar: React.FC<Props> = ({ size = 64 }) => {
setAnchorEl(null); setAnchorEl(null);
}; };
const handleOpenProfile = () => {
dispatch(setModalState({ modal: 'auth.profile', state: { open: true } }));
handleClose();
};
const handleLogout = () => { const handleLogout = () => {
dispatch(logout()); dispatch(logout());
handleClose(); handleClose();
@ -43,7 +50,7 @@ const Avatar: React.FC<Props> = ({ size = 64 }) => {
return ( return (
<> <>
<IconButton onClick={handleOpen}> <IconButton onClick={handleOpen} disabled={!interactive}>
<Image <Image
width={size} width={size}
height={size} height={size}
@ -54,9 +61,9 @@ const Avatar: React.FC<Props> = ({ size = 64 }) => {
</IconButton> </IconButton>
<Menu anchorEl={anchorEl} onClose={handleClose} open={Boolean(anchorEl)}> <Menu anchorEl={anchorEl} onClose={handleClose} open={Boolean(anchorEl)}>
<MenuItem> <MenuItem onClick={handleOpenProfile}>
<div> <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> <p>{user?.name}</p>
</div> </div>
</MenuItem> </MenuItem>

View File

@ -0,0 +1,154 @@
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 { 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 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="Name"
error={!!fieldState.error}
helperText={fieldState.error?.message}
{...field}
/>
)}
/>
<p className="pl-4 text-[10.5px] opacity-50">
You can update your profile picture on{' '}
<a href="https://gravatar.com/" target="_blank" rel="noreferrer">
Gravatar
</a>
</p>
</div>
</div>
<Controller
name="email"
control={control}
render={({ field, fieldState }) => (
<TextField
disabled
label="Email"
error={!!fieldState.error}
helperText="It is not possible to update your email address at the moment, please create a new account instead."
{...field}
/>
)}
/>
<div>
<Button onClick={handleUpdate}>Save Changes</Button>
</div>
</form>
<div className="my-2">
<Divider />
</div>
<div className="flex items-center gap-2">
<CrisisAlert />
<h5 className="font-medium">Danger Zone</h5>
</div>
<p className="text-xs opacity-75">
To delete your account, your data and all your resumes, type{' '}
<code className="font-bold">&apos;delete&apos;</code> into the text box below and click on the button. Please
note that this is an irreversible action and your data cannot be retrieved again.
</p>
<div className="flex max-w-xs flex-col gap-4">
<TextField size="small" value={deleteText} onChange={(e) => setDeleteText(e.target.value)} />
<div>
<Button variant="contained" color="error" disabled={!isDeleteTextValid} onClick={handleDelete}>
Delete Your Data
</Button>
</div>
</div>
</div>
</BaseModal>
);
};
export default UserProfileModal;

View File

@ -8,6 +8,7 @@ import ForgotPasswordModal from './auth/ForgotPasswordModal';
import LoginModal from './auth/LoginModal'; import LoginModal from './auth/LoginModal';
import RegisterModal from './auth/RegisterModal'; import RegisterModal from './auth/RegisterModal';
import ResetPasswordModal from './auth/ResetPasswordModal'; import ResetPasswordModal from './auth/ResetPasswordModal';
import UserProfileModal from './auth/UserProfileModal';
import AwardModal from './builder/sections/AwardModal'; import AwardModal from './builder/sections/AwardModal';
import CertificateModal from './builder/sections/CertificateModal'; import CertificateModal from './builder/sections/CertificateModal';
import CustomModal from './builder/sections/CustomModal'; import CustomModal from './builder/sections/CustomModal';
@ -49,6 +50,7 @@ const ModalWrapper: React.FC = () => {
<RegisterModal /> <RegisterModal />
<ForgotPasswordModal /> <ForgotPasswordModal />
<ResetPasswordModal /> <ResetPasswordModal />
<UserProfileModal />
{/* Dashboard */} {/* Dashboard */}
<CreateResumeModal /> <CreateResumeModal />

View File

@ -1,6 +1,7 @@
# * # *
User-agent: * User-agent: *
Allow: / Allow: /
Disallow: /*/*
# Host # Host
Host: https://rxresu.me Host: https://rxresu.me

View File

@ -2,7 +2,7 @@ import { User } from '@reactive-resume/schema';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import toast from 'react-hot-toast'; 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 store from '../store';
import axios from './axios'; import axios from './axios';
@ -37,6 +37,10 @@ export type ResetPasswordParams = {
password: string; password: string;
}; };
export type UpdateProfileParams = {
name: string;
};
export const login = async (loginParams: LoginParams) => { export const login = async (loginParams: LoginParams) => {
const { const {
data: { user, accessToken }, data: { user, accessToken },
@ -75,3 +79,23 @@ export const resetPassword = async (resetPasswordParams: ResetPasswordParams) =>
toast.success('Your password has been changed successfully, please login again.'); 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.');
};

View File

@ -39,7 +39,7 @@ export const buildSlice = createSlice({
name: 'build', name: 'build',
initialState, initialState,
reducers: { reducers: {
setTheme: (state, action: PayloadAction<SetThemePayload>) => { setTheme: (state: BuildState, action: PayloadAction<SetThemePayload>) => {
const { theme } = action.payload; const { theme } = action.payload;
state.theme = theme; state.theme = theme;

View File

@ -5,6 +5,7 @@ export type ModalName =
| 'auth.register' | 'auth.register'
| 'auth.forgot' | 'auth.forgot'
| 'auth.reset' | 'auth.reset'
| 'auth.profile'
| 'dashboard.create-resume' | 'dashboard.create-resume'
| 'dashboard.import-external' | 'dashboard.import-external'
| 'dashboard.rename-resume' | 'dashboard.rename-resume'
@ -24,6 +25,7 @@ const initialState: Record<ModalName, ModalState> = {
'auth.register': { open: false }, 'auth.register': { open: false },
'auth.forgot': { open: false }, 'auth.forgot': { open: false },
'auth.reset': { open: false }, 'auth.reset': { open: false },
'auth.profile': { open: false },
'dashboard.create-resume': { open: false }, 'dashboard.create-resume': { open: false },
'dashboard.import-external': { open: false }, 'dashboard.import-external': { open: false },
'dashboard.rename-resume': { open: false }, 'dashboard.rename-resume': { open: false },

View File

@ -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 } from '@/decorators/user.decorator';
import { User as UserEntity } from '@/users/entities/user.entity'; import { User as UserEntity } from '@/users/entities/user.entity';
@ -7,6 +7,7 @@ import { AuthService } from './auth.service';
import { ForgotPasswordDto } from './dto/forgot-password.dto'; import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto'; import { ResetPasswordDto } from './dto/reset-password.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { JwtAuthGuard } from './guards/jwt.guard'; import { JwtAuthGuard } from './guards/jwt.guard';
import { LocalAuthGuard } from './guards/local.guard'; import { LocalAuthGuard } from './guards/local.guard';
@ -57,6 +58,14 @@ export class AuthController {
return this.authService.resetPassword(resetPasswordDto); return this.authService.resetPassword(resetPasswordDto);
} }
@HttpCode(200)
@UseGuards(JwtAuthGuard)
@Patch('update-profile')
updateProfile(@User('id') userId: number, @Body() updateProfileDto: UpdateProfileDto) {
console.log({ userId, updateProfileDto });
return this.authService.updateProfile(userId, updateProfileDto);
}
@HttpCode(200) @HttpCode(200)
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Delete() @Delete()

View File

@ -6,12 +6,14 @@ import { compareSync, hashSync } from 'bcryptjs';
import { OAuth2Client } from 'google-auth-library'; import { OAuth2Client } from 'google-auth-library';
import { PostgresErrorCode } from '@/database/errorCodes.enum'; import { PostgresErrorCode } from '@/database/errorCodes.enum';
import { ResumeService } from '@/resume/resume.service';
import { CreateGoogleUserDto } from '@/users/dto/create-google-user.dto'; import { CreateGoogleUserDto } from '@/users/dto/create-google-user.dto';
import { User } from '@/users/entities/user.entity'; import { User } from '@/users/entities/user.entity';
import { UsersService } from '@/users/users.service'; import { UsersService } from '@/users/users.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto'; import { ResetPasswordDto } from './dto/reset-password.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -68,6 +70,10 @@ export class AuthService {
} }
} }
updateProfile(id: number, newData: UpdateProfileDto) {
return this.usersService.update(id, { name: newData.name });
}
forgotPassword(email: string) { forgotPassword(email: string) {
return this.usersService.generateResetToken(email); return this.usersService.generateResetToken(email);
} }

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class UpdateProfileDto {
@IsString()
@IsNotEmpty()
name: string;
}

View File

@ -7,7 +7,7 @@ const sampleData: Partial<Resume> = {
phone: '+1 800 1200 3820', phone: '+1 800 1200 3820',
birthdate: '1995-08-06', birthdate: '1995-08-06',
photo: { photo: {
url: `https://i.imgur.com/40gTnCx.jpg`, url: `https://i.imgur.com/O7iT9ke.jpg`,
filters: { filters: {
size: 128, size: 128,
shape: 'rounded-square', shape: 'rounded-square',

View File

@ -71,6 +71,12 @@ export class ResumeController {
return this.resumeService.update(+id, updateResumeDto, userId); return this.resumeService.update(+id, updateResumeDto, userId);
} }
@UseGuards(JwtAuthGuard)
@Delete('/all')
removeAllByUser(@User('id') userId: number) {
return this.resumeService.removeAllByUser(userId);
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Delete(':id') @Delete(':id')
remove(@Param('id') id: string, @User('id') userId: number) { remove(@Param('id') id: string, @User('id') userId: number) {

View File

@ -176,8 +176,12 @@ export class ResumeService {
return this.resumeRepository.save<Resume>(updatedResume); return this.resumeRepository.save<Resume>(updatedResume);
} }
async remove(id: number, userId: number) { remove(id: number, userId: number) {
await this.resumeRepository.delete({ id, user: { id: userId } }); return this.resumeRepository.delete({ id, user: { id: userId } });
}
removeAllByUser(userId: number) {
return this.resumeRepository.delete({ user: { id: userId } });
} }
async duplicate(id: number, userId: number) { async duplicate(id: number, userId: number) {