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 { 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 './error-handler.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