| // auth-page.scss - Fichier partagé pour login, register et forgot-password | |||||
| .auth-page-container { | |||||
| min-height: 100vh; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| background: linear-gradient(135deg, #243b5d 0%, #2984a1 100%); | |||||
| padding: 20px; | |||||
| position: relative; | |||||
| overflow: hidden; | |||||
| .auth-card { | |||||
| position: relative; | |||||
| z-index: 1; | |||||
| width: 100%; | |||||
| max-width: 440px; | |||||
| background: #ffffff; | |||||
| border-radius: 24px; | |||||
| box-shadow: | |||||
| 0 20px 60px rgba(0, 0, 0, 0.3), | |||||
| 0 0 100px rgba(102, 126, 234, 0.2); | |||||
| overflow: hidden; | |||||
| animation: fadeInUp 0.6s ease; | |||||
| .auth-header { | |||||
| padding: 48px 40px 32px; | |||||
| text-align: center; | |||||
| background: linear-gradient(180deg, #f9fafb 0%, #ffffff 100%); | |||||
| .logo { | |||||
| display: inline-block; | |||||
| font-size: 32px; | |||||
| font-weight: 800; | |||||
| color: #2984a1; | |||||
| margin-bottom: 24px; | |||||
| letter-spacing: -1px; | |||||
| } | |||||
| .auth-title { | |||||
| font-size: 28px; | |||||
| font-weight: 700; | |||||
| color: #1f2937; | |||||
| margin: 0 0 12px 0; | |||||
| line-height: 1.2; | |||||
| } | |||||
| .auth-subtitle { | |||||
| font-size: 15px; | |||||
| color: #6b7280; | |||||
| margin: 0; | |||||
| font-weight: 400; | |||||
| line-height: 1.5; | |||||
| } | |||||
| } | |||||
| .auth-content { | |||||
| padding: 32px 40px 40px; | |||||
| .auth-form { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: 24px; | |||||
| .form-field { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: 8px; | |||||
| .form-label { | |||||
| font-size: 14px; | |||||
| font-weight: 600; | |||||
| color: #374151; | |||||
| margin: 0; | |||||
| } | |||||
| .input-wrapper { | |||||
| position: relative; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| .form-input { | |||||
| width: 100%; | |||||
| padding: 14px 16px; | |||||
| padding-right: 48px; | |||||
| font-size: 15px; | |||||
| color: #1f2937; | |||||
| background: #f9fafb; | |||||
| border: 2px solid #e5e7eb; | |||||
| border-radius: 12px; | |||||
| transition: all 0.2s ease; | |||||
| outline: none; | |||||
| &::placeholder { | |||||
| color: #9ca3af; | |||||
| } | |||||
| &:focus { | |||||
| background: #ffffff; | |||||
| border-color: #667eea; | |||||
| box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | |||||
| } | |||||
| &.error { | |||||
| border-color: #ef4444; | |||||
| background: #fef2f2; | |||||
| &:focus { | |||||
| box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); | |||||
| } | |||||
| } | |||||
| } | |||||
| .input-icon { | |||||
| position: absolute; | |||||
| right: 16px; | |||||
| color: #9ca3af; | |||||
| pointer-events: none; | |||||
| transition: color 0.2s ease; | |||||
| &.clickable { | |||||
| pointer-events: all; | |||||
| cursor: pointer; | |||||
| &:hover { | |||||
| color: #667eea; | |||||
| } | |||||
| } | |||||
| } | |||||
| .form-input:focus ~ .input-icon { | |||||
| color: #667eea; | |||||
| } | |||||
| } | |||||
| .error-message { | |||||
| font-size: 13px; | |||||
| color: #ef4444; | |||||
| margin: 0; | |||||
| padding-left: 4px; | |||||
| } | |||||
| .success-message { | |||||
| font-size: 13px; | |||||
| color: #10b981; | |||||
| margin: 0; | |||||
| padding-left: 4px; | |||||
| } | |||||
| .info-message { | |||||
| font-size: 13px; | |||||
| color: #6b7280; | |||||
| margin: 0; | |||||
| padding-left: 4px; | |||||
| } | |||||
| } | |||||
| .form-options { | |||||
| display: flex; | |||||
| justify-content: space-between; | |||||
| align-items: center; | |||||
| gap: 16px; | |||||
| flex-wrap: wrap; | |||||
| .checkbox-wrapper { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 8px; | |||||
| cursor: pointer; | |||||
| input[type="checkbox"] { | |||||
| width: 18px; | |||||
| height: 18px; | |||||
| cursor: pointer; | |||||
| accent-color: #667eea; | |||||
| } | |||||
| .checkbox-label { | |||||
| font-size: 14px; | |||||
| color: #6b7280; | |||||
| user-select: none; | |||||
| } | |||||
| } | |||||
| .forgot-password-link { | |||||
| font-size: 14px; | |||||
| color: #667eea; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| transition: color 0.2s ease; | |||||
| &:hover { | |||||
| color: #5568d3; | |||||
| text-decoration: underline; | |||||
| } | |||||
| } | |||||
| } | |||||
| .auth-btn { | |||||
| width: 100%; | |||||
| padding: 14px 24px; | |||||
| font-size: 16px; | |||||
| font-weight: 600; | |||||
| color: #ffffff; | |||||
| background: linear-gradient(-45deg, #243b5d 0%, #2984a1 100%); | |||||
| border: none; | |||||
| border-radius: 12px; | |||||
| cursor: pointer; | |||||
| transition: all 0.3s ease; | |||||
| box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); | |||||
| margin-top: 8px; | |||||
| &:hover:not(:disabled) { | |||||
| transform: translateY(-2px); | |||||
| box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); | |||||
| } | |||||
| &:active:not(:disabled) { | |||||
| transform: translateY(0); | |||||
| } | |||||
| &:disabled { | |||||
| opacity: 0.6; | |||||
| cursor: not-allowed; | |||||
| } | |||||
| span { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| gap: 8px; | |||||
| } | |||||
| } | |||||
| .divider { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| text-align: center; | |||||
| margin: 8px 0; | |||||
| &::before, | |||||
| &::after { | |||||
| content: ''; | |||||
| flex: 1; | |||||
| border-bottom: 1px solid #e5e7eb; | |||||
| } | |||||
| span { | |||||
| padding: 0 16px; | |||||
| font-size: 14px; | |||||
| color: #9ca3af; | |||||
| font-weight: 500; | |||||
| } | |||||
| } | |||||
| .social-buttons { | |||||
| display: flex; | |||||
| gap: 12px; | |||||
| .social-btn { | |||||
| flex: 1; | |||||
| padding: 12px; | |||||
| background: #ffffff; | |||||
| border: 2px solid #e5e7eb; | |||||
| border-radius: 12px; | |||||
| cursor: pointer; | |||||
| transition: all 0.2s ease; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| gap: 8px; | |||||
| font-size: 14px; | |||||
| font-weight: 600; | |||||
| color: #374151; | |||||
| &:hover { | |||||
| border-color: #667eea; | |||||
| background: #f9fafb; | |||||
| } | |||||
| img, svg { | |||||
| width: 20px; | |||||
| height: 20px; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| .auth-footer { | |||||
| padding: 24px 40px; | |||||
| text-align: center; | |||||
| background: #f9fafb; | |||||
| border-top: 1px solid #e5e7eb; | |||||
| p { | |||||
| margin: 0; | |||||
| font-size: 14px; | |||||
| color: #6b7280; | |||||
| } | |||||
| .register-link, | |||||
| .login-link, | |||||
| .back-to-login-link { | |||||
| color: #667eea; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| transition: color 0.2s ease; | |||||
| &:hover { | |||||
| color: #5568d3; | |||||
| text-decoration: underline; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| // Animations | |||||
| @keyframes fadeInUp { | |||||
| from { | |||||
| opacity: 0; | |||||
| transform: translateY(30px); | |||||
| } | |||||
| to { | |||||
| opacity: 1; | |||||
| transform: translateY(0); | |||||
| } | |||||
| } | |||||
| @keyframes backgroundMove { | |||||
| 0%, 100% { | |||||
| transform: translate(0, 0) rotate(0deg); | |||||
| } | |||||
| 33% { | |||||
| transform: translate(5%, -5%) rotate(5deg); | |||||
| } | |||||
| 66% { | |||||
| transform: translate(-5%, 5%) rotate(-5deg); | |||||
| } | |||||
| } | |||||
| // Responsive | |||||
| @media (max-width: 640px) { | |||||
| .auth-page-container { | |||||
| padding: 16px; | |||||
| .auth-card { | |||||
| max-width: 100%; | |||||
| border-radius: 20px; | |||||
| .auth-header { | |||||
| padding: 32px 24px 24px; | |||||
| .logo { | |||||
| font-size: 28px; | |||||
| margin-bottom: 20px; | |||||
| } | |||||
| .auth-title { | |||||
| font-size: 24px; | |||||
| } | |||||
| .auth-subtitle { | |||||
| font-size: 14px; | |||||
| } | |||||
| } | |||||
| .auth-content { | |||||
| padding: 24px; | |||||
| .auth-form { | |||||
| gap: 20px; | |||||
| .form-field { | |||||
| .input-wrapper { | |||||
| .form-input { | |||||
| padding: 12px 14px; | |||||
| padding-right: 44px; | |||||
| font-size: 14px; | |||||
| } | |||||
| } | |||||
| } | |||||
| .form-options { | |||||
| flex-direction: column; | |||||
| align-items: flex-start; | |||||
| gap: 12px; | |||||
| } | |||||
| .auth-btn { | |||||
| padding: 12px 20px; | |||||
| font-size: 15px; | |||||
| } | |||||
| .social-buttons { | |||||
| flex-direction: column; | |||||
| .social-btn { | |||||
| width: 100%; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| .auth-footer { | |||||
| padding: 20px 24px; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| // Variantes spécifiques pour chaque page | |||||
| .auth-page-container { | |||||
| // Page Register - carte plus haute | |||||
| &.register-page { | |||||
| .auth-card { | |||||
| max-width: 480px; | |||||
| } | |||||
| } | |||||
| // Page Forgot Password - carte plus petite | |||||
| &.forgot-password-page { | |||||
| .auth-card { | |||||
| max-width: 400px; | |||||
| } | |||||
| } | |||||
| } |
| <div class="auth-page-container register-page"> | |||||
| <div class="auth-card"> | |||||
| <div class="auth-header"> | |||||
| <div class="logo">AIT</div> | |||||
| <h1 class="auth-title">Créer un compte</h1> | |||||
| <p class="auth-subtitle">Rejoignez-nous dès maintenant et commencez votre aventure.</p> | |||||
| </div> | |||||
| <div class="auth-content"> | |||||
| <!-- Success Message --> | |||||
| <div class="alert alert-success" *ngIf="success()"> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||||
| <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path> | |||||
| <polyline points="22 4 12 14.01 9 11.01"></polyline> | |||||
| </svg> | |||||
| <div> | |||||
| <strong>Inscription réussie !</strong> | |||||
| <p>Un email de confirmation vous a été envoyé. Veuillez vérifier votre boîte de réception.</p> | |||||
| </div> | |||||
| </div> | |||||
| <!-- Error Messages --> | |||||
| <div class="alert alert-danger" *ngIf="error()"> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||||
| <circle cx="12" cy="12" r="10"></circle> | |||||
| <line x1="12" y1="8" x2="12" y2="12"></line> | |||||
| <line x1="12" y1="16" x2="12.01" y2="16"></line> | |||||
| </svg> | |||||
| <p>Une erreur s'est produite lors de l'inscription. Veuillez réessayer.</p> | |||||
| </div> | |||||
| <div class="alert alert-danger" *ngIf="errorUserExists()"> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||||
| <circle cx="12" cy="12" r="10"></circle> | |||||
| <line x1="12" y1="8" x2="12" y2="12"></line> | |||||
| <line x1="12" y1="16" x2="12.01" y2="16"></line> | |||||
| </svg> | |||||
| <p>Ce nom d'utilisateur est déjà utilisé. Veuillez en choisir un autre.</p> | |||||
| </div> | |||||
| <div class="alert alert-danger" *ngIf="errorEmailExists()"> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||||
| <circle cx="12" cy="12" r="10"></circle> | |||||
| <line x1="12" y1="8" x2="12" y2="12"></line> | |||||
| <line x1="12" y1="16" x2="12.01" y2="16"></line> | |||||
| </svg> | |||||
| <p>Cette adresse email est déjà utilisée. Veuillez vous connecter ou utiliser une autre adresse.</p> | |||||
| </div> | |||||
| <div class="alert alert-danger" *ngIf="doNotMatch()"> | |||||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||||
| <circle cx="12" cy="12" r="10"></circle> | |||||
| <line x1="12" y1="8" x2="12" y2="12"></line> | |||||
| <line x1="12" y1="16" x2="12.01" y2="16"></line> | |||||
| </svg> | |||||
| <p>Les mots de passe ne correspondent pas.</p> | |||||
| </div> | |||||
| <form [formGroup]="registerForm" (ngSubmit)="register()" class="auth-form" *ngIf="!success()"> | |||||
| <!-- Login/Username Field --> | |||||
| <div class="form-field"> | |||||
| <label for="login" class="form-label">Nom d'utilisateur</label> | |||||
| <div class="input-wrapper"> | |||||
| <input | |||||
| #login | |||||
| id="login" | |||||
| type="text" | |||||
| formControlName="login" | |||||
| class="form-input" | |||||
| placeholder="Votre nom d'utilisateur" | |||||
| [class.error]="registerForm.get('login')?.invalid && registerForm.get('login')?.touched" | |||||
| > | |||||
| <svg class="input-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||||
| <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path> | |||||
| <circle cx="12" cy="7" r="4"></circle> | |||||
| </svg> | |||||
| </div> | |||||
| <span class="error-message" *ngIf="registerForm.get('login')?.hasError('required') && registerForm.get('login')?.touched"> | |||||
| Le nom d'utilisateur est requis. | |||||
| </span> | |||||
| <span class="error-message" *ngIf="registerForm.get('login')?.hasError('minlength') && registerForm.get('login')?.touched"> | |||||
| Le nom d'utilisateur doit contenir au moins 1 caractère. | |||||
| </span> | |||||
| <span class="error-message" *ngIf="registerForm.get('login')?.hasError('maxlength') && registerForm.get('login')?.touched"> | |||||
| Le nom d'utilisateur ne peut pas dépasser 50 caractères. | |||||
| </span> | |||||
| <span class="error-message" *ngIf="registerForm.get('login')?.hasError('pattern') && registerForm.get('login')?.touched"> | |||||
| Le nom d'utilisateur contient des caractères invalides. | |||||
| </span> | |||||
| </div> | |||||
| <!-- Email Field --> | |||||
| <div class="form-field"> | |||||
| <label for="email" class="form-label">Email</label> | |||||
| <div class="input-wrapper"> | |||||
| <input | |||||
| id="email" | |||||
| type="email" | |||||
| formControlName="email" | |||||
| class="form-input" | |||||
| placeholder="votre.email@exemple.com" | |||||
| [class.error]="registerForm.get('email')?.invalid && registerForm.get('email')?.touched" | |||||
| > | |||||
| <svg class="input-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||||
| <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path> | |||||
| <polyline points="22,6 12,13 2,6"></polyline> | |||||
| </svg> | |||||
| </div> | |||||
| <span class="error-message" *ngIf="registerForm.get('email')?.hasError('required') && registerForm.get('email')?.touched"> | |||||
| L'adresse email est requise. | |||||
| </span> | |||||
| <span class="error-message" *ngIf="registerForm.get('email')?.hasError('email') && registerForm.get('email')?.touched"> | |||||
| Veuillez entrer une adresse email valide. | |||||
| </span> | |||||
| <span class="error-message" *ngIf="registerForm.get('email')?.hasError('minlength') && registerForm.get('email')?.touched"> | |||||
| L'email doit contenir au moins 5 caractères. | |||||
| </span> | |||||
| <span class="error-message" *ngIf="registerForm.get('email')?.hasError('maxlength') && registerForm.get('email')?.touched"> | |||||
| L'email ne peut pas dépasser 254 caractères. | |||||
| </span> | |||||
| </div> | |||||
| <!-- Password Field --> | |||||
| <div class="form-field"> | |||||
| <label for="password" class="form-label">Mot de passe</label> | |||||
| <div class="input-wrapper"> | |||||
| <input | |||||
| id="password" | |||||
| type="password" | |||||
| formControlName="password" | |||||
| class="form-input" | |||||
| placeholder="••••••••" | |||||
| [class.error]="registerForm.get('password')?.invalid && registerForm.get('password')?.touched" | |||||
| > | |||||
| <svg class="input-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||||
| <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> | |||||
| <path d="M7 11V7a5 5 0 0 1 10 0v4"></path> | |||||
| </svg> | |||||
| </div> | |||||
| <span class="error-message" *ngIf="registerForm.get('password')?.hasError('required') && registerForm.get('password')?.touched"> | |||||
| Le mot de passe est requis. | |||||
| </span> | |||||
| <span class="error-message" *ngIf="registerForm.get('password')?.hasError('minlength') && registerForm.get('password')?.touched"> | |||||
| Le mot de passe doit contenir au moins 4 caractères. | |||||
| </span> | |||||
| <span class="error-message" *ngIf="registerForm.get('password')?.hasError('maxlength') && registerForm.get('password')?.touched"> | |||||
| Le mot de passe ne peut pas dépasser 50 caractères. | |||||
| </span> | |||||
| </div> | |||||
| <!-- Confirm Password Field --> | |||||
| <div class="form-field"> | |||||
| <label for="confirmPassword" class="form-label">Confirmer le mot de passe</label> | |||||
| <div class="input-wrapper"> | |||||
| <input | |||||
| id="confirmPassword" | |||||
| type="password" | |||||
| formControlName="confirmPassword" | |||||
| class="form-input" | |||||
| placeholder="••••••••" | |||||
| [class.error]="registerForm.get('confirmPassword')?.invalid && registerForm.get('confirmPassword')?.touched" | |||||
| > | |||||
| <svg class="input-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |||||
| <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect> | |||||
| <path d="M7 11V7a5 5 0 0 1 10 0v4"></path> | |||||
| </svg> | |||||
| </div> | |||||
| <span class="error-message" *ngIf="registerForm.get('confirmPassword')?.hasError('required') && registerForm.get('confirmPassword')?.touched"> | |||||
| La confirmation du mot de passe est requise. | |||||
| </span> | |||||
| <span class="error-message" *ngIf="registerForm.get('confirmPassword')?.hasError('minlength') && registerForm.get('confirmPassword')?.touched"> | |||||
| La confirmation doit contenir au moins 4 caractères. | |||||
| </span> | |||||
| </div> | |||||
| <!-- Submit Button --> | |||||
| <button | |||||
| type="submit" | |||||
| [disabled]="registerForm.invalid" | |||||
| class="auth-btn" | |||||
| > | |||||
| <span>Créer mon compte</span> | |||||
| </button> | |||||
| </form> | |||||
| </div> | |||||
| <!-- Footer --> | |||||
| <div class="auth-footer"> | |||||
| <p>Vous avez déjà un compte ? <a routerLink="/login" class="login-link">Se connecter</a></p> | |||||
| </div> | |||||
| </div> | |||||
| </div> |
| import { ComponentFixture, TestBed, fakeAsync, inject, tick, waitForAsync } from '@angular/core/testing'; | |||||
| import { provideHttpClient } from '@angular/common/http'; | |||||
| import { FormBuilder } from '@angular/forms'; | |||||
| import { of, throwError } from 'rxjs'; | |||||
| import { TranslateModule, TranslateService } from '@ngx-translate/core'; | |||||
| import { EMAIL_ALREADY_USED_TYPE, LOGIN_ALREADY_USED_TYPE } from 'app/config/error.constants'; | |||||
| import { RegisterService } from './register.service'; | |||||
| import RegisterComponent from './register.component'; | |||||
| describe('RegisterComponent', () => { | |||||
| let fixture: ComponentFixture<RegisterComponent>; | |||||
| let comp: RegisterComponent; | |||||
| beforeEach(waitForAsync(() => { | |||||
| TestBed.configureTestingModule({ | |||||
| imports: [TranslateModule.forRoot(), RegisterComponent], | |||||
| providers: [provideHttpClient(), FormBuilder], | |||||
| }) | |||||
| .overrideTemplate(RegisterComponent, '') | |||||
| .compileComponents(); | |||||
| })); | |||||
| beforeEach(() => { | |||||
| fixture = TestBed.createComponent(RegisterComponent); | |||||
| comp = fixture.componentInstance; | |||||
| }); | |||||
| it('should ensure the two passwords entered match', () => { | |||||
| comp.registerForm.patchValue({ | |||||
| password: 'password', | |||||
| confirmPassword: 'non-matching', | |||||
| }); | |||||
| comp.register(); | |||||
| expect(comp.doNotMatch()).toBe(true); | |||||
| }); | |||||
| it('should update success to true after creating an account', inject( | |||||
| [RegisterService, TranslateService], | |||||
| fakeAsync((service: RegisterService, mockTranslateService: TranslateService) => { | |||||
| jest.spyOn(service, 'save').mockReturnValue(of({})); | |||||
| mockTranslateService.currentLang = 'fr'; | |||||
| comp.registerForm.patchValue({ | |||||
| password: 'password', | |||||
| confirmPassword: 'password', | |||||
| }); | |||||
| comp.register(); | |||||
| tick(); | |||||
| expect(service.save).toHaveBeenCalledWith({ | |||||
| email: '', | |||||
| password: 'password', | |||||
| login: '', | |||||
| langKey: 'fr', | |||||
| }); | |||||
| expect(comp.success()).toBe(true); | |||||
| expect(comp.errorUserExists()).toBe(false); | |||||
| expect(comp.errorEmailExists()).toBe(false); | |||||
| expect(comp.error()).toBe(false); | |||||
| }), | |||||
| )); | |||||
| it('should notify of user existence upon 400/login already in use', inject( | |||||
| [RegisterService], | |||||
| fakeAsync((service: RegisterService) => { | |||||
| const err = { status: 400, error: { type: LOGIN_ALREADY_USED_TYPE } }; | |||||
| jest.spyOn(service, 'save').mockReturnValue(throwError(() => err)); | |||||
| comp.registerForm.patchValue({ | |||||
| password: 'password', | |||||
| confirmPassword: 'password', | |||||
| }); | |||||
| comp.register(); | |||||
| tick(); | |||||
| expect(comp.errorUserExists()).toBe(true); | |||||
| expect(comp.errorEmailExists()).toBe(false); | |||||
| expect(comp.error()).toBe(false); | |||||
| }), | |||||
| )); | |||||
| it('should notify of email existence upon 400/email address already in use', inject( | |||||
| [RegisterService], | |||||
| fakeAsync((service: RegisterService) => { | |||||
| const err = { status: 400, error: { type: EMAIL_ALREADY_USED_TYPE } }; | |||||
| jest.spyOn(service, 'save').mockReturnValue(throwError(() => err)); | |||||
| comp.registerForm.patchValue({ | |||||
| password: 'password', | |||||
| confirmPassword: 'password', | |||||
| }); | |||||
| comp.register(); | |||||
| tick(); | |||||
| expect(comp.errorEmailExists()).toBe(true); | |||||
| expect(comp.errorUserExists()).toBe(false); | |||||
| expect(comp.error()).toBe(false); | |||||
| }), | |||||
| )); | |||||
| it('should notify of generic error', inject( | |||||
| [RegisterService], | |||||
| fakeAsync((service: RegisterService) => { | |||||
| const err = { status: 503 }; | |||||
| jest.spyOn(service, 'save').mockReturnValue(throwError(() => err)); | |||||
| comp.registerForm.patchValue({ | |||||
| password: 'password', | |||||
| confirmPassword: 'password', | |||||
| }); | |||||
| comp.register(); | |||||
| tick(); | |||||
| expect(comp.errorUserExists()).toBe(false); | |||||
| expect(comp.errorEmailExists()).toBe(false); | |||||
| expect(comp.error()).toBe(true); | |||||
| }), | |||||
| )); | |||||
| }); |
| import { AfterViewInit, Component, ElementRef, inject, signal, viewChild } from '@angular/core'; | |||||
| import { HttpErrorResponse } from '@angular/common/http'; | |||||
| import { RouterModule } from '@angular/router'; | |||||
| import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; | |||||
| import { RegisterService } from './register.service'; | |||||
| import SharedModule from '../../shared/shared.module'; | |||||
| import {EMAIL_ALREADY_USED_TYPE, LOGIN_ALREADY_USED_TYPE} from '../../config/error.constants'; | |||||
| @Component({ | |||||
| selector: 'jhi-register', | |||||
| imports: [SharedModule, RouterModule, FormsModule, ReactiveFormsModule], | |||||
| templateUrl: './register.component.html', | |||||
| }) | |||||
| export default class RegisterComponent implements AfterViewInit { | |||||
| login = viewChild.required<ElementRef>('login'); | |||||
| doNotMatch = signal(false); | |||||
| error = signal(false); | |||||
| errorEmailExists = signal(false); | |||||
| errorUserExists = signal(false); | |||||
| success = signal(false); | |||||
| registerForm = new FormGroup({ | |||||
| login: new FormControl('', { | |||||
| nonNullable: true, | |||||
| validators: [ | |||||
| Validators.required, | |||||
| Validators.minLength(1), | |||||
| Validators.maxLength(50), | |||||
| Validators.pattern('^[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$|^[_.@A-Za-z0-9-]+$'), | |||||
| ], | |||||
| }), | |||||
| email: new FormControl('', { | |||||
| nonNullable: true, | |||||
| validators: [Validators.required, Validators.minLength(5), Validators.maxLength(254), Validators.email], | |||||
| }), | |||||
| password: new FormControl('', { | |||||
| nonNullable: true, | |||||
| validators: [Validators.required, Validators.minLength(4), Validators.maxLength(50)], | |||||
| }), | |||||
| confirmPassword: new FormControl('', { | |||||
| nonNullable: true, | |||||
| validators: [Validators.required, Validators.minLength(4), Validators.maxLength(50)], | |||||
| }), | |||||
| }); | |||||
| private readonly registerService = inject(RegisterService); | |||||
| ngAfterViewInit(): void { | |||||
| this.login().nativeElement.focus(); | |||||
| } | |||||
| register(): void { | |||||
| this.doNotMatch.set(false); | |||||
| this.error.set(false); | |||||
| this.errorEmailExists.set(false); | |||||
| this.errorUserExists.set(false); | |||||
| const { password, confirmPassword } = this.registerForm.getRawValue(); | |||||
| if (password !== confirmPassword) { | |||||
| this.doNotMatch.set(true); | |||||
| } else { | |||||
| const { login, email } = this.registerForm.getRawValue(); | |||||
| this.registerService | |||||
| .save({ login, email, password, langKey: 'fr' }) | |||||
| .subscribe({ next: () => this.success.set(true), error: response => this.processError(response) }); | |||||
| } | |||||
| } | |||||
| private processError(response: HttpErrorResponse): void { | |||||
| if (response.status === 400 && response.error.type === LOGIN_ALREADY_USED_TYPE) { | |||||
| this.errorUserExists.set(true); | |||||
| } else if (response.status === 400 && response.error.type === EMAIL_ALREADY_USED_TYPE) { | |||||
| this.errorEmailExists.set(true); | |||||
| } else { | |||||
| this.error.set(true); | |||||
| } | |||||
| } | |||||
| } |
| export class Registration { | |||||
| constructor( | |||||
| public login: string, | |||||
| public email: string, | |||||
| public password: string, | |||||
| public langKey: string, | |||||
| ) {} | |||||
| } |
| import { Route } from '@angular/router'; | |||||
| import RegisterComponent from './register.component'; | |||||
| const registerRoute: Route = { | |||||
| }; | |||||
| export default registerRoute; |
| import { TestBed } from '@angular/core/testing'; | |||||
| import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; | |||||
| import { provideHttpClient } from '@angular/common/http'; | |||||
| import { RegisterService } from './register.service'; | |||||
| import { Registration } from './register.model'; | |||||
| import {ApplicationConfigService} from '../../core/config/application-config.service'; | |||||
| describe('RegisterService Service', () => { | |||||
| let service: RegisterService; | |||||
| let httpMock: HttpTestingController; | |||||
| let applicationConfigService: ApplicationConfigService; | |||||
| beforeEach(() => { | |||||
| TestBed.configureTestingModule({ | |||||
| providers: [provideHttpClient(), provideHttpClientTesting()], | |||||
| }); | |||||
| service = TestBed.inject(RegisterService); | |||||
| applicationConfigService = TestBed.inject(ApplicationConfigService); | |||||
| httpMock = TestBed.inject(HttpTestingController); | |||||
| }); | |||||
| afterEach(() => { | |||||
| httpMock.verify(); | |||||
| }); | |||||
| describe('Service methods', () => { | |||||
| it('should call register endpoint with correct values', () => { | |||||
| // GIVEN | |||||
| const login = 'abc'; | |||||
| const email = 'test@test.com'; | |||||
| const password = 'pass'; | |||||
| const langKey = 'FR'; | |||||
| const registration = new Registration(login, email, password, langKey); | |||||
| // WHEN | |||||
| service.save(registration).subscribe(); | |||||
| const testRequest = httpMock.expectOne({ | |||||
| method: 'POST', | |||||
| url: applicationConfigService.getEndpointFor('api/register'), | |||||
| }); | |||||
| // THEN | |||||
| expect(testRequest.request.body).toEqual({ email, langKey, login, password }); | |||||
| }); | |||||
| }); | |||||
| }); |
| import { Injectable, inject } from '@angular/core'; | |||||
| import { HttpClient } from '@angular/common/http'; | |||||
| import { Observable } from 'rxjs'; | |||||
| import { Registration } from './register.model'; | |||||
| import {ApplicationConfigService} from '../../core/config/application-config.service'; | |||||
| @Injectable({ providedIn: 'root' }) | |||||
| export class RegisterService { | |||||
| private readonly http = inject(HttpClient); | |||||
| private readonly applicationConfigService = inject(ApplicationConfigService); | |||||
| save(registration: Registration): Observable<{}> { | |||||
| return this.http.post(this.applicationConfigService.getEndpointFor('api/register'), registration); | |||||
| } | |||||
| } |
| .p-2 { padding: 1rem; } | .p-2 { padding: 1rem; } | ||||
| .p-3 { padding: 1.5rem; } | .p-3 { padding: 1.5rem; } | ||||
| .p-4 { padding: 2rem; } | .p-4 { padding: 2rem; } | ||||
| .auth-page-container { | |||||
| min-height: 100vh; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| background: linear-gradient(135deg, #243b5d 0%, #2984a1 100%); | |||||
| padding: 20px; | |||||
| position: relative; | |||||
| overflow: hidden; | |||||
| .auth-card { | |||||
| position: relative; | |||||
| z-index: 1; | |||||
| width: 100%; | |||||
| max-width: 440px; | |||||
| background: #ffffff; | |||||
| border-radius: 24px; | |||||
| box-shadow: | |||||
| 0 20px 60px rgba(0, 0, 0, 0.3), | |||||
| 0 0 100px rgba(102, 126, 234, 0.2); | |||||
| overflow: hidden; | |||||
| animation: fadeInUp 0.6s ease; | |||||
| .auth-header { | |||||
| padding: 48px 40px 32px; | |||||
| text-align: center; | |||||
| background: linear-gradient(180deg, #f9fafb 0%, #ffffff 100%); | |||||
| .logo { | |||||
| display: inline-block; | |||||
| font-size: 32px; | |||||
| font-weight: 800; | |||||
| color: #2984a1; | |||||
| margin-bottom: 24px; | |||||
| letter-spacing: -1px; | |||||
| } | |||||
| .auth-title { | |||||
| font-size: 28px; | |||||
| font-weight: 700; | |||||
| color: #1f2937; | |||||
| margin: 0 0 12px 0; | |||||
| line-height: 1.2; | |||||
| } | |||||
| .auth-subtitle { | |||||
| font-size: 15px; | |||||
| color: #6b7280; | |||||
| margin: 0; | |||||
| font-weight: 400; | |||||
| line-height: 1.5; | |||||
| } | |||||
| } | |||||
| .auth-content { | |||||
| padding: 32px 40px 40px; | |||||
| .auth-form { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: 24px; | |||||
| .form-field { | |||||
| display: flex; | |||||
| flex-direction: column; | |||||
| gap: 8px; | |||||
| .form-label { | |||||
| font-size: 14px; | |||||
| font-weight: 600; | |||||
| color: #374151; | |||||
| margin: 0; | |||||
| } | |||||
| .input-wrapper { | |||||
| position: relative; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| .form-input { | |||||
| width: 100%; | |||||
| padding: 14px 16px; | |||||
| padding-right: 48px; | |||||
| font-size: 15px; | |||||
| color: #1f2937; | |||||
| background: #f9fafb; | |||||
| border: 2px solid #e5e7eb; | |||||
| border-radius: 12px; | |||||
| transition: all 0.2s ease; | |||||
| outline: none; | |||||
| &::placeholder { | |||||
| color: #9ca3af; | |||||
| } | |||||
| &:focus { | |||||
| background: #ffffff; | |||||
| border-color: #667eea; | |||||
| box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | |||||
| } | |||||
| &.error { | |||||
| border-color: #ef4444; | |||||
| background: #fef2f2; | |||||
| &:focus { | |||||
| box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); | |||||
| } | |||||
| } | |||||
| } | |||||
| .input-icon { | |||||
| position: absolute; | |||||
| right: 16px; | |||||
| color: #9ca3af; | |||||
| pointer-events: none; | |||||
| transition: color 0.2s ease; | |||||
| &.clickable { | |||||
| pointer-events: all; | |||||
| cursor: pointer; | |||||
| &:hover { | |||||
| color: #667eea; | |||||
| } | |||||
| } | |||||
| } | |||||
| .form-input:focus ~ .input-icon { | |||||
| color: #667eea; | |||||
| } | |||||
| } | |||||
| .error-message { | |||||
| font-size: 13px; | |||||
| color: #ef4444; | |||||
| margin: 0; | |||||
| padding-left: 4px; | |||||
| } | |||||
| .success-message { | |||||
| font-size: 13px; | |||||
| color: #10b981; | |||||
| margin: 0; | |||||
| padding-left: 4px; | |||||
| } | |||||
| .info-message { | |||||
| font-size: 13px; | |||||
| color: #6b7280; | |||||
| margin: 0; | |||||
| padding-left: 4px; | |||||
| } | |||||
| } | |||||
| .form-options { | |||||
| display: flex; | |||||
| justify-content: space-between; | |||||
| align-items: center; | |||||
| gap: 16px; | |||||
| flex-wrap: wrap; | |||||
| .checkbox-wrapper { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 8px; | |||||
| cursor: pointer; | |||||
| input[type="checkbox"] { | |||||
| width: 18px; | |||||
| height: 18px; | |||||
| cursor: pointer; | |||||
| accent-color: #667eea; | |||||
| } | |||||
| .checkbox-label { | |||||
| font-size: 14px; | |||||
| color: #6b7280; | |||||
| user-select: none; | |||||
| } | |||||
| } | |||||
| .forgot-password-link { | |||||
| font-size: 14px; | |||||
| color: #667eea; | |||||
| text-decoration: none; | |||||
| font-weight: 500; | |||||
| transition: color 0.2s ease; | |||||
| &:hover { | |||||
| color: #5568d3; | |||||
| text-decoration: underline; | |||||
| } | |||||
| } | |||||
| } | |||||
| .auth-btn { | |||||
| width: 100%; | |||||
| padding: 14px 24px; | |||||
| font-size: 16px; | |||||
| font-weight: 600; | |||||
| color: #ffffff; | |||||
| background: linear-gradient(-45deg, #243b5d 0%, #2984a1 100%); | |||||
| border: none; | |||||
| border-radius: 12px; | |||||
| cursor: pointer; | |||||
| transition: all 0.3s ease; | |||||
| box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); | |||||
| margin-top: 8px; | |||||
| &:hover:not(:disabled) { | |||||
| transform: translateY(-2px); | |||||
| box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); | |||||
| } | |||||
| &:active:not(:disabled) { | |||||
| transform: translateY(0); | |||||
| } | |||||
| &:disabled { | |||||
| opacity: 0.6; | |||||
| cursor: not-allowed; | |||||
| } | |||||
| span { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| gap: 8px; | |||||
| } | |||||
| } | |||||
| .divider { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| text-align: center; | |||||
| margin: 8px 0; | |||||
| &::before, | |||||
| &::after { | |||||
| content: ''; | |||||
| flex: 1; | |||||
| border-bottom: 1px solid #e5e7eb; | |||||
| } | |||||
| span { | |||||
| padding: 0 16px; | |||||
| font-size: 14px; | |||||
| color: #9ca3af; | |||||
| font-weight: 500; | |||||
| } | |||||
| } | |||||
| .social-buttons { | |||||
| display: flex; | |||||
| gap: 12px; | |||||
| .social-btn { | |||||
| flex: 1; | |||||
| padding: 12px; | |||||
| background: #ffffff; | |||||
| border: 2px solid #e5e7eb; | |||||
| border-radius: 12px; | |||||
| cursor: pointer; | |||||
| transition: all 0.2s ease; | |||||
| display: flex; | |||||
| align-items: center; | |||||
| justify-content: center; | |||||
| gap: 8px; | |||||
| font-size: 14px; | |||||
| font-weight: 600; | |||||
| color: #374151; | |||||
| &:hover { | |||||
| border-color: #667eea; | |||||
| background: #f9fafb; | |||||
| } | |||||
| img, svg { | |||||
| width: 20px; | |||||
| height: 20px; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| .auth-footer { | |||||
| padding: 24px 40px; | |||||
| text-align: center; | |||||
| background: #f9fafb; | |||||
| border-top: 1px solid #e5e7eb; | |||||
| p { | |||||
| margin: 0; | |||||
| font-size: 14px; | |||||
| color: #6b7280; | |||||
| } | |||||
| .register-link, | |||||
| .login-link, | |||||
| .back-to-login-link { | |||||
| color: #667eea; | |||||
| text-decoration: none; | |||||
| font-weight: 600; | |||||
| transition: color 0.2s ease; | |||||
| &:hover { | |||||
| color: #5568d3; | |||||
| text-decoration: underline; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| // Animations | |||||
| @keyframes fadeInUp { | |||||
| from { | |||||
| opacity: 0; | |||||
| transform: translateY(30px); | |||||
| } | |||||
| to { | |||||
| opacity: 1; | |||||
| transform: translateY(0); | |||||
| } | |||||
| } | |||||
| @keyframes backgroundMove { | |||||
| 0%, 100% { | |||||
| transform: translate(0, 0) rotate(0deg); | |||||
| } | |||||
| 33% { | |||||
| transform: translate(5%, -5%) rotate(5deg); | |||||
| } | |||||
| 66% { | |||||
| transform: translate(-5%, 5%) rotate(-5deg); | |||||
| } | |||||
| } | |||||
| @media (max-width: 640px) { | |||||
| .auth-page-container { | |||||
| padding: 16px; | |||||
| .auth-card { | |||||
| max-width: 100%; | |||||
| border-radius: 20px; | |||||
| .auth-header { | |||||
| padding: 32px 24px 24px; | |||||
| .logo { | |||||
| font-size: 28px; | |||||
| margin-bottom: 20px; | |||||
| } | |||||
| .auth-title { | |||||
| font-size: 24px; | |||||
| } | |||||
| .auth-subtitle { | |||||
| font-size: 14px; | |||||
| } | |||||
| } | |||||
| .auth-content { | |||||
| padding: 24px; | |||||
| .auth-form { | |||||
| gap: 20px; | |||||
| .form-field { | |||||
| .input-wrapper { | |||||
| .form-input { | |||||
| padding: 12px 14px; | |||||
| padding-right: 44px; | |||||
| font-size: 14px; | |||||
| } | |||||
| } | |||||
| } | |||||
| .form-options { | |||||
| flex-direction: column; | |||||
| align-items: flex-start; | |||||
| gap: 12px; | |||||
| } | |||||
| .auth-btn { | |||||
| padding: 12px 20px; | |||||
| font-size: 15px; | |||||
| } | |||||
| .social-buttons { | |||||
| flex-direction: column; | |||||
| .social-btn { | |||||
| width: 100%; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| .auth-footer { | |||||
| padding: 20px 24px; | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| .auth-page-container { | |||||
| &.register-page { | |||||
| .auth-card { | |||||
| max-width: 480px; | |||||
| } | |||||
| } | |||||
| &.forgot-password-page { | |||||
| .auth-card { | |||||
| max-width: 400px; | |||||
| } | |||||
| } | |||||
| } |