Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ jobs:
# repetition is tool output, not something to factor out, so exclude
# the Migrations folder from copy-paste detection. Migration Up is
# still executed by the integration fixture and reviewed in the PR.
"/d:sonar.cpd.exclusions=**/Migrations/**"
#
# Also excluded from copy-paste detection:
# - i18n dictionaries: `es` and `en` mirror each other by design
# (same keys, translated values), which CPD reads as duplication.
# - component templates: Tailwind markup (cards, form fields) is
# presentation, not logic; factoring it out adds indirection with
# no behavioural gain.
"/d:sonar.cpd.exclusions=**/Migrations/**,**/shared/i18n/translations/*.ts,**/*.html"
)
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
args+=(
Expand Down
11 changes: 10 additions & 1 deletion bikontrol-web/src/app/modules/auth/auth-imports.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { TranslatePipe } from '../../shared/i18n/translate.pipe';
import { LanguageSwitcherComponent } from '../../shared/i18n/language-switcher.component';

export const AUTH_IMPORTS = [CommonModule, FormsModule, ReactiveFormsModule, RouterModule];
export const AUTH_IMPORTS = [
CommonModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
TranslatePipe,
LanguageSwitcherComponent,
];
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
<div class="w-full max-w-sm p-6 bg-card rounded-2xl shadow-lg text-center">
<img src="logo.webp" alt="Bikontrol logo" class="mx-auto mb-4 w-24 h-24" />

<h1 class="text-2xl font-bold mb-6 text-primary">Confirmar correo</h1>
<h1 class="text-2xl font-bold mb-6 text-primary">{{ 'auth.confirm.title' | t }}</h1>

@if (loading()) {
<p class="text-sm text-textdark mb-6">Confirmando tu correo...</p>
<p class="text-sm text-textdark mb-6">{{ 'auth.confirm.loading' | t }}</p>
} @else if (success()) {
<small class="text-green-600 text-xs block mb-6 font-bold">{{ successMessage() }}</small>
} @else {
Expand All @@ -17,7 +17,7 @@ <h1 class="text-2xl font-bold mb-6 text-primary">Confirmar correo</h1>
routerLink="/login"
class="w-full py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors"
>
Ir al inicio de sesión
{{ 'auth.confirm.goToLogin' | t }}
</button>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import { HttpErrorService } from '../../../../shared/services/http-error.service';

describe('ConfirmEmailComponent', () => {
let authServiceMock: any;

Check warning on line 9 in bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.spec.ts

View workflow job for this annotation

GitHub Actions / Frontend tests

Unexpected any. Specify a different type
let routeMock: any;

Check warning on line 10 in bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.spec.ts

View workflow job for this annotation

GitHub Actions / Frontend tests

Unexpected any. Specify a different type
let httpErrorMock: any;

Check warning on line 11 in bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.spec.ts

View workflow job for this annotation

GitHub Actions / Frontend tests

Unexpected any. Specify a different type

// The component uses inject(), so it must be created inside an injection context.
function createComponent(): ConfirmEmailComponent {
Expand Down Expand Up @@ -37,7 +37,7 @@
},
};
httpErrorMock = {
message: vi.fn((error: any, fallback = 'Error inesperado en el servidor.') => {

Check warning on line 40 in bikontrol-web/src/app/modules/auth/pages/confirm-email/confirm-email.component.spec.ts

View workflow job for this annotation

GitHub Actions / Frontend tests

Unexpected any. Specify a different type
return error?.error?.error || error?.error?.message || error?.message || fallback;
}),
};
Expand All @@ -51,7 +51,7 @@

expect(authServiceMock.confirmEmail).toHaveBeenCalledWith('user@example.com', 'token-abc');
expect(component.success()).toBe(true);
expect(component.successMessage()).toBe('Correo confirmado.');
expect(component.successMessage()).toBe('Correo confirmado. Ya puedes iniciar sesión.');
expect(component.loading()).toBe(false);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { Component, OnInit, ChangeDetectionStrategy, signal, inject } from '@ang
import { ActivatedRoute, RouterModule } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { HttpErrorService } from '../../../../shared/services/http-error.service';
import { TranslatePipe } from '../../../../shared/i18n/translate.pipe';
import { I18nService } from '../../../../shared/i18n/i18n.service';

@Component({
selector: 'app-confirm-email',
imports: [RouterModule],
imports: [RouterModule, TranslatePipe],
templateUrl: './confirm-email.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './confirm-email.component.scss',
Expand All @@ -14,6 +16,7 @@ export class ConfirmEmailComponent implements OnInit {
private route = inject(ActivatedRoute);
private authService = inject(AuthService);
private httpError = inject(HttpErrorService);
private i18n = inject(I18nService);

readonly loading = signal(true);
readonly success = signal(false);
Expand All @@ -26,15 +29,15 @@ export class ConfirmEmailComponent implements OnInit {

if (!token || !email) {
this.loading.set(false);
this.errorMessage.set('El enlace de confirmación es inválido o está incompleto.');
this.errorMessage.set(this.i18n.t('auth.confirm.linkInvalid'));
return;
}

this.authService.confirmEmail(email, token).subscribe({
next: (response) => {
next: () => {
this.loading.set(false);
this.success.set(true);
this.successMessage.set(response.message);
this.successMessage.set(this.i18n.t('auth.confirm.success'));
},
error: (error) => {
this.loading.set(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,26 @@
<div class="w-full max-w-sm p-6 bg-card rounded-2xl shadow-lg">
<img src="logo.webp" alt="Bikontrol logo" class="mx-auto mb-4 w-24 h-24" />

<h1 class="text-2xl font-bold text-center mb-2 text-primary">Recuperar contraseña</h1>
<p class="text-sm text-textdark text-center mb-6">
Ingresa tu correo y te enviaremos un enlace para restablecer tu contraseña.
</p>
<h1 class="text-2xl font-bold text-center mb-2 text-primary">{{ 'auth.forgot.title' | t }}</h1>
<p class="text-sm text-textdark text-center mb-6">{{ 'auth.forgot.subtitle' | t }}</p>

<form [formGroup]="form" (ngSubmit)="onSubmit()" novalidate>
<div class="mb-4">
<label for="forgot-email" class="block text-sm font-medium text-textdark mb-1"
>Correo</label
>
<label for="forgot-email" class="block text-sm font-medium text-textdark mb-1">{{
'common.email' | t
}}</label>
<input
id="forgot-email"
type="email"
formControlName="email"
class="w-full px-4 py-2 border border-accent rounded-lg focus:ring-2 focus:ring-secondary focus:outline-none"
placeholder="correo@example.com"
[placeholder]="'common.emailPlaceholder' | t"
/>
@if (isInvalid('email') && f['email'].errors?.['required']) {
<small class="text-red-500 text-xs font-bold"> El correo es obligatorio. </small>
<small class="text-red-500 text-xs font-bold"> {{ 'common.emailRequired' | t }} </small>
}
@if (isInvalid('email') && f['email'].errors?.['email']) {
<small class="text-red-500 text-xs font-bold"> Ingresa un correo válido. </small>
<small class="text-red-500 text-xs font-bold"> {{ 'common.emailInvalid' | t }} </small>
}
</div>

Expand All @@ -32,7 +30,7 @@ <h1 class="text-2xl font-bold text-center mb-2 text-primary">Recuperar contrase
[disabled]="form.invalid"
class="w-full py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors disabled:bg-accent disabled:cursor-not-allowed"
>
Enviar enlace
{{ 'auth.forgot.submit' | t }}
</button>

@if (successMessage()) {
Expand All @@ -51,7 +49,7 @@ <h1 class="text-2xl font-bold text-center mb-2 text-primary">Recuperar contrase
routerLink="/login"
class="w-full mt-6 py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors"
>
Volver al inicio de sesión
{{ 'common.backToLogin' | t }}
</button>
</form>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@

describe('ForgotPasswordComponent', () => {
let component: ForgotPasswordComponent;
let authServiceMock: any;

Check warning on line 10 in bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.spec.ts

View workflow job for this annotation

GitHub Actions / Frontend tests

Unexpected any. Specify a different type
let httpErrorMock: any;

Check warning on line 11 in bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.spec.ts

View workflow job for this annotation

GitHub Actions / Frontend tests

Unexpected any. Specify a different type

beforeEach(() => {
authServiceMock = {
forgotPassword: vi.fn(),
};
httpErrorMock = {
message: vi.fn((error: any, fallback = 'Error inesperado en el servidor.') => {

Check warning on line 18 in bikontrol-web/src/app/modules/auth/pages/forgot-password/forgot-password.component.spec.ts

View workflow job for this annotation

GitHub Actions / Frontend tests

Unexpected any. Specify a different type
return error?.error?.error || error?.error?.message || error?.message || fallback;
}),
};
Expand Down Expand Up @@ -49,7 +49,9 @@
component.onSubmit();

expect(authServiceMock.forgotPassword).toHaveBeenCalledWith('user@example.com');
expect(component.successMessage()).toBe('Revisa tu correo.');
expect(component.successMessage()).toBe(
'Si el correo está registrado, recibirás un enlace para restablecer tu contraseña.',
);
expect(component.errorMessage()).toBeNull();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import { RouterModule } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { HttpErrorService } from '../../../../shared/services/http-error.service';
import { isInvalid as formIsInvalid } from '../../../../shared/utils/form.utils';
import { TranslatePipe } from '../../../../shared/i18n/translate.pipe';
import { I18nService } from '../../../../shared/i18n/i18n.service';

@Component({
selector: 'app-forgot-password',
imports: [FormsModule, ReactiveFormsModule, RouterModule],
imports: [FormsModule, ReactiveFormsModule, RouterModule, TranslatePipe],
templateUrl: './forgot-password.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './forgot-password.component.scss',
Expand All @@ -22,6 +24,7 @@ export class ForgotPasswordComponent {
private fb = inject(FormBuilder);
private authService = inject(AuthService);
private httpError = inject(HttpErrorService);
private i18n = inject(I18nService);

form: FormGroup;
readonly submitted = signal(false);
Expand Down Expand Up @@ -50,8 +53,8 @@ export class ForgotPasswordComponent {
if (this.form.invalid) return;

this.authService.forgotPassword(this.form.value.email).subscribe({
next: (response) => {
this.successMessage.set(response.message);
next: () => {
this.successMessage.set(this.i18n.t('auth.forgot.success'));
},
error: (error) => {
this.errorMessage.set(this.httpError.message(error));
Expand Down
57 changes: 32 additions & 25 deletions bikontrol-web/src/app/modules/auth/pages/login/login.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,32 @@
<div class="w-full max-w-sm p-6 bg-card rounded-2xl shadow-lg">
<img src="logo.webp" alt="Bikontrol logo" class="mx-auto mb-4 w-24 h-24" />

<h1 class="text-2xl font-bold text-center mb-6 text-primary">Bikontrol</h1>
<h1 class="text-2xl font-bold text-center mb-6 text-primary">{{ 'app.name' | t }}</h1>

<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" novalidate>
<div class="mb-4">
<label for="login-email" class="block text-sm font-medium text-textdark mb-1">Correo</label>
<label for="login-email" class="block text-sm font-medium text-textdark mb-1">{{
'common.email' | t
}}</label>
<input
id="login-email"
type="email"
formControlName="email"
class="w-full px-4 py-2 border border-accent rounded-lg focus:ring-2 focus:ring-secondary focus:outline-none"
placeholder="correo@example.com"
[placeholder]="'common.emailPlaceholder' | t"
/>
@if (isInvalid('email') && f['email'].errors?.['required']) {
<small class="text-red-500 text-xs font-bold"> El correo es obligatorio. </small>
<small class="text-red-500 text-xs font-bold"> {{ 'common.emailRequired' | t }} </small>
}
@if (isInvalid('email') && f['email'].errors?.['email']) {
<small class="text-red-500 text-xs font-bold"> Ingresa un correo válido. </small>
<small class="text-red-500 text-xs font-bold"> {{ 'common.emailInvalid' | t }} </small>
}
</div>

<div class="mb-2">
<label for="login-password" class="block text-sm font-medium text-textdark mb-1"
>Contraseña</label
>
<label for="login-password" class="block text-sm font-medium text-textdark mb-1">{{
'common.password' | t
}}</label>
<input
id="login-password"
type="password"
Expand All @@ -34,25 +36,27 @@ <h1 class="text-2xl font-bold text-center mb-6 text-primary">Bikontrol</h1>
placeholder="••••••••"
/>
@if (isInvalid('password') && f['password'].errors?.['required']) {
<small class="text-red-500 text-xs font-bold"> La contraseña es obligatoria. </small>
<small class="text-red-500 text-xs font-bold">
{{ 'common.passwordRequired' | t }}
</small>
}
@if (isInvalid('password') && f['password'].errors?.['minlength']) {
<small class="text-red-500 text-xs font-bold"> Debe tener al menos 6 caracteres. </small>
<small class="text-red-500 text-xs font-bold"> {{ 'common.passwordMin' | t }} </small>
}
</div>

<div class="text-right mb-4">
<a routerLink="/forgot-password" class="text-xs text-primary hover:underline"
>¿Olvidaste tu contraseña?</a
>
<a routerLink="/forgot-password" class="text-xs text-primary hover:underline">{{
'auth.login.forgot' | t
}}</a>
</div>

<button
type="submit"
[disabled]="loginForm.invalid"
class="w-full py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors disabled:bg-accent disabled:cursor-not-allowed"
>
Ingresar
{{ 'auth.login.submit' | t }}
</button>

@if (errorMessage()) {
Expand All @@ -68,7 +72,7 @@ <h1 class="text-2xl font-bold text-center mb-6 text-primary">Bikontrol</h1>
[disabled]="resendLoading()"
class="w-full mt-3 py-2 border border-accent text-primary rounded-lg hover:bg-gray-50 transition-colors text-sm disabled:opacity-50"
>
{{ resendLoading() ? 'Enviando...' : 'Reenviar correo de confirmación' }}
{{ (resendLoading() ? 'auth.login.resendLoading' : 'auth.login.resend') | t }}
</button>
}

Expand All @@ -81,7 +85,7 @@ <h1 class="text-2xl font-bold text-center mb-6 text-primary">Bikontrol</h1>

<div class="flex items-center gap-3 my-4">
<span class="flex-1 border-t border-accent"></span>
<span class="text-xs text-textdark">o continuar con</span>
<span class="text-xs text-textdark">{{ 'auth.login.orContinue' | t }}</span>
<span class="flex-1 border-t border-accent"></span>
</div>

Expand All @@ -92,7 +96,7 @@ <h1 class="text-2xl font-bold text-center mb-6 text-primary">Bikontrol</h1>
routerLink="/register"
class="w-full mt-6 py-2 bg-primary text-textlight rounded-lg hover:bg-accent transition-colors"
>
Registrarse
{{ 'auth.login.register' | t }}
</button>

@if (demoEnabled) {
Expand All @@ -102,16 +106,19 @@ <h1 class="text-2xl font-bold text-center mb-6 text-primary">Bikontrol</h1>
[disabled]="demoLoading()"
class="w-full mt-3 py-2 border border-accent text-primary rounded-lg hover:bg-gray-50 transition-colors text-sm disabled:opacity-50"
>
{{ demoLoading() ? 'Cargando demo...' : '¿Querés probar la demo?' }}
{{ (demoLoading() ? 'auth.login.demoLoading' : 'auth.login.demo') | t }}
</button>
}

<p class="text-center text-xs text-gray-500 mt-6">
<a routerLink="/help" class="hover:underline">Ayuda</a>
·
<a routerLink="/terms" class="hover:underline">Términos</a>
·
<a routerLink="/privacy" class="hover:underline">Privacidad</a>
</p>
<div class="flex flex-col items-center gap-2 mt-6">
<app-language-switcher />
<p class="text-center text-xs text-gray-500">
<a routerLink="/help" class="hover:underline">{{ 'common.help' | t }}</a>
·
<a routerLink="/terms" class="hover:underline">{{ 'common.terms' | t }}</a>
·
<a routerLink="/privacy" class="hover:underline">{{ 'common.privacy' | t }}</a>
</p>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

describe('LoginComponent', () => {
let component: LoginComponent;
let authServiceMock: any;

Check warning on line 11 in bikontrol-web/src/app/modules/auth/pages/login/login.component.spec.ts

View workflow job for this annotation

GitHub Actions / Frontend tests

Unexpected any. Specify a different type
let routerMock: any;

Check warning on line 12 in bikontrol-web/src/app/modules/auth/pages/login/login.component.spec.ts

View workflow job for this annotation

GitHub Actions / Frontend tests

Unexpected any. Specify a different type
let httpErrorMock: any;

beforeEach(() => {
Expand Down Expand Up @@ -132,7 +132,7 @@
component.onResendConfirmation();

expect(authServiceMock.resendConfirmation).toHaveBeenCalledWith('user@example.com');
expect(component.resendMessage()).toBe('Enviado');
expect(component.resendMessage()).toBe('Te enviamos un nuevo enlace de confirmación.');
});

it('should log in to the demo and navigate when demo is enabled', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { AUTH_IMPORTS } from '../../auth-imports';
import { HttpErrorService } from '../../../../shared/services/http-error.service';
import { isInvalid as formIsInvalid } from '../../../../shared/utils/form.utils';
import { I18nService } from '../../../../shared/i18n/i18n.service';
import { environment } from '@env/environment';

declare global {
Expand All @@ -25,6 +26,7 @@ export class LoginComponent implements AfterViewInit {
private authService = inject(AuthService);
private router = inject(Router);
private httpError = inject(HttpErrorService);
private i18n = inject(I18nService);

loginForm: FormGroup;
readonly submitted = signal(false);
Expand Down Expand Up @@ -93,9 +95,9 @@ export class LoginComponent implements AfterViewInit {
this.resendLoading.set(true);
this.resendMessage.set(null);
this.authService.resendConfirmation(email).subscribe({
next: (response) => {
next: () => {
this.resendLoading.set(false);
this.resendMessage.set(response.message);
this.resendMessage.set(this.i18n.t('auth.login.resendSent'));
},
error: (error) => {
this.resendLoading.set(false);
Expand All @@ -113,7 +115,7 @@ export class LoginComponent implements AfterViewInit {
next: () => this.router.navigate(['/dashboard']),
error: (error) => {
this.demoLoading.set(false);
this.errorMessage.set(this.httpError.message(error, 'No se pudo iniciar la demo.'));
this.errorMessage.set(this.httpError.message(error, this.i18n.t('auth.login.demoError')));
},
});
}
Expand Down Expand Up @@ -148,7 +150,7 @@ export class LoginComponent implements AfterViewInit {

onGoogleCredential(response: { credential?: string }): void {
if (!response?.credential) {
this.errorMessage.set('No se pudo obtener la credencial de Google.');
this.errorMessage.set(this.i18n.t('auth.login.googleError'));
return;
}

Expand Down
Loading
Loading