diff --git a/client/components/build/Center/Header.tsx b/client/components/build/Center/Header.tsx index 6d1b3a5e..20724944 100644 --- a/client/components/build/Center/Header.tsx +++ b/client/components/build/Center/Header.tsx @@ -53,13 +53,12 @@ const Header = () => { const [anchorEl, setAnchorEl] = useState(null); - const { mutateAsync: duplicateMutation } = useMutation(duplicateResume); - - const { mutateAsync: deleteMutation } = useMutation(deleteResume); - const resume = useAppSelector((state) => state.resume.present); const { left, right } = useAppSelector((state) => state.build.sidebar); + const { mutateAsync: deleteMutation } = useMutation(deleteResume); + const { mutateAsync: duplicateMutation } = useMutation(duplicateResume); + const name = useMemo(() => get(resume, 'name'), [resume]); useEffect(() => { diff --git a/client/components/shared/Avatar.tsx b/client/components/shared/Avatar.tsx index af276869..73fda68e 100644 --- a/client/components/shared/Avatar.tsx +++ b/client/components/shared/Avatar.tsx @@ -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 = ({ size = 64 }) => { +const Avatar: React.FC = ({ size = 64, interactive = true }) => { const router = useRouter(); const { t } = useTranslation(); @@ -34,6 +36,11 @@ const Avatar: React.FC = ({ 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 = ({ size = 64 }) => { return ( <> - + = ({ size = 64 }) => { - +
- {t('common.avatar.menu.greeting')} + {t('common.avatar.menu.greeting')},

{user?.name}

diff --git a/client/modals/auth/UserProfileModal.tsx b/client/modals/auth/UserProfileModal.tsx new file mode 100644 index 00000000..f188e1e8 --- /dev/null +++ b/client/modals/auth/UserProfileModal.tsx @@ -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(''); + 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(deleteAccount); + const { mutateAsync: updateProfileMutation } = useMutation(updateProfile); + + const { reset, getFieldState, control, handleSubmit } = useForm({ + 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 ( + }> +
+
+
+ + +
+ ( + + )} + /> + +

+ You can update your profile picture on{' '} + + Gravatar + +

+
+
+ + ( + + )} + /> + +
+ +
+ + +
+ +
+ +
+ +
Danger Zone
+
+ +

+ To delete your account, your data and all your resumes, type{' '} + 'delete' 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. +

+ +
+ setDeleteText(e.target.value)} /> + +
+ +
+
+
+
+ ); +}; + +export default UserProfileModal; diff --git a/client/modals/index.tsx b/client/modals/index.tsx index 8380923d..6a03e0ce 100644 --- a/client/modals/index.tsx +++ b/client/modals/index.tsx @@ -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 = () => { + {/* Dashboard */} diff --git a/client/public/robots.txt b/client/public/robots.txt index 3a7e2829..92e6bb35 100644 --- a/client/public/robots.txt +++ b/client/public/robots.txt @@ -1,6 +1,7 @@ # * User-agent: * Allow: / +Disallow: /*/* # Host Host: https://rxresu.me diff --git a/client/services/auth.ts b/client/services/auth.ts index 20a97384..2efafe53 100644 --- a/client/services/auth.ts +++ b/client/services/auth.ts @@ -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, 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.'); +}; diff --git a/client/store/build/buildSlice.ts b/client/store/build/buildSlice.ts index f3581ddc..3807ee61 100644 --- a/client/store/build/buildSlice.ts +++ b/client/store/build/buildSlice.ts @@ -39,7 +39,7 @@ export const buildSlice = createSlice({ name: 'build', initialState, reducers: { - setTheme: (state, action: PayloadAction) => { + setTheme: (state: BuildState, action: PayloadAction) => { const { theme } = action.payload; state.theme = theme; diff --git a/client/store/modal/modalSlice.ts b/client/store/modal/modalSlice.ts index f8f0e544..d16c466b 100644 --- a/client/store/modal/modalSlice.ts +++ b/client/store/modal/modalSlice.ts @@ -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 = { '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 }, diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index 8111aa67..87432675 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -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() diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 98750a33..2757dc9a 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -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); } diff --git a/server/src/auth/dto/update-profile.dto.ts b/server/src/auth/dto/update-profile.dto.ts new file mode 100644 index 00000000..1c9fbcca --- /dev/null +++ b/server/src/auth/dto/update-profile.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class UpdateProfileDto { + @IsString() + @IsNotEmpty() + name: string; +} diff --git a/server/src/resume/data/sampleData.ts b/server/src/resume/data/sampleData.ts index 664efc8a..bba6ee66 100644 --- a/server/src/resume/data/sampleData.ts +++ b/server/src/resume/data/sampleData.ts @@ -7,7 +7,7 @@ const sampleData: Partial = { 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', diff --git a/server/src/resume/resume.controller.ts b/server/src/resume/resume.controller.ts index 7f44762a..41696f26 100644 --- a/server/src/resume/resume.controller.ts +++ b/server/src/resume/resume.controller.ts @@ -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) { diff --git a/server/src/resume/resume.service.ts b/server/src/resume/resume.service.ts index 0393cb1e..9f04c9aa 100644 --- a/server/src/resume/resume.service.ts +++ b/server/src/resume/resume.service.ts @@ -176,8 +176,12 @@ export class ResumeService { return this.resumeRepository.save(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) {