From 381a25d92f8c074f34ee4197401cbac3cd97cf20 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Wed, 5 Mar 2025 07:05:14 -0800 Subject: [PATCH] feat: enable user to update his name and data (#332) * feat: enable user to update his name and data issue #307 * docs: add JSDocs * test: fix sidebar tests * test: fix test cases * test: add account test cases * test: add user controller and auth service tests --- client/package-lock.json | 31 ++ client/package.json | 1 + .../src/__test__/components/Sidebar.test.tsx | 13 +- client/src/__test__/views/Account.test.tsx | 56 +++- client/src/__test__/views/TaskAdd.test.tsx | 11 +- client/src/api-service/apiConfig.ts | 3 +- .../src/components/CompletedTasks/index.tsx | 2 +- client/src/components/FormInput/index.tsx | 88 ++++++ client/src/components/Sidebar/index.tsx | 29 +- client/src/context/AuthContext.ts | 5 +- client/src/context/AuthProvider.tsx | 42 ++- client/src/styles/custom.scss | 17 +- client/src/types/SigninResponse.ts | 5 + client/src/types/User.ts | 3 - client/src/types/UserPatchRequest.ts | 6 + client/src/types/UserResponse.ts | 7 + client/src/views/Account/index.tsx | 265 +++++++++++++++--- client/src/views/Account/styles.css | 14 + client/src/views/Home/index.tsx | 28 +- client/src/views/Home/style.css | 20 -- client/src/views/TaskAdd/index.tsx | 142 ++++------ .../controller/AuthenticationController.java | 16 +- .../server/controller/UserController.java | 37 +++ .../tasknoteapp/server/entity/UserEntity.java | 3 + .../server/request/UserPatchRequest.java | 11 + .../server/response/UserResponse.java | 6 +- .../response/UserResponseWithToken.java | 39 +++ .../server/service/AuthService.java | 57 +++- .../server/service/TaskService.java | 2 +- .../V202503042111__add_user_info.sql | 2 + .../AuthenticationControllerTest.java | 23 +- .../server/controller/UserControllerTest.java | 34 ++- .../controller/UserSessionControllerTest.java | 2 +- .../server/service/AuthServiceTest.java | 93 ++++-- 34 files changed, 875 insertions(+), 238 deletions(-) create mode 100644 client/src/components/FormInput/index.tsx delete mode 100644 client/src/types/User.ts create mode 100644 client/src/types/UserPatchRequest.ts create mode 100644 client/src/types/UserResponse.ts create mode 100644 client/src/views/Account/styles.css create mode 100644 server/src/main/java/br/com/tasknoteapp/server/request/UserPatchRequest.java create mode 100644 server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java create mode 100644 server/src/main/resources/db/migration/V202503042111__add_user_info.sql diff --git a/client/package-lock.json b/client/package-lock.json index 7dd8f5a..d1c338e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -14,6 +14,7 @@ "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "bootstrap": "^5.3.3", + "dompurify": "^3.2.4", "i18next": "^24.2.2", "react": "^19.0.0", "react-bootstrap": "^2.10.9", @@ -2315,6 +2316,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/warning": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", @@ -3648,6 +3656,15 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -9923,6 +9940,12 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "@types/warning": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", @@ -10801,6 +10824,14 @@ "csstype": "^3.0.2" } }, + "dompurify": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", + "requires": { + "@types/trusted-types": "^2.0.7" + } + }, "dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/client/package.json b/client/package.json index 84a7c69..8e5a609 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "bootstrap": "^5.3.3", + "dompurify": "^3.2.4", "i18next": "^24.2.2", "react": "^19.0.0", "react-bootstrap": "^2.10.9", diff --git a/client/src/__test__/components/Sidebar.test.tsx b/client/src/__test__/components/Sidebar.test.tsx index 1b8eaf2..f2112b8 100644 --- a/client/src/__test__/components/Sidebar.test.tsx +++ b/client/src/__test__/components/Sidebar.test.tsx @@ -9,12 +9,19 @@ import i18n from '../../i18n'; const authContextMock = { signed: true, - user: undefined, + user: { + userId: 1, + name: 'Ricardo', + email: 'ricardo@campos.com', + admin: false, + createdAt: new Date() + }, checkCurrentAuthUser: vi.fn(), signIn: vi.fn(), signOut: vi.fn(), register: vi.fn(), - isAdmin: false + isAdmin: false, + updateUser: vi.fn(), }; describe('Sidebar Component', () => { @@ -32,7 +39,7 @@ describe('Sidebar Component', () => { it('should render the Sidebar component', () => { const { getByText } = renderSidebar(); - expect(getByText('Ricardo Campos')).toBeDefined(); + expect(getByText('Ricardo')).toBeDefined(); expect(getByText('Main Menu')).toBeDefined(); expect(getByText('Preferences')).toBeDefined(); }); diff --git a/client/src/__test__/views/Account.test.tsx b/client/src/__test__/views/Account.test.tsx index e1b3c81..61d6005 100644 --- a/client/src/__test__/views/Account.test.tsx +++ b/client/src/__test__/views/Account.test.tsx @@ -11,10 +11,12 @@ import ApiConfig from '../../api-service/apiConfig'; vi.mock('../../api-service/api'); +const changeLanguageMock = vi.fn(); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ i18n: { - changeLanguage: vi.fn(), + changeLanguage: changeLanguageMock, }, t: (key: string) => key, }), @@ -27,12 +29,19 @@ vi.mock('react-i18next', () => ({ const authContextMock = { signed: true, - user: { email: 'test@example.com' }, + user: { + userId: 1, + name: 'Ricardo', + email: 'test@example.com', + admin: false, + createdAt: new Date() + }, checkCurrentAuthUser: vi.fn(), signIn: vi.fn(), signOut: vi.fn(), register: vi.fn(), - isAdmin: false + isAdmin: false, + updateUser: vi.fn() }; describe('Account Component', () => { @@ -50,18 +59,15 @@ describe('Account Component', () => { it('should render the Account component', () => { const { getByText } = renderAccount(); - expect(getByText('account_my_account_tittle')).toBeDefined(); - expect(getByText('account_my_account_hello')).toBeDefined(); - expect(getByText('account_my_account_logged')).toBeDefined(); - expect(getByText('test@example.com')).toBeDefined(); + expect(getByText('Change your info')).toBeDefined(); }); - // it('should change language when a language button is clicked', () => { - // const { getByText } = renderAccount(); - // const languageButton = getByText('account_app_lang_tittle'); - // fireEvent.click(languageButton); - // expect(changeLanguageMock).toHaveBeenCalled(); - // }); + it('should change language when a language button is clicked', () => { + const { getByTestId } = renderAccount(); + const languageButton = getByTestId('language-button-pt_br'); + fireEvent.click(languageButton); + expect(changeLanguageMock).toHaveBeenCalled(); + }); it('should show alert when delete button is clicked', () => { const { getByText } = renderAccount(); @@ -82,4 +88,28 @@ describe('Account Component', () => { expect(authContextMock.signOut).toHaveBeenCalled(); }); }); + + it('should submit the form with correct patchPayload', async () => { + const mockPatchJSON = vi.spyOn(api, 'patchJSON').mockResolvedValue(authContextMock.user); + + const { getByLabelText, getByText, getByTestId } = renderAccount(); + + fireEvent.change(getByLabelText(/First name/i), { target: { value: 'Jane' } }); + fireEvent.change(getByLabelText(/Email/i), { target: { value: 'jane.doe@example.com' } }); + fireEvent.change(getByTestId('account-password-one'), { target: { value: 'password123' } }); + fireEvent.change(getByLabelText(/Repeat password/i), { target: { value: 'password123' } }); + + fireEvent.click(getByText(/Save profile information/i)); + + await waitFor(() => { + expect(mockPatchJSON).toHaveBeenCalledWith(expect.any(String), { + name: 'Jane', + email: 'jane.doe@example.com', + password: 'password123', + passwordAgain: 'password123', + }); + }); + + mockPatchJSON.mockRestore(); + }); }); diff --git a/client/src/__test__/views/TaskAdd.test.tsx b/client/src/__test__/views/TaskAdd.test.tsx index d134873..a1c72b7 100644 --- a/client/src/__test__/views/TaskAdd.test.tsx +++ b/client/src/__test__/views/TaskAdd.test.tsx @@ -28,12 +28,19 @@ vi.mock('react-i18next', () => ({ const authContextMock = { signed: true, - user: { email: 'test@example.com' }, + user: { + userId: 1, + name: 'Ricardo', + email: 'test@example.com', + admin: false, + createdAt: new Date() + }, checkCurrentAuthUser: vi.fn(), signIn: vi.fn(), signOut: vi.fn(), register: vi.fn(), - isAdmin: false + isAdmin: false, + updateUser: vi.fn(), }; describe('TaskAdd Component', () => { diff --git a/client/src/api-service/apiConfig.ts b/client/src/api-service/apiConfig.ts index e61117e..603befb 100644 --- a/client/src/api-service/apiConfig.ts +++ b/client/src/api-service/apiConfig.ts @@ -16,8 +16,9 @@ const ApiConfig = { homeUrl: `${server}/rest/home`, - notesUrl: `${server}/rest/notes` + notesUrl: `${server}/rest/notes`, + userUrl: `${server}/rest/users` }; export default ApiConfig; diff --git a/client/src/components/CompletedTasks/index.tsx b/client/src/components/CompletedTasks/index.tsx index cd333ce..4fb9968 100644 --- a/client/src/components/CompletedTasks/index.tsx +++ b/client/src/components/CompletedTasks/index.tsx @@ -13,7 +13,7 @@ type Series = { }; const data: Series[] = [ { - label: 'Commpleted tasks', + label: 'Completed tasks', data: [ { date: 'S', // Sunday diff --git a/client/src/components/FormInput/index.tsx b/client/src/components/FormInput/index.tsx new file mode 100644 index 0000000..315ba5a --- /dev/null +++ b/client/src/components/FormInput/index.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from 'react'; +import { Col, Form, InputGroup, Row } from 'react-bootstrap'; +import * as Icons from 'react-bootstrap-icons'; + +type IconName = keyof typeof Icons; + +interface Props { + labelText: string; + iconName: IconName; + required: boolean; + type?: string; + name: string; + placeholder?: string; + value: string; + onChange: (e: React.ChangeEvent) => void; + data_testid?: string; +} + +/** + * FormInput component renders a form input with a label, icon, and optional password toggle. + * + * @param {Props} props - The properties for the FormInput component. + * @param {string} props.labelText - The text for the form label. + * @param {IconName} props.iconName - The name of the icon to display. + * @param {boolean} props.required - Whether the input is required. + * @param {string} [props.type] - The type of the input (e.g., 'text', 'password'). + * @param {string} props.name - The name of the input. + * @param {string} [props.placeholder] - The placeholder text for the input. + * @param {string} props.value - The value of the input. + * @param {(e: React.ChangeEvent) void} props.onChange - The change event handler for the input. + * @returns {React.ReactNode} The rendered FormInput component. + */ +function FormInput(props: React.PropsWithChildren): React.ReactNode { + const [showingPwd, setShowingPwd] = useState(false); + const [formType, setFormType] = useState(props.type ? props.type : 'text'); + const Icon = Icons[props.iconName]; + + /** + * Toggle the password visibility. + * + * @param {React.MouseEvent} e the click event. + */ + const toggleShowPassword = (e: React.MouseEvent): void => { + e.preventDefault(); + e.stopPropagation(); + + console.log('clicked to show password'); + setShowingPwd((prevValue: boolean) => !prevValue); + setFormType((previous: string) => previous === 'text' ? 'password' : 'text'); + }; + + useEffect(() => {}, [showingPwd, formType]); + + return ( + + + + + {props.labelText} + {props.type && props.type === 'password' && ( + + + {showingPwd ? ' (Hide)' : ' (Show)'} + + + )} + + + + + + + + + + + ); +} + +export default FormInput; diff --git a/client/src/components/Sidebar/index.tsx b/client/src/components/Sidebar/index.tsx index 5196582..135185d 100644 --- a/client/src/components/Sidebar/index.tsx +++ b/client/src/components/Sidebar/index.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { Nav } from 'react-bootstrap'; import { NavLink } from 'react-router'; import { useTranslation } from 'react-i18next'; @@ -8,18 +8,32 @@ import NavButton from '../NavButton'; import { env } from '../../env'; import './style.css'; +/** + * Sidebar component renders the sidebar navigation menu. + * + * @returns {React.ReactNode} The rendered Sidebar component. + */ function Sidebar(): React.ReactNode { - const { signOut } = useContext(AuthContext); + const { signOut, user } = useContext(AuthContext); const { t } = useTranslation(); const build = `Build: ${env.VITE_BUILD}`; const [current, setCurrent] = useState('/home'); // Note: when selected, change class to plus-jakarta-sans-thin and add background - const goOut = () => { + /** + * Handles the sign-out action. + */ + const goOut = (): void => { signOut(); }; + /** + * Gets the color for the selected navigation link. + * + * @param {string} path - The path of the navigation link. + * @returns {string} The color for the selected navigation link. + */ const getSelectedColor = (path: string): string => { if (path === current) { return '#4CD964'; @@ -27,15 +41,22 @@ function Sidebar(): React.ReactNode { return '#6A8996'; }; + /** + * Handles the navigation link click event. + * + * @param {string} menu - The menu path. + */ const navLinkClicked = (menu: string): void => { setCurrent(menu); }; + useEffect(() => {}, [user]); + return (
User icon - Ricardo Campos + {user?.name ? user?.name : 'User'}
diff --git a/client/src/context/AuthContext.ts b/client/src/context/AuthContext.ts index ad8f022..9308d4e 100644 --- a/client/src/context/AuthContext.ts +++ b/client/src/context/AuthContext.ts @@ -1,14 +1,15 @@ import { createContext } from 'react'; -import { User } from '../types/User'; +import { UserResponse } from '../types/UserResponse'; export interface AuthContextData { signed: boolean; - user: User | undefined; + user: UserResponse | undefined; checkCurrentAuthUser: (pathname: string) => Promise; signIn: (email: string, password: string) => Promise; signOut: () => void; register: (email: string, password: string) => Promise; isAdmin: boolean; + updateUser: (userUpdated: UserResponse) => void; } const AuthContext = createContext({} as AuthContextData); diff --git a/client/src/context/AuthProvider.tsx b/client/src/context/AuthProvider.tsx index 2e440fa..2868678 100644 --- a/client/src/context/AuthProvider.tsx +++ b/client/src/context/AuthProvider.tsx @@ -1,10 +1,10 @@ import React, { useMemo, useState } from 'react'; -import { User } from '../types/User'; import AuthContext, { AuthContextData } from './AuthContext'; import { API_TOKEN, REDIRECT_PATH, USER_DATA } from '../app-constants/app-constants'; import { SigninResponse } from '../types/SigninResponse'; import api from '../api-service/api'; import ApiConfig from '../api-service/apiConfig'; +import { UserResponse } from '../types/UserResponse'; interface Props { children: React.ReactNode; @@ -12,7 +12,7 @@ interface Props { const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Props) => { const [signed, setSigned] = useState(false); - const [user, setUser] = useState(); + const [user, setUser] = useState(); const [isAdmin, setIsAdmin] = useState(false); const [intervalInstance, setIntervalInstance] = useState(null); @@ -44,7 +44,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro return undefined; }; - const updateUserSession = (userPriv: User | null, bearerToken: string): User => { + const updateUserSession = (userPriv: UserResponse | null, bearerToken: string): UserResponse | null => { if (userPriv) { localStorage.setItem(USER_DATA, JSON.stringify(userPriv)); } @@ -59,14 +59,16 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro return JSON.parse(savedUser); } - return { email: 'undefined' }; + return null; }; const checkCurrentAuthUser = async (pathname: string): Promise => { const bearerToken: SigninResponse | undefined = await fetchCurrentSession(pathname); if (bearerToken && bearerToken.token) { const userLocal = updateUserSession(null, bearerToken.token); - setUser(userLocal); + if (userLocal) { + setUser(userLocal); + } } }; @@ -74,8 +76,12 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro try { const payload = { email, password }; const registerResponse: SigninResponse = await api.putJSON(ApiConfig.registerUrl, payload); - const currentUser: User = { - email + const currentUser: UserResponse = { + userId: registerResponse.userId, + name: registerResponse.name, + email: registerResponse.email, + admin: registerResponse.admin, + createdAt: new Date(registerResponse.createdAt) }; setSigned(true); @@ -95,8 +101,12 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro try { const payload = { email, password }; const registerResponse: SigninResponse = await api.postJSON(ApiConfig.signInUrl, payload); - const currentUser: User = { - email + const currentUser: UserResponse = { + userId: registerResponse.userId, + name: registerResponse.name, + email: registerResponse.email, + admin: registerResponse.admin, + createdAt: new Date(registerResponse.createdAt) }; setSigned(true); @@ -126,7 +136,9 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro const bearerToken: SigninResponse | undefined = await fetchCurrentSession('/'); if (bearerToken) { const userLocal = updateUserSession(null, bearerToken.token); - setUser(userLocal); + if (userLocal) { + setUser(userLocal); + } } return Promise.resolve(); }; @@ -148,6 +160,11 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro setIntervalInstance(instance); } + const updateUser = (userUpdated: UserResponse): void => { + setUser(userUpdated); + localStorage.setItem(USER_DATA, JSON.stringify(userUpdated)); + }; + const contextValue: AuthContextData = useMemo(() => ({ signed, user, @@ -155,8 +172,9 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro signIn, signOut, register, - isAdmin - }), [signed, user, checkCurrentAuthUser, signIn, signOut, register, isAdmin]); + isAdmin, + updateUser + }), [signed, user, checkCurrentAuthUser, signIn, signOut, register, isAdmin, updateUser]); return ( diff --git a/client/src/styles/custom.scss b/client/src/styles/custom.scss index 7720bdc..d488235 100644 --- a/client/src/styles/custom.scss +++ b/client/src/styles/custom.scss @@ -176,4 +176,19 @@ a:hover, .btn-link:hover { .about-title { font-size: 20px; -} \ No newline at end of file +} + +.home-hello { + font-size: 28px; + color: #233C46; +} + +.home-subtitle { + color: #A1A1A1; + font-size: 14px; + line-height: 21px; +} + +.home-productive { + color: #4CD964; +} diff --git a/client/src/types/SigninResponse.ts b/client/src/types/SigninResponse.ts index 2fad9e5..d1de349 100644 --- a/client/src/types/SigninResponse.ts +++ b/client/src/types/SigninResponse.ts @@ -1,3 +1,8 @@ export type SigninResponse = { + userId: number; + name: string; + email: string; + admin: boolean; + createdAt: Date; token: string; }; diff --git a/client/src/types/User.ts b/client/src/types/User.ts deleted file mode 100644 index 78db500..0000000 --- a/client/src/types/User.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type User = { - email: string; -}; diff --git a/client/src/types/UserPatchRequest.ts b/client/src/types/UserPatchRequest.ts new file mode 100644 index 0000000..e868f44 --- /dev/null +++ b/client/src/types/UserPatchRequest.ts @@ -0,0 +1,6 @@ +export type UserPatchRequest = { + name: string | null; + email: string | null; + password: string | null; + passwordAgain: string | null; +}; diff --git a/client/src/types/UserResponse.ts b/client/src/types/UserResponse.ts new file mode 100644 index 0000000..82d0fa6 --- /dev/null +++ b/client/src/types/UserResponse.ts @@ -0,0 +1,7 @@ +export type UserResponse = { + userId: number; + name: string | null; + email: string; + admin: boolean; + createdAt: Date; +}; diff --git a/client/src/views/Account/index.tsx b/client/src/views/Account/index.tsx index 23a201f..7ea02ca 100644 --- a/client/src/views/Account/index.tsx +++ b/client/src/views/Account/index.tsx @@ -1,12 +1,18 @@ -import React, { useContext, useState } from 'react'; -import { Alert, Button, Card, Col, Container, Row } from 'react-bootstrap'; +import React, { useContext, useEffect, useState } from 'react'; +import { Alert, Button, Col, Container, Form, Row } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { FileText, Person, ShieldCheck } from 'react-bootstrap-icons'; +import { FileText, Person } from 'react-bootstrap-icons'; +import DOMPurify from 'dompurify'; import { clearStorage, setDefaultLang } from '../../storage-service/storage'; import AuthContext from '../../context/AuthContext'; import { LangAvailable, languages } from '../../constants/languages_available'; import api from '../../api-service/api'; import ApiConfig from '../../api-service/apiConfig'; +import FormInput from '../../components/FormInput'; +import { translateServerResponse } from '../../utils/TranslatorUtils'; +import { UserPatchRequest } from '../../types/UserPatchRequest'; +import { UserResponse } from '../../types/UserResponse'; +import './styles.css'; /** * Account page component. @@ -16,15 +22,30 @@ import ApiConfig from '../../api-service/apiConfig'; * @returns {React.ReactNode} The Account page component. */ function Account(): React.ReactNode { - const { signOut, user } = useContext(AuthContext); + const { signOut, user, updateUser } = useContext(AuthContext); const { i18n, t } = useTranslation(); const [showAlert, setShowAlert] = useState(false); + const [validated, setValidated] = useState(false); + const [formInvalid, setFormInvalid] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [userName, setUserName] = useState(''); + const [userEmail, setUserEmail] = useState(''); + const [userPassword, setUserPassword] = useState(''); + const [userPasswordAgain, setUserPasswordAgain] = useState(''); - const handleLanguage = (lang: string) => { + /** + * Handle the language change. + * + * @param {string} lang the language to change to. + */ + const handleLanguage = (lang: string): void => { i18n.changeLanguage(lang); setDefaultLang(lang); }; + /** + * Deletes the user account + */ const deleteAccount = async (): Promise => { setShowAlert(false); await api.postJSON(ApiConfig.deleteAccountUrl, {}); @@ -32,38 +53,189 @@ function Account(): React.ReactNode { clearStorage(); }; + /** + * Handles errors by setting the error message and form invalid state. + * + * @param {unknown} e - The error to handle. + */ + const handleError = (e: unknown): void => { + if (typeof e === 'string') { + setErrorMessage(translateServerResponse(e, i18n.language)); + setFormInvalid(true); + } + else if (e instanceof Error) { + setErrorMessage(translateServerResponse(e.message, i18n.language)); + setFormInvalid(true); + } + }; + + /** + * Patches a user into. + * + * @param {UserPatchRequest} payload - The user data to patch. + * @returns {Promise} True if the task was added successfully, false otherwise. + */ + const patchUserInfo = async (payload: UserPatchRequest): Promise => { + try { + return await api.patchJSON(ApiConfig.userUrl, payload) as UserResponse; + } + catch (e) { + handleError(e); + } + }; + + /** + * Resets the input fields to their default values. + */ + const resetInputs = (userUpdated: UserResponse): void => { + setUserName(userUpdated.name ? userUpdated.name : ''); + setUserEmail(userUpdated.email); + updateUser(userUpdated); + + setValidated(false); + setFormInvalid(false); + }; + + const handleSubmit = async (event: React.FormEvent): Promise => { + event.preventDefault(); + event.stopPropagation(); + setValidated(true); + + const form = event.currentTarget; + + const patchPayload: UserPatchRequest = { + name: userName ? DOMPurify.sanitize(userName) : null, + email: userEmail, + password: userPassword, + passwordAgain: userPasswordAgain + }; + + const updated: UserResponse | undefined = await patchUserInfo(patchPayload); + if (updated) { + form.reset(); + resetInputs(updated); + } + }; + + useEffect(() => {}, [user]); + return ( - - + +

+ My + {' '} + Account +

+

+ {t('account_my_account_hello')} +

+ + - -

- - {' '} - {t('account_my_account_tittle')} -

-

{t('account_my_account_hello')}

-

- {t('account_my_account_logged')} - {' '} - - {user?.email} - -

-
+

Update and Manage, Your

+

Information

- - - -

+ + +
+
+ + {' '} + Change your info +
+ + Update only what you need. Blank fields will not be updated + + + {formInvalid + ? ( + + { errorMessage } + + ) + : null} + +
+ {/* User name */} + ) => { + setUserName(e.target.value); + }} + /> + + {/* User email */} + ) => { + setUserEmail(e.target.value); + }} + /> + + {/* User password */} + ) => { + setUserPassword(e.target.value); + }} + data_testid="account-password-one" + /> + + {/* User password again */} + ) => { + setUserPasswordAgain(e.target.value); + }} + /> + + + +
+

+ If you want to display your picture, we support Gravatar. + Please head to + {' '} + Gravatar + {' '} + to register or update your profile picture. +

+
+ + +
+
{' '} - {t('account_app_lang_tittle')} -

-

{t('account_app_lang_description')}

-
+ Change the app language +
+ {t('account_app_lang_description')} +
Available languages
+
{languages.map((lang: LangAvailable) => ( ))}
-
+
- + - -

- - {' '} - {t('account_privacy_little')} -

+

Your Privacy

+

Matters

+ +
+ + + +

{t('account_privacy_text')}

diff --git a/client/src/views/Account/styles.css b/client/src/views/Account/styles.css new file mode 100644 index 0000000..1eaa4d4 --- /dev/null +++ b/client/src/views/Account/styles.css @@ -0,0 +1,14 @@ +.user-info-card { + background-color: #fff; + padding: 2rem; + border-radius: 10px; + + .title { + font-size: 16px; + } + + .description { + font-size: 12px; + color: #6a8996; + } +} \ No newline at end of file diff --git a/client/src/views/Home/index.tsx b/client/src/views/Home/index.tsx index ea2fdce..db9e121 100644 --- a/client/src/views/Home/index.tsx +++ b/client/src/views/Home/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import { NavLink } from 'react-router'; import { useTranslation } from 'react-i18next'; import { @@ -22,6 +22,7 @@ import { handleDefaultLang } from '../../lang-service/LangHandler'; import { translateServerResponse } from '../../utils/TranslatorUtils'; import CompletedTasks from '../../components/CompletedTasks'; import TaskProgress from '../../components/TaskProgress'; +import AuthContext from '../../context/AuthContext'; import './style.css'; /** @@ -32,13 +33,19 @@ import './style.css'; * @returns {React.ReactNode} The Home page component. */ function Home(): React.ReactNode { + const { user } = useContext(AuthContext); + const { i18n, t } = useTranslation(); const [errorMessage, setErrorMessage] = useState(''); const [validated, setValidated] = useState(false); const [formInvalid, setFormInvalid] = useState(false); const [searchResults, setSearchResults] = useState(null); - const [name, setName] = useState('Ricardo'); - const { i18n, t } = useTranslation(); + const [name, setName] = useState(user?.name ? user?.name : 'User'); + /** + * Handles the error by setting the error message. + * + * @param {unknown} e - The error to handle. + */ const handleError = (e: unknown): void => { if (typeof e === 'string') { setErrorMessage(translateServerResponse(e, i18n.language)); @@ -48,6 +55,12 @@ function Home(): React.ReactNode { } }; + /** + * Searches for a term. + * + * @param {string} + * @returns {Promise} Whether the search was successful. + */ const searchTerm = async (term: string): Promise => { try { const response: HomeSearchResponse = await api.getJSON(`${ApiConfig.homeUrl}/search?term=${term}`); @@ -60,6 +73,11 @@ function Home(): React.ReactNode { return false; }; + /** + * Handles the search form submission. + * + * @param {React.FormEvent} event - The form submission event. + */ const handleSearch = async (event: React.FormEvent): Promise => { event.preventDefault(); event.stopPropagation(); @@ -81,8 +99,8 @@ function Home(): React.ReactNode { useEffect(() => { handleDefaultLang(); - setName('Ricardo'); - }, []); + setName(user?.name ? user?.name : 'User'); + }, [user]); return ( diff --git a/client/src/views/Home/style.css b/client/src/views/Home/style.css index 47438bd..f8e24e6 100644 --- a/client/src/views/Home/style.css +++ b/client/src/views/Home/style.css @@ -1,23 +1,3 @@ -.home-hello { - margin-top: 2rem; - font-size: 28px; - color: #233C46; -} - -.home-subtitle { - color: #A1A1A1; - font-size: 14px; - line-height: 21px; -} - -.home-productive { - color: #4CD964; -} - -.home-card { - padding-top: 100px; -} - .home-new-item { border: none; border-radius: 15px !important; diff --git a/client/src/views/TaskAdd/index.tsx b/client/src/views/TaskAdd/index.tsx index edc8872..01bc40a 100644 --- a/client/src/views/TaskAdd/index.tsx +++ b/client/src/views/TaskAdd/index.tsx @@ -6,10 +6,8 @@ import { Col, Container, Form, - InputGroup, Row } from 'react-bootstrap'; -import { CalendarCheck, Hash, PencilFill } from 'react-bootstrap-icons'; import { useNavigate, useParams } from 'react-router'; import TaskNoteRequest from '../../types/TaskNoteRequest'; import { TaskResponse } from '../../types/TaskResponse'; @@ -17,6 +15,7 @@ import { useTranslation } from 'react-i18next'; import api from '../../api-service/api'; import ApiConfig from '../../api-service/apiConfig'; import { translateServerResponse } from '../../utils/TranslatorUtils'; +import FormInput from '../../components/FormInput'; type TaskAction = 'add' | 'edit'; @@ -214,94 +213,61 @@ function TaskAdd(): React.ReactNode { : null}
- - - - {t('task_form_desc_label')} - - - - - ) => { - setTaskDescription(e.target.value); - }} - /> - - - - + {/* Description */} + ) => { + setTaskDescription(e.target.value); + }} + /> - - - - {t('task_form_url_label')} - - @ - ) => { - setTaskUrl(e.target.value); - }} - /> - - - - + {/* Task URL */} + ) => { + setTaskUrl(e.target.value); + }} + /> - - - - {t('task_form_duedate_label')} - - - - - ) => { - setDueDate(e.target.value); - }} - /> - - - - - - - - Tag - - - - - ) => { - setTag(e.target.value); - }} - /> - - - - + {/* Due date */} + ) => { + setDueDate(e.target.value); + }} + /> + + {/* Tag */} + ) => { + setTag(e.target.value); + }} + /> signUp( + public ResponseEntity signUp( @RequestBody @Valid LoginRequest loginRequest) { - String token = authService.signUpNewUser(loginRequest); - return ResponseEntity.status(HttpStatus.CREATED).body(new JwtAuthenticationResponse(token)); + UserResponseWithToken response = authService.signUpNewUser(loginRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } /** @@ -88,11 +88,11 @@ public class AuthenticationController { description = "User not found", content = @Content(schema = @Schema(implementation = Void.class))) }) - public JwtAuthenticationResponse signIn(@RequestBody @Valid LoginRequest loginRequest) { - String token = authService.signInUser(loginRequest); - if (Objects.isNull(token)) { + public ResponseEntity signIn(@RequestBody @Valid LoginRequest loginRequest) { + UserResponseWithToken response = authService.signInUser(loginRequest); + if (Objects.isNull(response)) { throw new InvalidCredentialsException(); } - return new JwtAuthenticationResponse(token); + return ResponseEntity.ok().body(response); } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/UserController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/UserController.java index fd92071..ea1b33d 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/UserController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/UserController.java @@ -1,5 +1,6 @@ package br.com.tasknoteapp.server.controller; +import br.com.tasknoteapp.server.request.UserPatchRequest; import br.com.tasknoteapp.server.response.UserResponse; import br.com.tasknoteapp.server.service.AuthService; import io.swagger.v3.oas.annotations.Operation; @@ -7,9 +8,13 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import java.util.List; import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -47,4 +52,36 @@ public class UserController { public List getAllUsers() { return authService.getAllUsers(); } + + @PatchMapping + @Operation( + summary = "Patch the user data", + description = "Patch all user information. Empty fields will not be updated", + responses = { + @ApiResponse( + responseCode = "200", + description = "Task successfully patched", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = UserResponse.class))), + @ApiResponse( + responseCode = "403", + description = "Forbidden. Access Denied", + content = @Content(schema = @Schema(implementation = Void.class))), + @ApiResponse( + responseCode = "404", + description = "Task not found", + content = @Content(schema = @Schema(implementation = Void.class))) + }) + public ResponseEntity patchUserInfo( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "User data to be patched.", + required = true) + @RequestBody + @Valid + UserPatchRequest taskRequest) { + UserResponse patched = authService.patchUserInfo(taskRequest); + return ResponseEntity.ok().body(patched); + } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/UserEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/UserEntity.java index ba1c3d4..da1668b 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/UserEntity.java +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/UserEntity.java @@ -39,6 +39,9 @@ public class UserEntity implements UserDetails { @Column(name = "inactivated_at", nullable = true) private LocalDateTime inactivatedAt; + @Column(name = "name", length = 20) + private String name; + @OneToMany(mappedBy = "user") private List tasks; diff --git a/server/src/main/java/br/com/tasknoteapp/server/request/UserPatchRequest.java b/server/src/main/java/br/com/tasknoteapp/server/request/UserPatchRequest.java new file mode 100644 index 0000000..b307047 --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/request/UserPatchRequest.java @@ -0,0 +1,11 @@ +package br.com.tasknoteapp.server.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** This record represents a user patch payload. */ +@Schema(description = "User patch payload.") +public record UserPatchRequest( + @Schema(description = "User first name. Optional.") String name, + @Schema(description = "User email. Optional.") String email, + @Schema(description = "User password. Optional.") String password, + @Schema(description = "User password again. Optional.") String passwordAgain) {} diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java index 48dd0d8..0a7aace 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java +++ b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponse.java @@ -1,14 +1,14 @@ package br.com.tasknoteapp.server.response; +import br.com.tasknoteapp.server.entity.UserEntity; import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; -import br.com.tasknoteapp.server.entity.UserEntity; - /** This record represents a User Response object. */ @Schema(description = "This record represents a User Response object.") public record UserResponse( @Schema(description = "The id of the user", example = "1") Long userId, + @Schema(description = "The name of the user", example = "John") String name, @Schema(description = "The email of the user", example = "user@domain.com") String email, @Schema(description = "The admin status of the user", example = "false") Boolean admin, @Schema(description = "The created date and time of the user", example = "2023-01-01T00:00:00") @@ -18,7 +18,6 @@ public record UserResponse( example = "2023-01-01T00:00:00") LocalDateTime inactivatedAt) { - /** * Create a {@link UserResponse} instance from a {@link UserEntity}. * @@ -28,6 +27,7 @@ public record UserResponse( public static UserResponse fromEntity(UserEntity user) { return new UserResponse( user.getId(), + user.getName(), user.getEmail(), user.getAdmin(), user.getCreatedAt(), diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java new file mode 100644 index 0000000..96459b3 --- /dev/null +++ b/server/src/main/java/br/com/tasknoteapp/server/response/UserResponseWithToken.java @@ -0,0 +1,39 @@ +package br.com.tasknoteapp.server.response; + +import br.com.tasknoteapp.server.entity.UserEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +/** This record represents a User Response object. */ +@Schema(description = "This record represents a User Response with token object.") +public record UserResponseWithToken( + @Schema(description = "The id of the user", example = "1") Long userId, + @Schema(description = "The name of the user", example = "John") String name, + @Schema(description = "The email of the user", example = "user@domain.com") String email, + @Schema(description = "The admin status of the user", example = "false") Boolean admin, + @Schema(description = "The created date and time of the user", example = "2023-01-01T00:00:00") + LocalDateTime createdAt, + @Schema( + description = "The inactivated date and time of the user", + example = "2023-01-01T00:00:00") + LocalDateTime inactivatedAt, + @Schema(description = "The token created upon login") String token) { + + /** + * Create a {@link UserResponseWithToken} instance from a {@link UserEntity}. + * + * @param user The user entity instance with user info to be used as source. + * @param token The token created upon registration or login. + * @return UserResponse instance. + */ + public static UserResponseWithToken fromEntity(UserEntity user, String token) { + return new UserResponseWithToken( + user.getId(), + user.getName(), + user.getEmail(), + user.getAdmin(), + user.getCreatedAt(), + user.getInactivatedAt(), + token); + } +} diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java index c4b104b..9fdddc7 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/AuthService.java @@ -11,13 +11,16 @@ import br.com.tasknoteapp.server.exception.UserNotFoundException; import br.com.tasknoteapp.server.repository.UserPwdLimitRepository; import br.com.tasknoteapp.server.repository.UserRepository; import br.com.tasknoteapp.server.request.LoginRequest; +import br.com.tasknoteapp.server.request.UserPatchRequest; import br.com.tasknoteapp.server.response.UserResponse; +import br.com.tasknoteapp.server.response.UserResponseWithToken; import br.com.tasknoteapp.server.util.AuthUtil; import jakarta.transaction.Transactional; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -54,7 +57,7 @@ public class AuthService { * @param login User details with email and password. * @return Token */ - public String signUpNewUser(LoginRequest login) { + public UserResponseWithToken signUpNewUser(LoginRequest login) { log.info("Signing up new user! {}", login.email()); if (findByEmail(login.email()).isPresent()) { @@ -76,7 +79,7 @@ public class AuthService { String token = jwtService.generateToken(user.getEmail()); log.info("User created! Token {}", token); - return token; + return UserResponseWithToken.fromEntity(user, token); } /** @@ -111,7 +114,7 @@ public class AuthService { * @return Token */ @Transactional - public String signInUser(LoginRequest login) { + public UserResponseWithToken signInUser(LoginRequest login) { log.info("Signing in user! {}", login.email()); Optional user = findByEmail(login.email()); @@ -130,7 +133,7 @@ public class AuthService { log.info("User authenticated! Token {}", token); userPwdLimitRepository.deleteAllForUser(user.get().getId()); - return token; + return UserResponseWithToken.fromEntity(user.get(), token); } catch (BadCredentialsException e) { log.error("BadCredentialsException when logging in user {}", user.get().getId()); @@ -213,12 +216,46 @@ public class AuthService { userPwdLimitRepository.deleteAllForUser(currentUser.getId()); userRepository.delete(currentUser); - return new UserResponse( - currentUser.getId(), - currentUser.getEmail(), - currentUser.getAdmin(), - currentUser.getCreatedAt(), - currentUser.getInactivatedAt()); + return UserResponse.fromEntity(currentUser); + } + + @Transactional + public UserResponse patchUserInfo(UserPatchRequest patchRequest) { + Optional currentUserEmail = authUtil.getCurrentUserEmail(); + String email = currentUserEmail.orElseThrow(); + UserEntity currentUser = findByEmail(email).orElseThrow(); + boolean shouldUpdate = false; + + if (!Objects.isNull(patchRequest.name()) && !patchRequest.name().isBlank()) { + currentUser.setName(patchRequest.name().trim()); + shouldUpdate = true; + } + if (!Objects.isNull(patchRequest.email()) && !patchRequest.email().isBlank()) { + currentUser.setEmail(patchRequest.email().trim()); + shouldUpdate = true; + } + + boolean updatePassword = + !Objects.isNull(patchRequest.password()) + && !patchRequest.password().isBlank() + && !Objects.isNull(patchRequest.passwordAgain()) + && !patchRequest.passwordAgain().isBlank(); + + if (updatePassword) { + Optional passwordValidation = authUtil.validatePassword(patchRequest.password()); + if (passwordValidation.isPresent()) { + throw new BadPasswordException(passwordValidation.get()); + } + + currentUser.setPassword(passwordEncoder.encode(patchRequest.password())); + shouldUpdate = true; + } + + if (shouldUpdate) { + userRepository.save(currentUser); + } + + return UserResponse.fromEntity(currentUser); } private void checkLoginAttemptLimit(Long userId) { diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java index 313ef37..e010a27 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/TaskService.java @@ -122,7 +122,7 @@ public class TaskService { TaskEntity taskEntity = task.get(); if (!Objects.isNull(patch.description()) && !patch.description().isBlank()) { - taskEntity.setDescription(patch.description()); + taskEntity.setDescription(patch.description().trim()); } if (!Objects.isNull(patch.done())) { taskEntity.setDone(patch.done()); diff --git a/server/src/main/resources/db/migration/V202503042111__add_user_info.sql b/server/src/main/resources/db/migration/V202503042111__add_user_info.sql new file mode 100644 index 0000000..7a4e36b --- /dev/null +++ b/server/src/main/resources/db/migration/V202503042111__add_user_info.sql @@ -0,0 +1,2 @@ +alter table tasknote.users + add name varchar(20) null; diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java index eccc5ca..a9b648c 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/AuthenticationControllerTest.java @@ -9,7 +9,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException; import br.com.tasknoteapp.server.request.LoginRequest; +import br.com.tasknoteapp.server.response.UserResponseWithToken; import br.com.tasknoteapp.server.service.AuthService; +import java.time.LocalDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -33,7 +35,10 @@ class AuthenticationControllerTest { LoginRequest request = new LoginRequest("user@domain.com", "abcde123456"); final String token = "xaxbxcxdx1x2x3A@"; - when(authService.signUpNewUser(request)).thenReturn(token); + UserResponseWithToken response = + new UserResponseWithToken( + 123L, null, request.email(), false, LocalDateTime.now(), null, token); + when(authService.signUpNewUser(request)).thenReturn(response); String jsonString = """ @@ -51,6 +56,9 @@ class AuthenticationControllerTest { .accept(MediaType.APPLICATION_JSON) .content(jsonString)) .andExpect(status().isCreated()) + .andExpect(jsonPath("$.userId").value(response.userId())) + .andExpect(jsonPath("$.email").value(response.email())) + .andExpect(jsonPath("$.admin").value(response.admin())) .andExpect(jsonPath("$.token").value(token)) .andReturn(); } @@ -61,7 +69,10 @@ class AuthenticationControllerTest { LoginRequest request = new LoginRequest("user@domain..com", "abcde123456"); final String token = "xaxbxcxdx1x2x3@A"; - when(authService.signUpNewUser(request)).thenReturn(token); + UserResponseWithToken response = + new UserResponseWithToken( + 123L, null, request.email(), false, LocalDateTime.now(), null, token); + when(authService.signUpNewUser(request)).thenReturn(response); String jsonString = """ @@ -114,7 +125,10 @@ class AuthenticationControllerTest { LoginRequest request = new LoginRequest("user@domain.com", "abcde123456"); final String token = "xaxbxcxdx1x2x3A@"; - when(authService.signInUser(request)).thenReturn(token); + UserResponseWithToken response = + new UserResponseWithToken( + 123L, null, request.email(), false, LocalDateTime.now(), null, token); + when(authService.signInUser(request)).thenReturn(response); String jsonString = """ @@ -132,6 +146,9 @@ class AuthenticationControllerTest { .accept(MediaType.APPLICATION_JSON) .content(jsonString)) .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(response.userId())) + .andExpect(jsonPath("$.email").value(response.email())) + .andExpect(jsonPath("$.admin").value(response.admin())) .andExpect(jsonPath("$.token").value(token)) .andReturn(); } diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java index 4fd6333..ec0118e 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/UserControllerTest.java @@ -3,9 +3,11 @@ package br.com.tasknoteapp.server.controller; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import br.com.tasknoteapp.server.request.UserPatchRequest; import br.com.tasknoteapp.server.response.UserResponse; import br.com.tasknoteapp.server.service.AuthService; import java.util.List; @@ -31,7 +33,7 @@ class UserControllerTest { @DisplayName("Get all users happy path should succeed") @WithMockUser(username = "user@domain.com", password = "abcde123456A@") void getAllUsers_happyPath_shouldSucceed() throws Exception { - UserResponse userResponse = new UserResponse(1L, "email@test.com", false, null, null); + UserResponse userResponse = new UserResponse(1L, "John", "email@test.com", false, null, null); when(authService.getAllUsers()).thenReturn(List.of(userResponse)); mockMvc @@ -59,4 +61,34 @@ class UserControllerTest { .andExpect(status().isForbidden()) .andReturn(); } + + @Test + @DisplayName("Patch user info happy path should succeed") + @WithMockUser(username = "user@domain.com", password = "abcde123456A@") + void patchUserInfo_happyPath_shouldSucceed() throws Exception { + UserResponse response = new UserResponse(1L, "John", "email@example.com", false, null, null); + UserPatchRequest request = new UserPatchRequest("John Doe", response.email(), null, null); + when(authService.patchUserInfo(request)).thenReturn(response); + + String jsonString = + """ + { + "name": "John Doe", + "email": "email@example.com" + } + """; + + mockMvc + .perform( + patch("/rest/users") + .with(csrf().asHeader()) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON) + .content(jsonString)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.userId").value(response.userId())) + .andExpect(jsonPath("$.email").value(response.email())) + .andExpect(jsonPath("$.admin").value(response.admin())) + .andReturn(); + } } diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java index 1bbf118..2e8240b 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/UserSessionControllerTest.java @@ -63,7 +63,7 @@ class UserSessionControllerTest { @DisplayName("Delete account happy path should succeed") @WithMockUser(username = "user@domain.com", password = "abcde123456A@") void deleteAccount_happyPath_shouldSucceed() throws Exception { - UserResponse response = new UserResponse(1L, "email@test.com", false, null, null); + UserResponse response = new UserResponse(1L, "John", "email@test.com", false, null, null); when(userSessionService.deleteCurrentUserAccount()).thenReturn(response); mockMvc diff --git a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java index 98189d6..8d58105 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/service/AuthServiceTest.java @@ -9,15 +9,17 @@ import static org.mockito.Mockito.when; import br.com.tasknoteapp.server.entity.UserEntity; import br.com.tasknoteapp.server.entity.UserPwdLimitEntity; import br.com.tasknoteapp.server.exception.BadPasswordException; -import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException; import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException; +import br.com.tasknoteapp.server.exception.InvalidCredentialsException; +import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException; import br.com.tasknoteapp.server.exception.UserForbiddenException; import br.com.tasknoteapp.server.exception.UserNotFoundException; -import br.com.tasknoteapp.server.exception.InvalidCredentialsException; import br.com.tasknoteapp.server.repository.UserPwdLimitRepository; import br.com.tasknoteapp.server.repository.UserRepository; import br.com.tasknoteapp.server.request.LoginRequest; +import br.com.tasknoteapp.server.request.UserPatchRequest; import br.com.tasknoteapp.server.response.UserResponse; +import br.com.tasknoteapp.server.response.UserResponseWithToken; import br.com.tasknoteapp.server.util.AuthUtil; import java.time.LocalDateTime; import java.util.List; @@ -80,11 +82,12 @@ class AuthServiceTest { when(userRepository.save(any())).thenReturn(entity); when(jwtService.generateToken(request.email())).thenReturn("a1b2c3"); - String token = authService.signUpNewUser(request); + UserResponseWithToken token = authService.signUpNewUser(request); Assertions.assertNotNull(token); - Assertions.assertFalse(token.isBlank()); - Assertions.assertEquals("a1b2c3", token); + Assertions.assertFalse(token.token().isBlank()); + Assertions.assertEquals("a1b2c3", token.token()); + Assertions.assertEquals(entity.getEmail(), token.email()); } @Test @@ -191,10 +194,10 @@ class AuthServiceTest { doNothing().when(userPwdLimitRepository).deleteAllForUser(existing.getId()); - String token = authService.signInUser(request); + UserResponseWithToken token = authService.signInUser(request); Assertions.assertNotNull(token); - Assertions.assertEquals("a1b2c3", token); + Assertions.assertEquals("a1b2c3", token.token()); } @Test @@ -248,7 +251,7 @@ class AuthServiceTest { when(userPwdLimitRepository.findAllByUser_id(existing.getId(), sort)).thenReturn(List.of()); when(authenticationManager.authenticate(any())).thenThrow(new BadCredentialsException("Wrong")); - String token = authService.signInUser(request); + UserResponseWithToken token = authService.signInUser(request); Assertions.assertNull(token); verify(userPwdLimitRepository, times(1)).save(any()); @@ -285,9 +288,11 @@ class AuthServiceTest { void getAllUsers_noCurrentUser_shouldFail() { when(authUtil.getCurrentUserEmail()).thenReturn(Optional.empty()); - Assertions.assertThrows(UserForbiddenException.class, () -> { - authService.getAllUsers(); - }); + Assertions.assertThrows( + UserForbiddenException.class, + () -> { + authService.getAllUsers(); + }); } @Test @@ -297,9 +302,11 @@ class AuthServiceTest { when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email)); when(userRepository.findByEmail(email)).thenReturn(Optional.empty()); - Assertions.assertThrows(UserForbiddenException.class, () -> { - authService.getAllUsers(); - }); + Assertions.assertThrows( + UserForbiddenException.class, + () -> { + authService.getAllUsers(); + }); } @Test @@ -314,9 +321,11 @@ class AuthServiceTest { existing.setAdmin(false); when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); - Assertions.assertThrows(UserForbiddenException.class, () -> { - authService.getAllUsers(); - }); + Assertions.assertThrows( + UserForbiddenException.class, + () -> { + authService.getAllUsers(); + }); } @Test @@ -356,4 +365,54 @@ class AuthServiceTest { Assertions.assertNotNull(response); } + + @Test + @DisplayName("Patch user info patch user name and email should succeed") + void pathUserInfo_nameAndEmail_shouldSucceed() { + String email = "user@domain.com"; + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email)); + + UserEntity existing = new UserEntity(); + existing.setId(919L); + existing.setName(null); + existing.setEmail(email); + existing.setAdmin(false); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); + + when(userRepository.save(any())).thenReturn(existing); + + UserPatchRequest patchRequest = new UserPatchRequest("Kong", "newemail@domain.com", null, null); + UserResponse response = authService.patchUserInfo(patchRequest); + + Assertions.assertNotNull(response); + Assertions.assertEquals("Kong", response.name()); + Assertions.assertEquals("newemail@domain.com", response.email()); + } + + @Test + @DisplayName("Patch user info patch user password should succeed") + void pathUserInfo_password_shouldSucceed() { + String email = "user@domain.com"; + when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(email)); + + UserEntity existing = new UserEntity(); + existing.setId(919L); + existing.setName(null); + existing.setEmail(email); + existing.setAdmin(false); + when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing)); + + when(userRepository.save(any())).thenReturn(existing); + + String newPassword = "TestHackedPw@difficult!#:)"; + UserPatchRequest patchRequest = new UserPatchRequest("Kong", "newemail@domain.com", newPassword, newPassword); + + when(authUtil.validatePassword(patchRequest.password())).thenReturn(Optional.empty()); + + UserResponse response = authService.patchUserInfo(patchRequest); + + Assertions.assertNotNull(response); + Assertions.assertEquals("Kong", response.name()); + Assertions.assertEquals("newemail@domain.com", response.email()); + } }