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 @@
- -
+
-
Loading user data...
@@ -12,9 +12,11 @@
{{user.username}}
@@ -24,11 +26,18 @@
Sign in
- -
+
-
Register
+
+ -
+
+ Signing out...
+
+
+
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 @@
-