diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index ef30769c..5fda6737 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -6,5 +6,5 @@ import { Component } from '@angular/core'; styleUrls: ['./app.component.scss'] }) export class AppComponent { - title = 'lettercraft'; + title = 'lettercraft'; } diff --git a/frontend/src/app/core/core.module.ts b/frontend/src/app/core/core.module.ts index 0366cabc..a84236ba 100644 --- a/frontend/src/app/core/core.module.ts +++ b/frontend/src/app/core/core.module.ts @@ -3,6 +3,7 @@ import { SharedModule } from '../shared/shared.module'; import { FooterComponent } from './footer/footer.component'; import { MenuComponent } from './menu/menu.component'; import { UserMenuComponent } from './menu/user-menu/user-menu.component'; +import { ToastContainerComponent } from './toast-container/toast-container.component'; @@ -11,6 +12,7 @@ import { UserMenuComponent } from './menu/user-menu/user-menu.component'; FooterComponent, MenuComponent, UserMenuComponent, + ToastContainerComponent, ], imports: [ SharedModule @@ -18,6 +20,7 @@ import { UserMenuComponent } from './menu/user-menu/user-menu.component'; exports: [ FooterComponent, MenuComponent, + ToastContainerComponent ] }) export class CoreModule { } diff --git a/frontend/src/app/core/menu/user-menu/user-menu.component.html b/frontend/src/app/core/menu/user-menu/user-menu.component.html index 161c77a9..0c9d31a2 100644 --- a/frontend/src/app/core/menu/user-menu/user-menu.component.html +++ b/frontend/src/app/core/menu/user-menu/user-menu.component.html @@ -1,5 +1,5 @@ diff --git a/frontend/src/app/core/menu/user-menu/user-menu.component.spec.ts b/frontend/src/app/core/menu/user-menu/user-menu.component.spec.ts index feccdda8..6874516c 100644 --- a/frontend/src/app/core/menu/user-menu/user-menu.component.spec.ts +++ b/frontend/src/app/core/menu/user-menu/user-menu.component.spec.ts @@ -1,16 +1,34 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; - import { UserMenuComponent } from './user-menu.component'; import { AuthService } from '@services/auth.service'; -import { AuthServiceMock, testUser } from '@mock/auth.service.mock'; import { SharedTestingModule } from '@shared/shared-testing.module'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { UserResponse } from 'src/app/user/models/user'; + +const fakeUserResponse: UserResponse = { + id: 1, + username: "frodo", + email: "frodo@shire.me", + first_name: "Frodo", + last_name: "Baggins", + is_staff: false +} + +const fakeAdminResponse: UserResponse = { + id: 1, + username: "gandalf", + email: "gandalf@istari.me", + first_name: "Gandalf", + last_name: "The Grey", + is_staff: true +} describe('UserMenuComponent', () => { let component: UserMenuComponent; let fixture: ComponentFixture; - let authService: AuthServiceMock; + let httpTestingController: HttpTestingController; const spinner = () => fixture.debugElement.query(By.css('.spinner-border')); const signInLink = () => fixture.debugElement.query(By.css('a[href="/login"]')); @@ -18,39 +36,57 @@ describe('UserMenuComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [ - { provide: AuthService, useClass: AuthServiceMock } - ], declarations: [UserMenuComponent], + providers: [AuthService], imports: [SharedTestingModule], }); - authService = TestBed.inject(AuthService) as any as AuthServiceMock; + httpTestingController = TestBed.inject(HttpTestingController); fixture = TestBed.createComponent(UserMenuComponent); component = fixture.componentInstance; fixture.detectChanges(); }); + afterEach(() => { + httpTestingController.verify(); + }); + it('should create', () => { + httpTestingController.expectOne('/users/user/'); expect(component).toBeTruthy(); }); + it('should show sign-in when not logged in', () => { + const req = httpTestingController.expectOne('/users/user/'); + req.flush(null); + fixture.detectChanges(); + + expect(spinner()).toBeFalsy(); + expect(signInLink()).toBeTruthy(); + expect(userDropdownTrigger()).toBeFalsy(); + }); + it('should show a loading spinner', () => { + httpTestingController.expectOne('/users/user/'); expect(spinner()).toBeTruthy(); expect(signInLink()).toBeFalsy(); expect(userDropdownTrigger()).toBeFalsy(); }); - it('should show sign-in when not logged in', () => { - authService._setUser(null); + it('should show an admin menu when user is a staff member', () => { + const req = httpTestingController.expectOne('/users/user/'); + req.flush(fakeAdminResponse); fixture.detectChanges(); expect(spinner()).toBeFalsy(); - expect(signInLink()).toBeTruthy(); - expect(userDropdownTrigger()).toBeFalsy(); + expect(signInLink()).toBeFalsy(); + expect(userDropdownTrigger()).toBeTruthy(); + expect(userDropdownTrigger().nativeElement.textContent).toContain('gandalf'); }); + it('should show a user menu when logged in', () => { - authService._setUser(testUser({})); + const req = httpTestingController.expectOne('/users/user/'); + req.flush(fakeUserResponse); fixture.detectChanges(); expect(spinner()).toBeFalsy(); diff --git a/frontend/src/app/core/menu/user-menu/user-menu.component.ts b/frontend/src/app/core/menu/user-menu/user-menu.component.ts index bca0dfd5..e92c1dd0 100644 --- a/frontend/src/app/core/menu/user-menu/user-menu.component.ts +++ b/frontend/src/app/core/menu/user-menu/user-menu.component.ts @@ -1,36 +1,66 @@ -import { Component } from '@angular/core'; +import { Component, DestroyRef, OnInit } from '@angular/core'; import { faUser } from '@fortawesome/free-solid-svg-icons'; -import { Observable, map } from 'rxjs'; -import { User } from '../../../user/models/user'; +import { map } from 'rxjs'; import { AuthService } from '@services/auth.service'; import _ from 'underscore'; +import { ToastService } from '@services/toast.service'; +import { Router } from '@angular/router'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'lc-user-menu', templateUrl: './user-menu.component.html', styleUrls: ['./user-menu.component.scss'] }) -export class UserMenuComponent { - isLoading$: Observable; - user$: Observable; - showSignIn$: Observable; +export class UserMenuComponent implements OnInit { + public authLoading$ = this.authService.currentUser$.pipe( + map(_.isUndefined) + ); - icons = { + public user$ = this.authService.currentUser$; + + public showSignIn$ = this.authService.currentUser$.pipe( + map(_.isNull) + ); + + public icons = { user: faUser, }; - constructor(private authService: AuthService) { - this.isLoading$ = this.authService.currentUser$.pipe( - map(_.isUndefined) - ); - this.user$ = this.authService.currentUser$; - this.showSignIn$ = this.authService.currentUser$.pipe( - map(_.isNull) - ); + public logoutLoading$ = this.authService.logout.loading$; + + constructor( + private authService: AuthService, + private toastService: ToastService, + private router: Router, + private destroyRef: DestroyRef + ) { } + + ngOnInit(): void { + this.authService.logout.error$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.show({ + header: 'Sign out failed', + body: 'There was an error signing you out. Please try again.', + type: 'danger' + }); + }); + + this.authService.logout.success$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.show({ + header: 'Sign out successful', + body: 'You have been successfully signed out.', + type: 'success' + }); + this.router.navigate(['/']); + }); } - logout() { - this.authService.logout(false); + public logout(): void { + this.authService.logout.subject.next(); } } diff --git a/frontend/src/app/core/toast-container/toast-container.component.html b/frontend/src/app/core/toast-container/toast-container.component.html new file mode 100644 index 00000000..2c65bb13 --- /dev/null +++ b/frontend/src/app/core/toast-container/toast-container.component.html @@ -0,0 +1,11 @@ + + {{ toast.body }} + diff --git a/frontend/src/app/core/toast-container/toast-container.component.scss b/frontend/src/app/core/toast-container/toast-container.component.scss new file mode 100644 index 00000000..42d1ad05 --- /dev/null +++ b/frontend/src/app/core/toast-container/toast-container.component.scss @@ -0,0 +1,7 @@ +:host { + position: fixed; + bottom: 0; + right: 0; + margin: 0.5em; + z-index: 1200; + } diff --git a/frontend/src/app/core/toast-container/toast-container.component.spec.ts b/frontend/src/app/core/toast-container/toast-container.component.spec.ts new file mode 100644 index 00000000..fb73b016 --- /dev/null +++ b/frontend/src/app/core/toast-container/toast-container.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ToastContainerComponent } from './toast-container.component'; + +describe('ToastContainerComponent', () => { + let component: ToastContainerComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ToastContainerComponent] + }); + fixture = TestBed.createComponent(ToastContainerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/core/toast-container/toast-container.component.ts b/frontend/src/app/core/toast-container/toast-container.component.ts new file mode 100644 index 00000000..efe7cd72 --- /dev/null +++ b/frontend/src/app/core/toast-container/toast-container.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { ToastService } from '@services/toast.service'; + +@Component({ + selector: 'lc-toast-container', + templateUrl: './toast-container.component.html', + styleUrls: ['./toast-container.component.scss'] +}) +export class ToastContainerComponent { + constructor(public toastService: ToastService) { } +} diff --git a/frontend/src/app/routes.ts b/frontend/src/app/routes.ts index 7c501e1e..cf8bbcf5 100644 --- a/frontend/src/app/routes.ts +++ b/frontend/src/app/routes.ts @@ -3,6 +3,10 @@ import { Routes } from '@angular/router'; import { HomeComponent } from './home/home.component'; import { LoginComponent } from './user/login/login.component'; import { VerifyEmailComponent } from './user/verify-email/verify-email.component'; +import { RegisterComponent } from './user/register/register.component'; +import { PasswordForgottenComponent } from './user/password-forgotten/password-forgotten.component'; +import { ResetPasswordComponent } from './user/reset-password/reset-password.component'; +import { UserSettingsComponent } from './user/user-settings/user-settings.component'; const routes: Routes = [ { @@ -13,10 +17,26 @@ const routes: Routes = [ path: 'login', component: LoginComponent, }, + { + path: 'register', + component: RegisterComponent, + }, { path: 'confirm-email/:key', component: VerifyEmailComponent, }, + { + path: 'password-forgotten', + component: PasswordForgottenComponent + }, + { + path: 'reset-password/:uid/:token', + component: ResetPasswordComponent + }, + { + path: 'user-settings', + component: UserSettingsComponent + }, { path: '', redirectTo: '/home', diff --git a/frontend/src/app/services/auth.service.ts b/frontend/src/app/services/auth.service.ts index 22914a79..77418daf 100644 --- a/frontend/src/app/services/auth.service.ts +++ b/frontend/src/app/services/auth.service.ts @@ -1,151 +1,126 @@ import { Injectable } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { SessionService } from './session.service'; -import { Router } from '@angular/router'; -import { BehaviorSubject, Observable, catchError, map, mergeMap, of, tap } from 'rxjs'; -import { User, UserResponse } from '../user/models/user'; +import { catchError, map, of, switchMap, merge, share, startWith, withLatestFrom, shareReplay } from 'rxjs'; +import { UserRegistration, UserResponse, UserLogin, PasswordForgotten, ResetPassword, KeyInfo, UserSettings } from '../user/models/user'; import { encodeUserData, parseUserData } from '../user/utils'; import _ from 'underscore'; import { HttpClient } from '@angular/common/http'; +import { HttpVerb, Request } from '../user/Request'; + +export interface AuthAPIResult { + detail: string; +} @Injectable({ providedIn: 'root' }) export class AuthService { - private currentUserSubject$ = new BehaviorSubject(undefined); - - currentUser$: Observable = this.currentUserSubject$.pipe(); - isAuthenticated$: Observable = this.currentUserSubject$.pipe( - map(_.isObject) + public login = this.createRequest( + this.authRoute('login/'), + 'post' + ); + public registration = this.createRequest( + this.authRoute('registration/'), + 'post' + ); + public passwordForgotten = this.createRequest( + this.authRoute('password/reset/'), + 'post' + ); + public resetPassword = this.createRequest( + this.authRoute('password/reset/confirm/'), + 'post' + ); + public verifyEmail = this.createRequest( + this.authRoute('registration/verify-email/'), + 'post' + ); + public updateSettings = this.createRequest, UserResponse>( + this.authRoute('user/'), + 'patch' + ); + public keyInfo = this.createRequest( + this.authRoute('registration/key-info/'), + 'post' ) + public deleteUser = this.createRequest( + this.authRoute('delete/'), + 'delete' + ); + public logout = this.createRequest( + this.authRoute('logout/'), + 'post' + ); + + public backendUser$ = this.login.result$.pipe( + startWith(undefined), + switchMap(() => this.http.get( + this.authRoute('user/') + ).pipe( + catchError(() => of(null)), + map(parseUserData), + )), + share() + ); + + private updateSettingsUser$ = this.updateSettings.result$ + .pipe( + withLatestFrom(this.backendUser$), + map(([userData, currentUser]) => 'error' in userData ? currentUser : parseUserData(userData)), + ); - private authRoute(route: string): string { - return `/users/${route}` - } + public currentUser$ = merge( + this.logout.result$.pipe(map(() => null)), + this.backendUser$, + this.updateSettingsUser$ + ).pipe( + startWith(undefined), + shareReplay(1) + ); + + public isAuthenticated$ = this.currentUser$.pipe( + map(_.isObject) + ); constructor( private sessionService: SessionService, - private router: Router, private http: HttpClient, ) { this.sessionService.expired.pipe( takeUntilDestroyed() - ).subscribe(() => this.logout()); - this.setInitialAuth(); + ).subscribe(() => this.logout.subject.next()); } - private setAuth(user: User | null): void { - this.currentUserSubject$.next(user); - } - - private purgeAuth(): void { - this.currentUserSubject$.next(null); - } - - private checkUser(): Observable { - return this.http.get(this.authRoute('user/')).pipe( - catchError(error => of(null)), - map(parseUserData), - ); - } - - private setInitialAuth(): void { - this.checkUser() - .pipe(takeUntilDestroyed()) - .subscribe(user => - this.setAuth(user), - ); - } - - public currentUser(): User | null | undefined { - return this.currentUserSubject$.value; - } + // Keeping track of the latest version of the username + private currentUserName = toSignal( + this.currentUser$.pipe( + map(user => user?.username ?? null) + ) + ); /** - * Logs in, retrieves user response, transforms to User object + * Encodes the user settings and sends them to the server to be updated. + * + * Due to a quirk in dj-auth-rest, the username must only be sent along if it has changed. + * If the username is not changed, it will be removed from the input. + * + * @param userSettings - The user settings to be submitted. + * @returns void */ - public login(username: string, password: string): Observable { - return this.http.post<{ key: string }>( - this.authRoute('login/'), - { username, password } - ).pipe( - mergeMap(() => this.checkUser()), - tap(data => this.setAuth(data)), - ); - } - - public logout(redirectToLogin: boolean = false): void { - this.purgeAuth(); - this.http.post( - this.authRoute('logout/'), - {} - ).subscribe((data) => { - if (redirectToLogin) { - this.showLogin(); - } - }); - } - - public register( - username: string, email: string, password1: string, password2: string - ): Observable { - const data = { username, email, password1, password2 }; - return this.http.post(this.authRoute('registration/'), data); - } - - public verifyEmail(key: string): Observable { - return this.http.post( - this.authRoute('registration/verify-email/'), - { key } - ); - } - - public keyInfo(key: string): Observable<{ username: string, email: string }> { - return this.http.post<{ username: string; email: string }>( - this.authRoute('registration/key-info/'), - { key } - ); + public newUserSettings(userSettings: UserSettings): void { + if (userSettings.username === this.currentUserName()) { + delete userSettings.username; + } + const encoded = encodeUserData(userSettings); + this.updateSettings.subject.next(encoded); } - public showLogin(returnUrl?: string) { - this.router.navigate( - ['/login'], - returnUrl ? { queryParams: { returnUrl } } : undefined - ); - } - - public requestResetPassword(email: string): Observable<{ detail: string }> { - return this.http.post<{ detail: string }>( - this.authRoute('password/reset/'), - { email } - ); - } - - public resetPassword( - uid: string, - token: string, - newPassword1: string, - newPassword2: string - ): Observable<{ detail: string }> { - return this.http.post<{ detail: string }>( - this.authRoute('password/reset/confirm/'), - { - uid, - token, - new_password1: newPassword1, - new_password2: newPassword2, - } - ); + private authRoute(route: string): string { + return `/users/${route}`; } - public updateSettings(update: Partial): Observable { - const data = encodeUserData(update); - return this.http.patch( - this.authRoute('user/'), - data - ).pipe( - tap(res => this.setAuth(parseUserData(res))), - ); + private createRequest(route: string, verb: HttpVerb): Request { + return new Request(this.http, route, verb); } - } diff --git a/frontend/src/app/services/toast.service.spec.ts b/frontend/src/app/services/toast.service.spec.ts new file mode 100644 index 00000000..8272bbdc --- /dev/null +++ b/frontend/src/app/services/toast.service.spec.ts @@ -0,0 +1,73 @@ +import { TestBed } from '@angular/core/testing'; + +import { TOAST_STYLES, ToastInput, ToastService } from './toast.service'; + +describe('ToastService', () => { + let service: ToastService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ToastService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should show a toast', () => { + const toastInput: ToastInput = { + header: 'Test Header', + body: 'Test Body', + type: 'success', + delay: 3000, + }; + + const toast = service.show(toastInput); + + expect(service.toasts.length).toBe(1); + expect(service.toasts[0]).toEqual({ + className: TOAST_STYLES['success'], + header: 'Test Header', + body: 'Test Body', + delay: 3000, + }); + expect(toast).toEqual(service.toasts[0]); + }); + + it('should remove a toast', () => { + const toastInput: ToastInput = { + header: 'Test Header', + body: 'Test Body', + type: 'info', + delay: 5000, + }; + + const toast = service.show(toastInput); + service.remove(toast); + + expect(service.toasts.length).toBe(0); + }); + + it('should clear all toasts', () => { + const toastInput1: ToastInput = { + header: 'Test Header 1', + body: 'Test Body 1', + type: 'warning', + delay: 2000, + }; + + const toastInput2: ToastInput = { + header: 'Test Header 2', + body: 'Test Body 2', + type: 'danger', + delay: 4000, + }; + + service.show(toastInput1); + service.show(toastInput2); + service.clear(); + + expect(service.toasts.length).toBe(0); + }); +}); + diff --git a/frontend/src/app/services/toast.service.ts b/frontend/src/app/services/toast.service.ts new file mode 100644 index 00000000..bd40e04e --- /dev/null +++ b/frontend/src/app/services/toast.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; + +export type ToastType = 'success' | 'info' | 'warning' | 'danger'; + +export interface Toast { + header: string; + body: string; + className: string; + delay: number; +} + +export interface ToastInput { + header: string; + body: string; + type?: ToastType; + delay?: number; +} + +export const TOAST_STYLES: Record = { + success: 'text-bg-success', + info: 'text-bg-info', + warning: 'text-bg-warning', + danger: 'text-bg-danger', +}; + +@Injectable({ + providedIn: 'root' +}) +export class ToastService { + public toasts: Toast[] = []; + + public show(toastInput: ToastInput): Toast { + const type = toastInput.type || 'info'; + const toast: Toast = { + className: TOAST_STYLES[type], + header: toastInput.header, + body: toastInput.body, + delay: toastInput.delay || 5000, + } + this.toasts.push(toast); + return toast; + } + + public remove(toast: Toast): void { + this.toasts = this.toasts.filter(t => t !== toast); + } + + public clear(): void { + this.toasts = []; + } +} diff --git a/frontend/src/app/user/Request.ts b/frontend/src/app/user/Request.ts new file mode 100644 index 00000000..05aa7d0c --- /dev/null +++ b/frontend/src/app/user/Request.ts @@ -0,0 +1,76 @@ +import { HttpClient } from "@angular/common/http"; +import { Observable, OperatorFunction, Subject, catchError, filter, map, merge, of, share, startWith, switchMap, throttleTime } from "rxjs"; + + +export type HttpVerb = 'get' | 'post' | 'patch' | 'delete'; +export type RequestError = { error: Record; }; + + +/** + * Represents a HTTP request made to the application backend. + * + * Contains the following properties: + * - `subject`: The subject used to emit input values for the request. + * - `result$`: Emits the raw result or error of the request. + * - `error$`: Emits the error of the request. + * - `success$`: Emits when the request is successful. + * + * @template Input The type of the request input. + * @template Result The type of the request result. + */ +export class Request { + public subject: Subject; + public result$: Observable; + public error$: Observable; + public success$: Observable; + public loading$: Observable; + + /** + * Creates an instance of AuthRequest. + * @param http The HttpClient instance used to make the HTTP request. + * @param route The route for the request. + * @param verb The HTTP verb for the request. + */ + constructor( + private http: HttpClient, + private route: string, + private verb: HttpVerb + ) { + this.subject = new Subject(); + + // Catches errors and returns them as a RequestError object. + const catchToError: OperatorFunction = catchError(error => of({ error: error.error })); + + this.result$ = this.subject.pipe( + throttleTime(500), + switchMap(input => { + switch (this.verb) { + case "get": + return this.http.get(this.route).pipe(catchToError); + case "post": + return this.http.post(this.route, input).pipe(catchToError); + case "patch": + return this.http.patch(this.route, input).pipe(catchToError); + case "delete": + return this.http.delete(this.route).pipe(catchToError); + } + }), + share() + ); + + this.success$ = this.result$.pipe( + filter((result): result is Result => result ? !('error' in result) : true) + ); + + this.error$ = this.result$.pipe( + filter((result): result is RequestError => result && 'error' in result) + ); + + this.loading$ = merge( + this.subject.pipe(map(() => true)), + this.result$.pipe(map(() => false)) + ).pipe( + startWith(false), + ); + } +} diff --git a/frontend/src/app/user/login/login.component.html b/frontend/src/app/user/login/login.component.html index 0f7f8433..e4d0ff85 100644 --- a/frontend/src/app/user/login/login.component.html +++ b/frontend/src/app/user/login/login.component.html @@ -1,26 +1,66 @@ -
-
- - -
- {{usernameErrorMessage}} -
+

Sign in

+ +

Sign in using the form below.

+ + +
+ + +

+ {{ error }} +

-
- - -
- {{passwordErrorMessage}} -
+
+ + +

+ {{ error }} +

+

+ {{ error }} +

-
- + + diff --git a/frontend/src/app/user/login/login.component.spec.ts b/frontend/src/app/user/login/login.component.spec.ts index d85c20f2..a04398f2 100644 --- a/frontend/src/app/user/login/login.component.spec.ts +++ b/frontend/src/app/user/login/login.component.spec.ts @@ -1,62 +1,92 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { LoginComponent } from './login.component'; -import { AuthService } from '@services/auth.service'; -import { AuthServiceMock } from '@mock/auth.service.mock'; -import { SharedTestingModule } from '@shared/shared-testing.module'; -import { Router } from '@angular/router'; +import { LoginComponent } from "./login.component"; +import { AuthService } from "@services/auth.service"; +import { SharedTestingModule } from "@shared/shared-testing.module"; +import { Router } from "@angular/router"; +import { HttpTestingController } from "@angular/common/http/testing"; +import { ToastService } from "@services/toast.service"; +import { toSignal } from "@angular/core/rxjs-interop"; -describe('LoginComponent', () => { +describe("LoginComponent", () => { let component: LoginComponent; let fixture: ComponentFixture; - let authService: AuthService; + let toastService: ToastService; let router: Router; + let httpTestingController: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ declarations: [LoginComponent], - providers: [ - { provide: AuthService, useClass: AuthServiceMock } - ], - imports: [SharedTestingModule] + providers: [ToastService, AuthService], + imports: [SharedTestingModule], }); - authService = TestBed.inject(AuthService); + httpTestingController = TestBed.inject(HttpTestingController); + toastService = TestBed.inject(ToastService); router = TestBed.inject(Router); fixture = TestBed.createComponent(LoginComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { + it("should create", () => { expect(component).toBeTruthy(); }); - it('should check missing input', () => { + it("should check missing input", () => { component.submit(); - expect(component.usernameInput.invalid).toBeTrue(); - expect(component.usernameErrorMessage).toBe('username is required'); - expect(component.passwordInput.invalid).toBeTrue(); - expect(component.passwordErrorMessage).toBe('password is required'); + expect(component.form.controls.username.invalid).toBeTrue(); + expect(component.form.controls.username.errors).toEqual({ + required: true, + }); + expect(component.form.controls.password.invalid).toBeTrue(); + expect(component.form.controls.password.errors).toEqual({ + required: true, + }); }); - it('should check invalid usernames', () => { - component.usernameInput.setValue('te$t'); - component.passwordInput.setValue('secretpassword'); + it("should check invalid login details", () => { + component.form.controls.username.setValue("te$t"); + component.form.controls.password.setValue("secretpassword"); component.submit(); - expect(component.usernameInput.invalid).toBeTrue(); - expect(component.usernameErrorMessage).toBe('invalid username'); + + const req = httpTestingController.expectOne("/users/login/"); + req.flush( + { + non_field_errors: [ + "Unable to log in with provided credentials.", + ], + }, + { status: 400, statusText: "Bad request" }, + ); + + expect(component.form.invalid).toBeTrue(); + expect(component.form.errors).toEqual({ + invalid: "Unable to log in with provided credentials.", + }); }); - it('should accept valid input', () => { - component.usernameInput.setValue('test'); - component.passwordInput.setValue('secretpassword'); + it("should accept valid input", () => { + component.form.controls.username.setValue("user"); + component.form.controls.password.setValue("secretpassword"); + + const routerSpy = spyOn(router, "navigate"); - const loginSpy = spyOn(authService, 'login').and.callThrough(); - const routerSpy = spyOn(router, 'navigate'); + // toSignal lets us inspect the latest value of an Observable. + // It basically turns it into a BehaviorSubject. + const loading = TestBed.runInInjectionContext(() => + toSignal(component.loading$), + ); component.submit(); + expect(loading()).toBeTrue(); + expect(component.form.valid).toBeTrue(); + + const req = httpTestingController.expectOne("/users/login/"); + req.flush({ key: "abcdefghijklmnopqrstuvwxyz" }); - expect(loginSpy).toHaveBeenCalled(); - expect(routerSpy).toHaveBeenCalledWith(['/']); + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(1); + expect(routerSpy).toHaveBeenCalledWith(["/"]); }); }); diff --git a/frontend/src/app/user/login/login.component.ts b/frontend/src/app/user/login/login.component.ts index ec06c180..7d86cc53 100644 --- a/frontend/src/app/user/login/login.component.ts +++ b/frontend/src/app/user/login/login.component.ts @@ -1,82 +1,74 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component } from '@angular/core'; -import { FormControl, Validators } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; -import { AuthService } from '@services/auth.service'; -import { passwordValidators, usernameValidators } from '../validation'; - +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { AuthService } from "@services/auth.service"; +import { UserLogin } from "../models/user"; +import { + controlErrorMessages$, + formErrorMessages$, + setErrors, + updateFormValidity, +} from "../utils"; +import { ToastService } from "@services/toast.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Router } from "@angular/router"; +type LoginForm = { + [key in keyof UserLogin]: FormControl; +}; @Component({ - selector: 'lc-login', - templateUrl: './login.component.html', - styleUrls: ['./login.component.scss'] + selector: "lc-login", + templateUrl: "./login.component.html", + styleUrls: ["./login.component.scss"], }) -export class LoginComponent { - usernameInput = new FormControl('', [ - Validators.required, - ...usernameValidators - ]); - usernameErrorMessage = ''; - passwordInput = new FormControl('', [ - Validators.required, - ...passwordValidators, - ]); - passwordErrorMessage = ''; +export class LoginComponent implements OnInit { + public form = new FormGroup({ + username: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + password: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + }); - requestFailed = false; + public usernameErrors$ = controlErrorMessages$(this.form, "username"); + public passwordErrors$ = controlErrorMessages$(this.form, "password"); + public formErrors$ = formErrorMessages$(this.form); - private returnUrl: string; + public loading$ = this.authService.login.loading$; constructor( private authService: AuthService, - private activatedRoute: ActivatedRoute, + private toastService: ToastService, + private destroyRef: DestroyRef, private router: Router, - ) { - this.returnUrl = this.activatedRoute.snapshot.queryParams['returnUrl'] || '/'; - } + ) {} - submit() { - const valid = this.checkValidation(); - if (valid) { - this.authService.login( - this.usernameInput.value as string, this.passwordInput.value as string - ).subscribe({ - next: () => this.loginSucces(), - error: (e: HttpErrorResponse) => this.loginFailed(e) + ngOnInit(): void { + this.authService.login.success$.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.toastService.show({ + header: "Sign in successful", + body: "You have been successfully signed in.", + type: "success", }); - } - } - - private checkValidation() { - if (this.usernameInput.invalid) { - if (this.usernameInput.errors?.['required']) { - this.usernameErrorMessage = 'username is required'; - } else { - this.usernameErrorMessage = 'invalid username'; - } - } else { - this.usernameErrorMessage = ''; - } + this.router.navigate(["/"]); + }); - if (this.passwordInput.invalid) { - if (this.passwordInput.errors?.['required']) { - this.passwordErrorMessage = 'password is required'; - } else { - this.passwordErrorMessage = 'invalid password'; - } - } else { - this.passwordErrorMessage = ''; - } - - return this.usernameInput.valid && this.passwordInput.valid; + this.authService.login.error$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => setErrors(result.error, this.form)); } - private loginSucces() { - this.router.navigate([this.returnUrl]); - } - - private loginFailed(error: HttpErrorResponse) { - this.requestFailed = true; + public submit(): void { + this.form.markAllAsTouched(); + updateFormValidity(this.form); + if (!this.form.valid) { + return; + } + this.authService.login.subject.next(this.form.getRawValue()); } } diff --git a/frontend/src/app/user/models/user.ts b/frontend/src/app/user/models/user.ts index 667b3e4f..b3d2748e 100644 --- a/frontend/src/app/user/models/user.ts +++ b/frontend/src/app/user/models/user.ts @@ -17,3 +17,39 @@ export class User { public isStaff: boolean, ) { } } + +export interface UserRegistration { + username: string; + email: string; + password1: string; + password2: string; +} + +export interface UserLogin { + username: string; + password: string; +} + +export interface ResetPassword { + uid: string; + token: string; + new_password1: string; + new_password2: string; +} + +export interface PasswordForgotten { + email: string; +} + +export interface KeyInfo { + username: string; + email: string; +} + +// Dj-rest-auth does not let you update your email address, but we need it to request the password reset form. +export type UserSettings = Pick< + User, + "id" | "email" | "firstName" | "lastName" +> & { + username?: string; +}; diff --git a/frontend/src/app/user/password-forgotten/password-forgotten.component.html b/frontend/src/app/user/password-forgotten/password-forgotten.component.html new file mode 100644 index 00000000..7b894926 --- /dev/null +++ b/frontend/src/app/user/password-forgotten/password-forgotten.component.html @@ -0,0 +1,35 @@ +

Forgotten your password?

+ +

+ Enter your email address in the form below. You will receive an email with instructions to reset your password. +

+ +
+
+ + +

+ {{ error }} +

+
+ +
diff --git a/frontend/src/app/user/password-forgotten/password-forgotten.component.scss b/frontend/src/app/user/password-forgotten/password-forgotten.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/user/password-forgotten/password-forgotten.component.spec.ts b/frontend/src/app/user/password-forgotten/password-forgotten.component.spec.ts new file mode 100644 index 00000000..dfb9a3b8 --- /dev/null +++ b/frontend/src/app/user/password-forgotten/password-forgotten.component.spec.ts @@ -0,0 +1,68 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { PasswordForgottenComponent } from "./password-forgotten.component"; +import { HttpTestingController } from "@angular/common/http/testing"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { ToastService } from "@services/toast.service"; +import { SharedTestingModule } from "@shared/shared-testing.module"; + +describe("PasswordForgottenComponent", () => { + let component: PasswordForgottenComponent; + let fixture: ComponentFixture; + let httpTestingController: HttpTestingController; + let toastService: ToastService; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [PasswordForgottenComponent], + imports: [SharedTestingModule], + }); + fixture = TestBed.createComponent(PasswordForgottenComponent); + httpTestingController = TestBed.inject(HttpTestingController); + toastService = TestBed.inject(ToastService); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should check missing input", () => { + component.submit(); + + httpTestingController.expectNone("/users/password/reset/"); + + expect(component.form.controls.email.invalid).toBeTrue(); + expect(component.form.controls.email.errors).toEqual({ + required: true, + }); + }); + + it("should check invalid email", () => { + component.form.controls.email.setValue("test"); + component.submit(); + + httpTestingController.expectNone("/users/password/reset/"); + + expect(component.form.invalid).toBeTrue(); + expect(component.form.controls.email.errors).toEqual({ email: true }); + }); + + it("should submit valid input", () => { + component.form.controls.email.setValue("test@test.nl"); + + const loading = TestBed.runInInjectionContext(() => + toSignal(component.loading$), + ); + + component.submit(); + expect(loading()).toBeTrue(); + + const req = httpTestingController.expectOne("/users/password/reset/"); + req.flush({ detail: "Password reset e-mail has been sent." }); + + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(1); + }); +}); diff --git a/frontend/src/app/user/password-forgotten/password-forgotten.component.ts b/frontend/src/app/user/password-forgotten/password-forgotten.component.ts new file mode 100644 index 00000000..75cc7720 --- /dev/null +++ b/frontend/src/app/user/password-forgotten/password-forgotten.component.ts @@ -0,0 +1,66 @@ +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { controlErrorMessages$, updateFormValidity } from "../utils"; +import { AuthService } from "@services/auth.service"; +import { PasswordForgotten } from "../models/user"; +import { ToastService } from "@services/toast.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +type PasswordForgottenForm = { + [key in keyof PasswordForgotten]: FormControl; +}; + +@Component({ + selector: "lc-password-forgotten", + templateUrl: "./password-forgotten.component.html", + styleUrls: ["./password-forgotten.component.scss"], +}) +export class PasswordForgottenComponent implements OnInit { + form = new FormGroup({ + email: new FormControl("", { + nonNullable: true, + validators: [Validators.required, Validators.email], + }), + }); + + public emailErrors$ = controlErrorMessages$(this.form, "email"); + + public loading$ = this.authService.passwordForgotten.loading$; + + constructor( + private authService: AuthService, + private toastService: ToastService, + private destroyRef: DestroyRef, + ) {} + + ngOnInit(): void { + this.authService.passwordForgotten.success$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.show({ + header: "Password reset request successful", + body: "If your email address is known to us, an email has been sent containing a link to a page where you may reset your password.", + type: "success", + // This is a long message, so we show it for 10 seconds. + delay: 10000, + }); + }); + + this.authService.passwordForgotten.error$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.show({ + header: "Reset request failed", + body: "Request to send password reset email failed. Please try again.", + type: "danger", + })); + } + + public submit(): void { + this.form.markAllAsTouched(); + updateFormValidity(this.form); + if (!this.form.valid) { + return; + } + this.authService.passwordForgotten.subject.next(this.form.getRawValue()); + } +} diff --git a/frontend/src/app/user/register/register.component.html b/frontend/src/app/user/register/register.component.html new file mode 100644 index 00000000..64b95d21 --- /dev/null +++ b/frontend/src/app/user/register/register.component.html @@ -0,0 +1,95 @@ +

Register

+ +

Enter your details in the form below to create a new user account.

+ +

Already have an account? Sign in

+ +
+
+ + +

+ {{ error }} +

+
+
+ + +

+ {{ error }} +

+
+
+ + +

+ {{ error }} +

+
+
+ + +

+ {{ error }} +

+
+

+ {{ error }} +

+ +
diff --git a/frontend/src/app/user/register/register.component.scss b/frontend/src/app/user/register/register.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/user/register/register.component.spec.ts b/frontend/src/app/user/register/register.component.spec.ts new file mode 100644 index 00000000..7ff77b54 --- /dev/null +++ b/frontend/src/app/user/register/register.component.spec.ts @@ -0,0 +1,151 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { RegisterComponent } from "./register.component"; +import { HttpTestingController } from "@angular/common/http/testing"; +import { SharedTestingModule } from "@shared/shared-testing.module"; +import { Router } from "@angular/router"; +import { ToastService } from "@services/toast.service"; +import { toSignal } from "@angular/core/rxjs-interop"; + +describe("RegisterComponent", () => { + let component: RegisterComponent; + let fixture: ComponentFixture; + let httpTestingController: HttpTestingController; + let router: Router; + let toastService: ToastService; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [RegisterComponent], + imports: [SharedTestingModule] + }); + httpTestingController = TestBed.inject(HttpTestingController); + toastService = TestBed.inject(ToastService); + router = TestBed.inject(Router); + fixture = TestBed.createComponent(RegisterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should check missing input", () => { + component.submit(); + + httpTestingController.expectNone("/users/registration/"); + + expect(component.form.controls.username.invalid).toBeTrue(); + expect(component.form.controls.username.errors).toEqual({ + required: true, + }); + + expect(component.form.controls.email.invalid).toBeTrue(); + expect(component.form.controls.email.errors).toEqual({ + required: true, + }); + + expect(component.form.controls.password1.invalid).toBeTrue(); + expect(component.form.controls.password1.errors).toEqual({ + required: true, + }); + + expect(component.form.controls.password2.invalid).toBeTrue(); + expect(component.form.controls.password2.errors).toEqual({ + required: true, + }); + }); + + it("should check whether a username has the required length", () => { + const control = component.form.controls.username; + control.setValue("a"); + control.updateValueAndValidity(); + expect(control.errors).toEqual({ + minlength: { requiredLength: 3, actualLength: 1 }, + }); + + control.setValue("ThisNameIsTooLong".repeat(10)); + control.updateValueAndValidity(); + expect(control.errors).toEqual({ + maxlength: { requiredLength: 150, actualLength: 170 }, + }); + }); + + it("should check whether an email is valid", () => { + const control = component.form.controls.email; + control.setValue("invalid"); + control.updateValueAndValidity(); + expect(control.errors).toEqual({ + email: true, + }); + }); + + it("should check whether a password has the required length", () => { + const control = component.form.controls.password1; + control.setValue("a"); + control.updateValueAndValidity(); + expect(control.errors).toEqual({ + minlength: { requiredLength: 8, actualLength: 1 }, + }); + }); + + it("should check whether passwords are identical", () => { + component.form.controls.password1.setValue("password"); + component.form.controls.password2.setValue("password1"); + component.submit(); + + expect(component.form.invalid).toBeTrue(); + expect(component.form.errors).toEqual({ + passwords: true, + }); + }); + + it("should handle valid input", () => { + component.form.controls.username.setValue("frodo"); + component.form.controls.email.setValue("frodo@shire.me"); + component.form.controls.password1.setValue("theonering"); + component.form.controls.password2.setValue("theonering"); + + const routerSpy = spyOn(router, "navigate"); + + const loading = TestBed.runInInjectionContext(() => + toSignal(component.loading$), + ); + + component.submit(); + expect(loading()).toBeTrue(); + expect(component.form.valid).toBeTrue(); + + const req = httpTestingController.expectOne("/users/registration/"); + req.flush(null); + + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(1); + expect(routerSpy).toHaveBeenCalledWith(["/"]); + }); + + it("should handle trying to register with an existing username", () => { + component.form.controls.username.setValue("frodo"); + component.form.controls.email.setValue("frodo@shire.me"); + component.form.controls.password1.setValue("theonering"); + component.form.controls.password2.setValue("theonering"); + + const routerSpy = spyOn(router, "navigate"); + + const loading = TestBed.runInInjectionContext(() => + toSignal(component.loading$), + ); + + component.submit(); + expect(loading()).toBeTrue(); + expect(component.form.valid).toBeTrue(); + + const req = httpTestingController.expectOne("/users/registration/"); + req.flush({"username":["A user with that username already exists."]}, { status: 400, statusText: "Bad request" }); + + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(0); + expect(routerSpy).not.toHaveBeenCalled(); + expect(component.form.controls.username.errors).toEqual({ invalid: 'A user with that username already exists.' }); + }); +}); diff --git a/frontend/src/app/user/register/register.component.ts b/frontend/src/app/user/register/register.component.ts new file mode 100644 index 00000000..44a46ad2 --- /dev/null +++ b/frontend/src/app/user/register/register.component.ts @@ -0,0 +1,105 @@ +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { UserRegistration } from "../models/user"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { + usernameValidators, + passwordValidators, + identicalPasswordsValidator, +} from "../validation"; +import { AuthService } from "@services/auth.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { + controlErrorMessages$, + formErrorMessages$, + setErrors, + updateFormValidity, +} from "../utils"; +import { ToastService } from "@services/toast.service"; +import { Router } from "@angular/router"; + +type RegisterForm = { + [key in keyof UserRegistration]: FormControl; +}; + +@Component({ + selector: "lc-register", + templateUrl: "./register.component.html", + styleUrls: ["./register.component.scss"], +}) +export class RegisterComponent implements OnInit { + public form = new FormGroup( + { + username: new FormControl("", { + nonNullable: true, + validators: [Validators.required, ...usernameValidators], + }), + email: new FormControl("", { + nonNullable: true, + validators: [Validators.required, Validators.email], + }), + password1: new FormControl("", { + nonNullable: true, + validators: [Validators.required, ...passwordValidators], + }), + password2: new FormControl("", { + nonNullable: true, + validators: [Validators.required, ...passwordValidators], + }), + }, + { + validators: identicalPasswordsValidator( + "password1", + "password2", + ), + }, + ); + + public usernameErrors$ = controlErrorMessages$(this.form, "username"); + public emailErrors$ = controlErrorMessages$(this.form, "email"); + public password1Errors$ = controlErrorMessages$( + this.form, + "password1", + "password", + ); + public password2Errors$ = controlErrorMessages$( + this.form, + "password2", + "password", + ); + public formErrors$ = formErrorMessages$(this.form); + + public loading$ = this.authService.registration.loading$; + + constructor( + private authService: AuthService, + private toastService: ToastService, + private destroyRef: DestroyRef, + private router: Router, + ) {} + + ngOnInit(): void { + this.authService.registration.error$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => setErrors(result.error, this.form)); + + this.authService.registration.success$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.show({ + header: "Registration successful", + body: "You have been successfully registered. Please check your email for a confirmation link.", + type: "success", + }); + this.router.navigate(["/"]); + }); + } + + public submit(): void { + this.form.markAllAsTouched(); + updateFormValidity(this.form); + if (!this.form.valid) { + return; + } + this.authService.registration.subject.next(this.form.getRawValue()); + } +} diff --git a/frontend/src/app/user/reset-password/reset-password.component.html b/frontend/src/app/user/reset-password/reset-password.component.html new file mode 100644 index 00000000..8e8e484b --- /dev/null +++ b/frontend/src/app/user/reset-password/reset-password.component.html @@ -0,0 +1,62 @@ +

Reset password

+ +

Enter your new password below to change your password.

+ +
+
+ + +

+ {{ error }} +

+
+
+ + +

+ {{ error }} +

+
+

+ {{ error }} +

+ +
diff --git a/frontend/src/app/user/reset-password/reset-password.component.scss b/frontend/src/app/user/reset-password/reset-password.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/user/reset-password/reset-password.component.spec.ts b/frontend/src/app/user/reset-password/reset-password.component.spec.ts new file mode 100644 index 00000000..d0990934 --- /dev/null +++ b/frontend/src/app/user/reset-password/reset-password.component.spec.ts @@ -0,0 +1,128 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ResetPasswordComponent } from "./reset-password.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; +import { ToastService } from "@services/toast.service"; +import { HttpTestingController } from "@angular/common/http/testing"; +import { toSignal } from "@angular/core/rxjs-interop"; + +describe("ResetPasswordComponent", () => { + let component: ResetPasswordComponent; + let fixture: ComponentFixture; + let toastService: ToastService; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ResetPasswordComponent], + imports: [SharedTestingModule] + }); + toastService = TestBed.inject(ToastService); + httpTestingController = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(ResetPasswordComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should check missing input", () => { + component.submit(); + expect(component.form.controls.new_password1.invalid).toBeTrue(); + expect(component.form.controls.new_password1.errors).toEqual({ + required: true + }); + expect(component.form.controls.new_password2.invalid).toBeTrue(); + expect(component.form.controls.new_password2.errors).toEqual({ + required: true + }); + }); + + it("should handle an invalid UID", () => { + component.form.setValue({ + uid: "abcdefg", + token: "hijklm", + new_password1: "balrogofmorgoth", + new_password2: "balrogofmorgoth" + }) + component.submit(); + + const req = httpTestingController.expectOne("/users/password/reset/confirm/"); + req.flush({ + uid: ["Invalid value"] + }, { + status: 400, statusText: "Bad request" + }); + + expect(component.form.invalid).toBeTrue(); + expect(component.form.controls.uid.errors).toEqual({ + 'invalid': 'Invalid value' + }); + }); + + it("should handle an invalid token", () => { + component.form.setValue({ + uid: "abcdefg", + token: "hijklm", + new_password1: "balrogofmorgoth", + new_password2: "balrogofmorgoth" + }) + component.submit(); + + const req = httpTestingController.expectOne("/users/password/reset/confirm/"); + req.flush({ + token: ["Invalid value"] + }, { + status: 400, statusText: "Bad request" + }); + + expect(component.form.invalid).toBeTrue(); + expect(component.form.controls.token.errors).toEqual({ + 'invalid': 'Invalid value' + }); + }); + + it("should handle a password mismatch", () => { + component.form.setValue({ + uid: "valid", + token: "valid", + new_password1: "balrogofmorgoth", + new_password2: "frodooftheshire" + }); + component.submit(); + + httpTestingController.expectNone("/users/password/reset/confirm/"); + + expect(component.form.invalid).toBeTrue(); + expect(component.form.errors).toEqual({ + 'passwords': true + }); + }); + + it("should handle a successful password reset", () => { + component.form.setValue({ + uid: "valid", + token: "valid", + new_password1: "balrogofmorgoth", + new_password2: "balrogofmorgoth" + }); + + const loading = TestBed.runInInjectionContext(() => + toSignal(component.loading$) + ); + + component.submit(); + expect(loading()).toBeTrue(); + expect(component.form.valid).toBeTrue(); + + const req = httpTestingController.expectOne("/users/password/reset/confirm/"); + req.flush({ + detail: "Password has been reset with the new password." + }); + + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(1); + }); +}); diff --git a/frontend/src/app/user/reset-password/reset-password.component.ts b/frontend/src/app/user/reset-password/reset-password.component.ts new file mode 100644 index 00000000..1e56843e --- /dev/null +++ b/frontend/src/app/user/reset-password/reset-password.component.ts @@ -0,0 +1,103 @@ +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { ResetPassword } from "../models/user"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { identicalPasswordsValidator, passwordValidators } from "../validation"; +import { + controlErrorMessages$, + formErrorMessages$, + setErrors, + updateFormValidity, +} from "../utils"; +import { combineLatest, map, merge, startWith } from "rxjs"; +import { AuthService } from "@services/auth.service"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import _ from "underscore"; +import { ToastService } from "@services/toast.service"; + +type ResetPasswordForm = { + [key in keyof ResetPassword]: FormControl; +}; + +@Component({ + selector: "lc-reset-password", + templateUrl: "./reset-password.component.html", + styleUrls: ["./reset-password.component.scss"], +}) +export class ResetPasswordComponent implements OnInit { + private uid = this.activatedRoute.snapshot.params["uid"]; + private token = this.activatedRoute.snapshot.params["token"]; + + public form = new FormGroup( + { + uid: new FormControl(this.uid, { + nonNullable: true, + }), + token: new FormControl(this.token, { + nonNullable: true, + }), + new_password1: new FormControl("", { + nonNullable: true, + validators: [Validators.required, ...passwordValidators], + }), + new_password2: new FormControl("", { + nonNullable: true, + validators: [Validators.required, ...passwordValidators], + }), + }, + { + validators: identicalPasswordsValidator( + "new_password1", + "new_password2", + ), + }, + ); + + public password1Errors$ = controlErrorMessages$( + this.form, + "new_password1", + "password", + ); + public password2Errors$ = controlErrorMessages$( + this.form, + "new_password2", + "password", + ); + public formErrors$ = combineLatest([ + formErrorMessages$(this.form), + controlErrorMessages$(this.form, "token"), + controlErrorMessages$(this.form, "uid"), + ]).pipe(map((errorLists) => _.flatten(errorLists, 1))); + + public loading$ = this.authService.resetPassword.loading$; + + constructor( + private activatedRoute: ActivatedRoute, + private authService: AuthService, + private toastService: ToastService, + private destroyRef: DestroyRef, + ) {} + + ngOnInit(): void { + this.authService.resetPassword.error$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(result => setErrors(result.error, this.form)); + + this.authService.resetPassword.success$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.show({ + header: "Password reset", + body: "Your password has been successfully reset.", + type: "success", + })); + } + + public submit(): void { + this.form.markAllAsTouched(); + updateFormValidity(this.form); + if (!this.form.valid) { + return; + } + this.authService.resetPassword.subject.next(this.form.getRawValue()); + } +} diff --git a/frontend/src/app/user/user-settings/user-settings.component.html b/frontend/src/app/user/user-settings/user-settings.component.html new file mode 100644 index 00000000..0538995b --- /dev/null +++ b/frontend/src/app/user/user-settings/user-settings.component.html @@ -0,0 +1,96 @@ +

User settings

+ +

Use the form below to change your name or password.

+ +
+
+ + +

+ {{ error }} +

+
+
+ + +
+
+ + +
+

+ {{ error }} +

+ +
+ + + +
+
diff --git a/frontend/src/app/user/user-settings/user-settings.component.scss b/frontend/src/app/user/user-settings/user-settings.component.scss new file mode 100644 index 00000000..b265c0f8 --- /dev/null +++ b/frontend/src/app/user/user-settings/user-settings.component.scss @@ -0,0 +1,3 @@ +.button-row { + gap: 2em; +} diff --git a/frontend/src/app/user/user-settings/user-settings.component.spec.ts b/frontend/src/app/user/user-settings/user-settings.component.spec.ts new file mode 100644 index 00000000..1844a71a --- /dev/null +++ b/frontend/src/app/user/user-settings/user-settings.component.spec.ts @@ -0,0 +1,178 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { UserSettingsComponent } from "./user-settings.component"; +import { ToastService } from "@services/toast.service"; +import { AuthService } from "@services/auth.service"; +import { HttpTestingController } from "@angular/common/http/testing"; +import { SharedTestingModule } from "@shared/shared-testing.module"; +import { User } from "../models/user"; +import { Observable, of } from "rxjs"; +import { Injectable } from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; + +const fakeUser: User = { + id: 1, + email: 'frodo@shire.me', + firstName: 'Frodo', + lastName: 'Baggins', + username: 'frodo', + isStaff: false +} + +@Injectable({ providedIn: 'root' }) +class AuthServiceMock extends AuthService { + public override currentUser$: Observable = of(fakeUser); +} + +describe("UserSettingsComponent", () => { + let component: UserSettingsComponent; + let fixture: ComponentFixture; + let toastService: ToastService; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [UserSettingsComponent], + providers: [{ + provide: AuthService, + useClass: AuthServiceMock + }], + imports: [SharedTestingModule] + }); + toastService = TestBed.inject(ToastService); + httpTestingController = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(UserSettingsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + // Initial request to get the user data in AuthService + httpTestingController.expectOne("/users/user/").flush(fakeUser); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should patch the form with existing user data during OnInit", () => { + expect(component.form.value).toEqual({ + id: 1, + email: 'frodo@shire.me', + username: 'frodo', + firstName: 'Frodo', + lastName: 'Baggins' + }); + }); + + it("should check missing input", () => { + component.form.controls.username?.setValue(''); + component.submit(); + + httpTestingController.expectNone("/users/user/"); + + expect(component.form.controls.username?.invalid).toBeTrue(); + expect(component.form.controls.username?.errors).toEqual({ + required: true + }); + }); + + it("should handle an invalid username", () => { + component.form.controls.username?.setValue('fb'); + component.submit(); + + httpTestingController.expectNone("/users/user/"); + + expect(component.form.controls.username?.invalid).toBeTrue(); + expect(component.form.controls.username?.errors).toEqual({ + minlength: {requiredLength: 3, actualLength: 2} + }); + }); + + it("should handle a username that is already taken", () => { + component.form.controls.username?.setValue('frodo'); + component.submit(); + + const req = httpTestingController.expectOne("/users/user/"); + req.flush({ + username: ["A user with that username already exists." ] + }, { + status: 400, statusText: "Bad request" + }); + + expect(component.form.invalid).toBeTrue(); + expect(component.form.controls.username?.errors).toEqual({ + 'invalid': 'A user with that username already exists.' + }); + }); + + it("should handle a password reset request", () => { + const loading = TestBed.runInInjectionContext(() => toSignal(component.requestResetLoading$)); + + component.requestPasswordReset(); + expect(loading()).toBeTrue() + + const req = httpTestingController.expectOne("/users/password/reset/"); + req.flush({ + detail: "Password reset e-mail has been sent." + }); + + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(1); + }); + + it("should handle a user settings update", () => { + const loading = TestBed.runInInjectionContext(() => toSignal(component.updateSettingsLoading$)); + + component.form.controls.firstName.setValue('Bilbo'); + + component.submit(); + expect(loading()).toBeTrue() + + const req = httpTestingController.expectOne("/users/user/"); + req.flush({ + "id": 1, + "username": "frodo", + "email": "frodo@shire.me", + "first_name": "Bilbo", + "last_name": "Baggins", + "is_staff": false + }); + + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(1); + expect(component.form.controls.firstName.value).toBe('Bilbo'); + }); + + it("should handle a username change", () => { + const loading = TestBed.runInInjectionContext(() => toSignal(component.updateSettingsLoading$)); + + component.form.controls.username?.setValue('Samwise'); + + component.submit(); + expect(loading()).toBeTrue(); + + const req = httpTestingController.expectOne("/users/user/"); + req.flush({ + "id": 1, + "username": "Samwise", + "email": "frodo@shire.me", + "first_name": "Frodo", + "last_name": "Baggins", + "is_staff": false + }); + + expect(loading()).toBeFalse(); + expect(toastService.toasts.length).toBe(1); + expect(component.form.controls.username?.value).toBe('Samwise'); + }); + + it("should remove the username from the input if it's the same as the current username", () => { + const loading = TestBed.runInInjectionContext(() => toSignal(component.updateSettingsLoading$)); + + component.submit(); + expect(loading()).toBeTrue(); + + const req = httpTestingController.expectOne("/users/user/").request; + expect(req.method).toBe('PATCH'); + expect(req.body).not.toContain('username'); + }); +}); diff --git a/frontend/src/app/user/user-settings/user-settings.component.ts b/frontend/src/app/user/user-settings/user-settings.component.ts new file mode 100644 index 00000000..1c60fc29 --- /dev/null +++ b/frontend/src/app/user/user-settings/user-settings.component.ts @@ -0,0 +1,135 @@ +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { AuthService } from "@services/auth.service"; +import { UserSettings } from "../models/user"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { filter } from "rxjs"; +import { + controlErrorMessages$, + formErrorMessages$, + setErrors, + updateFormValidity, +} from "../utils"; +import { ToastService } from "@services/toast.service"; +import { usernameValidators } from "../validation"; + +type UserSettingsForm = { + [key in keyof UserSettings]: FormControl; +}; + +@Component({ + selector: "lc-user-settings", + templateUrl: "./user-settings.component.html", + styleUrls: ["./user-settings.component.scss"], +}) +export class UserSettingsComponent implements OnInit { + public form = new FormGroup({ + id: new FormControl(-1, { + nonNullable: true, + validators: [Validators.required], + }), + // dj-rest-auth does not let you change your email address, so we don't need to validate it. + email: new FormControl("", { + nonNullable: true, + }), + username: new FormControl("", { + nonNullable: true, + validators: [ + Validators.required, + ...usernameValidators + ], + }), + firstName: new FormControl("", { + nonNullable: true, + }), + lastName: new FormControl("", { + nonNullable: true, + }), + }); + + public usernameErrors$ = controlErrorMessages$(this.form, "username"); + public formErrors$ = formErrorMessages$(this.form); + + public updateSettingsLoading$ = this.authService.updateSettings.loading$; + public requestResetLoading$ = this.authService.passwordForgotten.loading$; + public deleteUserLoading$ = this.authService.deleteUser.loading$; + + constructor( + private authService: AuthService, + private toastService: ToastService, + private destroyRef: DestroyRef, + ) {} + + ngOnInit(): void { + this.authService.currentUser$ + .pipe( + filter((user) => !!user), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((user) => { + if (!user) { + return; + } + this.form.patchValue(user); + }); + + this.authService.passwordForgotten.success$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.toastService.show({ + header: "Password reset email sent", + body: "An email has been sent to you with instructions on how to reset your password.", + type: "success", + }); + }); + + + this.authService.deleteUser.error$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.show({ + header: "Error deleting account", + body: "An error occurred while deleting your account. Please try again later.", + type: "danger", + })); + + this.authService.deleteUser.success$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.show({ + header: "Account deleted", + body: "Your account has been successfully deleted.", + type: "success", + })); + + this.authService.updateSettings.error$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(result => setErrors(result.error, this.form)); + + this.authService.updateSettings.success$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.show({ + header: "Settings updated", + body: "Your settings have been successfully updated.", + type: "success", + })); + } + + public requestPasswordReset(): void { + this.authService.passwordForgotten.subject.next({ + email: this.form.getRawValue().email, + }); + } + + public deleteAccount(): void { + this.authService.deleteUser.subject.next(); + } + + public submit(): void { + this.form.markAllAsTouched(); + updateFormValidity(this.form); + if (this.form.invalid) { + return; + } + const userSettings = this.form.getRawValue(); + this.authService.newUserSettings(userSettings); + } +} diff --git a/frontend/src/app/user/user.module.ts b/frontend/src/app/user/user.module.ts index c7f72b4f..6cd5b480 100644 --- a/frontend/src/app/user/user.module.ts +++ b/frontend/src/app/user/user.module.ts @@ -2,13 +2,19 @@ import { NgModule } from '@angular/core'; import { LoginComponent } from './login/login.component'; import { SharedModule } from '../shared/shared.module'; import { VerifyEmailComponent } from './verify-email/verify-email.component'; - - +import { RegisterComponent } from './register/register.component'; +import { PasswordForgottenComponent } from './password-forgotten/password-forgotten.component'; +import { ResetPasswordComponent } from './reset-password/reset-password.component'; +import { UserSettingsComponent } from './user-settings/user-settings.component'; @NgModule({ declarations: [ LoginComponent, - VerifyEmailComponent + VerifyEmailComponent, + RegisterComponent, + PasswordForgottenComponent, + ResetPasswordComponent, + UserSettingsComponent, ], imports: [ SharedModule diff --git a/frontend/src/app/user/utils.spec.ts b/frontend/src/app/user/utils.spec.ts new file mode 100644 index 00000000..34649e6b --- /dev/null +++ b/frontend/src/app/user/utils.spec.ts @@ -0,0 +1,152 @@ +import { TestBed } from "@angular/core/testing"; +import { FormGroup, FormControl, Validators } from "@angular/forms"; +import { User, UserResponse } from "./models/user"; +import { + parseUserData, + encodeUserData, + setErrors, + controlErrorMessages$, + formErrorMessages$, + updateFormValidity, +} from "./utils"; +import { toSignal } from "@angular/core/rxjs-interop"; + +describe("User utils", () => { + let form: FormGroup; + beforeEach(() => { + form = new FormGroup({ + username: new FormControl("", { + validators: [Validators.required], + }), + email: new FormControl("", { + validators: [Validators.required, Validators.email], + }), + }); + }); + + describe("parseUserData", () => { + it("should return null if result is null", () => { + const result: UserResponse | null = null; + const user = parseUserData(result); + expect(user).toBeNull(); + }); + + it("should return a User object if result is not null", () => { + const result: UserResponse = { + id: 1, + username: "testuser", + email: "test@example.com", + first_name: "Test", + last_name: "User", + is_staff: true, + }; + const user = parseUserData(result); + expect(user).toBeInstanceOf(User); + expect(user?.id).toBe(1); + expect(user?.username).toBe("testuser"); + expect(user?.email).toBe("test@example.com"); + expect(user?.firstName).toBe("Test"); + expect(user?.lastName).toBe("User"); + expect(user?.isStaff).toBe(true); + }); + }); + + describe("encodeUserData", () => { + it("should encode partial User data to UserResponse object", () => { + const data: Partial = { + id: 1, + username: "testuser", + email: "test@example.com", + firstName: "Test", + lastName: "User", + isStaff: true, + }; + const encoded = encodeUserData(data); + expect(encoded).toEqual({ + id: 1, + username: "testuser", + email: "test@example.com", + first_name: "Test", + last_name: "User", + is_staff: true, + }); + }); + }); + + describe("setErrors", () => { + it("should set errors on associated controls", () => { + const errorObject = { + username: "Username is required.", + email: "Email is invalid.", + }; + setErrors(errorObject, form); + expect(form.get("username")?.errors).toEqual({ + invalid: "Username is required.", + }); + expect(form.get("email")?.errors).toEqual({ + invalid: "Email is invalid.", + }); + }); + + it("should set errors on the form itself for non-associated controls", () => { + const errorObject = { + random: "Passwords must be identical.", + }; + setErrors(errorObject, form); + expect(form.errors).toEqual({ + invalid: "Passwords must be identical.", + }); + }); + }); + + describe("controlErrorMessages$", () => { + it("should return an Observable of error messages for a specific control", () => { + const usernameMessages = TestBed.runInInjectionContext(() => + toSignal(controlErrorMessages$(form, "username")), + ); + + form.get("username")?.updateValueAndValidity(); + + expect(usernameMessages()).toEqual(["Username is required."]); + }); + + it("should use errors based on a provided lookup name", () => { + const usernameMessages = TestBed.runInInjectionContext(() => + toSignal(controlErrorMessages$(form, "username", "email")), + ); + + form.get("username")?.updateValueAndValidity(); + + expect(usernameMessages()).toEqual(["Email is required."]); + }); + }); + + describe("formErrorMessages$", () => { + it("should return an Observable of error messages for the form", () => { + const formMessages = TestBed.runInInjectionContext(() => + toSignal(formErrorMessages$(form)), + ); + + form.updateValueAndValidity(); + + expect(formMessages()?.length).toBe(0); + + form.setErrors({ random: "This is a random error." }); + + expect(formMessages()).toEqual(["This is a random error."]); + }); + }); + + describe("updateFormValidity", () => { + it("should update the validity of all controls in the form", () => { + form.controls["username"].setErrors({ + invalid: "Username is required.", + }); + form.controls["email"].setErrors({ invalid: "Email is invalid." }); + updateFormValidity(form); + expect(form.controls["username"].valid).toBe(false); + expect(form.controls["email"].valid).toBe(false); + expect(form.valid).toBe(false); + }); + }); +}); diff --git a/frontend/src/app/user/utils.ts b/frontend/src/app/user/utils.ts index 113a9386..7d02b5c6 100644 --- a/frontend/src/app/user/utils.ts +++ b/frontend/src/app/user/utils.ts @@ -1,26 +1,28 @@ +import { AbstractControl, FormGroup } from "@angular/forms"; import { User, UserResponse } from "./models/user"; -import _ from 'underscore'; +import _ from "underscore"; +import { Observable, map } from "rxjs"; +import { RequestError } from "./Request"; /** -* Transforms backend user response to User object -* -* @param result User response data -* @returns User object -*/ + * Transforms backend user response to User object + * + * @param result User response data + * @returns User object + */ export const parseUserData = (result: UserResponse | null): User | null => { - if (result) { - return new User( - result.id, - result.username, - result.email, - result.first_name, - result.last_name, - result.is_staff, - ); - } else { + if (!result) { return null; } -} + return new User( + result.id, + result.username, + result.email, + result.first_name, + result.last_name, + result.is_staff, + ); +}; /** * Transfroms User data to backend UserResponse object @@ -41,3 +43,134 @@ export const encodeUserData = (data: Partial): Partial => { }; return _.omit(encoded, _.isUndefined); }; + +/** + * Interprets backend validation errors and adds errors to their associated controls. + * + * Others are not tied to specific controls. These are added to the form itself with a generic 'invalid' key. + * + * @param errorObject - The error object containing the control names as keys and the corresponding error messages as values. + * @param form - The form to which the errors should be added. + */ +export function setErrors( + errorObject: RequestError["error"], + form: FormGroup, +): void { + for (const errorKey in errorObject) { + const control = form.get(errorKey); + const error = errorObject[errorKey]; + const errorMessage = Array.isArray(error) ? error.join("; ") : error; + if (control) { + control.setErrors({ invalid: errorMessage }); + } else { + form.setErrors({ invalid: errorMessage }); + } + } +} + +export const ERROR_MAP: Record> = { + username: { + required: "Username is required.", + minlength: "Username must be at least 3 characters long.", + maxlength: "Username must be at most 150 characters long.", + }, + email: { + required: "Email is required.", + email: "Email is invalid.", + }, + password: { + required: "Password is required.", + minlength: "Password must be at least 8 characters long.", + }, + token: { + invalid: "The URL is invalid. Please request a new one.", + }, + uid: { + invalid: "The URL is invalid. Please request a new one.", + }, + form: { + passwords: "Passwords must be identical.", + }, + firstName: { + required: "First name is required.", + }, + lastName: { + required: "Last name is required.", + }, +}; + +/** + * Watches a FormControl and returns an Observable that yields an array of error messages. + * + * Uses the optional parameter `lookup` to determine which error messages from `ERROR_MAP` to use. If no `lookup` is + * provided, `controlName` is used. If no error messages are found using either `lookup` or `controlName`, `ERROR_MAP['form']` is used. + * + * @param form - The FormGroup instance. + * @param controlName - The name of the form control. + * @param lookup - The key to use in the error map. Defaults to the control name. + * @returns An Observable that emits an array of error messages every time the control's status changes. + */ +export function controlErrorMessages$< + F extends FormGroup, + K extends string & keyof F["controls"], +>(form: F, controlName: K, lookup?: string): Observable { + const control = form.controls[controlName]; + // Get a subset of error messages based on the lookup key, if provided, or the control name. + const messagesForControl = lookup + ? ERROR_MAP[lookup] + : ERROR_MAP[controlName] ?? ERROR_MAP["form"]; + return control.statusChanges.pipe( + map(() => mapErrorsToMessages(control, messagesForControl)), + ); +} + +/** + * Watches a FormGroup and turns its errors into an array of string messages. + * + * Uses the optional parameter `lookup` to determine which error messages from `ERROR_MAP` to use. If no `lookup` is + * provided, or if no error messages are found, `ERROR_MAP['form']` is used. + * + * @param form The form group to check for errors. + * @param lookup Optional parameter to specify a specific error lookup key. + * @returns An observable that emits an array of error messages. + */ +export function formErrorMessages$( + form: F, + lookup?: string, +): Observable { + const messagesForForm = lookup ? ERROR_MAP[lookup] : ERROR_MAP["form"]; + return form.statusChanges.pipe( + map(() => mapErrorsToMessages(form, messagesForForm)), + ); +} + +/** + * Maps control errors to error messages using the provided error map. + * + * If no message is specified for an error key, the error value itself is used as the message. + * + * @param control - The control containing the errors. + * @param errorMap - The map of error keys to error messages. + * @returns An array of error messages. + */ +function mapErrorsToMessages( + control: AbstractControl, + errorMap: Record, +): string[] { + const errors = control.errors ?? {}; + return Object.keys(errors).map((errorKey) => + errorKey in errorMap ? errorMap[errorKey] : errors[errorKey], + ); +} + +/** + * Updates the validity of all controls in the given form and the form itself. + * + * @param form - The form group to update. + */ +export function updateFormValidity(form: FormGroup): void { + Object.values(form.controls).forEach((control) => { + control.updateValueAndValidity(); + }); + form.updateValueAndValidity(); +} diff --git a/frontend/src/app/user/validation.ts b/frontend/src/app/user/validation.ts index b8174ae4..7c1ba1bb 100644 --- a/frontend/src/app/user/validation.ts +++ b/frontend/src/app/user/validation.ts @@ -1,4 +1,9 @@ -import { Validators } from "@angular/forms"; +import { + AbstractControl, + ValidationErrors, + ValidatorFn, + Validators, +} from "@angular/forms"; /** * Validation for usernames @@ -8,13 +13,37 @@ import { Validators } from "@angular/forms"; * - letters, digits and `@.+-_` only */ export const usernameValidators = [ + Validators.minLength(3), Validators.maxLength(150), Validators.pattern(/^[\w@+-]+$/), ]; /** * Validation for passwords -*/ -export const passwordValidators = [ - Validators.minLength(8), -]; + */ +export const passwordValidators = [Validators.minLength(8)]; + +/** + * Form-level validator that checks whether two password fields have identical values. + * + * @param password1ControlName - The name of the first password control field on the form. + * @param password2ControlName - The name of the second password control field on the form. + * @returns A `ValidationErrors` object if the passwords do not match, otherwise `null`. + */ +export function identicalPasswordsValidator( + password1ControlName: string & T, + password2ControlName: string & T, +): ValidatorFn { + return (form: AbstractControl): ValidationErrors | null => { + const password1 = form.get(password1ControlName); + const password2 = form.get(password2ControlName); + + if (!password1?.value || !password2?.value) { + return null; + } + + return password1.value === password2.value + ? null + : { passwords: true }; + }; +} diff --git a/frontend/src/app/user/verify-email/verify-email.component.html b/frontend/src/app/user/verify-email/verify-email.component.html index 3a944177..0d61394a 100644 --- a/frontend/src/app/user/verify-email/verify-email.component.html +++ b/frontend/src/app/user/verify-email/verify-email.component.html @@ -1,27 +1,27 @@

Confirm e-mail address

-
+

Please confirm that - {{userDetails.email}} - is an e-mail address for user {{userDetails.username}}. + {{ userDetails.email }} + is an e-mail address for user {{ userDetails.username }}.

- +
- - - + +
+
User details could not be verified.
+

Please check the URL, refresh the page and request a new verification link if the problem persists.

+
+
diff --git a/frontend/src/app/user/verify-email/verify-email.component.spec.ts b/frontend/src/app/user/verify-email/verify-email.component.spec.ts index 62cf6285..e7f46030 100644 --- a/frontend/src/app/user/verify-email/verify-email.component.spec.ts +++ b/frontend/src/app/user/verify-email/verify-email.component.spec.ts @@ -1,23 +1,75 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { VerifyEmailComponent } from './verify-email.component'; -import { SharedTestingModule } from '@shared/shared-testing.module'; +import { VerifyEmailComponent } from "./verify-email.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; +import { AuthService } from "@services/auth.service"; +import { ToastService } from "@services/toast.service"; +import { HttpTestingController } from "@angular/common/http/testing"; +import { By } from "@angular/platform-browser"; +import { toSignal } from "@angular/core/rxjs-interop"; -describe('VerifyEmailComponent', () => { +describe("VerifyEmailComponent", () => { let component: VerifyEmailComponent; let fixture: ComponentFixture; + let toastService: ToastService; + let httpTestingController: HttpTestingController beforeEach(() => { TestBed.configureTestingModule({ declarations: [VerifyEmailComponent], + providers: [AuthService], imports: [SharedTestingModule], }); + toastService = TestBed.inject(ToastService); + httpTestingController = TestBed.inject(HttpTestingController); fixture = TestBed.createComponent(VerifyEmailComponent); component = fixture.componentInstance; fixture.detectChanges(); }); - it('should create', () => { + it("should create", () => { expect(component).toBeTruthy(); }); + + it("should handle a key info error", () => { + const req = httpTestingController.expectOne('/users/registration/key-info/'); + + req.flush('Confirmation key does not exist.', { status: 400, statusText: 'Bad request' }); + + expect(toastService.toasts.length).toBe(1); + + fixture.detectChanges(); + const element = fixture.debugElement; + expect(element.query(By.css('.no-user-details'))).toBeTruthy(); + }); + + it("should handle a successful email verification", () => { + const loading = TestBed.runInInjectionContext(() => toSignal(component.loading$)); + + component.confirm(); + expect(loading()).toBeTrue(); + + const req = httpTestingController.expectOne('/users/registration/verify-email/'); + req.flush({ detail: "ok" }); + + expect(toastService.toasts.length).toBe(1); + expect(toastService.toasts[0].header).toBe('Email verified'); + expect(loading()).toBeFalse(); + }); + + it("should handle a failed email verification", () => { + const loading = TestBed.runInInjectionContext(() => toSignal(component.loading$)); + + component.confirm(); + expect(loading()).toBeTrue(); + + const req = httpTestingController.expectOne('/users/registration/verify-email/'); + req.flush({ + detail: "Not found." + }, { status: 404, statusText: "Not Found" }); + + expect(toastService.toasts.length).toBe(1); + expect(toastService.toasts[0].header).toBe('Email verification failed'); + expect(loading()).toBeFalse(); + }); }); diff --git a/frontend/src/app/user/verify-email/verify-email.component.ts b/frontend/src/app/user/verify-email/verify-email.component.ts index a2ee9941..529b72fb 100644 --- a/frontend/src/app/user/verify-email/verify-email.component.ts +++ b/frontend/src/app/user/verify-email/verify-email.component.ts @@ -1,51 +1,70 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { Component, DestroyRef } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; -import { AuthService } from '@services/auth.service'; -import { Observable, Subject, catchError, filter, of, switchMap, take } from 'rxjs'; -import _ from 'underscore'; +import { AfterViewInit, Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute } from "@angular/router"; +import { AuthService } from "@services/auth.service"; +import { ToastService } from "@services/toast.service"; +import { map, merge, share, startWith } from "rxjs"; @Component({ - selector: 'lc-verify-email', - templateUrl: './verify-email.component.html', - styleUrls: ['./verify-email.component.scss'] + selector: "lc-verify-email", + templateUrl: "./verify-email.component.html", + styleUrls: ["./verify-email.component.scss"], }) -export class VerifyEmailComponent { - key: string; - userDetails$: Observable<{ username: string, email: string } | undefined>; - error$ = new Subject(); - success$ = new Subject(); - directToLogin: boolean = false; +export class VerifyEmailComponent implements OnInit, AfterViewInit { + private key = this.activatedRoute.snapshot.params["key"]; + + public userDetails$ = this.authService.keyInfo.result$.pipe( + map((results) => ("error" in results ? null : results)), + share(), + ); + + public loading$ = this.authService.verifyEmail.loading$; constructor( private activatedRoute: ActivatedRoute, private authService: AuthService, + private toastService: ToastService, private destroyRef: DestroyRef, - ) { - this.key = this.activatedRoute.snapshot.params['key']; - this.userDetails$ = this.authService.keyInfo(this.key).pipe( - takeUntilDestroyed(), - catchError((e: HttpErrorResponse) => { - this.error$.next(e); - return of(undefined); - }) - ); - this.success$.pipe( - switchMap(() => this.authService.isAuthenticated$.pipe( - filter(_.negate(_.isUndefined)), - take(1)) - ), - takeUntilDestroyed(), - ).subscribe(isLoggedIn => this.directToLogin = !isLoggedIn); + ) {} + + ngOnInit(): void { + this.authService.keyInfo.error$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (!result) { + return; + } + this.toastService.show({ + header: "Email address verification failed.", + body: "Failed to verify email address.", + type: "danger", + }); + }); + + this.authService.verifyEmail.error$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.show({ + header: "Email verification failed", + body: "Failed to verify email address.", + type: "danger", + })); + + this.authService.verifyEmail.success$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.show({ + header: "Email verified", + body: "Email address has been verified.", + type: "success", + })); + } + + // We are subscribing to results of this call in the template, so we should + // only start listening after the view has been initialized. + ngAfterViewInit(): void { + this.authService.keyInfo.subject.next(this.key); } - confirm() { - this.authService.verifyEmail(this.key).pipe( - takeUntilDestroyed(this.destroyRef) - ).subscribe({ - next: () => this.success$.next(true), - error: (e) => this.error$.next(e), - }) + public confirm(): void { + this.authService.verifyEmail.subject.next(this.key); } } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c58dc05c..be6bfabe 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,3 +1,5 @@ +/// + import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app/app.module'; diff --git a/frontend/src/mock/auth.service.mock.ts b/frontend/src/mock/auth.service.mock.ts index b3525a62..43d79d74 100644 --- a/frontend/src/mock/auth.service.mock.ts +++ b/frontend/src/mock/auth.service.mock.ts @@ -44,6 +44,7 @@ export class AuthServiceMock { this.currentUser$.next(value); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars login(username: string, password: string): Observable { return of(testUser({ username })); } diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 319df4dc..6b6004e7 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -12,3 +12,7 @@ padding: 0 !important; } } + +.always-block { + display: block !important; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 374cc9d2..ec26f703 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -3,7 +3,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", - "types": [] + "types": [ + "@angular/localize" + ] }, "files": [ "src/main.ts" diff --git a/frontend/tsconfig.spec.json b/frontend/tsconfig.spec.json index 18ce5c40..c63b6982 100644 --- a/frontend/tsconfig.spec.json +++ b/frontend/tsconfig.spec.json @@ -4,7 +4,8 @@ "compilerOptions": { "outDir": "./out-tsc/spec", "types": [ - "jasmine" + "jasmine", + "@angular/localize" ] }, "include": [