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
text/typescript
/**
* @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;
}
}