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:
Andrés Eduardo García Márquez 2026-01-08 09:14:52 -05:00
parent cf61fb70e3
commit 891d177b8c
9 changed files with 943 additions and 18 deletions

View File

@ -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,15 +12,16 @@ import { ConnectivityOverlayComponent } from '@shared/index';
standalone: true,
imports: [
RouterOutlet, RouterLink, RouterLinkActive,
MatToolbarModule, MatButtonModule, MatIconModule,
MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule,
ConnectivityOverlayComponent
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Overlay de conectividad - bloquea UI si no hay conexión -->
<!-- Overlay de conectividad - bloquea UI si no hay conexion -->
<app-connectivity-overlay />
<div class="app-container">
@if (authService.isAuthenticated()) {
<header class="app-header">
<a routerLink="/" class="logo">
<span class="logo-icon">S</span>
@ -30,8 +32,26 @@ import { ConnectivityOverlayComponent } from '@shared/index';
Estudiantes
</a>
</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>
<main class="app-main">
}
<main class="app-main" [class.full-height]="!authService.isAuthenticated()">
<router-outlet />
</main>
</div>
@ -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();
}
}

View File

@ -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);

View File

@ -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' },
];

View File

@ -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;
};

View File

@ -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);
};

View File

@ -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);
}
}

View File

@ -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';

View File

@ -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');
},
});
}
}

View File

@ -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');
},
});
}
}