From 891d177b8c0f3e72787cc95fb67b50993aabaab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Eduardo=20Garc=C3=ADa=20M=C3=A1rquez?= Date: Thu, 8 Jan 2026 09:14:52 -0500 Subject: [PATCH] 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 --- src/frontend/src/app/app.component.ts | 119 ++++++- src/frontend/src/app/app.config.ts | 3 +- src/frontend/src/app/app.routes.ts | 22 +- .../src/app/core/guards/auth.guard.ts | 51 +++ .../app/core/interceptors/auth.interceptor.ts | 23 ++ .../src/app/core/services/auth.service.ts | 167 ++++++++++ src/frontend/src/app/core/services/index.ts | 1 + .../auth/pages/login/login.component.ts | 263 +++++++++++++++ .../auth/pages/register/register.component.ts | 312 ++++++++++++++++++ 9 files changed, 943 insertions(+), 18 deletions(-) create mode 100644 src/frontend/src/app/core/guards/auth.guard.ts create mode 100644 src/frontend/src/app/core/interceptors/auth.interceptor.ts create mode 100644 src/frontend/src/app/core/services/auth.service.ts create mode 100644 src/frontend/src/app/features/auth/pages/login/login.component.ts create mode 100644 src/frontend/src/app/features/auth/pages/register/register.component.ts diff --git a/src/frontend/src/app/app.component.ts b/src/frontend/src/app/app.component.ts index 8c70148..c4ee4ba 100644 --- a/src/frontend/src/app/app.component.ts +++ b/src/frontend/src/app/app.component.ts @@ -3,7 +3,8 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatButtonModule } from '@angular/material/button'; 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'; @Component({ @@ -11,27 +12,46 @@ import { ConnectivityOverlayComponent } from '@shared/index'; standalone: true, imports: [ RouterOutlet, RouterLink, RouterLinkActive, - MatToolbarModule, MatButtonModule, MatIconModule, + MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule, ConnectivityOverlayComponent ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` - +
-
- - -
-
+ +
+ + + + + +
+ + } +
@@ -118,12 +138,70 @@ import { ConnectivityOverlayComponent } from '@shared/index'; 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) { .app-header { padding: 0 1rem; } - .logo-text { + .logo-text, .user-name { display: none; } @@ -135,9 +213,18 @@ import { ConnectivityOverlayComponent } from '@shared/index'; }) export class AppComponent implements OnInit { private connectivity = inject(ConnectivityService); + authService = inject(AuthService); ngOnInit(): void { - // Iniciar monitoreo de conectividad cada 5 segundos this.connectivity.startMonitoring(); } + + getUserInitial(): string { + const username = this.authService.user()?.username; + return username ? username.charAt(0).toUpperCase() : '?'; + } + + logout(): void { + this.authService.logout(); + } } diff --git a/src/frontend/src/app/app.config.ts b/src/frontend/src/app/app.config.ts index 4ac75a4..1d4302b 100644 --- a/src/frontend/src/app/app.config.ts +++ b/src/frontend/src/app/app.config.ts @@ -8,11 +8,12 @@ import { InMemoryCache } from '@apollo/client/core'; import { routes } from './app.routes'; import { environment } from '@env/environment'; import { errorInterceptor } from '@core/interceptors/error.interceptor'; +import { authInterceptor } from '@core/interceptors/auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes, withComponentInputBinding()), - provideHttpClient(withInterceptors([errorInterceptor])), + provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])), provideAnimationsAsync(), provideApollo(() => { const httpLink = inject(HttpLink); diff --git a/src/frontend/src/app/app.routes.ts b/src/frontend/src/app/app.routes.ts index 02edcda..e26d01a 100644 --- a/src/frontend/src/app/app.routes.ts +++ b/src/frontend/src/app/app.routes.ts @@ -1,36 +1,56 @@ import { Routes } from '@angular/router'; +import { authGuard, guestGuard } from '@core/guards/auth.guard'; export const routes: Routes = [ { 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', loadComponent: () => import('@features/students/pages/student-list/student-list.component') .then(m => m.StudentListComponent), + canActivate: [authGuard], }, { path: 'students/new', loadComponent: () => import('@features/students/pages/student-form/student-form.component') .then(m => m.StudentFormComponent), + canActivate: [authGuard], }, { path: 'students/:id/edit', loadComponent: () => import('@features/students/pages/student-form/student-form.component') .then(m => m.StudentFormComponent), + canActivate: [authGuard], }, { path: 'enrollment/:studentId', loadComponent: () => import('@features/enrollment/pages/enrollment-page/enrollment-page.component') .then(m => m.EnrollmentPageComponent), + canActivate: [authGuard], }, { path: 'classmates/:studentId', loadComponent: () => import('@features/classmates/pages/classmates-page/classmates-page.component') .then(m => m.ClassmatesPageComponent), + canActivate: [authGuard], }, - { path: '**', redirectTo: 'students' }, + { path: '**', redirectTo: 'login' }, ]; diff --git a/src/frontend/src/app/core/guards/auth.guard.ts b/src/frontend/src/app/core/guards/auth.guard.ts new file mode 100644 index 0000000..effd4b5 --- /dev/null +++ b/src/frontend/src/app/core/guards/auth.guard.ts @@ -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; +}; diff --git a/src/frontend/src/app/core/interceptors/auth.interceptor.ts b/src/frontend/src/app/core/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..7d7d7f6 --- /dev/null +++ b/src/frontend/src/app/core/interceptors/auth.interceptor.ts @@ -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); +}; diff --git a/src/frontend/src/app/core/services/auth.service.ts b/src/frontend/src/app/core/services/auth.service.ts new file mode 100644 index 0000000..65d1e1a --- /dev/null +++ b/src/frontend/src/app/core/services/auth.service.ts @@ -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(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); + } +} diff --git a/src/frontend/src/app/core/services/index.ts b/src/frontend/src/app/core/services/index.ts index 5af33ff..82e7415 100644 --- a/src/frontend/src/app/core/services/index.ts +++ b/src/frontend/src/app/core/services/index.ts @@ -3,3 +3,4 @@ export * from './enrollment.service'; export * from './notification.service'; export * from './error-handler.service'; export * from './connectivity.service'; +export * from './auth.service'; diff --git a/src/frontend/src/app/features/auth/pages/login/login.component.ts b/src/frontend/src/app/features/auth/pages/login/login.component.ts new file mode 100644 index 0000000..73d53ac --- /dev/null +++ b/src/frontend/src/app/features/auth/pages/login/login.component.ts @@ -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: ` +
+
+
+ +

Iniciar Sesion

+

Sistema de Gestion de Estudiantes

+
+ +
+
+ + + @if (showError('username')) { + El usuario es requerido + } +
+ +
+ + + @if (showError('password')) { + La contrasena es requerida + } +
+ + @if (serverError()) { +
+ error + {{ serverError() }} +
+ } + + +
+ + +
+
+ `, + 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(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'); + }, + }); + } +} diff --git a/src/frontend/src/app/features/auth/pages/register/register.component.ts b/src/frontend/src/app/features/auth/pages/register/register.component.ts new file mode 100644 index 0000000..3c0a7d2 --- /dev/null +++ b/src/frontend/src/app/features/auth/pages/register/register.component.ts @@ -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: ` +
+
+
+ +

Crear Cuenta

+

Registrate en el sistema

+
+ +
+
+ + + @if (showError('username')) { + + @if (form.get('username')?.errors?.['required']) { + El usuario es requerido + } @else if (form.get('username')?.errors?.['minlength']) { + Minimo 3 caracteres + } + + } +
+ +
+ + + @if (showError('password')) { + + @if (form.get('password')?.errors?.['required']) { + La contrasena es requerida + } @else if (form.get('password')?.errors?.['minlength']) { + Minimo 6 caracteres + } + + } +
+ +
+ + +
+ +
+ + + @if (showError('email')) { + Email invalido + } +
+ +

Si proporcionas nombre y email, se creara tu perfil de estudiante automaticamente.

+ + @if (serverError()) { +
+ error + {{ serverError() }} +
+ } + + +
+ + +
+
+ `, + 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(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'); + }, + }); + } +}