265 lines
7.3 KiB
TypeScript
265 lines
7.3 KiB
TypeScript
import { Component, inject, signal, computed, OnDestroy } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms';
|
|
import { Router, RouterModule } from '@angular/router';
|
|
import { MatCardModule } from '@angular/material/card';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
import { AuthService } from '../../../core/services/auth.service';
|
|
import { StorageService } from '../../../core/services/storage.service';
|
|
import { Subject, takeUntil } from 'rxjs';
|
|
|
|
@Component({
|
|
selector: 'app-register',
|
|
imports: [
|
|
CommonModule,
|
|
ReactiveFormsModule,
|
|
RouterModule,
|
|
MatCardModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatProgressBarModule,
|
|
MatProgressSpinnerModule
|
|
],
|
|
templateUrl: './register.html',
|
|
styleUrl: './register.scss'
|
|
})
|
|
export class RegisterComponent implements OnDestroy {
|
|
private fb = inject(FormBuilder);
|
|
private authService = inject(AuthService);
|
|
private storageService = inject(StorageService);
|
|
private router = inject(Router);
|
|
private destroy$ = new Subject<void>();
|
|
|
|
// Signals
|
|
isSubmitting = signal<boolean>(false);
|
|
hidePassword = signal<boolean>(true);
|
|
hideConfirmPassword = signal<boolean>(true);
|
|
|
|
// Form
|
|
registerForm: FormGroup;
|
|
|
|
// Password strength computed signal
|
|
passwordStrength = computed(() => {
|
|
const password = this.registerForm?.get('password')?.value || '';
|
|
return this.calculatePasswordStrength(password);
|
|
});
|
|
|
|
constructor() {
|
|
// Check if converting from guest
|
|
const guestToken = this.storageService.getGuestToken();
|
|
|
|
// Initialize form
|
|
this.registerForm = this.fb.group({
|
|
username: ['', [
|
|
Validators.required,
|
|
Validators.minLength(3),
|
|
Validators.maxLength(30),
|
|
Validators.pattern(/^[a-zA-Z0-9_]+$/)
|
|
]],
|
|
email: ['', [
|
|
Validators.required,
|
|
Validators.email
|
|
]],
|
|
password: ['', [
|
|
Validators.required,
|
|
Validators.minLength(8),
|
|
this.passwordStrengthValidator
|
|
]],
|
|
confirmPassword: ['', [Validators.required]]
|
|
}, { validators: this.passwordMatchValidator });
|
|
|
|
// Redirect if already authenticated
|
|
if (this.authService.isAuthenticated()) {
|
|
this.router.navigate(['/dashboard']);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Password strength validator
|
|
*/
|
|
private passwordStrengthValidator(control: AbstractControl): ValidationErrors | null {
|
|
const password = control.value;
|
|
|
|
if (!password) {
|
|
return null;
|
|
}
|
|
|
|
const hasUpperCase = /[A-Z]/.test(password);
|
|
const hasLowerCase = /[a-z]/.test(password);
|
|
const hasNumber = /[0-9]/.test(password);
|
|
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
|
|
|
const isValid = hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar;
|
|
|
|
return isValid ? null : { weakPassword: true };
|
|
}
|
|
|
|
/**
|
|
* Password match validator
|
|
*/
|
|
private passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
|
|
const password = group.get('password')?.value;
|
|
const confirmPassword = group.get('confirmPassword')?.value;
|
|
|
|
return password === confirmPassword ? null : { passwordMismatch: true };
|
|
}
|
|
|
|
/**
|
|
* Calculate password strength
|
|
*/
|
|
private calculatePasswordStrength(password: string): {
|
|
score: number;
|
|
label: string;
|
|
color: string;
|
|
} {
|
|
if (!password) {
|
|
return { score: 0, label: '', color: '' };
|
|
}
|
|
|
|
let score = 0;
|
|
|
|
// Length
|
|
if (password.length >= 8) score += 25;
|
|
if (password.length >= 12) score += 25;
|
|
|
|
// Character types
|
|
if (/[a-z]/.test(password)) score += 15;
|
|
if (/[A-Z]/.test(password)) score += 15;
|
|
if (/[0-9]/.test(password)) score += 10;
|
|
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) score += 10;
|
|
|
|
let label = '';
|
|
let color = '';
|
|
|
|
if (score < 40) {
|
|
label = 'Weak';
|
|
color = 'warn';
|
|
} else if (score < 70) {
|
|
label = 'Fair';
|
|
color = 'accent';
|
|
} else if (score < 90) {
|
|
label = 'Good';
|
|
color = 'primary';
|
|
} else {
|
|
label = 'Strong';
|
|
color = 'primary';
|
|
}
|
|
|
|
return { score, label, color };
|
|
}
|
|
|
|
/**
|
|
* Toggle password visibility
|
|
*/
|
|
togglePasswordVisibility(): void {
|
|
this.hidePassword.update(val => !val);
|
|
}
|
|
|
|
/**
|
|
* Toggle confirm password visibility
|
|
*/
|
|
toggleConfirmPasswordVisibility(): void {
|
|
this.hideConfirmPassword.update(val => !val);
|
|
}
|
|
|
|
/**
|
|
* Submit registration form
|
|
*/
|
|
onSubmit(): void {
|
|
if (this.registerForm.invalid || this.isSubmitting()) {
|
|
this.registerForm.markAllAsTouched();
|
|
return;
|
|
}
|
|
|
|
this.isSubmitting.set(true);
|
|
|
|
const { username, email, password } = this.registerForm.value;
|
|
const guestSessionId = this.storageService.getGuestToken() || undefined;
|
|
|
|
this.authService.register(username, email, password, guestSessionId)
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe({
|
|
next: () => {
|
|
this.isSubmitting.set(false);
|
|
// Navigation handled by service
|
|
},
|
|
error: () => {
|
|
this.isSubmitting.set(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get form control error message
|
|
*/
|
|
getErrorMessage(controlName: string): string {
|
|
const control = this.registerForm.get(controlName);
|
|
|
|
if (!control || !control.touched) {
|
|
return '';
|
|
}
|
|
|
|
if (control.hasError('required')) {
|
|
return `${this.getFieldLabel(controlName)} is required`;
|
|
}
|
|
|
|
if (control.hasError('email')) {
|
|
return 'Please enter a valid email address';
|
|
}
|
|
|
|
if (control.hasError('minlength')) {
|
|
const minLength = control.getError('minlength').requiredLength;
|
|
return `Must be at least ${minLength} characters`;
|
|
}
|
|
|
|
if (control.hasError('maxlength')) {
|
|
const maxLength = control.getError('maxlength').requiredLength;
|
|
return `Must not exceed ${maxLength} characters`;
|
|
}
|
|
|
|
if (control.hasError('pattern') && controlName === 'username') {
|
|
return 'Username can only contain letters, numbers, and underscores';
|
|
}
|
|
|
|
if (control.hasError('weakPassword')) {
|
|
return 'Password must include uppercase, lowercase, number, and special character';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Get field label
|
|
*/
|
|
private getFieldLabel(controlName: string): string {
|
|
const labels: { [key: string]: string } = {
|
|
username: 'Username',
|
|
email: 'Email',
|
|
password: 'Password',
|
|
confirmPassword: 'Confirm Password'
|
|
};
|
|
return labels[controlName] || controlName;
|
|
}
|
|
|
|
/**
|
|
* Check if form has password mismatch error
|
|
*/
|
|
hasPasswordMismatch(): boolean {
|
|
const confirmControl = this.registerForm.get('confirmPassword');
|
|
return !!confirmControl?.touched && this.registerForm.hasError('passwordMismatch');
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
}
|
|
}
|
|
|