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: `
+
+ `,
+ 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: `
+
+ `,
+ 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');
+ },
+ });
+ }
+}