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.

437 lines (427 loc) 19.9 kB
import { AuthService } from '../services/AuthService'; import { Component } from './Component'; export class LoginModal extends Component { constructor(options = {}) { 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.isVisible = false; this.previousFocus = null; this.unsubscribeAuth = null; this.usernameInput = null; this.passwordInput = null; this.loginButton = null; this.cancelButton = null; this.errorContainer = null; this.loadingSpinner = null; this.authService = AuthService.getInstance(); this.options = options; } show() { if (this.isVisible) return; this.previousFocus = document.activeElement; this.render(); this.bindEvents(); this.mount(); this.element.classList.remove('hidden'); this.element.offsetHeight; 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'); } setTimeout(() => { this.usernameInput?.focus(); }, 150); this.isVisible = true; this.announceToScreenReader('Login dialog opened'); } hide() { 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'); } setTimeout(() => { this.element.classList.add('hidden'); this.unmount(); if (this.previousFocus) { this.previousFocus.focus(); } }, 300); this.isVisible = false; this.announceToScreenReader('Login dialog closed'); } render() { 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> `; this.cacheElements(); } cacheElements() { this.usernameInput = this.element.querySelector('.username-input'); this.passwordInput = this.element.querySelector('.password-input'); this.loginButton = this.element.querySelector('.login-btn'); this.cancelButton = this.element.querySelector('.cancel-btn'); this.errorContainer = this.element.querySelector('.error-container'); this.loadingSpinner = this.element.querySelector('.loading-spinner'); } bindEvents() { const form = this.element.querySelector('.login-form'); form?.addEventListener('submit', this.handleSubmit.bind(this)); this.loginButton?.addEventListener('click', this.handleSubmit.bind(this)); this.cancelButton?.addEventListener('click', this.handleCancel.bind(this)); const passwordToggle = this.element.querySelector('.password-toggle'); passwordToggle?.addEventListener('click', this.togglePasswordVisibility.bind(this)); const guestButton = this.element.querySelector('.guest-btn'); guestButton?.addEventListener('click', this.handleGuestMode.bind(this)); const registerLink = this.element.querySelector('.register-link'); registerLink?.addEventListener('click', this.handleRegisterLink.bind(this)); const forgotLink = this.element.querySelector('.forgot-password-link'); forgotLink?.addEventListener('click', this.handleForgotPassword.bind(this)); document.addEventListener('keydown', this.handleKeyDown.bind(this)); const overlay = this.element.querySelector('.login-overlay'); overlay?.addEventListener('click', e => { if (e.target === overlay) { this.handleCancel(); } }); this.unsubscribeAuth = this.authService.subscribe(this.handleAuthStateChange.bind(this)); this.usernameInput?.addEventListener('input', this.validateForm.bind(this)); this.passwordInput?.addEventListener('input', this.validateForm.bind(this)); } async handleSubmit(event) { 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; 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; } 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'}!`); if (this.options.onSuccess) { this.options.onSuccess(authState.user); } this.hide(); } } catch (error) { console.error('Login error:', error); this.showError('An unexpected error occurred. Please try again.'); } finally { this.setLoadingState(false); } } handleCancel() { if (this.options.onCancel) { this.options.onCancel(); } this.hide(); } handleKeyDown(event) { if (!this.isVisible) return; switch (event.key) { case 'Escape': event.preventDefault(); this.handleCancel(); break; case 'Enter': if (event.target !== this.loginButton) { event.preventDefault(); this.handleSubmit(event); } break; case 'Tab': this.trapFocus(event); break; } } trapFocus(event) { const focusableElements = this.element.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); 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(); } } } togglePasswordVisibility() { if (!this.passwordInput) return; const isPassword = this.passwordInput.type === 'password'; const toggle = this.element.querySelector('.password-toggle'); 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'); } } handleGuestMode() { this.announceToScreenReader('Continuing as guest user'); if (this.options.onSuccess) { this.options.onSuccess(null); } this.hide(); } handleRegisterLink() { console.log('Registration requested'); this.announceToScreenReader('Registration feature coming soon'); } handleForgotPassword() { console.log('Password reset requested'); this.announceToScreenReader('Password reset feature coming soon'); } handleAuthStateChange(authState) { if (authState.error) { this.showError(authState.error); this.setLoadingState(false); } if (authState.isLoading) { this.setLoadingState(true); } else { this.setLoadingState(false); } } showError(message) { 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}`); } hideError() { this.errorContainer?.classList.add('hidden'); } setLoadingState(loading) { 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'); } } validateForm() { 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; } announceToScreenReader(message) { 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); } onUnmount() { document.removeEventListener('keydown', this.handleKeyDown.bind(this)); this.unsubscribeAuth?.(); } static show(options = {}) { const modal = new LoginModal(options); modal.show(); return modal; } } //# sourceMappingURL=LoginModal.js.map