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
JavaScript
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