Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Connect frontend with user service #47

Merged
merged 33 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7450fca
Merge branch 'main' of https://github.com/CS3219-AY2425S1/cs3219-ay24…
samuelim01 Sep 27, 2024
b33dc0e
Copy user service
samuelim01 Sep 27, 2024
1291b29
Dockerise user-service
LimZiJia Sep 23, 2024
0d1984e
Dockerise user-service
LimZiJia Sep 23, 2024
d91d061
Merge with main
LimZiJia Sep 23, 2024
3ae459f
Create .gitignore
LimZiJia Sep 23, 2024
dbbef27
Fix POST causing SEGSEGV
LimZiJia Sep 24, 2024
fd7d2bc
Add test
LimZiJia Sep 24, 2024
5dbe575
Basic user service
LimZiJia Sep 27, 2024
667f3b5
Move user service folder
samuelim01 Sep 27, 2024
7f27325
Fix minor user service issues
samuelim01 Sep 27, 2024
ab2ce43
Change login to use username
samuelim01 Sep 27, 2024
5c490d0
Fix user env typo
samuelim01 Sep 27, 2024
661a430
Enable login
LimZiJia Sep 28, 2024
8605f38
Same commit as before
LimZiJia Sep 28, 2024
8be5003
Implement login and register
LimZiJia Sep 28, 2024
dbf74f6
Rename files
LimZiJia Sep 29, 2024
3114a9e
Merge remote-tracking branch 'origin/main' into connect-frontend-with…
LimZiJia Sep 29, 2024
ac136c6
Fix wrong merge
LimZiJia Sep 29, 2024
b01dceb
Init guards
LimZiJia Sep 29, 2024
4d48539
Implement auth-guard and interceptors
LimZiJia Sep 30, 2024
4545df7
Fix prettier
LimZiJia Sep 30, 2024
fb9e5c4
Fix silly issues
LimZiJia Sep 30, 2024
dad4d75
Fix linting
LimZiJia Sep 30, 2024
7a5faa8
Temp commit
LimZiJia Sep 30, 2024
c1b6bff
Fix error handling
LimZiJia Oct 16, 2024
c2f1cfc
Fix linting
LimZiJia Oct 16, 2024
cc25c15
Merge branch 'main' into connect-frontend-with-user-service
samuelim01 Oct 17, 2024
383be75
Minor fixes
samuelim01 Oct 17, 2024
f096a0e
Clean JWT interceptor
samuelim01 Oct 17, 2024
5936fdd
Use API_CONFIG instead of environment.ts
samuelim01 Oct 17, 2024
2d797f9
Oops
samuelim01 Oct 17, 2024
072f677
Tie in gateway changes properly
samuelim01 Oct 17, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,4 @@ networks:
question-db-network:
driver: bridge
user-db-network:
driver: bridge
driver: bridge
1,708 changes: 773 additions & 935 deletions frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@types/jasmine": "~5.1.0",
"angular-eslint": "18.3.1",
"eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jasmine-core": "~5.2.0",
"karma": "~6.4.0",
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/_interceptors/error.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return next.handle(request).pipe(
catchError(err => {
console.error(err);

const errorMessage = err.error.message;
return throwError(() => new Error(errorMessage, { cause: err }));
}),
);
}
}
64 changes: 64 additions & 0 deletions frontend/src/_interceptors/jwt.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { JwtInterceptor } from './jwt.interceptor';
import { AuthenticationService } from '../_services/authentication.service';
import { API_CONFIG } from '../app/api.config';

describe('JwtInterceptor', () => {
let httpMock: HttpTestingController;
let httpClient: HttpClient;
let mockAuthService: jasmine.SpyObj<AuthenticationService>;

beforeEach(() => {
mockAuthService = jasmine.createSpyObj('AuthenticationService', ['userValue'], {
userValue: { accessToken: 'fake-jwt-token' },
});

TestBed.configureTestingModule({
imports: [HttpClient],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
{ provide: AuthenticationService, useValue: mockAuthService },
provideHttpClientTesting(),
],
});

httpMock = TestBed.inject(HttpTestingController);
httpClient = TestBed.inject(HttpClient);
});

afterEach(() => {
// Check if all Http requests were handled
httpMock.verify();
});

it('should add an Authorization header', () => {
httpClient.get(`${API_CONFIG.baseUrl}/user/test`).subscribe();

const httpRequest = httpMock.expectOne(`${API_CONFIG.baseUrl}/user/test`);

expect(httpRequest.request.headers.has('Authorization')).toBeTruthy();
expect(httpRequest.request.headers.get('Authorization')).toBe('Bearer fake-jwt-token');
});

it('should not add an Authorization header if the user is not logged in', () => {
mockAuthService = jasmine.createSpyObj('AuthenticationService', ['userValue'], {
userValue: {},
});

httpClient.get(`${API_CONFIG.baseUrl}/user/test`).subscribe();

const httpRequest = httpMock.expectOne(`${API_CONFIG.baseUrl}/user/test`);

expect(httpRequest.request.headers.has('Authorization')).toBeFalsy();
});

it('should not add an Authorization header for non-API URLs', () => {
httpClient.get('https://example.com/test').subscribe();

const httpRequest = httpMock.expectOne('https://example.com/test');

expect(httpRequest.request.headers.has('Authorization')).toBeFalsy();
});
});
21 changes: 21 additions & 0 deletions frontend/src/_interceptors/jwt.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthenticationService } from '../_services/authentication.service';

@Injectable()
export class JwtInterceptor implements HttpInterceptor {
constructor(private authenticationService: AuthenticationService) {}

intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// add auth header with jwt if user is logged in and request is to the api url
const currentUser = this.authenticationService.userValue;
if (currentUser) {
const accessToken = currentUser.accessToken;
request = request.clone({
headers: request.headers.set('Authorization', `Bearer ${accessToken}`),
});
}
return next.handle(request);
}
}
8 changes: 8 additions & 0 deletions frontend/src/_models/user.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface User {
id: string;
username: string;
email: string;
isAdmin: boolean;
createdAt: string;
accessToken: string;
}
11 changes: 11 additions & 0 deletions frontend/src/_models/user.service.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { User } from './user.model';

export interface BaseResponse {
status: string;
message: string;
}

export interface UServRes extends BaseResponse {
message: string;
data: User;
}
42 changes: 42 additions & 0 deletions frontend/src/_services/auth.guard.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { AuthenticationService } from './authentication.service';
import { filter, map, Observable, of, switchMap } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { UServRes } from '../_models/user.service.model';
import { ApiService } from './api.service';

@Injectable()
export class AuthGuardService extends ApiService implements CanActivate {
protected apiPath = 'user';
constructor(
private authenticationService: AuthenticationService,
private http: HttpClient,
private router: Router,
) {
super();
}

canActivate(): Observable<boolean> {
return this.authenticationService.user$.pipe(
filter(user => user !== undefined),
switchMap(user => {
// switchMap to flatten the observable from http.get
if (user === null) {
// not logged in so redirect to login page with the return url
this.router.navigate(['/account/login']);
return of(false); // of() to return an observable to be flattened
}
// call to user service endpoint '/users/{user_id}' to check user is still valid
return this.http.get<UServRes>(`${this.apiUrl}/users/${user.id}`, { observe: 'response' }).pipe(
map(response => {
if (response.status === 200) {
return true;
}
return false;
}),
);
}),
);
}
}
71 changes: 71 additions & 0 deletions frontend/src/_services/authentication.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Modified from https://jasonwatmore.com/post/2022/11/15/angular-14-jwt-authentication-example-tutorial#login-component-ts
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { UServRes } from '../_models/user.service.model';
import { User } from '../_models/user.model';
import { ApiService } from './api.service';

@Injectable({ providedIn: 'root' })
export class AuthenticationService extends ApiService {
protected apiPath = 'user';

private userSubject: BehaviorSubject<User | null>;
public user$: Observable<User | null>;

constructor(
private router: Router,
private http: HttpClient,
) {
super();
const userData = localStorage.getItem('user');
this.userSubject = new BehaviorSubject(userData ? JSON.parse(userData) : null);
this.user$ = this.userSubject.asObservable();
}

public get userValue() {
return this.userSubject.value;
}

login(username: string, password: string) {
console.log('login', `${this.apiUrl}/auth/login`);
return this.http
.post<UServRes>(
`${this.apiUrl}/auth/login`,
{ username: username, password: password },
{ observe: 'response' },
)
.pipe(
map(response => {
// store user details and jwt token in local storage to keep user logged in between page refreshes
let user = null;
if (response.body) {
const { id, username, email, accessToken, isAdmin, createdAt } = response.body.data;
user = { id, username, email, accessToken, isAdmin, createdAt };
}
localStorage.setItem('user', JSON.stringify(user));
this.userSubject.next(user);
return user;
}),
);
}

createAccount(username: string, email: string, password: string) {
return this.http
.post<UServRes>(
`${this.apiUrl}/users`,
{ username: username, email: email, password: password },
{ observe: 'response' },
)
.pipe(switchMap(() => this.login(username, password))); // auto login after registration
}

logout() {
// remove user from local storage to log user out
localStorage.removeItem('user');
this.userSubject.next(null);
this.router.navigate(['/account/login']);
}
}
4 changes: 2 additions & 2 deletions frontend/src/app/account/login.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ <h2 class="mt-0 align-self-start">Log In</h2>
<form #loginForm="ngForm" (ngSubmit)="onSubmit()" class="form-container">
<div class="form-field">
<label for="username">Username</label>
<input pInputText required type="text" id="username" name="username" [(ngModel)]="user.username" />
<input pInputText required type="text" id="username" name="username" [(ngModel)]="userForm.username" />
</div>

<div class="form-field">
Expand All @@ -13,7 +13,7 @@ <h2 class="mt-0 align-self-start">Log In</h2>
required
inputId="password"
name="password"
[(ngModel)]="user.password"
[(ngModel)]="userForm.password"
[toggleMask]="true"
[feedback]="false"
class="p-fluid" />
Expand Down
56 changes: 41 additions & 15 deletions frontend/src/app/account/login.component.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,70 @@
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { RouterLink, Router, ActivatedRoute } from '@angular/router';
import { SelectButtonModule } from 'primeng/selectbutton';
import { InputTextModule } from 'primeng/inputtext';
import { PasswordModule } from 'primeng/password';
import { ButtonModule } from 'primeng/button';
import { ToastModule } from 'primeng/toast';
import { MessageService } from 'primeng/api';
import { AuthenticationService } from '../../_services/authentication.service';

@Component({
selector: 'app-login',
standalone: true,
imports: [RouterLink, FormsModule, InputTextModule, ButtonModule, SelectButtonModule, PasswordModule, ToastModule],
providers: [MessageService],
providers: [MessageService, AuthenticationService],
templateUrl: './login.component.html',
styleUrl: './account.component.css',
})
export class LoginComponent {
constructor(private messageService: MessageService) {}
constructor(
private messageService: MessageService,
private authenticationService: AuthenticationService,
private router: Router,
private route: ActivatedRoute,
) {
//redirect to home if already logged in
if (this.authenticationService.userValue) {
this.router.navigate(['/']);
}
}

user = {
userForm = {
username: '',
password: '',
};

isProcessingLogin = false;

showError() {
this.messageService.add({ severity: 'error', summary: 'Log In Error', detail: 'Missing Details' });
}

onSubmit() {
if (this.user.username && this.user.password) {
if (this.userForm.username && this.userForm.password) {
this.isProcessingLogin = true;
this.showError();
// Simulate API request
setTimeout(() => {
this.isProcessingLogin = false;
console.log('Form Submitted', this.user);
}, 3000);

// authenticationService returns an observable that we can subscribe to
this.authenticationService
.login(this.userForm.username, this.userForm.password)
.pipe()
.subscribe({
next: () => {
// get return url from route parameters or default to '/'
const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
this.router.navigate([returnUrl]);
},
error: error => {
this.isProcessingLogin = false;
const status = error.cause.status;
let errorMessage = 'An unknown error occurred';
if (status === 400) {
errorMessage = 'Missing Fields';
} else if (status === 401) {
errorMessage = 'Invalid username or password';
} else if (status === 500) {
errorMessage = 'Internal Server Error';
}
this.messageService.add({ severity: 'error', summary: 'Log In Error', detail: errorMessage });
},
});
} else {
console.log('Invalid form');
}
Expand Down
Loading