Skip to content

Commit fa522fd

Browse files
LimZiJiasamuelim01
andauthored
Connect frontend with user service (#47)
* Copy user service https://github.com/CS3219-AY2425S1/PeerPrep-UserService * Dockerise user-service Got user service to work in docker. It can only interface with an atlas cloud server currently. * Dockerise user-service User-service in docker can only communicate with an atlas mongodb server. * Merge with main Some files should have been committed in the previous commit. They are mostly to do with the dockerisation process. The merge with main might have messed with it. * Create .gitignore Somehow .gitignore was not commited with the last commit * Fix POST causing SEGSEGV Apparently bcrypt library cannot run on Docker's architecture. Change bcrypt to bcryptjs for compatibility. * Add test Test does not currently work * Basic user service Not connected to the login interface yet. Commiting to make a clean Pr. * Move user service folder * Fix minor user service issues * Remove orphan history file * Move `index.js` and `server.js` to the correct directory * Reduce use of port * Change login to use username * Fix user env typo * Enable login Frontend is now able to POST to user login endpoint and receive a JWT token and some user details. These are stored in localStorage. * Same commit as before Some new files were untracked. * Implement login and register Right now if register is successful, it will auto login. Login if successful will bring users to the '/' directory. If user is already logged in, attemptes to reach '/account' will be immediately redirected to '/' * Rename files To keep it consistent with main, I have renamed files that are not part of app from filename to "_filename" out of app. I also cleaned up the outdated user-service because it was renamed already. * Fix wrong merge There was a duplicated of "user" in compose.yml. * Init guards Used ng generate guard to generate these. * Implement auth-guard and interceptors Removed those _guards and implemented as a service instead. Added a guard to '/questions' so only logged in users with tokens that have not expired can access it. If they are not logged in, users will be directed to '/login' to log in. * Fix prettier Was magically missing from last commit. * Fix silly issues - Removed user network (errornous merging). - Removed duplicate providers * Fix linting Added interface for user service response. * Temp commit Trying to figure out how to get status code from HttpClient with HttpResponse. * Fix error handling Realised that the error interceptor was mishandling the errors. Now it will throw an Error object with properties .message and .cause. Where message is the original error.message, and cause is just the original error itself. * Fix linting * Minor fixes * Move interceptors into _interceptors * Clean user and user service response models * Fix auth service unhandled cases * Minor phrasing fixes * Clean JWT interceptor * Use API_CONFIG instead of environment.ts * Oops * Tie in gateway changes properly --------- Co-authored-by: samuelim01 <[email protected]>
1 parent 6f8ab95 commit fa522fd

15 files changed

+1105
-963
lines changed

compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,4 @@ networks:
8989
question-db-network:
9090
driver: bridge
9191
user-db-network:
92-
driver: bridge
92+
driver: bridge

frontend/package-lock.json

Lines changed: 773 additions & 935 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@types/jasmine": "~5.1.0",
3535
"angular-eslint": "18.3.1",
3636
"eslint": "^9.9.1",
37+
"eslint-config-prettier": "^9.1.0",
3738
"eslint-plugin-prettier": "^5.2.1",
3839
"jasmine-core": "~5.2.0",
3940
"karma": "~6.4.0",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Injectable } from '@angular/core';
2+
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
3+
import { Observable, throwError } from 'rxjs';
4+
import { catchError } from 'rxjs/operators';
5+
6+
@Injectable()
7+
export class ErrorInterceptor implements HttpInterceptor {
8+
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
9+
return next.handle(request).pipe(
10+
catchError(err => {
11+
console.error(err);
12+
13+
const errorMessage = err.error.message;
14+
return throwError(() => new Error(errorMessage, { cause: err }));
15+
}),
16+
);
17+
}
18+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
3+
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
4+
import { JwtInterceptor } from './jwt.interceptor';
5+
import { AuthenticationService } from '../_services/authentication.service';
6+
import { API_CONFIG } from '../app/api.config';
7+
8+
describe('JwtInterceptor', () => {
9+
let httpMock: HttpTestingController;
10+
let httpClient: HttpClient;
11+
let mockAuthService: jasmine.SpyObj<AuthenticationService>;
12+
13+
beforeEach(() => {
14+
mockAuthService = jasmine.createSpyObj('AuthenticationService', ['userValue'], {
15+
userValue: { accessToken: 'fake-jwt-token' },
16+
});
17+
18+
TestBed.configureTestingModule({
19+
imports: [HttpClient],
20+
providers: [
21+
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
22+
{ provide: AuthenticationService, useValue: mockAuthService },
23+
provideHttpClientTesting(),
24+
],
25+
});
26+
27+
httpMock = TestBed.inject(HttpTestingController);
28+
httpClient = TestBed.inject(HttpClient);
29+
});
30+
31+
afterEach(() => {
32+
// Check if all Http requests were handled
33+
httpMock.verify();
34+
});
35+
36+
it('should add an Authorization header', () => {
37+
httpClient.get(`${API_CONFIG.baseUrl}/user/test`).subscribe();
38+
39+
const httpRequest = httpMock.expectOne(`${API_CONFIG.baseUrl}/user/test`);
40+
41+
expect(httpRequest.request.headers.has('Authorization')).toBeTruthy();
42+
expect(httpRequest.request.headers.get('Authorization')).toBe('Bearer fake-jwt-token');
43+
});
44+
45+
it('should not add an Authorization header if the user is not logged in', () => {
46+
mockAuthService = jasmine.createSpyObj('AuthenticationService', ['userValue'], {
47+
userValue: {},
48+
});
49+
50+
httpClient.get(`${API_CONFIG.baseUrl}/user/test`).subscribe();
51+
52+
const httpRequest = httpMock.expectOne(`${API_CONFIG.baseUrl}/user/test`);
53+
54+
expect(httpRequest.request.headers.has('Authorization')).toBeFalsy();
55+
});
56+
57+
it('should not add an Authorization header for non-API URLs', () => {
58+
httpClient.get('https://example.com/test').subscribe();
59+
60+
const httpRequest = httpMock.expectOne('https://example.com/test');
61+
62+
expect(httpRequest.request.headers.has('Authorization')).toBeFalsy();
63+
});
64+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Injectable } from '@angular/core';
2+
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
3+
import { Observable } from 'rxjs';
4+
import { AuthenticationService } from '../_services/authentication.service';
5+
6+
@Injectable()
7+
export class JwtInterceptor implements HttpInterceptor {
8+
constructor(private authenticationService: AuthenticationService) {}
9+
10+
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
11+
// add auth header with jwt if user is logged in and request is to the api url
12+
const currentUser = this.authenticationService.userValue;
13+
if (currentUser) {
14+
const accessToken = currentUser.accessToken;
15+
request = request.clone({
16+
headers: request.headers.set('Authorization', `Bearer ${accessToken}`),
17+
});
18+
}
19+
return next.handle(request);
20+
}
21+
}

frontend/src/_models/user.model.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export interface User {
2+
id: string;
3+
username: string;
4+
email: string;
5+
isAdmin: boolean;
6+
createdAt: string;
7+
accessToken: string;
8+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { User } from './user.model';
2+
3+
export interface BaseResponse {
4+
status: string;
5+
message: string;
6+
}
7+
8+
export interface UServRes extends BaseResponse {
9+
message: string;
10+
data: User;
11+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Injectable } from '@angular/core';
2+
import { CanActivate, Router } from '@angular/router';
3+
import { AuthenticationService } from './authentication.service';
4+
import { filter, map, Observable, of, switchMap } from 'rxjs';
5+
import { HttpClient } from '@angular/common/http';
6+
import { UServRes } from '../_models/user.service.model';
7+
import { ApiService } from './api.service';
8+
9+
@Injectable()
10+
export class AuthGuardService extends ApiService implements CanActivate {
11+
protected apiPath = 'user';
12+
constructor(
13+
private authenticationService: AuthenticationService,
14+
private http: HttpClient,
15+
private router: Router,
16+
) {
17+
super();
18+
}
19+
20+
canActivate(): Observable<boolean> {
21+
return this.authenticationService.user$.pipe(
22+
filter(user => user !== undefined),
23+
switchMap(user => {
24+
// switchMap to flatten the observable from http.get
25+
if (user === null) {
26+
// not logged in so redirect to login page with the return url
27+
this.router.navigate(['/account/login']);
28+
return of(false); // of() to return an observable to be flattened
29+
}
30+
// call to user service endpoint '/users/{user_id}' to check user is still valid
31+
return this.http.get<UServRes>(`${this.apiUrl}/users/${user.id}`, { observe: 'response' }).pipe(
32+
map(response => {
33+
if (response.status === 200) {
34+
return true;
35+
}
36+
return false;
37+
}),
38+
);
39+
}),
40+
);
41+
}
42+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Modified from https://jasonwatmore.com/post/2022/11/15/angular-14-jwt-authentication-example-tutorial#login-component-ts
2+
import { Injectable } from '@angular/core';
3+
import { Router } from '@angular/router';
4+
import { HttpClient } from '@angular/common/http';
5+
import { BehaviorSubject, Observable } from 'rxjs';
6+
import { map, switchMap } from 'rxjs/operators';
7+
import { UServRes } from '../_models/user.service.model';
8+
import { User } from '../_models/user.model';
9+
import { ApiService } from './api.service';
10+
11+
@Injectable({ providedIn: 'root' })
12+
export class AuthenticationService extends ApiService {
13+
protected apiPath = 'user';
14+
15+
private userSubject: BehaviorSubject<User | null>;
16+
public user$: Observable<User | null>;
17+
18+
constructor(
19+
private router: Router,
20+
private http: HttpClient,
21+
) {
22+
super();
23+
const userData = localStorage.getItem('user');
24+
this.userSubject = new BehaviorSubject(userData ? JSON.parse(userData) : null);
25+
this.user$ = this.userSubject.asObservable();
26+
}
27+
28+
public get userValue() {
29+
return this.userSubject.value;
30+
}
31+
32+
login(username: string, password: string) {
33+
console.log('login', `${this.apiUrl}/auth/login`);
34+
return this.http
35+
.post<UServRes>(
36+
`${this.apiUrl}/auth/login`,
37+
{ username: username, password: password },
38+
{ observe: 'response' },
39+
)
40+
.pipe(
41+
map(response => {
42+
// store user details and jwt token in local storage to keep user logged in between page refreshes
43+
let user = null;
44+
if (response.body) {
45+
const { id, username, email, accessToken, isAdmin, createdAt } = response.body.data;
46+
user = { id, username, email, accessToken, isAdmin, createdAt };
47+
}
48+
localStorage.setItem('user', JSON.stringify(user));
49+
this.userSubject.next(user);
50+
return user;
51+
}),
52+
);
53+
}
54+
55+
createAccount(username: string, email: string, password: string) {
56+
return this.http
57+
.post<UServRes>(
58+
`${this.apiUrl}/users`,
59+
{ username: username, email: email, password: password },
60+
{ observe: 'response' },
61+
)
62+
.pipe(switchMap(() => this.login(username, password))); // auto login after registration
63+
}
64+
65+
logout() {
66+
// remove user from local storage to log user out
67+
localStorage.removeItem('user');
68+
this.userSubject.next(null);
69+
this.router.navigate(['/account/login']);
70+
}
71+
}

0 commit comments

Comments
 (0)