From a3e960e5f2697de31d53e79a3d5f8bd664414d82 Mon Sep 17 00:00:00 2001 From: samuelim01 <61283948+samuelim01@users.noreply.github.com> Date: Wed, 13 Nov 2024 21:10:19 +0800 Subject: [PATCH 1/6] Fix forfeit not retrieving snapshot (#95) * Fix forfeit not retrieving snapshot * oops --- services/collaboration/src/controllers/roomController.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/collaboration/src/controllers/roomController.ts b/services/collaboration/src/controllers/roomController.ts index 915115237f..a0918ca6cb 100644 --- a/services/collaboration/src/controllers/roomController.ts +++ b/services/collaboration/src/controllers/roomController.ts @@ -120,7 +120,7 @@ export const closeRoomController = async (req: Request, res: Response) => { export const updateUserStatusInRoomController = async (req: Request, res: Response) => { const userId = req.user.id; const { roomId } = req.params; - const { isForfeit, snapshot } = req.body; + const { isForfeit } = req.body; // Validate that isForfeit is a boolean value if (typeof isForfeit !== 'boolean') { @@ -140,6 +140,9 @@ export const updateUserStatusInRoomController = async (req: Request, res: Respon return handleHttpNotFound(res, 'User not found in room'); } + // Obtain code and language + const snapshot = await retrieveSnapshot(roomId); + // Record the forfeited status in the user's history await produceUpdateHistory(roomId, userId, HistoryStatus.FORFEITED, snapshot); From de92e85c0f04bf698119694abaf7e10d07d92e24 Mon Sep 17 00:00:00 2001 From: limcaaarl <42115432+limcaaarl@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:34:32 +0800 Subject: [PATCH 2/6] Bug fix for #77 (#91) * Bug fix for #77 - Users will now be warned if their tokens are about to expire - Users will be logged out upon token expiration * Update from toast to alert for expired token * Replace interval with timer * Enhance warning * Fix linting * Fix takeUntilDestroyed https://stackoverflow.com/questions/76264067/takeuntildestroyed-can-only-be-used-within-an-injection-context --------- Co-authored-by: Samuel Lim --- .../src/_services/authentication.service.ts | 48 ++++++++++++++++++- frontend/src/app/app.component.html | 1 + frontend/src/app/app.component.ts | 16 +++++-- 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/frontend/src/_services/authentication.service.ts b/frontend/src/_services/authentication.service.ts index 3f450877f8..8458fac1c7 100644 --- a/frontend/src/_services/authentication.service.ts +++ b/frontend/src/_services/authentication.service.ts @@ -1,23 +1,28 @@ // Modified from https://jasonwatmore.com/post/2022/11/15/angular-14-jwt-authentication-example-tutorial#login-component-ts -import { Injectable } from '@angular/core'; +import { DestroyRef, inject, Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { HttpClient } from '@angular/common/http'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, Observable, timer } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { map, switchMap } from 'rxjs/operators'; import { UServRes } from '../_models/user.service.model'; import { User } from '../_models/user.model'; import { ApiService } from './api.service'; +import { ToastService } from './toast.service'; @Injectable({ providedIn: 'root' }) export class AuthenticationService extends ApiService { protected apiPath = 'user'; + private destroyRef = inject(DestroyRef); + private userSubject: BehaviorSubject; public user$: Observable; constructor( private router: Router, private http: HttpClient, + private toastService: ToastService, ) { super(); const userData = localStorage.getItem('user'); @@ -53,6 +58,8 @@ export class AuthenticationService extends ApiService { } localStorage.setItem('user', JSON.stringify(user)); this.userSubject.next(user); + this.startTokenExpiryCheck(); + return user; }), ); @@ -119,4 +126,41 @@ export class AuthenticationService extends ApiService { }), ); } + + displaySessionExpiryWarning(): void { + this.toastService.showToast('Your session will expire in less than 5 minutes. Please log in again.'); + } + + public startTokenExpiryCheck(): void { + const tokenExpirationTime = this.getTokenExpiration(); + if (!tokenExpirationTime) { + this.logout(); + return; + } + + const oneMinute = 60 * 1000; + const timeLeft = tokenExpirationTime - Date.now(); + if (timeLeft > 5 * oneMinute) { + timer(timeLeft - 5 * oneMinute) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.displaySessionExpiryWarning()); + } else { + this.displaySessionExpiryWarning(); + } + + timer(timeLeft) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + alert('Your session has expired. Please log in again.'); + this.logout(); + }); + } + + private getTokenExpiration() { + const user = this.userValue; + if (!user || !user.accessToken) return null; + + const tokenPayload = JSON.parse(atob(user.accessToken.split('.')[1])); + return tokenPayload.exp ? tokenPayload.exp * 1000 : null; + } } diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index a20a5cb8af..d702e4580e 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1,4 +1,5 @@
+
diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index d0722c2b03..0e29dabf67 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,16 +1,24 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { RouterOutlet } from '@angular/router'; import { ButtonModule } from 'primeng/button'; import { PasswordModule } from 'primeng/password'; +import { ToastModule } from 'primeng/toast'; import { NavigationBarComponent } from './navigation-bar/navigation-bar.component'; - +import { AuthenticationService } from '../_services/authentication.service'; +import { MessageService } from 'primeng/api'; @Component({ selector: 'app-root', standalone: true, - imports: [NavigationBarComponent, RouterOutlet, ButtonModule, PasswordModule], + imports: [NavigationBarComponent, RouterOutlet, ButtonModule, PasswordModule, ToastModule], + providers: [MessageService], templateUrl: './app.component.html', styleUrl: './app.component.css', }) -export class AppComponent { +export class AppComponent implements OnInit { title = 'frontend'; + + constructor(private authService: AuthenticationService) {} + ngOnInit() { + this.authService.startTokenExpiryCheck(); + } } From deb6415b9f83aac60db0a58639bd393a026f5653 Mon Sep 17 00:00:00 2001 From: Lim Zi Jia <62246275+LimZiJia@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:55:43 +0800 Subject: [PATCH 3/6] Fixed bugs (#99) * Fixed bugs - Changed transitions to 50ms so that it doesnt break - Disabled clicks to checkbox and edit buttons propagating to row select * Adjust sidebar speed --------- Co-authored-by: McNaBry --- frontend/src/app/questions/questions.component.html | 7 ++++--- frontend/src/app/questions/questions.component.ts | 4 +--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/questions/questions.component.html b/frontend/src/app/questions/questions.component.html index cc5f7421b3..bf897793d3 100644 --- a/frontend/src/app/questions/questions.component.html +++ b/frontend/src/app/questions/questions.component.html @@ -76,7 +76,7 @@

Questions

@if (isAdmin) { - + {{ question.id }} {{ question.title }} {{ question.topics.join(', ') }} @@ -88,7 +88,7 @@

Questions

icon="pi pi-file-edit" class="mr-2" [text]="true" - (onClick)="editQuestion(question)" /> + (onClick)="$event.stopPropagation(); editQuestion(question)" /> } @else { {{ question.id }} @@ -118,7 +118,8 @@

Questions

position="right" [blockScroll]="true" styleClass="w-10 md:w-4" - transitionOptions="300ms cubic-bezier(0, 0, 0.2, 1)"> + transitionOptions="200ms cubic-bezier(0, 0, 0.2, 1)" + (onHide)="closePanel()">

{{ clickedOnQuestion?.title }}

diff --git a/frontend/src/app/questions/questions.component.ts b/frontend/src/app/questions/questions.component.ts index a2a05652e1..ad2d71af6d 100644 --- a/frontend/src/app/questions/questions.component.ts +++ b/frontend/src/app/questions/questions.component.ts @@ -138,9 +138,7 @@ export class QuestionsComponent implements OnInit { onRowSelect(question: Question) { this.clickedOnQuestion = question; - if (!this.isDialogVisible) { - this.isPanelVisible = true; - } + this.isPanelVisible = true; } closePanel() { From fdd794dad4ecc52f635f7fbe13c1a9c94e4b64bc Mon Sep 17 00:00:00 2001 From: McNaBry Date: Wed, 13 Nov 2024 22:58:49 +0800 Subject: [PATCH 4/6] Add backend validation when updating user and update documentation (#98) * Update user service README * Add documentation for new routes to update username, email and password * Modify examples to conform to format requirements * Add new section detailing input format requirements * Add input validation segment to relevant routes * Add input validation for user service when updating user * Fix linting * Rename required_errors to requiredErrors * Update user service README * Shift endpoint input validation to be under body section --- services/user/README.md | 152 +++++++++++++++++- .../user/src/controller/user-controller.ts | 48 +++--- services/user/src/types/custom.ts | 18 +++ 3 files changed, 189 insertions(+), 29 deletions(-) diff --git a/services/user/README.md b/services/user/README.md index da7c8ca45d..822a33cc80 100644 --- a/services/user/README.md +++ b/services/user/README.md @@ -46,12 +46,12 @@ - Body - Required: `username` (string), `email` (string), `password` (string) - + - This endpoint validates whether the [username](#password), [email](#email) and [password](#password) supplied are of the correct format. ```json { "username": "SampleUserName", "email": "sample@gmail.com", - "password": "SecurePassword" + "password": "SecurePassword@3219" } ``` @@ -135,12 +135,13 @@ - Body - At least one of the following fields is required: `username` (string), `email` (string), `password` (string) + - This endpoint validates whether the [username](#password), [email](#email) or [password](#password) supplied are of the correct format. ```json { "username": "SampleUserName", "email": "sample@gmail.com", - "password": "SecurePassword" + "password": "SecurePassword@8" } ``` @@ -157,13 +158,97 @@ | Response Code | Explanation | |-----------------------------|---------------------------------------------------------| | 200 (OK) | User updated successfully, updated user data returned | - | 400 (Bad Request) | Missing fields | + | 400 (Bad Request) | Missing/invalid fields | | 401 (Unauthorized) | Access denied due to missing/invalid/expired JWT | | 403 (Forbidden) | Access denied for non-admin users updating others' data | | 404 (Not Found) | User with the specified ID not found | | 409 (Conflict) | Duplicate username or email encountered | | 500 (Internal Server Error) | Database or server error | +### Update Username and Email + +- This endpoint allows updating a user's username and email using the user's ID given the correct password. + +- HTTP Method: `PATCH` + +- Endpoint: http://localhost:8082/api/user/users/username-email/{userId} + +- Parameters + - Required: `userId` path parameter + +- Body + - All of the following fields are required: `username` (string), `email` (string), `password` (string) + - This endpoint validates whether the [username](#username) and [email](#email) supplied are of the correct format. + + ```json + { + "username": "SampleUserName", + "email": "sample@gmail.com", + "password": "SecurePassword@3219" + } + ``` + +- Headers + - Required: `Authorization: Bearer ` + - Auth Rules: + + - Admin users: Cannot update any user's data as it requires the user's password to authorize the request. Admin users should update user details via http://localhost:8082/api/user/users/{userId} instead. + + - Non-admin users: Can only update their own data. The server checks if the user ID in the request URL matches the ID of the user associated with the JWT token. If it matches and the user supplies their own password correctly, the server updates the user's own data. + +- Responses: + + | Response Code | Explanation | + |-----------------------------|-----------------------------------------------------------------------------| + | 200 (OK) | User updated successfully, updated user data returned | + | 400 (Bad Request) | Missing/invalid fields | + | 401 (Unauthorized) | Access denied due to missing/invalid/expired JWT or wrong password | + | 403 (Forbidden) | Access denied for non-admin users updating others' data | + | 404 (Not Found) | User with the specified ID not found | + | 409 (Conflict) | Duplicate username or email encountered | + | 500 (Internal Server Error) | Database or server error | + +### Update Password + +- This endpoint allows updating a user's password given the correct old/original password. + +- HTTP Method: `PATCH` + +- Endpoint: http://localhost:8082/api/user/users/password/{userId} + +- Parameters + - Required: `userId` path parameter + +- Body + - All of the following fields are required: `oldPassword` (string), `newPassword` (string) + - This endpoint validates whether the [new password](#password) supplied is of the correct format. + + ```json + { + "oldPassword": "SecurePassword", + "newPassword": "SecurePassword@3219", + } + ``` + +- Headers + - Required: `Authorization: Bearer ` + - Auth Rules: + + - Admin users: Cannot update any user's data as it requires the user's password to authorize the request. Admin users should update user details via http://localhost:8082/api/user/users/{userId} instead. + + - Non-admin users: Can only update their own data. The server checks if the user ID in the request URL matches the ID of the user associated with the JWT token. If it matches and the user supplies their own password correctly, the server updates the user's password. + +- Responses: + + | Response Code | Explanation | + |-----------------------------|-----------------------------------------------------------------------------| + | 200 (OK) | User updated successfully, updated user data returned | + | 400 (Bad Request) | Missing/invalid fields | + | 401 (Unauthorized) | Access denied due to missing/invalid/expired JWT or wrong original password | + | 403 (Forbidden) | Access denied for non-admin users updating others' data | + | 404 (Not Found) | User with the specified ID not found | + | 500 (Internal Server Error) | Database or server error | + ### Update User Privilege - This endpoint allows updating a user’s privilege, i.e., promoting or demoting them from admin status. @@ -242,7 +327,7 @@ ```json { "username": "sample123", - "password": "SecurePassword" + "password": "SecurePassword@3219" } ``` @@ -269,4 +354,59 @@ |-----------------------------|----------------------------------------------------| | 200 (OK) | Token verified, authenticated user's data returned | | 401 (Unauthorized) | Missing/invalid/expired JWT | - | 500 (Internal Server Error) | Database or server error | \ No newline at end of file + | 500 (Internal Server Error) | Database or server error | + +## User Service Input Validation + +This section outlines how the user service validates user details when creating or updating a user. + +### Username +- Requirement(s) + - The username can only contain alphanumeric characters. + +- Regex Used for Validation + - ```regex + /^[a-zA-Z0-9._-]+$/ + ``` + +- Valid Example + - ``` + sampleUsername + ``` + +### Email +- Requirement(s) + - The email cannot start with a period. + - The local part of the email cannot contain consecutive periods. + - The local part may include alphanumeric and specific special characters (`+_-.'`). + - The domain must start with an alphanumeric character + - The domain part must be alphanumeric and can include hyphens but not as the first or last character. + - The top-level domain must be composed of letters only and have at least 2 characters. + +- Regex Used for Validation (Based on [zod's emailRegex](https://github.com/colinhacks/zod/blob/main/src/types.ts)) + - ``` + /^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i + ``` + +- Valid Example + - ``` + sample@gmail.com + ``` + +### Password +- Requirement(s) + - The password must contain at least one lowercase letter. + - The password must contain at least one uppercase letter. + - The password must contain at least one digit. + - The password must contain at least one special character (``?=.*[!"#$%&'()*+,-.:;<=>?@\\/\\[\]^_`{|}~]``). + - The password must be at least 8 characters long. + +- Regex Used for Validation + - ``` + /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{8,})(?=.*[!"#$%&'()*+,-.:;<=>?@\\/\\[\]^_`{|}~])/ + ``` + +- Valid Example + - ``` + SecurePassword@3219 + ``` diff --git a/services/user/src/controller/user-controller.ts b/services/user/src/controller/user-controller.ts index d6d7451b79..12e47278d0 100644 --- a/services/user/src/controller/user-controller.ts +++ b/services/user/src/controller/user-controller.ts @@ -25,6 +25,7 @@ import { registrationSchema, updatePasswordSchema, updateUsernameAndEmailSchema, + updateUserSchema, UserValidationErrors, } from '../types/custom'; @@ -44,10 +45,8 @@ export async function createUser(req: Request, res: Response) { const createdUser = await _createUser(username, email, hashedPassword); handleSuccess(res, 201, `Created new user ${username} successfully`, formatUserResponse(createdUser)); } else { - const required_errors = parseResult.error.errors.filter( - err => err.message == UserValidationErrors.REQUIRED, - ); - if (required_errors.length > 0) { + const requiredErrors = parseResult.error.errors.filter(err => err.message == UserValidationErrors.REQUIRED); + if (requiredErrors.length > 0) { handleBadRequest(res, 'username and/or email and/or password are missing'); } handleBadRequest(res, 'invalid username and/or email and/or password'); @@ -128,10 +127,8 @@ export async function updateUsernameAndEmail(req: Request, res: Response) { handleSuccess(res, 200, `Updated data for user ${userId}`, formatUserResponse(updatedUser)); } else { console.log(parseResult.error.errors); - const required_errors = parseResult.error.errors.filter( - err => err.message == UserValidationErrors.REQUIRED, - ); - if (required_errors.length > 0) { + const requiredErrors = parseResult.error.errors.filter(err => err.message == UserValidationErrors.REQUIRED); + if (requiredErrors.length > 0) { handleBadRequest(res, 'username and/or email and/or password are missing'); return; } @@ -177,11 +174,9 @@ export async function updatePassword(req: Request, res: Response) { )) as User; handleSuccess(res, 200, `Updated data for user ${userId}`, formatUserResponse(updatedUser)); } else { - const required_errors = parseResult.error.errors.filter( - err => err.message == UserValidationErrors.REQUIRED, - ); - if (required_errors.length > 0) { - handleBadRequest(res, 'old password and/or new password are missing'); + const requiredErrors = parseResult.error.errors.filter(err => err.message == UserValidationErrors.REQUIRED); + if (requiredErrors.length > 0) { + handleBadRequest(res, 'No field to update: username and email and password are all missing!'); } handleBadRequest(res, 'invalid password'); } @@ -194,12 +189,19 @@ export async function updatePassword(req: Request, res: Response) { export async function updateUser(req: Request, res: Response) { try { - const { username, email, password } = req.body; - if (!(username || email || password)) { - handleBadRequest(res, 'No field to update: username and email and password are all missing!'); + const parseResult = updateUserSchema.safeParse(req.body); + if (!parseResult.success) { + const requiredErrors = parseResult.error.errors.filter(err => err.message == UserValidationErrors.REQUIRED); + if (requiredErrors.length > 0) { + handleBadRequest(res, 'No field to update: username and email and password are all missing!'); + return; + } + handleBadRequest(res, 'invalid username and/or email and/or password'); return; } + const { username, email, password } = req.body; + const userId = req.params.id; if (!isValidObjectId(userId)) { handleNotFound(res, `User ${userId} not found`); @@ -210,6 +212,7 @@ export async function updateUser(req: Request, res: Response) { handleNotFound(res, `User ${userId} not found`); return; } + if (username || email) { const userByUsername = await _findUserByUsername(username); if (userByUsername && userByUsername.id !== userId) { @@ -223,14 +226,13 @@ export async function updateUser(req: Request, res: Response) { } } - if (!password) { - handleBadRequest(res, 'No field to update: password is missing!'); - return; - } const salt = bcrypt.genSaltSync(10); - const hashedPassword = bcrypt.hashSync(password, salt); - - const updatedUser = (await _updateUserById(userId, username, email, hashedPassword)) as User; + const updatedUser = (await _updateUserById( + userId, + username ?? user.username, + email ?? user.email, + password ? bcrypt.hashSync(password, salt) : user.password, + )) as User; handleSuccess(res, 200, `Updated data for user ${userId}`, formatUserResponse(updatedUser)); } catch (err) { console.error(err); diff --git a/services/user/src/types/custom.ts b/services/user/src/types/custom.ts index 37d895bfaf..e17e6ec0a6 100644 --- a/services/user/src/types/custom.ts +++ b/services/user/src/types/custom.ts @@ -43,6 +43,24 @@ export const registrationSchema = z.object({ password: passwordSchema, }); +export const updateUserSchema = z + .object({ + username: usernameSchema.optional(), + email: emailSchema.optional(), + password: passwordSchema.optional(), + }) + .superRefine((data, ctx) => { + if (!data.username && !data.email && !data.password) { + // If none of the variables are present, assign error to each one + // Granular control over which is missing is not needed + ctx.addIssue({ + path: ['username', 'password', 'email'], + message: UserValidationErrors.REQUIRED, + code: z.ZodIssueCode.custom, + }); + } + }); + export const updateUsernameAndEmailSchema = z.object({ username: usernameSchema, email: emailSchema, From 8520762e8559d6f9d3876ed353b3e07f399c8f68 Mon Sep 17 00:00:00 2001 From: samuelim01 <61283948+samuelim01@users.noreply.github.com> Date: Thu, 14 Nov 2024 01:48:24 +0800 Subject: [PATCH 5/6] Fix question page error (#100) * Fix question page error It was using the old error system and was missing a toast for messagingService to serve error messages to user * Fix linting --------- Co-authored-by: LimZiJia --- frontend/src/app/questions/question-dialog.component.ts | 6 +++--- frontend/src/app/questions/questions.component.html | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/questions/question-dialog.component.ts b/frontend/src/app/questions/question-dialog.component.ts index 3d11e7ee1e..43568dbe8e 100644 --- a/frontend/src/app/questions/question-dialog.component.ts +++ b/frontend/src/app/questions/question-dialog.component.ts @@ -130,7 +130,7 @@ export class QuestionDialogComponent implements OnInit { this.questionUpdate.emit(response.data); }, error: (error: HttpErrorResponse) => { - this.errorReceive.emit(error.error.message); + this.errorReceive.emit(error.message); }, complete: () => { this.question = {} as Question; @@ -145,7 +145,7 @@ export class QuestionDialogComponent implements OnInit { this.questionAdd.emit(response.data); }, error: (error: HttpErrorResponse) => { - this.errorReceive.emit('Failed to add new question. ' + error.error.message); + this.errorReceive.emit('Failed to add new question. ' + error.message); }, complete: () => { this.question = {} as Question; @@ -182,7 +182,7 @@ export class QuestionDialogComponent implements OnInit { }, error: (error: HttpErrorResponse) => { this.topics = []; - this.errorReceive.emit('Failed to load topics. ' + error.error.message); + this.errorReceive.emit('Failed to load topics. ' + error.message); }, }); } diff --git a/frontend/src/app/questions/questions.component.html b/frontend/src/app/questions/questions.component.html index bf897793d3..d3d7a54f6c 100644 --- a/frontend/src/app/questions/questions.component.html +++ b/frontend/src/app/questions/questions.component.html @@ -128,3 +128,4 @@

{{ clickedOnQuestion?.title }}

+ From 0c243875226017dfc0fbdcb43ab54a98693bce16 Mon Sep 17 00:00:00 2001 From: McNaBry Date: Thu, 14 Nov 2024 03:14:38 +0800 Subject: [PATCH 6/6] Update history page styles (#101) * Update history page styles * Move history out of account as parent div being flex/grid affects table style * Adjust width of side panel to be maximum 90% of screen width * Update styles of readonly editor * Fix compose.dev.yml * Fix panel description styling Co-authored-by: samuelim01 <61283948+samuelim01@users.noreply.github.com> * Conver history panel to primeNG sidebar * Fix sorting for history table * Remove console log statements in history component * Add fix to history * Make SortEvent required * Adjust undefined handling for dt * Adjust time format * Remove removable sort * Fix question sort * Fix filter --------- Co-authored-by: Samuel Lim Co-authored-by: samuelim01 <61283948+samuelim01@users.noreply.github.com> --- compose.dev.yml | 4 + frontend/src/_services/history.service.ts | 5 +- frontend/src/app/account/account.component.ts | 2 - frontend/src/app/account/account.module.ts | 2 - .../account/history/history.component.html | 84 ------------------- frontend/src/app/app.routes.ts | 6 ++ .../history/history.component.css | 10 +++ .../src/app/history/history.component.html | 83 ++++++++++++++++++ .../history/history.component.ts | 81 +++++++++++++++--- .../{account => }/history/history.model.ts | 5 +- .../navigation-bar.component.ts | 2 +- 11 files changed, 179 insertions(+), 105 deletions(-) delete mode 100644 frontend/src/app/account/history/history.component.html rename frontend/src/app/{account => }/history/history.component.css (82%) create mode 100644 frontend/src/app/history/history.component.html rename frontend/src/app/{account => }/history/history.component.ts (53%) rename frontend/src/app/{account => }/history/history.model.ts (91%) diff --git a/compose.dev.yml b/compose.dev.yml index cb9433736b..ceb93c8369 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -48,6 +48,10 @@ services: - /app/node_modules - ./services/collaboration:/app + collaboration-db: + ports: + - 27020:27017 + history: command: npm run dev ports: diff --git a/frontend/src/_services/history.service.ts b/frontend/src/_services/history.service.ts index 3ee0240ca3..41b5a71930 100644 --- a/frontend/src/_services/history.service.ts +++ b/frontend/src/_services/history.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { historyResponse, MatchingHistory } from '../app/account/history/history.model'; +import { historyResponse, MatchingHistory } from '../app/history/history.model'; import { ApiService } from './api.service'; @Injectable({ @@ -22,7 +22,8 @@ export class HistoryService extends ApiService { id: item._id, roomId: item.roomId, collaborator: item.collaborator.username, - question: item.question, + title: item.question.title, + description: item.question.description, topics: item.question.topics, difficulty: item.question.difficulty, status: item.status, diff --git a/frontend/src/app/account/account.component.ts b/frontend/src/app/account/account.component.ts index aa1bc14b19..a2f6e4c0f1 100644 --- a/frontend/src/app/account/account.component.ts +++ b/frontend/src/app/account/account.component.ts @@ -5,7 +5,6 @@ import { LoginComponent } from './login/login.component'; import { RegisterComponent } from './register/register.component'; import { LayoutComponent } from './layout.component'; import { ProfileComponent } from './profile/profile.component'; -import { HistoryComponent } from './history/history.component'; const routes: Routes = [ { @@ -16,7 +15,6 @@ const routes: Routes = [ { path: 'login', component: LoginComponent }, { path: 'register', component: RegisterComponent }, { path: 'profile', component: ProfileComponent }, - { path: 'history', component: HistoryComponent }, ], }, ]; diff --git a/frontend/src/app/account/account.module.ts b/frontend/src/app/account/account.module.ts index 10b87cfb34..33e7cb9efa 100644 --- a/frontend/src/app/account/account.module.ts +++ b/frontend/src/app/account/account.module.ts @@ -7,7 +7,6 @@ import { RegisterComponent } from './register/register.component'; import { LayoutComponent } from './layout.component'; import { AccountRoutingModule } from './account.component'; import { ProfileComponent } from './profile/profile.component'; -import { HistoryComponent } from './history/history.component'; @NgModule({ imports: [ @@ -18,7 +17,6 @@ import { HistoryComponent } from './history/history.component'; LoginComponent, RegisterComponent, ProfileComponent, - HistoryComponent, ], }) export class AccountModule {} diff --git a/frontend/src/app/account/history/history.component.html b/frontend/src/app/account/history/history.component.html deleted file mode 100644 index ed1d597ccf..0000000000 --- a/frontend/src/app/account/history/history.component.html +++ /dev/null @@ -1,84 +0,0 @@ -
- - -
-

Matching History

-
-
- -
- - - - - - -
-
- - - - Question - - - Difficulty - - Topics - - Collaborator - - Status - Time - - - - - {{ history.question.title }} - {{ history.difficulty }} - {{ history.topics.join(', ') }} - {{ history.collaborator }} - - @if (history.status === 'COMPLETED') { - - } @else if (history.status === 'FORFEITED') { - - } @else if (history.status === 'IN_PROGRESS') { - - } - - {{ history.time }} - - -
-
-
-
-

{{ panelHistory?.question?.title }}

- -
-
-

{{ panelHistory?.question?.description }}

-
-
-
- diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 59b77f39aa..2ce0db61b0 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -5,6 +5,7 @@ import { MatchingComponent } from './matching/matching.component'; import { HomeComponent } from './home/home.component'; import { AuthGuardService } from '../_services/auth.guard.service'; import { CollabGuardService } from '../_services/collab.guard.service'; +import { HistoryComponent } from './history/history.component'; const accountModule = () => import('./account/account.module').then(x => x.AccountModule); @@ -33,6 +34,11 @@ export const routes: Routes = [ component: HomeComponent, canActivate: [AuthGuardService], }, + { + path: 'history', + component: HistoryComponent, + canActivate: [AuthGuardService], + }, { path: '**', redirectTo: '/home', diff --git a/frontend/src/app/account/history/history.component.css b/frontend/src/app/history/history.component.css similarity index 82% rename from frontend/src/app/account/history/history.component.css rename to frontend/src/app/history/history.component.css index 2053d5c2d0..155f32abb6 100644 --- a/frontend/src/app/account/history/history.component.css +++ b/frontend/src/app/history/history.component.css @@ -1,3 +1,12 @@ +.container { + min-height: calc(100dvh - 160px); + width: 100%; + justify-content: center; + align-items: center; + padding: 1rem; + margin-top: auto; +} + .sliding-panel { position: fixed; top: 0; @@ -9,6 +18,7 @@ box-shadow: -2px 0 5px rgba(0,0,0,0.5); transition: right 0.3s ease; z-index: 1000; + max-width: 90%; } .sliding-panel.open { diff --git a/frontend/src/app/history/history.component.html b/frontend/src/app/history/history.component.html new file mode 100644 index 0000000000..050c8801c4 --- /dev/null +++ b/frontend/src/app/history/history.component.html @@ -0,0 +1,83 @@ +
+
+ + +
+

Matching History

+ + + + + + +
+
+ + + Question + + Difficulty + + Topics + + Collaborator + + Status + Time + + + + + {{ history.title }} + {{ history.difficulty }} + {{ history.topics.join(', ') }} + {{ history.collaborator }} + + @if (history.status === 'COMPLETED') { + + } @else if (history.status === 'FORFEITED') { + + } @else if (history.status === 'IN_PROGRESS') { + + } + + {{ history.time | date: 'dd/MM/yyyy hh:mm a' }} + + +
+
+ + +

{{ panelHistory?.title }}

+
+
+

{{ panelHistory?.description }}

+

Submitted Solution

+
+
+
+ +
diff --git a/frontend/src/app/account/history/history.component.ts b/frontend/src/app/history/history.component.ts similarity index 53% rename from frontend/src/app/account/history/history.component.ts rename to frontend/src/app/history/history.component.ts index 728a0b8782..f6d10f75aa 100644 --- a/frontend/src/app/account/history/history.component.ts +++ b/frontend/src/app/history/history.component.ts @@ -1,50 +1,61 @@ import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; -import { TableModule } from 'primeng/table'; +import { Table, TableModule } from 'primeng/table'; import { CommonModule, DatePipe } from '@angular/common'; import { HistoryStatus, MatchingHistory } from './history.model'; -import { HistoryService } from '../../../_services/history.service'; -import { MessageService } from 'primeng/api'; +import { HistoryService } from '../../_services/history.service'; +import { MessageService, SortEvent } from 'primeng/api'; import { InputTextModule } from 'primeng/inputtext'; import { ButtonModule } from 'primeng/button'; import { IconFieldModule } from 'primeng/iconfield'; import { InputIconModule } from 'primeng/inputicon'; import { oneDark } from '@codemirror/theme-one-dark'; -import { EditorState } from '@codemirror/state'; +import { EditorState, Extension } from '@codemirror/state'; import { EditorView, basicSetup } from 'codemirror'; -import { languageMap } from '../../collaboration/editor/languages'; +import { languageMap } from '../collaboration/editor/languages'; import { ToastModule } from 'primeng/toast'; +import { SidebarModule } from 'primeng/sidebar'; import { Router } from '@angular/router'; @Component({ standalone: true, - imports: [TableModule, CommonModule, InputTextModule, ButtonModule, IconFieldModule, InputIconModule, ToastModule], + imports: [ + SidebarModule, + TableModule, + CommonModule, + InputTextModule, + ButtonModule, + IconFieldModule, + InputIconModule, + ToastModule, + ], providers: [MessageService, DatePipe], templateUrl: './history.component.html', styleUrl: './history.component.css', }) export class HistoryComponent implements OnInit { @ViewChild('editor') editor!: ElementRef; + @ViewChild('dt') dt!: Table; histories: MatchingHistory[] = []; + initialValue: MatchingHistory[] = []; loading = true; isPanelVisible = false; panelHistory: MatchingHistory | null = null; editorView: EditorView | null = null; + customTheme!: Extension; + isSorted: null | undefined | boolean; constructor( private historyService: HistoryService, private messageService: MessageService, - private datePipe: DatePipe, private router: Router, ) {} ngOnInit() { this.historyService.getHistories().subscribe({ next: data => { - this.histories = data.map(history => ({ - ...history, - time: this.datePipe.transform(history.time, 'short'), // Pipe to format date for searching - })); + this.histories = data; + this.initialValue = [...data]; this.loading = false; }, error: () => { @@ -60,6 +71,33 @@ export class HistoryComponent implements OnInit { }); } + customSort(event: Required) { + event.data?.sort((data1, data2) => { + const value1 = data1[event.field]; + const value2 = data2[event.field]; + let result = 0; + + // Null checks + if (value1 === null && value2 !== null) { + result = -1; + } else if (value1 !== null && value2 === null) { + result = 1; + } else if (value1 === null && value2 === null) { + result = 0; + } else if (event.field == 'time') { + result = new Date(value1) >= new Date(value2) ? 1 : -1; + } else if (typeof value1 === 'string' && typeof value2 === 'string') { + // String comparison + result = value1.localeCompare(value2); + } else { + // Generic comparison for numbers and other types + result = value1 < value2 ? -1 : value1 > value2 ? 1 : 0; + } + + return event.order * result; + }); + } + onRowSelect(history: MatchingHistory) { this.panelHistory = history; if (history.status != HistoryStatus.IN_PROGRESS) { @@ -80,10 +118,29 @@ export class HistoryComponent implements OnInit { this.editorView.destroy(); } + const customTheme = EditorView.theme( + { + '&': { + backgroundColor: 'var(--surface-section)', + }, + '.cm-gutters': { + backgroundColor: 'var(--surface-section)', + }, + }, + { dark: true }, + ); + const languageExtension = languageMap[language] || languageMap['java']; const state = EditorState.create({ doc: code, - extensions: [basicSetup, languageExtension, oneDark, EditorView.editable.of(false)], + extensions: [ + basicSetup, + languageExtension, + customTheme, + oneDark, + EditorView.lineWrapping, + EditorView.editable.of(false), + ], }); this.editorView = new EditorView({ diff --git a/frontend/src/app/account/history/history.model.ts b/frontend/src/app/history/history.model.ts similarity index 91% rename from frontend/src/app/account/history/history.model.ts rename to frontend/src/app/history/history.model.ts index e0972b1411..a10893323e 100644 --- a/frontend/src/app/account/history/history.model.ts +++ b/frontend/src/app/history/history.model.ts @@ -1,4 +1,4 @@ -import { DifficultyLevels } from '../../questions/difficulty-levels.enum'; +import { DifficultyLevels } from '../questions/difficulty-levels.enum'; export enum HistoryStatus { COMPLETED = 'COMPLETED', @@ -10,7 +10,8 @@ export interface MatchingHistory { id: string; roomId: string; collaborator: string; // collaborator username - question: Question; // question + title: string; + description: string; difficulty: DifficultyLevels; // question difficulty topics: string[]; // question topics status: HistoryStatus; // status of the session diff --git a/frontend/src/app/navigation-bar/navigation-bar.component.ts b/frontend/src/app/navigation-bar/navigation-bar.component.ts index 1bf5fb0bce..774cfd5aab 100644 --- a/frontend/src/app/navigation-bar/navigation-bar.component.ts +++ b/frontend/src/app/navigation-bar/navigation-bar.component.ts @@ -71,7 +71,7 @@ export class NavigationBarComponent implements OnInit { { label: 'Match History', icon: 'pi pi-trophy', - routerLink: '/account/history', + routerLink: '/history', class: 'p-submenu-list', }, {