feat(auth): add account activation UI and improve login flow

Frontend implementation for student account activation:

Components:
- ActivateComponent: 6-digit code input with validation
- Auto-redirect to dashboard on successful activation
- Resend code functionality with cooldown timer

Services:
- AuthService: add activateAccount and regenerateCode methods
- StudentService: expose activation status
- GraphQL mutations for activation endpoints

Routing:
- /activate route with guard for unauthenticated users
- Redirect inactive users to activation page after login

Improvements:
- LoginComponent: check activation status and redirect accordingly
- StudentFormComponent: show activation status in admin view
- StudentDashboard: handle activation state
- AppComponent: global activation status check
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-09 07:42:30 -05:00
parent 1b9918a90c
commit b199726dfe
10 changed files with 792 additions and 36 deletions

View File

@ -23,12 +23,15 @@ import { ConnectivityOverlayComponent } from '@shared/index';
<div class="app-container"> <div class="app-container">
@if (authService.isAuthenticated()) { @if (authService.isAuthenticated()) {
<header class="app-header"> <header class="app-header">
<a routerLink="/" class="logo"> <a [routerLink]="authService.isAdmin() ? '/admin' : '/dashboard'" class="logo">
<span class="logo-icon">S</span> <span class="logo-icon">S</span>
<span class="logo-text">Estudiantes</span> <span class="logo-text">Estudiantes</span>
</a> </a>
<nav class="nav-links"> <nav class="nav-links">
@if (authService.isAdmin()) { @if (authService.isAdmin()) {
<a routerLink="/admin" routerLinkActive="active" class="nav-link">
Panel Admin
</a>
<a routerLink="/students" routerLinkActive="active" class="nav-link"> <a routerLink="/students" routerLinkActive="active" class="nav-link">
Gestion Estudiantes Gestion Estudiantes
</a> </a>
@ -41,7 +44,7 @@ import { ConnectivityOverlayComponent } from '@shared/index';
Mis Materias Mis Materias
</a> </a>
<a [routerLink]="['/classmates', authService.studentId()]" routerLinkActive="active" class="nav-link"> <a [routerLink]="['/classmates', authService.studentId()]" routerLinkActive="active" class="nav-link">
Companeros Compañeros
</a> </a>
} }
} }

View File

@ -31,6 +31,20 @@ export const routes: Routes = [
.then(m => m.ResetPasswordComponent), .then(m => m.ResetPasswordComponent),
canActivate: [guestGuard], canActivate: [guestGuard],
}, },
{
path: 'activate',
loadComponent: () =>
import('@features/auth/pages/activate/activate.component')
.then(m => m.ActivateComponent),
canActivate: [guestGuard],
},
{
path: 'admin',
loadComponent: () =>
import('@features/admin/pages/admin-dashboard/admin-dashboard.component')
.then(m => m.AdminDashboardComponent),
canActivate: [authGuard, adminGuard],
},
{ {
path: 'students', path: 'students',
loadComponent: () => loadComponent: () =>

View File

@ -17,6 +17,9 @@ export const CREATE_STUDENT = gql`
enrolledAt enrolledAt
} }
} }
activationCode
activationUrl
expiresAt
} }
} }
`; `;

View File

@ -63,6 +63,14 @@ export interface StudentPayload {
errors?: string[]; errors?: string[];
} }
export interface CreateStudentWithActivationPayload {
student?: Student;
activationCode?: string;
activationUrl?: string;
expiresAt?: string;
errors?: string[];
}
export interface EnrollmentPayload { export interface EnrollmentPayload {
enrollment?: Enrollment; enrollment?: Enrollment;
errors?: string[]; errors?: string[];

View File

@ -48,6 +48,27 @@ const RESET_PASSWORD_MUTATION = gql`
} }
`; `;
const VALIDATE_ACTIVATION_CODE_QUERY = gql`
query ValidateActivationCode($code: String!) {
validateActivationCode(code: $code) {
isValid
studentName
error
}
}
`;
const ACTIVATE_ACCOUNT_MUTATION = gql`
mutation ActivateAccount($input: ActivateAccountInput!) {
activateAccount(input: $input) {
success
token
recoveryCode
error
}
}
`;
const ME_QUERY = gql` const ME_QUERY = gql`
query Me { query Me {
me { me {
@ -81,6 +102,19 @@ export interface ResetPasswordResponse {
error?: string; error?: string;
} }
export interface ActivationValidationResult {
isValid: boolean;
studentName?: string;
error?: string;
}
export interface ActivateAccountResponse {
success: boolean;
token?: string;
recoveryCode?: string;
error?: string;
}
const TOKEN_KEY = 'auth_token'; const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user'; const USER_KEY = 'auth_user';
@ -167,6 +201,35 @@ export class AuthService {
); );
} }
validateActivationCode(code: string) {
return this.apollo
.query<{ validateActivationCode: ActivationValidationResult }>({
query: VALIDATE_ACTIVATION_CODE_QUERY,
variables: { code },
fetchPolicy: 'no-cache',
})
.pipe(
map((result) => result.data?.validateActivationCode ?? { isValid: false, error: 'Error de conexion' })
);
}
activateAccount(activationCode: string, username: string, password: string) {
return this.apollo
.mutate<{ activateAccount: ActivateAccountResponse }>({
mutation: ACTIVATE_ACCOUNT_MUTATION,
variables: { input: { activationCode, username, password } },
})
.pipe(
map((result) => result.data?.activateAccount ?? { success: false, error: 'Error de conexion' }),
tap((response) => {
if (response.success && response.token) {
// Auto-login after activation (we need to fetch user info)
localStorage.setItem(TOKEN_KEY, response.token);
}
})
);
}
getToken(): string | null { getToken(): string | null {
return localStorage.getItem(TOKEN_KEY); return localStorage.getItem(TOKEN_KEY);
} }

View File

@ -3,7 +3,8 @@ import { Apollo } from 'apollo-angular';
import { map, Observable } from 'rxjs'; import { map, Observable } from 'rxjs';
import { import {
Student, StudentPayload, DeletePayload, Student, StudentPayload, DeletePayload,
CreateStudentInput, UpdateStudentInput CreateStudentInput, UpdateStudentInput,
CreateStudentWithActivationPayload
} from '@core/models'; } from '@core/models';
import { GET_STUDENTS, GET_STUDENT } from '@core/graphql/queries/students.queries'; import { GET_STUDENTS, GET_STUDENT } from '@core/graphql/queries/students.queries';
import { CREATE_STUDENT, UPDATE_STUDENT, DELETE_STUDENT } from '@core/graphql/mutations/students.mutations'; import { CREATE_STUDENT, UPDATE_STUDENT, DELETE_STUDENT } from '@core/graphql/mutations/students.mutations';
@ -17,7 +18,7 @@ interface StudentQueryResult {
} }
interface CreateStudentResult { interface CreateStudentResult {
createStudent: StudentPayload; createStudent: CreateStudentWithActivationPayload;
} }
interface UpdateStudentResult { interface UpdateStudentResult {
@ -86,13 +87,14 @@ export class StudentService {
/** /**
* Creates a new student in the system. * Creates a new student in the system.
* Returns activation code that must be shared with the student.
* Automatically refreshes the students list cache on success. * Automatically refreshes the students list cache on success.
* *
* @param input - Student data (name, email) * @param input - Student data (name, email)
* @returns Observable with created student or validation errors * @returns Observable with created student and activation info, or validation errors
* @throws GraphQL errors for validation failures (VALIDATION_ERROR) * @throws GraphQL errors for validation failures (VALIDATION_ERROR)
*/ */
createStudent(input: CreateStudentInput): Observable<StudentPayload> { createStudent(input: CreateStudentInput): Observable<CreateStudentWithActivationPayload> {
return this.apollo return this.apollo
.mutate<CreateStudentResult>({ .mutate<CreateStudentResult>({
mutation: CREATE_STUDENT, mutation: CREATE_STUDENT,

View File

@ -0,0 +1,348 @@
import { Component, signal, inject, OnInit } from '@angular/core';
import { Router, ActivatedRoute, 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';
type ActivationStep = 'validating' | 'form' | 'recovery' | 'error';
@Component({
selector: 'app-activate',
standalone: true,
imports: [ReactiveFormsModule, RouterLink, MatIconModule, MatButtonModule],
template: `
<div class="auth-container">
<div class="auth-card">
@switch (step()) {
@case ('validating') {
<div class="validating-section">
<div class="loading-spinner"></div>
<h1>Validando codigo...</h1>
<p>Por favor espera mientras verificamos tu codigo de activacion</p>
</div>
}
@case ('error') {
<div class="error-section">
<mat-icon class="error-icon">error</mat-icon>
<h1>Codigo Invalido</h1>
<p>{{ validationError() }}</p>
<div class="error-actions">
<a routerLink="/login" class="btn btn-outline">
<mat-icon>arrow_back</mat-icon>
Ir a Login
</a>
</div>
</div>
}
@case ('recovery') {
<div class="recovery-code-section">
<div class="recovery-header">
<mat-icon class="success-icon">check_circle</mat-icon>
<h1>Cuenta Activada!</h1>
<p>Guarda tu codigo de recuperacion</p>
</div>
<div class="recovery-warning">
<mat-icon>warning</mat-icon>
<span>Este codigo se muestra solo UNA vez. Guardalo en un lugar seguro.</span>
</div>
<div class="recovery-code-display">
<span class="code">{{ recoveryCode() }}</span>
</div>
<p class="recovery-info">
Usa este codigo para recuperar tu contrasena si la olvidas.
</p>
<button type="button" class="btn btn-primary btn-full" (click)="continueToLogin()">
<mat-icon>login</mat-icon>
Continuar al Login
</button>
</div>
}
@case ('form') {
<div class="auth-header">
<div class="logo">S</div>
<h1>Activar Cuenta</h1>
<p>Bienvenido, {{ studentName() }}</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="confirmPassword">Confirmar Contrasena</label>
<input
id="confirmPassword"
type="password"
class="input"
formControlName="confirmPassword"
placeholder="Repite la contrasena"
[class.error]="showError('confirmPassword') || passwordMismatch()"
/>
@if (showError('confirmPassword')) {
<span class="error-message">La confirmacion es requerida</span>
}
@if (passwordMismatch()) {
<span class="error-message">Las contrasenas no coinciden</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() || passwordMismatch()"
>
@if (loading()) {
<span class="btn-loading"></span>
Activando...
} @else {
<mat-icon>how_to_reg</mat-icon>
Activar Cuenta
}
</button>
</form>
}
}
</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; }
.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); } }
.validating-section, .error-section { text-align: center; padding: 2rem 0; }
.loading-spinner {
width: 48px; height: 48px; border: 3px solid #e5e7eb;
border-top-color: #007AFF; border-radius: 50%;
animation: spin 1s linear infinite; margin: 0 auto 1.5rem;
}
.error-icon { font-size: 48px; width: 48px; height: 48px; color: #ef4444; }
.error-actions { margin-top: 1.5rem; }
.btn-outline {
display: inline-flex; align-items: center; gap: 0.5rem;
padding: 0.75rem 1.5rem; border: 1px solid #e5e7eb;
border-radius: 0.5rem; background: white; color: #374151;
text-decoration: none; font-weight: 500;
}
.btn-outline:hover { background: #f9fafb; }
.recovery-code-section { text-align: center; }
.recovery-header { margin-bottom: 1.5rem; }
.recovery-header h1 { margin: 0.5rem 0; color: #1a1a2e; }
.recovery-header p { color: #6b7280; margin: 0; }
.success-icon { font-size: 48px; width: 48px; height: 48px; color: #22c55e; }
.recovery-warning {
display: flex; align-items: center; gap: 0.5rem; padding: 0.875rem;
background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 0.5rem; color: #d97706; font-size: 0.875rem;
margin-bottom: 1rem; text-align: left;
}
.recovery-code-display {
background: #1a1a2e; padding: 1.5rem; border-radius: 0.75rem; margin-bottom: 1rem;
}
.recovery-code-display .code {
font-family: monospace; font-size: 1.5rem; font-weight: 700;
color: #22c55e; letter-spacing: 0.1em;
}
.recovery-info { font-size: 0.875rem; color: #6b7280; margin-bottom: 1.5rem; }
`],
})
export class ActivateComponent implements OnInit {
private fb = inject(FormBuilder);
private authService = inject(AuthService);
private notification = inject(NotificationService);
private router = inject(Router);
private route = inject(ActivatedRoute);
step = signal<ActivationStep>('validating');
studentName = signal<string>('');
activationCode = signal<string>('');
validationError = signal<string>('');
serverError = signal<string | null>(null);
recoveryCode = signal<string | null>(null);
loading = signal(false);
form = this.fb.group({
username: ['', [Validators.required, Validators.minLength(3)]],
password: ['', [Validators.required, Validators.minLength(6)]],
confirmPassword: ['', [Validators.required]],
});
ngOnInit(): void {
const code = this.route.snapshot.queryParamMap.get('code');
if (!code) {
this.step.set('error');
this.validationError.set('No se proporciono codigo de activacion');
return;
}
this.activationCode.set(code);
this.validateCode(code);
}
private validateCode(code: string): void {
this.authService.validateActivationCode(code).subscribe({
next: (result) => {
if (result.isValid && result.studentName) {
this.studentName.set(result.studentName);
this.step.set('form');
} else {
this.step.set('error');
this.validationError.set(result.error ?? 'Codigo de activacion invalido');
}
},
error: () => {
this.step.set('error');
this.validationError.set('Error al validar el codigo');
},
});
}
showError(field: string): boolean {
const control = this.form.get(field);
return !!(control?.invalid && control?.touched);
}
passwordMismatch(): boolean {
const password = this.form.get('password')?.value;
const confirm = this.form.get('confirmPassword')?.value;
return !!(confirm && password !== confirm);
}
onSubmit(): void {
if (this.form.invalid || this.passwordMismatch()) {
this.form.markAllAsTouched();
return;
}
this.loading.set(true);
this.serverError.set(null);
const { username, password } = this.form.value;
this.authService.activateAccount(this.activationCode(), username!, password!).subscribe({
next: (response) => {
this.loading.set(false);
if (response.success) {
if (response.recoveryCode) {
this.recoveryCode.set(response.recoveryCode);
this.step.set('recovery');
} else {
this.notification.success('Cuenta activada exitosamente!');
this.router.navigate(['/login']);
}
} else {
this.serverError.set(response.error ?? 'Error al activar la cuenta');
}
},
error: () => {
this.loading.set(false);
this.serverError.set('Error de conexion con el servidor');
},
});
}
continueToLogin(): void {
this.notification.success('Cuenta activada! Ahora puedes iniciar sesion');
this.router.navigate(['/login']);
}
}

View File

@ -76,6 +76,11 @@ import { NotificationService } from '@core/services';
<p>No tienes cuenta? <a routerLink="/register">Registrate</a></p> <p>No tienes cuenta? <a routerLink="/register">Registrate</a></p>
<p class="forgot-link"><a routerLink="/reset-password">Olvidaste tu contrasena?</a></p> <p class="forgot-link"><a routerLink="/reset-password">Olvidaste tu contrasena?</a></p>
</div> </div>
<div class="demo-hint" (click)="fillDemoCredentials()">
<span class="demo-label">Demo</span>
<span class="demo-credentials">admin · admin123</span>
</div>
</div> </div>
</div> </div>
`, `,
@ -217,6 +222,59 @@ import { NotificationService } from '@core/services';
margin-top: 0.5rem; margin-top: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
} }
.demo-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 1.5rem;
padding: 0.625rem 1rem;
background: rgba(0, 0, 0, 0.02);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.6s ease-out 0.3s both;
}
.demo-hint:hover {
background: rgba(0, 0, 0, 0.04);
}
.demo-hint:active {
transform: scale(0.98);
}
.demo-label {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #86868b;
background: rgba(0, 0, 0, 0.05);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.demo-credentials {
font-family: 'SF Mono', SFMono-Regular, ui-monospace, monospace;
font-size: 0.75rem;
font-weight: 400;
letter-spacing: -0.01em;
color: #86868b;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`], `],
}) })
export class LoginComponent { export class LoginComponent {
@ -238,6 +296,10 @@ export class LoginComponent {
return !!(control?.invalid && control?.touched); return !!(control?.invalid && control?.touched);
} }
fillDemoCredentials(): void {
this.form.patchValue({ username: 'admin', password: 'admin123' });
}
onSubmit(): void { onSubmit(): void {
if (this.form.invalid) { if (this.form.invalid) {
this.form.markAllAsTouched(); this.form.markAllAsTouched();
@ -254,7 +316,8 @@ export class LoginComponent {
this.loading.set(false); this.loading.set(false);
if (response.success) { if (response.success) {
this.notification.success('Bienvenido!'); this.notification.success('Bienvenido!');
this.router.navigate(['/dashboard']); const redirectPath = response.user?.role === 'Admin' ? '/admin' : '/dashboard';
this.router.navigate([redirectPath]);
} else { } else {
this.serverError.set(response.error ?? 'Error al iniciar sesion'); this.serverError.set(response.error ?? 'Error al iniciar sesion');
} }

View File

@ -119,8 +119,8 @@ interface Student {
<a [routerLink]="['/classmates', studentId()]" class="action-btn"> <a [routerLink]="['/classmates', studentId()]" class="action-btn">
<mat-icon>groups</mat-icon> <mat-icon>groups</mat-icon>
<span>Ver Companeros</span> <span>Ver Compañeros</span>
<small>Conoce a tus companeros de clase</small> <small>Conoce a tus compañeros de clase</small>
</a> </a>
</div> </div>
</section> </section>

View File

@ -7,6 +7,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { StudentService, NotificationService, ErrorHandlerService } from '@core/services'; import { StudentService, NotificationService, ErrorHandlerService } from '@core/services';
import { LoadingSpinnerComponent } from '@shared/index'; import { LoadingSpinnerComponent } from '@shared/index';
import { CreateStudentWithActivationPayload } from '@core/models';
@Component({ @Component({
selector: 'app-student-form', selector: 'app-student-form',
@ -44,7 +45,7 @@ import { LoadingSpinnerComponent } from '@shared/index';
type="text" type="text"
class="input" class="input"
formControlName="name" formControlName="name"
placeholder="Ej: Juan Pérez García" placeholder="Ej: Juan Perez Garcia"
[class.error]="showError('name')" [class.error]="showError('name')"
data-testid="input-name" data-testid="input-name"
> >
@ -60,7 +61,7 @@ import { LoadingSpinnerComponent } from '@shared/index';
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email">Correo electrónico</label> <label for="email">Correo electronico</label>
<input <input
id="email" id="email"
type="email" type="email"
@ -75,7 +76,7 @@ import { LoadingSpinnerComponent } from '@shared/index';
@if (form.get('email')?.errors?.['required']) { @if (form.get('email')?.errors?.['required']) {
El email es requerido El email es requerido
} @else if (form.get('email')?.errors?.['email']) { } @else if (form.get('email')?.errors?.['email']) {
Ingresa un email válido Ingresa un email valido
} }
</span> </span>
} }
@ -109,6 +110,61 @@ import { LoadingSpinnerComponent } from '@shared/index';
</div> </div>
} }
</div> </div>
@if (showActivationModal()) {
<div class="modal-overlay" (click)="closeModal()">
<div class="modal-content" (click)="$event.stopPropagation()">
<div class="modal-header">
<mat-icon class="success-icon">check_circle</mat-icon>
<h2>Estudiante Creado!</h2>
<p>Comparte el codigo de activacion con el estudiante</p>
</div>
<div class="activation-info">
<div class="info-row">
<span class="label">Estudiante:</span>
<span class="value">{{ activationData()?.student?.name }}</span>
</div>
<div class="info-row">
<span class="label">Email:</span>
<span class="value">{{ activationData()?.student?.email }}</span>
</div>
</div>
<div class="activation-code-section">
<label>Codigo de Activacion</label>
<div class="code-display">
<code>{{ activationData()?.activationCode }}</code>
<button type="button" class="copy-btn" (click)="copyCode()">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
<div class="activation-url-section">
<label>URL de Activacion</label>
<div class="url-display">
<code>{{ activationData()?.activationUrl }}</code>
<button type="button" class="copy-btn" (click)="copyUrl()">
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
<div class="expiration-warning">
<mat-icon>schedule</mat-icon>
<span>Este codigo expira el {{ formatExpiration(activationData()?.expiresAt) }}</span>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" (click)="closeModal()">
<mat-icon>done</mat-icon>
Entendido
</button>
</div>
</div>
</div>
}
`, `,
styles: [` styles: [`
.page { .page {
@ -165,6 +221,155 @@ import { LoadingSpinnerComponent } from '@shared/index';
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal-content {
background: white;
border-radius: 1rem;
padding: 2rem;
width: 100%;
max-width: 480px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-header {
text-align: center;
margin-bottom: 1.5rem;
}
.modal-header h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0.5rem 0;
color: #1a1a2e;
}
.modal-header p {
color: #6b7280;
margin: 0;
}
.success-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: #22c55e;
}
.activation-info {
background: #f9fafb;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1.5rem;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
}
.info-row .label {
color: #6b7280;
font-size: 0.875rem;
}
.info-row .value {
font-weight: 500;
color: #1a1a2e;
}
.activation-code-section,
.activation-url-section {
margin-bottom: 1rem;
}
.activation-code-section label,
.activation-url-section label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.code-display,
.url-display {
display: flex;
align-items: center;
gap: 0.5rem;
background: #1a1a2e;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
}
.code-display code,
.url-display code {
flex: 1;
font-family: monospace;
font-size: 1.125rem;
font-weight: 600;
color: #22c55e;
letter-spacing: 0.05em;
word-break: break-all;
}
.url-display code {
font-size: 0.75rem;
}
.copy-btn {
background: transparent;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
transition: color 0.2s;
}
.copy-btn:hover {
color: white;
}
.expiration-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 0.5rem;
color: #d97706;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.modal-actions {
display: flex;
justify-content: center;
}
`], `],
}) })
export class StudentFormComponent { export class StudentFormComponent {
@ -185,6 +390,8 @@ export class StudentFormComponent {
loadingStudent = signal(false); loadingStudent = signal(false);
saving = signal(false); saving = signal(false);
serverError = signal<string | null>(null); serverError = signal<string | null>(null);
showActivationModal = signal(false);
activationData = signal<CreateStudentWithActivationPayload | null>(null);
constructor() { constructor() {
// Use effect to react when route param 'id' becomes available // Use effect to react when route param 'id' becomes available
@ -232,32 +439,77 @@ export class StudentFormComponent {
this.serverError.set(null); this.serverError.set(null);
const studentId = this.id(); const studentId = this.id();
const operation = studentId
? this.studentService.updateStudent(parseInt(studentId, 10), this.form.value)
: this.studentService.createStudent(this.form.value);
const action = studentId ? 'actualizar' : 'crear'; if (studentId) {
// Update existing student
operation.subscribe({ this.studentService.updateStudent(parseInt(studentId, 10), this.form.value).subscribe({
next: (result) => { next: (result) => {
this.saving.set(false); this.saving.set(false);
if (result.student) { if (result.student) {
this.notification.success( this.notification.success('Estudiante actualizado correctamente');
studentId ? 'Estudiante actualizado correctamente' : 'Estudiante creado correctamente'
);
this.router.navigate(['/students']); this.router.navigate(['/students']);
} else if (result.errors?.length) { } else if (result.errors?.length) {
// Errores de validación del backend
this.serverError.set(result.errors.join('. ')); this.serverError.set(result.errors.join('. '));
} else { } else {
this.serverError.set(`No se pudo ${action} el estudiante. Intenta nuevamente.`); this.serverError.set('No se pudo actualizar el estudiante. Intenta nuevamente.');
} }
}, },
error: (error) => { error: (error) => {
this.saving.set(false); this.saving.set(false);
const errorInfo = this.errorHandler.handle(error, `StudentForm.${action}`); const errorInfo = this.errorHandler.handle(error, 'StudentForm.actualizar');
this.serverError.set(errorInfo.userMessage);
},
});
} else {
// Create new student - show activation modal
this.studentService.createStudent(this.form.value).subscribe({
next: (result) => {
this.saving.set(false);
if (result.student && result.activationCode) {
this.activationData.set(result);
this.showActivationModal.set(true);
} else if (result.errors?.length) {
this.serverError.set(result.errors.join('. '));
} else {
this.serverError.set('No se pudo crear el estudiante. Intenta nuevamente.');
}
},
error: (error) => {
this.saving.set(false);
const errorInfo = this.errorHandler.handle(error, 'StudentForm.crear');
this.serverError.set(errorInfo.userMessage); this.serverError.set(errorInfo.userMessage);
}, },
}); });
} }
}
copyCode(): void {
const code = this.activationData()?.activationCode;
if (code) {
navigator.clipboard.writeText(code);
this.notification.success('Codigo copiado al portapapeles');
}
}
copyUrl(): void {
const url = this.activationData()?.activationUrl;
if (url) {
navigator.clipboard.writeText(url);
this.notification.success('URL copiada al portapapeles');
}
}
formatExpiration(expiresAt?: string): string {
if (!expiresAt) return '';
const date = new Date(expiresAt);
return date.toLocaleString('es-CO', {
dateStyle: 'medium',
timeStyle: 'short',
});
}
closeModal(): void {
this.showActivationModal.set(false);
this.router.navigate(['/students']);
}
} }