feat(frontend): implement authentication UI and guards
- Add AuthService with login/logout/register functionality - Create auth guard for protected routes - Create guest guard for login/register pages - Add auth interceptor to attach JWT tokens - Create login page with form validation - Create register page with student profile option - Update app component with user menu and logout - Configure routes with authentication guards
This commit is contained in:
parent
cf61fb70e3
commit
891d177b8c
|
|
@ -3,7 +3,8 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { ConnectivityService } from '@core/services';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { ConnectivityService, AuthService } from '@core/services';
|
||||||
import { ConnectivityOverlayComponent } from '@shared/index';
|
import { ConnectivityOverlayComponent } from '@shared/index';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -11,15 +12,16 @@ import { ConnectivityOverlayComponent } from '@shared/index';
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
RouterOutlet, RouterLink, RouterLinkActive,
|
RouterOutlet, RouterLink, RouterLinkActive,
|
||||||
MatToolbarModule, MatButtonModule, MatIconModule,
|
MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule,
|
||||||
ConnectivityOverlayComponent
|
ConnectivityOverlayComponent
|
||||||
],
|
],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<!-- Overlay de conectividad - bloquea UI si no hay conexión -->
|
<!-- Overlay de conectividad - bloquea UI si no hay conexion -->
|
||||||
<app-connectivity-overlay />
|
<app-connectivity-overlay />
|
||||||
|
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
|
@if (authService.isAuthenticated()) {
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<a routerLink="/" class="logo">
|
<a routerLink="/" class="logo">
|
||||||
<span class="logo-icon">S</span>
|
<span class="logo-icon">S</span>
|
||||||
|
|
@ -30,8 +32,26 @@ import { ConnectivityOverlayComponent } from '@shared/index';
|
||||||
Estudiantes
|
Estudiantes
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="user-menu">
|
||||||
|
<button class="user-button" [matMenuTriggerFor]="userMenu">
|
||||||
|
<span class="user-avatar">{{ getUserInitial() }}</span>
|
||||||
|
<span class="user-name">{{ authService.user()?.username }}</span>
|
||||||
|
<mat-icon>expand_more</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #userMenu="matMenu">
|
||||||
|
<div class="menu-header">
|
||||||
|
<strong>{{ authService.user()?.username }}</strong>
|
||||||
|
<span class="role-badge">{{ authService.user()?.role }}</span>
|
||||||
|
</div>
|
||||||
|
<button mat-menu-item (click)="logout()">
|
||||||
|
<mat-icon>logout</mat-icon>
|
||||||
|
Cerrar sesion
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="app-main">
|
}
|
||||||
|
<main class="app-main" [class.full-height]="!authService.isAuthenticated()">
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -118,12 +138,70 @@ import { ConnectivityOverlayComponent } from '@shared/index';
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-button:hover {
|
||||||
|
background: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-height {
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.app-header {
|
.app-header {
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text, .user-name {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,9 +213,18 @@ import { ConnectivityOverlayComponent } from '@shared/index';
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit {
|
export class AppComponent implements OnInit {
|
||||||
private connectivity = inject(ConnectivityService);
|
private connectivity = inject(ConnectivityService);
|
||||||
|
authService = inject(AuthService);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Iniciar monitoreo de conectividad cada 5 segundos
|
|
||||||
this.connectivity.startMonitoring();
|
this.connectivity.startMonitoring();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUserInitial(): string {
|
||||||
|
const username = this.authService.user()?.username;
|
||||||
|
return username ? username.charAt(0).toUpperCase() : '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.authService.logout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,12 @@ import { InMemoryCache } from '@apollo/client/core';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { environment } from '@env/environment';
|
import { environment } from '@env/environment';
|
||||||
import { errorInterceptor } from '@core/interceptors/error.interceptor';
|
import { errorInterceptor } from '@core/interceptors/error.interceptor';
|
||||||
|
import { authInterceptor } from '@core/interceptors/auth.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideRouter(routes, withComponentInputBinding()),
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
provideHttpClient(withInterceptors([errorInterceptor])),
|
provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
|
||||||
provideAnimationsAsync(),
|
provideAnimationsAsync(),
|
||||||
provideApollo(() => {
|
provideApollo(() => {
|
||||||
const httpLink = inject(HttpLink);
|
const httpLink = inject(HttpLink);
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,56 @@
|
||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
import { authGuard, guestGuard } from '@core/guards/auth.guard';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'students', pathMatch: 'full' },
|
{ path: '', redirectTo: 'students', pathMatch: 'full' },
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('@features/auth/pages/login/login.component')
|
||||||
|
.then(m => m.LoginComponent),
|
||||||
|
canActivate: [guestGuard],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('@features/auth/pages/register/register.component')
|
||||||
|
.then(m => m.RegisterComponent),
|
||||||
|
canActivate: [guestGuard],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'students',
|
path: 'students',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@features/students/pages/student-list/student-list.component')
|
import('@features/students/pages/student-list/student-list.component')
|
||||||
.then(m => m.StudentListComponent),
|
.then(m => m.StudentListComponent),
|
||||||
|
canActivate: [authGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'students/new',
|
path: 'students/new',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@features/students/pages/student-form/student-form.component')
|
import('@features/students/pages/student-form/student-form.component')
|
||||||
.then(m => m.StudentFormComponent),
|
.then(m => m.StudentFormComponent),
|
||||||
|
canActivate: [authGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'students/:id/edit',
|
path: 'students/:id/edit',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@features/students/pages/student-form/student-form.component')
|
import('@features/students/pages/student-form/student-form.component')
|
||||||
.then(m => m.StudentFormComponent),
|
.then(m => m.StudentFormComponent),
|
||||||
|
canActivate: [authGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'enrollment/:studentId',
|
path: 'enrollment/:studentId',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@features/enrollment/pages/enrollment-page/enrollment-page.component')
|
import('@features/enrollment/pages/enrollment-page/enrollment-page.component')
|
||||||
.then(m => m.EnrollmentPageComponent),
|
.then(m => m.EnrollmentPageComponent),
|
||||||
|
canActivate: [authGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'classmates/:studentId',
|
path: 'classmates/:studentId',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('@features/classmates/pages/classmates-page/classmates-page.component')
|
import('@features/classmates/pages/classmates-page/classmates-page.component')
|
||||||
.then(m => m.ClassmatesPageComponent),
|
.then(m => m.ClassmatesPageComponent),
|
||||||
|
canActivate: [authGuard],
|
||||||
},
|
},
|
||||||
{ path: '**', redirectTo: 'students' },
|
{ path: '**', redirectTo: 'login' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { Router, CanActivateFn } from '@angular/router';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard that requires authentication to access a route.
|
||||||
|
* Redirects to login if not authenticated.
|
||||||
|
*/
|
||||||
|
export const authGuard: CanActivateFn = () => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
if (authService.isAuthenticated()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.navigate(['/login']);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard that requires admin role to access a route.
|
||||||
|
* Redirects to home if not admin.
|
||||||
|
*/
|
||||||
|
export const adminGuard: CanActivateFn = () => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
if (authService.isAdmin()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.navigate(['/students']);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard that prevents authenticated users from accessing a route.
|
||||||
|
* Useful for login page - redirects to home if already logged in.
|
||||||
|
*/
|
||||||
|
export const guestGuard: CanActivateFn = () => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
if (!authService.isAuthenticated()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.navigate(['/students']);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP interceptor that adds JWT token to outgoing requests.
|
||||||
|
* Automatically attaches the Bearer token to the Authorization header.
|
||||||
|
*/
|
||||||
|
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const token = authService.getToken();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const authReq = req.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return next(authReq);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(req);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { Injectable, inject, signal, computed } from '@angular/core';
|
||||||
|
import { Apollo } from 'apollo-angular';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { gql } from 'apollo-angular';
|
||||||
|
import { map, tap, catchError, of } from 'rxjs';
|
||||||
|
|
||||||
|
const LOGIN_MUTATION = gql`
|
||||||
|
mutation Login($input: LoginRequestInput!) {
|
||||||
|
login(input: $input) {
|
||||||
|
success
|
||||||
|
token
|
||||||
|
error
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
username
|
||||||
|
role
|
||||||
|
studentId
|
||||||
|
studentName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const REGISTER_MUTATION = gql`
|
||||||
|
mutation Register($input: RegisterRequestInput!) {
|
||||||
|
register(input: $input) {
|
||||||
|
success
|
||||||
|
token
|
||||||
|
error
|
||||||
|
user {
|
||||||
|
id
|
||||||
|
username
|
||||||
|
role
|
||||||
|
studentId
|
||||||
|
studentName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ME_QUERY = gql`
|
||||||
|
query Me {
|
||||||
|
me {
|
||||||
|
id
|
||||||
|
username
|
||||||
|
role
|
||||||
|
studentId
|
||||||
|
studentName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
studentId: number | null;
|
||||||
|
studentName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
success: boolean;
|
||||||
|
token?: string;
|
||||||
|
user?: UserInfo;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'auth_token';
|
||||||
|
const USER_KEY = 'auth_user';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AuthService {
|
||||||
|
private apollo = inject(Apollo);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
private _user = signal<UserInfo | null>(null);
|
||||||
|
private _isAuthenticated = signal(false);
|
||||||
|
private _isLoading = signal(true);
|
||||||
|
|
||||||
|
readonly user = this._user.asReadonly();
|
||||||
|
readonly isAuthenticated = this._isAuthenticated.asReadonly();
|
||||||
|
readonly isLoading = this._isLoading.asReadonly();
|
||||||
|
readonly isAdmin = computed(() => this._user()?.role === 'Admin');
|
||||||
|
readonly studentId = computed(() => this._user()?.studentId ?? null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadStoredAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadStoredAuth(): void {
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const userJson = localStorage.getItem(USER_KEY);
|
||||||
|
|
||||||
|
if (token && userJson) {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(userJson) as UserInfo;
|
||||||
|
this._user.set(user);
|
||||||
|
this._isAuthenticated.set(true);
|
||||||
|
} catch {
|
||||||
|
this.clearAuth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._isLoading.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
login(username: string, password: string) {
|
||||||
|
return this.apollo
|
||||||
|
.mutate<{ login: AuthResponse }>({
|
||||||
|
mutation: LOGIN_MUTATION,
|
||||||
|
variables: { input: { username, password } },
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
map((result) => result.data?.login ?? { success: false, error: 'Error de conexion' }),
|
||||||
|
tap((response) => {
|
||||||
|
if (response.success && response.token && response.user) {
|
||||||
|
this.setAuth(response.token, response.user);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
register(username: string, password: string, name?: string, email?: string) {
|
||||||
|
return this.apollo
|
||||||
|
.mutate<{ register: AuthResponse }>({
|
||||||
|
mutation: REGISTER_MUTATION,
|
||||||
|
variables: { input: { username, password, name, email } },
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
map((result) => result.data?.register ?? { success: false, error: 'Error de conexion' }),
|
||||||
|
tap((response) => {
|
||||||
|
if (response.success && response.token && response.user) {
|
||||||
|
this.setAuth(response.token, response.user);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.clearAuth();
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(): string | null {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
canModifyStudent(studentId: number): boolean {
|
||||||
|
const user = this._user();
|
||||||
|
if (!user) return false;
|
||||||
|
if (user.role === 'Admin') return true;
|
||||||
|
return user.studentId === studentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setAuth(token: string, user: UserInfo): void {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
this._user.set(user);
|
||||||
|
this._isAuthenticated.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAuth(): void {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
this._user.set(null);
|
||||||
|
this._isAuthenticated.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,3 +3,4 @@ export * from './enrollment.service';
|
||||||
export * from './notification.service';
|
export * from './notification.service';
|
||||||
export * from './error-handler.service';
|
export * from './error-handler.service';
|
||||||
export * from './connectivity.service';
|
export * from './connectivity.service';
|
||||||
|
export * from './auth.service';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
import { Component, signal, inject } from '@angular/core';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { AuthService } from '@core/services/auth.service';
|
||||||
|
import { NotificationService } from '@core/services';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-login',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ReactiveFormsModule, RouterLink, MatIconModule, MatButtonModule],
|
||||||
|
template: `
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<div class="logo">S</div>
|
||||||
|
<h1>Iniciar Sesion</h1>
|
||||||
|
<p>Sistema de Gestion de Estudiantes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Usuario</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
formControlName="username"
|
||||||
|
placeholder="Tu nombre de usuario"
|
||||||
|
[class.error]="showError('username')"
|
||||||
|
/>
|
||||||
|
@if (showError('username')) {
|
||||||
|
<span class="error-message">El usuario es requerido</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Contrasena</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
formControlName="password"
|
||||||
|
placeholder="Tu contrasena"
|
||||||
|
[class.error]="showError('password')"
|
||||||
|
/>
|
||||||
|
@if (showError('password')) {
|
||||||
|
<span class="error-message">La contrasena es requerida</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (serverError()) {
|
||||||
|
<div class="server-error">
|
||||||
|
<mat-icon>error</mat-icon>
|
||||||
|
{{ serverError() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary btn-full"
|
||||||
|
[disabled]="form.invalid || loading()"
|
||||||
|
>
|
||||||
|
@if (loading()) {
|
||||||
|
<span class="btn-loading"></span>
|
||||||
|
Iniciando...
|
||||||
|
} @else {
|
||||||
|
<mat-icon>login</mat-icon>
|
||||||
|
Iniciar Sesion
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p>No tienes cuenta? <a routerLink="/register">Registrate</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.auth-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007AFF;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input.error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-loading {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer a {
|
||||||
|
color: #007AFF;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
})
|
||||||
|
export class LoginComponent {
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
private notification = inject(NotificationService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
form = this.fb.group({
|
||||||
|
username: ['', Validators.required],
|
||||||
|
password: ['', Validators.required],
|
||||||
|
});
|
||||||
|
|
||||||
|
loading = signal(false);
|
||||||
|
serverError = signal<string | null>(null);
|
||||||
|
|
||||||
|
showError(field: string): boolean {
|
||||||
|
const control = this.form.get(field);
|
||||||
|
return !!(control?.invalid && control?.touched);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.form.invalid) {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading.set(true);
|
||||||
|
this.serverError.set(null);
|
||||||
|
|
||||||
|
const { username, password } = this.form.value;
|
||||||
|
|
||||||
|
this.authService.login(username!, password!).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
if (response.success) {
|
||||||
|
this.notification.success('Bienvenido!');
|
||||||
|
this.router.navigate(['/students']);
|
||||||
|
} else {
|
||||||
|
this.serverError.set(response.error ?? 'Error al iniciar sesion');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.serverError.set('Error de conexion con el servidor');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { Component, signal, inject } from '@angular/core';
|
||||||
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { AuthService } from '@core/services/auth.service';
|
||||||
|
import { NotificationService } from '@core/services';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-register',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ReactiveFormsModule, RouterLink, MatIconModule, MatButtonModule],
|
||||||
|
template: `
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<div class="logo">S</div>
|
||||||
|
<h1>Crear Cuenta</h1>
|
||||||
|
<p>Registrate en el sistema</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Usuario</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
formControlName="username"
|
||||||
|
placeholder="Elige un nombre de usuario"
|
||||||
|
[class.error]="showError('username')"
|
||||||
|
/>
|
||||||
|
@if (showError('username')) {
|
||||||
|
<span class="error-message">
|
||||||
|
@if (form.get('username')?.errors?.['required']) {
|
||||||
|
El usuario es requerido
|
||||||
|
} @else if (form.get('username')?.errors?.['minlength']) {
|
||||||
|
Minimo 3 caracteres
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Contrasena</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
formControlName="password"
|
||||||
|
placeholder="Minimo 6 caracteres"
|
||||||
|
[class.error]="showError('password')"
|
||||||
|
/>
|
||||||
|
@if (showError('password')) {
|
||||||
|
<span class="error-message">
|
||||||
|
@if (form.get('password')?.errors?.['required']) {
|
||||||
|
La contrasena es requerida
|
||||||
|
} @else if (form.get('password')?.errors?.['minlength']) {
|
||||||
|
Minimo 6 caracteres
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Nombre completo (opcional)</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
formControlName="name"
|
||||||
|
placeholder="Tu nombre completo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email (opcional)</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
class="input"
|
||||||
|
formControlName="email"
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
[class.error]="showError('email')"
|
||||||
|
/>
|
||||||
|
@if (showError('email')) {
|
||||||
|
<span class="error-message">Email invalido</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">Si proporcionas nombre y email, se creara tu perfil de estudiante automaticamente.</p>
|
||||||
|
|
||||||
|
@if (serverError()) {
|
||||||
|
<div class="server-error">
|
||||||
|
<mat-icon>error</mat-icon>
|
||||||
|
{{ serverError() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary btn-full"
|
||||||
|
[disabled]="form.invalid || loading()"
|
||||||
|
>
|
||||||
|
@if (loading()) {
|
||||||
|
<span class="btn-loading"></span>
|
||||||
|
Registrando...
|
||||||
|
} @else {
|
||||||
|
<mat-icon>person_add</mat-icon>
|
||||||
|
Crear Cuenta
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p>Ya tienes cuenta? <a routerLink="/login">Inicia sesion</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.auth-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007AFF;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input.error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-loading {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer a {
|
||||||
|
color: #007AFF;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
})
|
||||||
|
export class RegisterComponent {
|
||||||
|
private fb = inject(FormBuilder);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
private notification = inject(NotificationService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
form = this.fb.group({
|
||||||
|
username: ['', [Validators.required, Validators.minLength(3)]],
|
||||||
|
password: ['', [Validators.required, Validators.minLength(6)]],
|
||||||
|
name: [''],
|
||||||
|
email: ['', Validators.email],
|
||||||
|
});
|
||||||
|
|
||||||
|
loading = signal(false);
|
||||||
|
serverError = signal<string | null>(null);
|
||||||
|
|
||||||
|
showError(field: string): boolean {
|
||||||
|
const control = this.form.get(field);
|
||||||
|
return !!(control?.invalid && control?.touched);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.form.invalid) {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading.set(true);
|
||||||
|
this.serverError.set(null);
|
||||||
|
|
||||||
|
const { username, password, name, email } = this.form.value;
|
||||||
|
|
||||||
|
this.authService.register(
|
||||||
|
username!,
|
||||||
|
password!,
|
||||||
|
name || undefined,
|
||||||
|
email || undefined
|
||||||
|
).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.loading.set(false);
|
||||||
|
if (response.success) {
|
||||||
|
this.notification.success('Cuenta creada exitosamente!');
|
||||||
|
this.router.navigate(['/students']);
|
||||||
|
} else {
|
||||||
|
this.serverError.set(response.error ?? 'Error al crear la cuenta');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
this.serverError.set('Error de conexion con el servidor');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue