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:
parent
1b9918a90c
commit
b199726dfe
|
|
@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: () =>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ export const CREATE_STUDENT = gql`
|
||||||
enrolledAt
|
enrolledAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
activationCode
|
||||||
|
activationUrl
|
||||||
|
expiresAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue