diff --git a/cypress.config.ts b/cypress.config.ts index 0f66080fd0637080f5e2f5151146e89797be2e54..f80530a2a66e24534ac18223b7603fd3d0206f49 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from 'cypress' export default defineConfig({ e2e: { specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}', - baseUrl: 'http://localhost:4173' + baseUrl: 'http://localhost:5173' } }) diff --git a/cypress/e2e/Authentication/LoginView.cy.ts b/cypress/e2e/Authentication/LoginView.cy.ts new file mode 100644 index 0000000000000000000000000000000000000000..c498a772125535ca7d217abe276a309ddd0b3d0c --- /dev/null +++ b/cypress/e2e/Authentication/LoginView.cy.ts @@ -0,0 +1,33 @@ +describe('Login Form Tests', () => { + beforeEach(() => { + cy.visit('/login'); + }); + + it('validates user input and displays error messages', () => { + cy.get('#emailInput input').type('test'); + cy.get('#passwordInput input').type('pass'); + cy.get('form').submit(); + cy.get('#invalid').should('contain', 'Invalid email'); + }); + + it('successfully logs in and redirects to the home page', () => { + cy.visit('/'); + cy.url().should('include', '/login'); + }); + + it('successfully logs in and redirects to the home page', () => { + cy.get('#emailInput input').type('user@example.com'); + cy.get('#passwordInput input').type('John1'); + cy.get('form').submit(); + cy.wait(1000); + cy.url().should('include', '/'); + }); + + it('shows an error message on login failure', () => { + cy.get('#emailInput input').type('wrong@example.com'); + cy.get('#passwordInput input').type('wrongPass1'); + cy.get('#confirmButton').click(); + cy.wait(1000); + cy.get('[data-cy="error"]').should('contain', 'User not found'); + }); +}); diff --git a/cypress/e2e/Authentication/SignUpView.cy.ts b/cypress/e2e/Authentication/SignUpView.cy.ts new file mode 100644 index 0000000000000000000000000000000000000000..652e6e3adafd4674478d3dd4bd63b9814b0fa32d --- /dev/null +++ b/cypress/e2e/Authentication/SignUpView.cy.ts @@ -0,0 +1,39 @@ +describe('Login Form Tests', () => { + beforeEach(() => { + cy.visit('/sign-up'); + }); + + it('validates user input and displays error messages', () => { + cy.get('#emailInput input').type('test'); + cy.get('#passwordInput input').type('pass'); + cy.get('form').submit(); + cy.get('#invalid').should('contain', 'Invalid email'); + }); + + it('successfully logs in and redirects to the home page', () => { + cy.visit('/'); + cy.url().should('include', '/login'); + }); + + it('successfully logs in and redirects to the home page', () => { + cy.get('#emailInput input').type('user@example.com'); + cy.get('#passwordInput input').type('John1'); + cy.get('form').submit(); + cy.wait(1000); + cy.url().should('include', '/'); + }); + + it('shows an error message on login failure', () => { + // Update the intercept to simulate a login failure + cy.intercept('POST', '/api/login', { + statusCode: 401, + body: { message: 'Invalid credentials' } + }).as('apiLoginFail'); + + cy.get('#emailInput input').type('wrong@example.com'); + cy.get('#passwordInput input').type('wrongPass1'); + cy.get('#confirmButton').click(); + cy.wait(1000); + cy.get('[data-cy="error"]').should('contain', 'User not found'); + }); +}); diff --git a/spec.json b/spec.json index ec255e673f3a4bc77e022576875626abde8758f4..ad608d501e4712ac0170288ec46ff2533b93dfbc 100644 --- a/spec.json +++ b/spec.json @@ -16,7 +16,46 @@ "Bearer Authentication": [] } ], + "tags": [ + { + "name": "Friend", + "description": "API for managing friend relationships" + } + ], "paths": { + "/api/friends/{friendId}": { + "put": { + "tags": [ + "Friend" + ], + "summary": "Accept a friend request", + "description": "Accepts a friend request from another user.", + "operationId": "acceptFriendRequest", + "parameters": [ + { + "name": "friendId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Friend request successfully accepted", + "content": { + "*/*": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, "/bank/v1/transaction/norwegian-domestic-payment-to-self": { "post": { "tags": [ @@ -280,6 +319,32 @@ } } }, + "/api/friends/{userId}": { + "post": { + "tags": [ + "Friend" + ], + "summary": "Send a friend request", + "description": "Sends a new friend request to another user.", + "operationId": "addFriendRequest", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "201": { + "description": "Friend request successfully created" + } + } + } + }, "/api/budget/update/{budgetId}": { "post": { "tags": [ @@ -480,22 +545,22 @@ "required": true }, "responses": { - "201": { - "description": "Successfully signed up", + "409": { + "description": "Email already exists", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthenticationResponse" + "$ref": "#/components/schemas/ExceptionResponse" } } } }, - "409": { - "description": "Email already exists", + "201": { + "description": "Successfully signed up", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExceptionResponse" + "$ref": "#/components/schemas/AuthenticationResponse" } } } @@ -533,8 +598,8 @@ } } }, - "401": { - "description": "Invalid credentials", + "404": { + "description": "User not found", "content": { "application/json": { "schema": { @@ -543,8 +608,8 @@ } } }, - "404": { - "description": "User not found", + "401": { + "description": "Invalid credentials", "content": { "application/json": { "schema": { @@ -890,6 +955,16 @@ } ], "responses": { + "404": { + "description": "Image not found", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ExceptionResponse" + } + } + } + }, "200": { "description": "Successfully retrieved the image", "content": { @@ -950,6 +1025,56 @@ } } }, + "/api/friends": { + "get": { + "tags": [ + "Friend" + ], + "summary": "Get all friends", + "description": "Returns a list of all friends.", + "operationId": "getFriends", + "responses": { + "200": { + "description": "Successfully retrieved list of friends", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDTO" + } + } + } + } + } + } + } + }, + "/api/friends/requests": { + "get": { + "tags": [ + "Friend" + ], + "summary": "Get friend requests", + "description": "Returns a list of all users who have sent a friend request.", + "operationId": "getFriendRequests", + "responses": { + "200": { + "description": "Successfully retrieved friend requests", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserDTO" + } + } + } + } + } + } + } + }, "/api/budget": { "get": { "tags": [ @@ -1690,22 +1815,22 @@ "enabled": { "type": "boolean" }, + "username": { + "type": "string" + }, "authorities": { "type": "array", "items": { "$ref": "#/components/schemas/GrantedAuthority" } }, - "username": { - "type": "string" - }, - "accountNonExpired": { + "accountNonLocked": { "type": "boolean" }, "credentialsNonExpired": { "type": "boolean" }, - "accountNonLocked": { + "accountNonExpired": { "type": "boolean" } } diff --git a/src/App.vue b/src/App.vue index f7a239c42135aae98c86203521fd01c1e98cd18a..d32330ffb583d487044a1e59f4703d7072a35ba5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -15,6 +15,5 @@ import { RouterView } from 'vue-router' main { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-weight: 600; - } </style> \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts index c2c621ddd2ef1287251a040c2e8e999471704f49..384ad7693642773834ae4b5fdbd8a2654b9e378e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -50,6 +50,7 @@ export type { UserUpdateDTO } from './models/UserUpdateDTO'; export { AccountControllerService } from './services/AccountControllerService'; export { AuthenticationService } from './services/AuthenticationService'; export { BankProfileControllerService } from './services/BankProfileControllerService'; +export { FriendService } from './services/FriendService'; export { GoalService } from './services/GoalService'; export { ImageService } from './services/ImageService'; export { LeaderboardService } from './services/LeaderboardService'; diff --git a/src/api/models/User.ts b/src/api/models/User.ts index d6bb1f5767a27a191df1b0297b8ad49b2c7fab44..7f5c5051e6dfc83847a43a8e3cf8f3424e7020d6 100644 --- a/src/api/models/User.ts +++ b/src/api/models/User.ts @@ -24,11 +24,11 @@ export type User = { streak?: Streak; configuration?: Configuration; enabled?: boolean; - authorities?: Array<GrantedAuthority>; username?: string; - accountNonExpired?: boolean; - credentialsNonExpired?: boolean; + authorities?: Array<GrantedAuthority>; accountNonLocked?: boolean; + credentialsNonExpired?: boolean; + accountNonExpired?: boolean; }; export namespace User { export enum role { diff --git a/src/api/services/FriendService.ts b/src/api/services/FriendService.ts new file mode 100644 index 0000000000000000000000000000000000000000..6754fc894ccd0732dc1c827a6ae2f6f5277dd310 --- /dev/null +++ b/src/api/services/FriendService.ts @@ -0,0 +1,72 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { UserDTO } from '../models/UserDTO'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class FriendService { + /** + * Accept a friend request + * Accepts a friend request from another user. + * @returns any Friend request successfully accepted + * @throws ApiError + */ + public static acceptFriendRequest({ + friendId, + }: { + friendId: number, + }): CancelablePromise<Record<string, any>> { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/friends/{friendId}', + path: { + 'friendId': friendId, + }, + }); + } + /** + * Send a friend request + * Sends a new friend request to another user. + * @returns any Friend request successfully created + * @throws ApiError + */ + public static addFriendRequest({ + userId, + }: { + userId: number, + }): CancelablePromise<any> { + return __request(OpenAPI, { + method: 'POST', + url: '/api/friends/{userId}', + path: { + 'userId': userId, + }, + }); + } + /** + * Get all friends + * Returns a list of all friends. + * @returns UserDTO Successfully retrieved list of friends + * @throws ApiError + */ + public static getFriends(): CancelablePromise<Array<UserDTO>> { + return __request(OpenAPI, { + method: 'GET', + url: '/api/friends', + }); + } + /** + * Get friend requests + * Returns a list of all users who have sent a friend request. + * @returns UserDTO Successfully retrieved friend requests + * @throws ApiError + */ + public static getFriendRequests(): CancelablePromise<Array<UserDTO>> { + return __request(OpenAPI, { + method: 'GET', + url: '/api/friends/requests', + }); + } +} diff --git a/src/components/Login/LoginForm.vue b/src/components/Login/LoginForm.vue index a6021798954054cd7aeb616733e6c03e8560b24c..9e58d96f32ddd9d57fdbf9d65d75b439aa56ace0 100644 --- a/src/components/Login/LoginForm.vue +++ b/src/components/Login/LoginForm.vue @@ -105,7 +105,7 @@ const handleSubmit = async () => { /> <p>Forgotten password? <RouterLink to="/forgotten-password">Reset password</RouterLink></p> - <p class="text-danger">{{ errorMsg }}</p> + <p class="text-danger" data-cy="error">{{ errorMsg }}</p> <button1 id="confirmButton" type="submit" @click="handleSubmit" button-text="Login"></button1> <SignUpLink/> </form> diff --git a/src/components/UpdateUserComponents/UpdateUserLayout.vue b/src/components/UpdateUserComponents/UpdateUserLayout.vue index 42a3dd14b5a2b9d050706567e552ae60559a7790..7c9e81cd0b2dff2477e0de17e93ca12982b9155a 100644 --- a/src/components/UpdateUserComponents/UpdateUserLayout.vue +++ b/src/components/UpdateUserComponents/UpdateUserLayout.vue @@ -62,7 +62,7 @@ const handleSubmit = async () => { const updateUserPayload: UserUpdateDTO = { firstName: firstNameRef.value, lastName: surnameRef.value, - email: emailRef.value + email: emailRef.value, }; diff --git a/src/components/UserProfile/UserProfileForeignLayout.vue b/src/components/UserProfile/UserProfileForeignLayout.vue index 43ca37ef0103117e46ac94acdf73a5ee0498c152..df6ea8befbd76da3b997b92105fb51f64ce09029 100644 --- a/src/components/UserProfile/UserProfileForeignLayout.vue +++ b/src/components/UserProfile/UserProfileForeignLayout.vue @@ -2,7 +2,7 @@ import {useRoute, useRouter} from "vue-router"; import {onMounted, ref} from "vue"; -import {UserService} from "@/api"; +import {UserService, type ProfileDTO} from "@/api"; let numberOfHistory = 6; @@ -12,6 +12,10 @@ let username = ref() let friend = ref(false) +let profile: ProfileDTO;; + +const imageUrl = ref(`../src/assets/userprofile.png`); + let id = ref() @@ -33,7 +37,12 @@ onMounted(async () => { let response = await UserService.getProfile({ userId: id.value.id }) - username.value = response.firstName + profile = response; + console.log(profile) + username.value = profile.firstName + if (profile.profileImage){ + imageUrl.value = `http://localhost:8080/api/images/${profile.profileImage}` + } console.log(username) } catch (error) { console.error("Something went wrong getting the profile: ", error) @@ -69,7 +78,7 @@ function toUpdateUserSettings(){ <div class="card"> <div class="rounded-top text-white d-flex flex-row bg-primary" style="height:200px;"> <div class="ms-4 mt-5 d-flex flex-column" style="width: 150px;"> - <img src="https://bootdey.com/img/Content/avatar/avatar3.png" alt="Generic placeholder image" + <img :src="imageUrl" alt="Generic placeholder image" class="img-fluid img-thumbnail mt-4 mb-2" style="width: 150px; z-index: 1"> <button v-if="!friend" type="button" data-mdb-button-init data-mdb-ripple-init class="btn btn-outline-primary" data-mdb-ripple-color="dark" style="z-index: 1;" @click="addFriend"> diff --git a/src/components/UserProfile/UserProfileLayout.vue b/src/components/UserProfile/UserProfileLayout.vue index b9a6c8bc30a445b141863805d0637efe687d9524..e0371f2169c1ccf11bff011ff397618c8c33d2a1 100644 --- a/src/components/UserProfile/UserProfileLayout.vue +++ b/src/components/UserProfile/UserProfileLayout.vue @@ -1,20 +1,35 @@ <script setup lang="ts"> -import { ref } from "vue"; +import { ref, onMounted } from "vue"; import { useRouter } from "vue-router"; import { useUserInfoStore } from "../../stores/UserStore"; +import { UserService } from "@/api"; let numberOfHistory = 6; let cardTitles = ["Spain tour", "Food waste", "Coffee", "Concert", "New book", "Pretty clothes"] -let firstname = ref(""); -let lastname = ref(""); +let firstname = ref(); +let lastname = ref(); +const imageUrl = ref(`../src/assets/userprofile.png`); const router = useRouter(); -const userStore = useUserInfoStore(); -firstname.value = userStore.firstname; -lastname.value = userStore.lastname; +async function setupForm() { + try { + const response = await UserService.getUser(); + console.log(response.firstName) + + firstname.value = response.firstName; + lastname.value = response.lastName; + imageUrl.value = "http://localhost:8080/api/images/" + response.profileImage; + } catch (err) { + console.error(err) + } +} + +onMounted(() => { + setupForm() +}) const toRoadmap = () => { router.push('/'); @@ -33,7 +48,7 @@ const toUpdateUserSettings = () => { <div class="card"> <div class="rounded-top text-white d-flex flex-row bg-primary" style="height:200px;"> <div class="ms-4 mt-5 d-flex flex-column" style="width: 150px;"> - <img src="https://bootdey.com/img/Content/avatar/avatar3.png" alt="Generic placeholder image" + <img :src="imageUrl" alt="Generic placeholder image" class="img-fluid img-thumbnail mt-4 mb-2" style="width: 150px; z-index: 1"> <button type="button" data-mdb-button-init data-mdb-ripple-init class="btn btn-outline-primary" data-mdb-ripple-color="dark" style="z-index: 1;" id="toUpdate" @click="toUpdateUserSettings"> diff --git a/src/components/UserProfile/__tests__/UserProfileLayout.spec.ts b/src/components/UserProfile/__tests__/UserProfileLayout.spec.ts index e7d4f11a7149f66a87807a3850ba8cbb45cc35f1..ea5ed5fc8478886407332d09c04ffdba7ccb9156 100644 --- a/src/components/UserProfile/__tests__/UserProfileLayout.spec.ts +++ b/src/components/UserProfile/__tests__/UserProfileLayout.spec.ts @@ -29,19 +29,14 @@ describe('MyComponent and Router Tests', () => { }); describe('Component Rendering', () => { - it('renders MyComponent correctly with data from the store', () => { - // Mock user information - store.setUserInfo({ firstname: 'Jane', lastname: 'Doe', accessToken: 'thisIsATestToken' }); + it('renders MyComponent correctly', () => { const wrapper = mount(MyComponent, { global: { plugins: [mockRouter], }, }); - - // Check for text or elements that depend on user info - expect(wrapper.text()).toContain('Jane'); - expect(wrapper.text()).toContain('Doe'); + expect(wrapper.text()).toContain('Edit profile'); }); }); diff --git a/src/views/Settings/SettingsProfileView.vue b/src/views/Settings/SettingsProfileView.vue index c572d7b627eda82cd7b917ddbc04101bdeb607d7..dcba9e73ada6cab265f09828914f5d02f2064b3d 100644 --- a/src/views/Settings/SettingsProfileView.vue +++ b/src/views/Settings/SettingsProfileView.vue @@ -2,7 +2,7 @@ import { ref, onMounted } from 'vue'; import BaseInput from '@/components/InputFields/BaseInput.vue'; import { useUserInfoStore } from "@/stores/UserStore"; -import { UserService } from '@/api'; +import { UserService, ImageService } from '@/api'; import type { UserUpdateDTO } from '@/api'; const firstNameRef = ref() @@ -12,6 +12,9 @@ const passwordRef = ref('') const formRef = ref() let samePasswords = ref(true) +const iconSrc = ref('../src/assets/userprofile.png'); +const fileInputRef = ref(); + const handleFirstNameInputEvent = (newValue: any) => { firstNameRef.value = newValue } @@ -21,15 +24,44 @@ const handleSurnameInputEvent = (newValue: any) => { surnameRef.value = newValue } +const triggerFileUpload = () => { + fileInputRef.value.click(); +}; + +const handleFileChange = (event: any) => { + const file = event.target.files[0]; + if (file) { + uploadImage(file); + } +}; + +const uploadImage = async (file: any) => { + const formData = { file: new Blob([file]) } + + try { + const response = await ImageService.uploadImage({ formData }); + iconSrc.value = "http://localhost:8080/api/images/" + response; + + const updateUserPayload: UserUpdateDTO = { + profileImage: response, + }; + UserService.update({ requestBody: updateUserPayload }) + } catch (error) { + console.error('Failed to upload image:', error); + } +}; + async function setupForm() { try { - let response = await UserService.getUser(); + const response = await UserService.getUser(); console.log(response.firstName) firstNameRef.value = response.firstName; if (response.lastName != null) { surnameRef.value = response.lastName; } + console.log(response.profileImage) + iconSrc.value = "http://localhost:8080/api/images/" + response.profileImage; } catch (err) { console.error(err) } @@ -66,11 +98,12 @@ onMounted(() => { <hr> <form @submit.prevent="handleSubmit" novalidate> <div class="user-avatar"> - <img id="icon" src="https://bootdey.com/img/Content/avatar/avatar7.png" alt="Maxwell Admin"> - </div> - <div class="btn"> + <input type="file" ref="fileInputRef" @change="handleFileChange" accept=".jpg, .jpeg, .png" + style="display: none;" /> + <img :src="iconSrc" alt="User Avatar" style="width: 300px"> <div class="mt-2"> - <span class="btn btn-primary"><img src="@/assets/icons/download.svg"></span> + <button type="button" class="btn btn-primary" @click="triggerFileUpload"><img + src="@/assets/icons/download.svg"> Upload Image</button> </div> </div> <div class="form-group"> diff --git a/src/views/Settings/SettingsSecurityView.vue b/src/views/Settings/SettingsSecurityView.vue index 62b021cc667c4785bb5f62d026b50e7ce35bd2c0..db55bb062446fec0738188c2da1a1c74e3e145b3 100644 --- a/src/views/Settings/SettingsSecurityView.vue +++ b/src/views/Settings/SettingsSecurityView.vue @@ -2,22 +2,76 @@ <div class="tab-pane active" id="security"> <h6>SECURITY SETTINGS</h6> <hr> - <form> + <form @submit.prevent="handleSubmit" novalidate> <div class="form-group"> <label class="d-block">Change Password</label> - <input type="text" class="form-control" placeholder="Enter your old password"> - <input type="text" class="form-control mt-3" placeholder="New password"> - <input type="text" class="form-control mt-3 mb-2" placeholder="Confirm new password"> + <BaseInput :model-value="oldPasswordRef" @input-change-event="handleOldPasswordInputEvent" + id="passwordInput-change" input-id="password-old" type="password" + pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{4,16}" label="Old Password" placeholder="Enter password" + invalid-message="Password must be between 4 and 16 characters and contain one capital letter, small letter and a number" /> + + <BaseInput :model-value="newPasswordRef" @input-change-event="handleNewPasswordInputEvent" + id="passwordInput-change" input-id="password-new" type="password" + pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{4,16}" label="New Password" placeholder="Enter password" + invalid-message="Password must be between 4 and 16 characters and contain one capital letter, small letter and a number" /> + + <BaseInput :model-value="confirmPasswordRef" @input-change-event="handleConfirmPasswordInputEvent" + id="passwordInput-change" input-id="password-confirm" type="password" + pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{4,16}" label="Confirm New Password" placeholder="Enter password" + invalid-message="Password must be between 4 and 16 characters and contain one capital letter, small letter and a number" /> </div> - <button type="button" class="btn btn-primary">Update Password</button> + <button type="submit" class="btn btn-primary">Update Password</button> <button type="reset" class="btn btn-light">Reset Changes</button> </form> <hr> </div> </template> + <script setup lang="ts"> -</script> + import { ref } from 'vue' + import BaseInput from '@/components/InputFields/BaseInput.vue' + import { type PasswordUpdateDTO, UserService } from '@/api' + + const oldPasswordRef = ref(''); + const newPasswordRef = ref(''); + const confirmPasswordRef = ref(''); + + + const handleOldPasswordInputEvent = (newValue: any) => { + oldPasswordRef.value = newValue +} + + const handleNewPasswordInputEvent = (newValue: any) => { + newPasswordRef.value = newValue +} + + const handleConfirmPasswordInputEvent = (newValue: any) => { + confirmPasswordRef.value = newValue +} -<style scoped> +const handleSubmit = async () => { + if (newPasswordRef.value !== confirmPasswordRef.value) { + console.error('Passwords do not match') + return + } + + + const updateUserPayload: PasswordUpdateDTO = { + oldPassword: oldPasswordRef.value, + newPassword: newPasswordRef.value, + }; + + try { + const response = UserService.updatePassword({ requestBody: updateUserPayload }) + console.log(response) + } catch (err) { + console.error(err) + } +} + + + + +</script> -</style> \ No newline at end of file +<style scoped></style> \ No newline at end of file