mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-13 16:22:59 +10:00
[Feature] Implement Self-Serve Account Deletion
This commit is contained in:
@ -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(() => {
|
||||
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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}
|
||||
@ -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>
|
||||
|
||||
154
client/modals/auth/UserProfileModal.tsx
Normal file
154
client/modals/auth/UserProfileModal.tsx
Normal 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">'delete'</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;
|
||||
@ -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';
|
||||
@ -49,6 +50,7 @@ const ModalWrapper: React.FC = () => {
|
||||
<RegisterModal />
|
||||
<ForgotPasswordModal />
|
||||
<ResetPasswordModal />
|
||||
<UserProfileModal />
|
||||
|
||||
{/* Dashboard */}
|
||||
<CreateResumeModal />
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# *
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /*/*
|
||||
|
||||
# Host
|
||||
Host: https://rxresu.me
|
||||
|
||||
@ -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';
|
||||
@ -37,6 +37,10 @@ export type ResetPasswordParams = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type UpdateProfileParams = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const login = async (loginParams: LoginParams) => {
|
||||
const {
|
||||
data: { user, accessToken },
|
||||
@ -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.');
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -5,6 +5,7 @@ export type ModalName =
|
||||
| 'auth.register'
|
||||
| 'auth.forgot'
|
||||
| 'auth.reset'
|
||||
| 'auth.profile'
|
||||
| 'dashboard.create-resume'
|
||||
| 'dashboard.import-external'
|
||||
| 'dashboard.rename-resume'
|
||||
@ -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 },
|
||||
|
||||
@ -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';
|
||||
@ -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';
|
||||
|
||||
@ -57,6 +58,14 @@ export class AuthController {
|
||||
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)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete()
|
||||
|
||||
@ -6,12 +6,14 @@ import { compareSync, hashSync } from 'bcryptjs';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
|
||||
import { PostgresErrorCode } from '@/database/errorCodes.enum';
|
||||
import { ResumeService } from '@/resume/resume.service';
|
||||
import { CreateGoogleUserDto } from '@/users/dto/create-google-user.dto';
|
||||
import { User } from '@/users/entities/user.entity';
|
||||
import { UsersService } from '@/users/users.service';
|
||||
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
|
||||
@Injectable()
|
||||
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) {
|
||||
return this.usersService.generateResetToken(email);
|
||||
}
|
||||
|
||||
7
server/src/auth/dto/update-profile.dto.ts
Normal file
7
server/src/auth/dto/update-profile.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
}
|
||||
@ -7,7 +7,7 @@ const sampleData: Partial<Resume> = {
|
||||
phone: '+1 800 1200 3820',
|
||||
birthdate: '1995-08-06',
|
||||
photo: {
|
||||
url: `https://i.imgur.com/40gTnCx.jpg`,
|
||||
url: `https://i.imgur.com/O7iT9ke.jpg`,
|
||||
filters: {
|
||||
size: 128,
|
||||
shape: 'rounded-square',
|
||||
|
||||
@ -71,6 +71,12 @@ export class ResumeController {
|
||||
return this.resumeService.update(+id, updateResumeDto, userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('/all')
|
||||
removeAllByUser(@User('id') userId: number) {
|
||||
return this.resumeService.removeAllByUser(userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string, @User('id') userId: number) {
|
||||
|
||||
@ -176,8 +176,12 @@ export class ResumeService {
|
||||
return this.resumeRepository.save<Resume>(updatedResume);
|
||||
}
|
||||
|
||||
async remove(id: number, userId: number) {
|
||||
await this.resumeRepository.delete({ id, user: { id: userId } });
|
||||
remove(id: number, userId: number) {
|
||||
return this.resumeRepository.delete({ id, user: { id: userId } });
|
||||
}
|
||||
|
||||
removeAllByUser(userId: number) {
|
||||
return this.resumeRepository.delete({ user: { id: userId } });
|
||||
}
|
||||
|
||||
async duplicate(id: number, userId: number) {
|
||||
|
||||
Reference in New Issue
Block a user