UNPKG

mcp-quiz-server

Version:

🧠 AI-Powered Quiz Management via Model Context Protocol (MCP) - Create, manage, and take quizzes directly from VS Code, Claude, and other AI agents.

788 lines (697 loc) 27.8 kB
/** * @fileoverview Login Modal Component - User Authentication Interface * @version 1.0.0 * @since 2025-08-04 * @lastUpdated 2025-08-04 * @module LoginModal * @description Modal component for user authentication with form validation and accessibility features * @contributors Claude Code Agent * @dependencies Component base class, AuthService, DOMUtils * @requirements REQ-AUTH-012 (Login UI component) * @testCoverage Authentication flow, form validation, accessibility compliance */ import { AuthService } from '../services/AuthService'; import { Component } from './Component'; export interface LoginModalOptions { onSuccess?: (user: any) => void; onCancel?: () => void; showRegisterLink?: boolean; allowGuestMode?: boolean; } /** * Login Modal Component * * @description Provides user authentication interface with form validation, * accessibility features, and error handling. Follows established * modal patterns from the existing codebase. * * @example * ```typescript * const loginModal = new LoginModal({ * onSuccess: (user) => console.log('Logged in:', user), * onCancel: () => console.log('Login cancelled') * }); * loginModal.show(); * ``` * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-012 (Login modal interface) * @accessibility WCAG 2.1 AA compliant with focus management and screen reader support */ export class LoginModal extends Component { private authService: AuthService; private options: LoginModalOptions; private isVisible: boolean = false; private previousFocus: HTMLElement | null = null; private unsubscribeAuth: (() => void) | null = null; // Form elements private usernameInput: HTMLInputElement | null = null; private passwordInput: HTMLInputElement | null = null; private loginButton: HTMLButtonElement | null = null; private cancelButton: HTMLButtonElement | null = null; private errorContainer: HTMLElement | null = null; private loadingSpinner: HTMLElement | null = null; constructor(options: LoginModalOptions = {}) { // Create modal element if it doesn't exist if (!document.getElementById('login-modal')) { const modalElement = document.createElement('div'); modalElement.id = 'login-modal'; modalElement.className = 'fixed inset-0 z-[10000] hidden'; document.body.appendChild(modalElement); } super('#login-modal'); this.authService = AuthService.getInstance(); this.options = options; } /** * Show the login modal * * @description Displays the login modal with proper focus management * and accessibility announcements. * * @since 2025-08-04 * @author Claude Code Agent * @accessibility Saves focus and announces modal opening to screen readers */ show(): void { if (this.isVisible) return; // Save currently focused element this.previousFocus = document.activeElement as HTMLElement; this.render(); this.bindEvents(); this.mount(); // Show modal with animation this.element.classList.remove('hidden'); // Force reflow for animation this.element.offsetHeight; // Add visible class for animation const overlay = this.element.querySelector('.login-overlay'); const modal = this.element.querySelector('.login-modal'); if (overlay && modal) { overlay.classList.add('opacity-100'); modal.classList.add('scale-100', 'opacity-100'); } // Focus the username input setTimeout(() => { this.usernameInput?.focus(); }, 150); this.isVisible = true; // Announce to screen readers this.announceToScreenReader('Login dialog opened'); } /** * Hide the login modal * * @description Hides the modal with animation and restores focus * * @since 2025-08-04 * @author Claude Code Agent * @accessibility Restores focus to previously focused element */ hide(): void { if (!this.isVisible) return; const overlay = this.element.querySelector('.login-overlay'); const modal = this.element.querySelector('.login-modal'); if (overlay && modal) { overlay.classList.remove('opacity-100'); modal.classList.remove('scale-100', 'opacity-100'); } // Hide after animation setTimeout(() => { this.element.classList.add('hidden'); this.unmount(); // Restore focus if (this.previousFocus) { this.previousFocus.focus(); } }, 300); this.isVisible = false; // Announce to screen readers this.announceToScreenReader('Login dialog closed'); } /** * Render the modal content * * @description Creates the modal HTML structure with form elements, * error handling, and accessibility attributes. * * @since 2025-08-04 * @author Claude Code Agent */ protected render(): void { this.element.innerHTML = ` <div class="login-overlay fixed inset-0 bg-black bg-opacity-60 backdrop-blur-sm opacity-0 transition-all duration-300 flex items-center justify-center"> <div class="login-modal bg-white dark:bg-gray-800 rounded-2xl shadow-2xl max-w-md w-11/12 mx-4 overflow-hidden transform scale-95 opacity-0 transition-all duration-300" role="dialog" aria-labelledby="login-title" aria-describedby="login-description"> <!-- Header --> <div class="login-header bg-gradient-to-r from-blue-600 to-blue-700 px-6 py-4"> <div class="flex items-center justify-between"> <div class="flex items-center"> <div class="login-icon text-2xl text-white mr-3">🔐</div> <h2 id="login-title" class="text-xl font-semibold text-white"> Sign In </h2> </div> <button type="button" class="cancel-btn text-white hover:text-gray-200 transition-colors p-1 rounded focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-blue-600" aria-label="Close login dialog"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> </svg> </button> </div> </div> <!-- Content --> <div class="login-content p-6"> <p id="login-description" class="text-gray-600 dark:text-gray-400 mb-6 text-center"> Sign in to access your personalized quiz experience and track your progress. </p> <!-- Error Alert --> <div class="error-container hidden mb-4" role="alert" aria-live="polite"> <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"> <div class="flex items-center"> <div class="error-icon text-red-500 mr-3">⚠️</div> <div class="error-message text-red-700 dark:text-red-400 font-medium"></div> </div> </div> </div> <!-- Login Form --> <form class="login-form space-y-4"> <!-- Username Field --> <div class="form-group"> <label for="username-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> Username or Email </label> <input type="text" id="username-input" class="username-input w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white transition-colors" placeholder="Enter your username or email" required autocomplete="username" aria-describedby="username-help"> <div id="username-help" class="text-xs text-gray-500 dark:text-gray-400 mt-1"> Use your registered username or email address </div> </div> <!-- Password Field --> <div class="form-group"> <label for="password-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> Password </label> <div class="relative"> <input type="password" id="password-input" class="password-input w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white transition-colors pr-12" placeholder="Enter your password" required autocomplete="current-password" aria-describedby="password-help"> <button type="button" class="password-toggle absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1" aria-label="Toggle password visibility"> <svg class="w-5 h-5 password-show" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path> </svg> <svg class="w-5 h-5 password-hide hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L12 12m-3.122-3.122l4.242 4.242M21 21l-4.24-4.24"></path> </svg> </button> </div> <div id="password-help" class="text-xs text-gray-500 dark:text-gray-400 mt-1"> Enter your account password </div> </div> <!-- Remember Me --> <div class="form-group"> <label class="flex items-center"> <input type="checkbox" class="remember-checkbox rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700"> <span class="ml-2 text-sm text-gray-700 dark:text-gray-300"> Keep me signed in </span> </label> </div> </form> <!-- Action Buttons --> <div class="login-actions flex flex-col space-y-3 mt-6"> <button type="submit" class="login-btn w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"> <span class="btn-text">Sign In</span> <div class="loading-spinner hidden ml-2 inline-block"> <svg class="animate-spin h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> </div> </button> ${ this.options.allowGuestMode ? ` <button type="button" class="guest-btn w-full bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 font-medium py-3 px-6 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"> Continue as Guest </button> ` : '' } </div> ${ this.options.showRegisterLink ? ` <!-- Register Link --> <div class="text-center mt-6 pt-4 border-t border-gray-200 dark:border-gray-700"> <p class="text-sm text-gray-600 dark:text-gray-400"> Don't have an account? <button type="button" class="register-link text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium focus:outline-none focus:underline"> Create Account </button> </p> </div> ` : '' } <!-- Forgot Password --> <div class="text-center mt-4"> <button type="button" class="forgot-password-link text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none focus:underline"> Forgot your password? </button> </div> </div> </div> </div> `; // Cache form elements this.cacheElements(); } /** * Cache form elements for efficient access * * @description Stores references to frequently accessed DOM elements * * @since 2025-08-04 * @author Claude Code Agent */ private cacheElements(): void { this.usernameInput = this.element.querySelector('.username-input') as HTMLInputElement; this.passwordInput = this.element.querySelector('.password-input') as HTMLInputElement; this.loginButton = this.element.querySelector('.login-btn') as HTMLButtonElement; this.cancelButton = this.element.querySelector('.cancel-btn') as HTMLButtonElement; this.errorContainer = this.element.querySelector('.error-container') as HTMLElement; this.loadingSpinner = this.element.querySelector('.loading-spinner') as HTMLElement; } /** * Bind event listeners * * @description Sets up all event listeners for form interaction, * keyboard navigation, and accessibility features. * * @since 2025-08-04 * @author Claude Code Agent * @accessibility Includes keyboard navigation and escape key handling */ protected bindEvents(): void { // Form submission const form = this.element.querySelector('.login-form') as HTMLFormElement; form?.addEventListener('submit', this.handleSubmit.bind(this)); // Login button this.loginButton?.addEventListener('click', this.handleSubmit.bind(this)); // Cancel button this.cancelButton?.addEventListener('click', this.handleCancel.bind(this)); // Password toggle const passwordToggle = this.element.querySelector('.password-toggle') as HTMLButtonElement; passwordToggle?.addEventListener('click', this.togglePasswordVisibility.bind(this)); // Guest mode button const guestButton = this.element.querySelector('.guest-btn') as HTMLButtonElement; guestButton?.addEventListener('click', this.handleGuestMode.bind(this)); // Register link const registerLink = this.element.querySelector('.register-link') as HTMLButtonElement; registerLink?.addEventListener('click', this.handleRegisterLink.bind(this)); // Forgot password link const forgotLink = this.element.querySelector('.forgot-password-link') as HTMLButtonElement; forgotLink?.addEventListener('click', this.handleForgotPassword.bind(this)); // Keyboard navigation document.addEventListener('keydown', this.handleKeyDown.bind(this)); // Click outside to close const overlay = this.element.querySelector('.login-overlay'); overlay?.addEventListener('click', e => { if (e.target === overlay) { this.handleCancel(); } }); // Subscribe to auth state changes this.unsubscribeAuth = this.authService.subscribe(this.handleAuthStateChange.bind(this)); // Input validation this.usernameInput?.addEventListener('input', this.validateForm.bind(this)); this.passwordInput?.addEventListener('input', this.validateForm.bind(this)); } /** * Handle form submission * * @description Processes login form submission with validation * and error handling. * * @param {Event} event - Form or button click event * * @since 2025-08-04 * @author Claude Code Agent */ private async handleSubmit(event: Event): Promise<void> { event.preventDefault(); if (!this.usernameInput || !this.passwordInput) { this.showError('Form elements not found'); return; } const username = this.usernameInput.value.trim(); const password = this.passwordInput.value; // Client-side validation if (!username) { this.showError('Please enter your username or email'); this.usernameInput.focus(); return; } if (!password) { this.showError('Please enter your password'); this.passwordInput.focus(); return; } // Show loading state this.setLoadingState(true); this.hideError(); try { const success = await this.authService.login({ username, password }); if (success) { const authState = this.authService.getAuthState(); this.announceToScreenReader(`Welcome back, ${authState.user?.username || 'user'}!`); // Call success callback if (this.options.onSuccess) { this.options.onSuccess(authState.user); } // Hide modal this.hide(); } // Error handling is done through auth state subscription } catch (error) { console.error('Login error:', error); this.showError('An unexpected error occurred. Please try again.'); } finally { this.setLoadingState(false); } } /** * Handle cancel button click * * @description Closes the modal and calls the cancel callback * * @since 2025-08-04 * @author Claude Code Agent */ private handleCancel(): void { if (this.options.onCancel) { this.options.onCancel(); } this.hide(); } /** * Handle keyboard navigation * * @description Manages keyboard shortcuts and navigation within the modal * * @param {KeyboardEvent} event - Keyboard event * * @since 2025-08-04 * @author Claude Code Agent * @accessibility Supports escape key and tab navigation */ private handleKeyDown(event: KeyboardEvent): void { if (!this.isVisible) return; switch (event.key) { case 'Escape': event.preventDefault(); this.handleCancel(); break; case 'Enter': // Allow form submission via Enter key if (event.target !== this.loginButton) { event.preventDefault(); this.handleSubmit(event); } break; case 'Tab': // Trap focus within modal this.trapFocus(event); break; } } /** * Trap focus within the modal * * @description Ensures tab navigation stays within the modal for accessibility * * @param {KeyboardEvent} event - Tab key event * * @since 2025-08-04 * @author Claude Code Agent * @accessibility Implements focus trap for screen reader users */ private trapFocus(event: KeyboardEvent): void { const focusableElements = this.element.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) as NodeListOf<HTMLElement>; const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; if (event.shiftKey) { if (document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } } } /** * Toggle password field visibility * * @description Shows/hides password text with proper accessibility attributes * * @since 2025-08-04 * @author Claude Code Agent * @accessibility Updates aria-label and visual indicators */ private togglePasswordVisibility(): void { if (!this.passwordInput) return; const isPassword = this.passwordInput.type === 'password'; const toggle = this.element.querySelector('.password-toggle') as HTMLButtonElement; const showIcon = toggle?.querySelector('.password-show'); const hideIcon = toggle?.querySelector('.password-hide'); if (isPassword) { this.passwordInput.type = 'text'; toggle?.setAttribute('aria-label', 'Hide password'); showIcon?.classList.add('hidden'); hideIcon?.classList.remove('hidden'); } else { this.passwordInput.type = 'password'; toggle?.setAttribute('aria-label', 'Show password'); showIcon?.classList.remove('hidden'); hideIcon?.classList.add('hidden'); } } /** * Handle guest mode selection * * @description Allows users to continue without authentication * * @since 2025-08-04 * @author Claude Code Agent */ private handleGuestMode(): void { this.announceToScreenReader('Continuing as guest user'); if (this.options.onSuccess) { this.options.onSuccess(null); // Null indicates guest mode } this.hide(); } /** * Handle register link click * * @description Placeholder for registration functionality * * @since 2025-08-04 * @author Claude Code Agent */ private handleRegisterLink(): void { // TODO: Implement registration modal or redirect console.log('Registration requested'); this.announceToScreenReader('Registration feature coming soon'); } /** * Handle forgot password link * * @description Placeholder for password reset functionality * * @since 2025-08-04 * @author Claude Code Agent */ private handleForgotPassword(): void { // TODO: Implement password reset modal or redirect console.log('Password reset requested'); this.announceToScreenReader('Password reset feature coming soon'); } /** * Handle authentication state changes * * @description Responds to auth service state updates * * @param {object} authState - Current authentication state * * @since 2025-08-04 * @author Claude Code Agent */ private handleAuthStateChange(authState: any): void { if (authState.error) { this.showError(authState.error); this.setLoadingState(false); } if (authState.isLoading) { this.setLoadingState(true); } else { this.setLoadingState(false); } } /** * Show error message * * @description Displays error message with accessibility announcement * * @param {string} message - Error message to display * * @since 2025-08-04 * @author Claude Code Agent * @accessibility Announces errors to screen readers */ private showError(message: string): void { if (!this.errorContainer) return; const errorMessage = this.errorContainer.querySelector('.error-message'); if (errorMessage) { errorMessage.textContent = message; } this.errorContainer.classList.remove('hidden'); this.announceToScreenReader(`Error: ${message}`); } /** * Hide error message * * @description Hides the error container * * @since 2025-08-04 * @author Claude Code Agent */ private hideError(): void { this.errorContainer?.classList.add('hidden'); } /** * Set loading state * * @description Updates UI to show/hide loading indicators * * @param {boolean} loading - Whether to show loading state * * @since 2025-08-04 * @author Claude Code Agent */ private setLoadingState(loading: boolean): void { if (!this.loginButton) return; const btnText = this.loginButton.querySelector('.btn-text'); const spinner = this.loginButton.querySelector('.loading-spinner'); if (loading) { this.loginButton.disabled = true; btnText && (btnText.textContent = 'Signing In...'); spinner?.classList.remove('hidden'); } else { this.loginButton.disabled = false; btnText && (btnText.textContent = 'Sign In'); spinner?.classList.add('hidden'); } } /** * Validate form inputs * * @description Performs real-time form validation * * @since 2025-08-04 * @author Claude Code Agent */ private validateForm(): void { if (!this.usernameInput || !this.passwordInput || !this.loginButton) return; const username = this.usernameInput.value.trim(); const password = this.passwordInput.value; const isValid = username.length > 0 && password.length > 0; this.loginButton.disabled = !isValid; } /** * Announce message to screen readers * * @description Creates temporary element for screen reader announcements * * @param {string} message - Message to announce * * @since 2025-08-04 * @author Claude Code Agent * @accessibility Provides screen reader feedback */ private announceToScreenReader(message: string): void { const announcement = document.createElement('div'); announcement.setAttribute('aria-live', 'polite'); announcement.setAttribute('aria-atomic', 'true'); announcement.className = 'sr-only'; announcement.textContent = message; document.body.appendChild(announcement); setTimeout(() => { document.body.removeChild(announcement); }, 1000); } /** * Clean up event listeners and resources * * @description Removes event listeners when component is unmounted * * @since 2025-08-04 * @author Claude Code Agent */ protected onUnmount(): void { document.removeEventListener('keydown', this.handleKeyDown.bind(this)); this.unsubscribeAuth?.(); } /** * Static factory method for showing login modal * * @description Convenience method for creating and showing login modal * * @param {LoginModalOptions} options - Modal configuration options * @returns {LoginModal} Modal instance * * @example * ```typescript * LoginModal.show({ * onSuccess: (user) => console.log('Logged in:', user), * showRegisterLink: true * }); * ``` * * @since 2025-08-04 * @author Claude Code Agent */ static show(options: LoginModalOptions = {}): LoginModal { const modal = new LoginModal(options); modal.show(); return modal; } }