{{ 'app.auth.login.title' | transloco }}
- @if (error()) {
- {{ error() }}
+ @if (loginAction.error(); as error) {
+ {{ error.message | transloco }}
+
}
- @if (!error() && isRegistered()) {
+
+ @if (!loginAction.error() && isRegistered()) {
{{ 'app.auth.login.registrationSuccess' | transloco }}
}
+
+ @if (!loginAction.error() && resetSuccess()) {
+
+ {{ 'app.auth.login.resetSuccess' | transloco }}
+
+ }
diff --git a/frontend/src/app/features/auth/login/login.component.spec.ts b/frontend/src/app/features/auth/login/login.component.spec.ts
new file mode 100644
index 00000000..3341735d
--- /dev/null
+++ b/frontend/src/app/features/auth/login/login.component.spec.ts
@@ -0,0 +1,166 @@
+import {ComponentFixture, TestBed} from '@angular/core/testing';
+import {LoginComponent} from './login.component';
+import {ActivatedRoute, provideRouter, Router} from '@angular/router';
+import {AuthService} from '../../../core/services/auth.service';
+import {getTranslocoModule} from '../../../transloco/testing/transloco-testing.module';
+import {of, throwError} from 'rxjs';
+import {By} from '@angular/platform-browser';
+import {HttpErrorResponse} from '@angular/common/http';
+import {Mock, vi} from 'vitest';
+import {provideZonelessChangeDetection} from '@angular/core';
+
+describe('LoginComponent', () => {
+ let component: LoginComponent;
+ let fixture: ComponentFixture;
+ let authService: AuthService;
+ let router: Router;
+
+ beforeEach(async () => {
+ const authServiceMock = {
+ login: vi.fn()
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [
+ LoginComponent,
+ getTranslocoModule()
+ ],
+ providers: [
+ provideZonelessChangeDetection(),
+ {provide: AuthService, useValue: authServiceMock},
+ provideRouter([]),
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ snapshot: {
+ queryParams: {returnUrl: '/dashboard'}
+ }
+ }
+ }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(LoginComponent);
+ component = fixture.componentInstance;
+ authService = TestBed.inject(AuthService);
+ router = TestBed.inject(Router);
+ vi.spyOn(router, 'navigate');
+ await fixture.whenStable();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize with returnUrl from queryParams', () => {
+ expect(component['returnUrl']).toBe('/dashboard');
+ });
+
+ it('should show success message if registered query param is true', async () => {
+ // Re-create component with registered=true
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ imports: [LoginComponent, getTranslocoModule()],
+ providers: [
+ provideZonelessChangeDetection(),
+ {provide: AuthService, useValue: {login: vi.fn()}},
+ provideRouter([]),
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ snapshot: {
+ queryParams: {registered: 'true'}
+ }
+ }
+ }
+ ]
+ });
+ fixture = TestBed.createComponent(LoginComponent);
+ fixture.detectChanges();
+
+ const successAlert = fixture.debugElement.query(By.css('[data-cy="success-message"]'));
+ expect(successAlert).toBeTruthy();
+ expect(successAlert.nativeElement.textContent).toContain('Registration completed successfully');
+ });
+
+ it('should show error when fields are empty and submitted', async () => {
+ const form = fixture.debugElement.query(By.css('form'));
+ form.triggerEventHandler('submit', {
+ preventDefault: () => {
+ }
+ });
+ fixture.detectChanges();
+
+ expect(authService.login).not.toHaveBeenCalled();
+
+ const errors = fixture.debugElement.queryAll(By.css('.invalid-feedback'));
+ expect(errors.length).toBeGreaterThan(0);
+ });
+
+ it('should call authService.login when form is valid and submitted', async () => {
+ const usernameInput = fixture.debugElement.query(By.css('[data-cy="username-input"]')).nativeElement;
+ const passwordInput = fixture.debugElement.query(By.css('[data-cy="password-input"]')).nativeElement;
+
+ usernameInput.value = 'testuser';
+ usernameInput.dispatchEvent(new Event('input'));
+ passwordInput.value = 'mypassword';
+ passwordInput.dispatchEvent(new Event('input'));
+
+ fixture.detectChanges();
+
+ (authService.login as Mock).mockReturnValue(of({accessToken: 'fake-token'}));
+
+ const form = fixture.debugElement.query(By.css('form'));
+ form.triggerEventHandler('submit', {
+ preventDefault: () => {
+ }
+ });
+
+ fixture.detectChanges();
+
+ expect(authService.login).toHaveBeenCalledWith({
+ username: 'testuser',
+ password: 'mypassword'
+ });
+ expect(router.navigate).toHaveBeenCalledWith(['/dashboard']);
+ });
+
+ it('should show error message when login fails', async () => {
+ const usernameInput = fixture.debugElement.query(By.css('[data-cy="username-input"]')).nativeElement;
+ const passwordInput = fixture.debugElement.query(By.css('[data-cy="password-input"]')).nativeElement;
+
+ usernameInput.value = 'testuser';
+ usernameInput.dispatchEvent(new Event('input'));
+ passwordInput.value = 'wrongpassword';
+ passwordInput.dispatchEvent(new Event('input'));
+
+ fixture.detectChanges();
+
+ const errorResponse = new HttpErrorResponse({
+ error: {error: 'INVALID_CREDENTIALS'},
+ status: 401
+ });
+ (authService.login as Mock).mockReturnValue(throwError(() => errorResponse));
+
+ const form = fixture.debugElement.query(By.css('form'));
+ form.triggerEventHandler('submit', {
+ preventDefault: () => {
+ }
+ });
+
+ fixture.detectChanges();
+
+ const alert = fixture.debugElement.query(By.css('.alert-danger'));
+ expect(alert).toBeTruthy();
+ expect(alert.nativeElement.textContent).toContain('errors.INVALID_CREDENTIALS');
+ });
+
+ it('should disable submit button when loading', async () => {
+ component.loginAction.loading.set(true);
+ fixture.detectChanges();
+
+ const submitBtn = fixture.debugElement.query(By.css('[data-cy="login-button"]')).nativeElement;
+ expect(submitBtn.disabled).toBe(true);
+ expect(fixture.debugElement.query(By.css('.spinner-border'))).toBeTruthy();
+ });
+});
diff --git a/frontend/src/app/features/auth/login/login.component.ts b/frontend/src/app/features/auth/login/login.component.ts
index cc8109b5..2463531c 100644
--- a/frontend/src/app/features/auth/login/login.component.ts
+++ b/frontend/src/app/features/auth/login/login.component.ts
@@ -1,61 +1,62 @@
import {Component, inject, OnInit, signal} from '@angular/core';
-import {FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms';
+import {form, FormField, required} from '@angular/forms/signals';
import {ActivatedRoute, Router, RouterLink} from '@angular/router';
import {AuthService} from '../../../core/services/auth.service';
-import {NgClass} from '@angular/common';
import {TranslocoPipe} from '@jsverse/transloco';
+import {createAsyncAction} from '../../../core/utils/async-action.util';
+import {LoginCredentials} from '../../../core/models/auth.model';
+import {FormInputComponent} from "../../../core/components/form-input/form-input.component";
+import {FormSubmitDirective} from "../../../core/directives/form-submit.directive";
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
- imports: [ReactiveFormsModule, NgClass, RouterLink, TranslocoPipe]
+ imports: [RouterLink, TranslocoPipe, FormField, FormInputComponent, FormSubmitDirective]
})
export class LoginComponent implements OnInit {
- private formBuilder = inject(FormBuilder);
private route = inject(ActivatedRoute);
private router = inject(Router);
private authService = inject(AuthService);
- error = signal(null);
- loading = signal(false);
isRegistered = signal(false);
+ resetSuccess = signal(false);
private returnUrl: string = '/';
- loginForm = this.formBuilder.group({
- username: ['', Validators.required],
- password: ['', Validators.required]
+ loginModel = signal({
+ username: '',
+ password: ''
});
+ loginForm = form(this.loginModel, (fields) => {
+ required(fields.username);
+ required(fields.password);
+ });
+
+ loginAction = createAsyncAction(
+ (credentials: LoginCredentials) => this.authService.login(credentials),
+ {
+ onSuccess: () => this.router.navigate([this.returnUrl]),
+ defaultErrorMessage: 'Login failed'
+ }
+ );
+
ngOnInit(): void {
// Get return URL from route parameters or default to '/dashboard'
this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard';
// Check if user was redirected after registration
this.isRegistered.set(this.route.snapshot.queryParams['registered'] === 'true');
- }
-
- get f() {
- return this.loginForm.controls;
+ // Check if user was redirected after password reset
+ this.resetSuccess.set(this.route.snapshot.queryParams['resetSuccess'] === 'true');
}
onSubmit(): void {
- if (this.loginForm.invalid) {
+ if (!this.loginForm().valid()) {
return;
}
- this.loading.set(true);
- this.error.set(null);
-
- this.authService.login({
- username: this.f['username'].value as string,
- password: this.f['password'].value as string
- }).subscribe({
- next: () => {
- this.router.navigate([this.returnUrl]);
- },
- error: error => {
- this.error.set(error.error?.message || 'Login failed');
- this.loading.set(false);
- }
+ this.loginAction.execute(this.loginModel()).subscribe({
+ next: (res) => this.loginAction.handleSuccess(res),
+ error: (err) => this.loginAction.handleError(err)
});
}
}
diff --git a/frontend/src/app/features/auth/password-reset-request/password-reset-request.component.html b/frontend/src/app/features/auth/password-reset-request/password-reset-request.component.html
index c74a8c3a..52363908 100644
--- a/frontend/src/app/features/auth/password-reset-request/password-reset-request.component.html
+++ b/frontend/src/app/features/auth/password-reset-request/password-reset-request.component.html
@@ -12,44 +12,38 @@ {{ 'app.auth.passwordReset.title' | tran
}
- @if (errorMessage()) {
-
- {{ errorMessage() }}
+ @if (resetAction.error(); as error) {
+
{{ error.message | transloco }}
}
-