| @@ -1,430 +0,0 @@ | |||
| // 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; | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,197 @@ | |||
| <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> | |||
| @@ -0,0 +1,123 @@ | |||
| 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); | |||
| }), | |||
| )); | |||
| }); | |||
| @@ -0,0 +1,81 @@ | |||
| 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); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,8 @@ | |||
| export class Registration { | |||
| constructor( | |||
| public login: string, | |||
| public email: string, | |||
| public password: string, | |||
| public langKey: string, | |||
| ) {} | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| import { Route } from '@angular/router'; | |||
| import RegisterComponent from './register.component'; | |||
| const registerRoute: Route = { | |||
| }; | |||
| export default registerRoute; | |||
| @@ -0,0 +1,49 @@ | |||
| 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 }); | |||
| }); | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,16 @@ | |||
| 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); | |||
| } | |||
| } | |||
| @@ -68,3 +68,429 @@ body { | |||
| .p-2 { padding: 1rem; } | |||
| .p-3 { padding: 1.5rem; } | |||
| .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; | |||
| } | |||
| } | |||
| } | |||