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.

328 lines (321 loc) • 14.1 kB
import { AuthService } from '../services/AuthService'; import { AppStore } from '../store/AppStore'; import { Component } from './Component'; import { LoginModal } from './LoginModal'; export class AuthManager extends Component { constructor() { if (!document.getElementById('auth-manager')) { const container = document.createElement('div'); container.id = 'auth-manager'; container.className = 'auth-manager'; document.body.appendChild(container); } super('#auth-manager'); this.loginModal = null; this.unsubscribeStore = null; this.userStatusElement = null; this.loginButton = null; this.logoutButton = null; this.userMenuElement = null; this.guestIndicator = null; this.appStore = AppStore.getInstance(); this.authService = AuthService.getInstance(); } static getInstance() { if (!AuthManager.instance) { AuthManager.instance = new AuthManager(); } return AuthManager.instance; } onMount() { this.setupStoreSubscription(); this.createAuthUI(); this.updateAuthUI(); } render() { } bindEvents() { this.loginButton?.addEventListener('click', () => this.showLoginModal()); this.logoutButton?.addEventListener('click', this.handleLogout.bind(this)); this.userMenuElement?.addEventListener('click', this.toggleUserMenu.bind(this)); } createAuthUI() { const header = document.querySelector('header') || document.querySelector('.header') || document.querySelector('nav'); if (!header) { console.warn('No header element found for authentication UI'); return; } let authContainer = header.querySelector('.auth-container'); if (!authContainer) { authContainer = document.createElement('div'); authContainer.className = 'auth-container flex items-center space-x-3'; header.appendChild(authContainer); } authContainer.innerHTML = ` <!-- Guest State --> <div class="guest-indicator hidden"> <span class="text-sm text-gray-600 dark:text-gray-400">Guest Mode</span> </div> <!-- Login Button (when not authenticated) --> <button class="login-btn hidden bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" aria-label="Sign in to your account"> <span class="flex items-center"> <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path> </svg> Sign In </span> </button> <!-- User Menu (when authenticated) --> <div class="user-menu hidden relative"> <button class="user-menu-toggle flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-2" aria-expanded="false" aria-haspopup="true"> <div class="user-avatar w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-medium text-sm"> <span class="user-initials">U</span> </div> <span class="user-name hidden sm:block font-medium">User</span> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> </svg> </button> <!-- Dropdown Menu --> <div class="user-dropdown hidden absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50" role="menu" aria-orientation="vertical"> <div class="py-2"> <!-- User Info --> <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> <p class="text-sm font-medium text-gray-900 dark:text-white user-display-name">User Name</p> <p class="text-sm text-gray-500 dark:text-gray-400 user-email">user@example.com</p> </div> <!-- Menu Items --> <div class="py-1"> <button class="profile-btn w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center" role="menuitem"> <svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path> </svg> Profile </button> <button class="settings-btn w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center" role="menuitem"> <svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> </svg> Settings </button> <div class="border-t border-gray-200 dark:border-gray-700 my-1"></div> <button class="logout-btn w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center" role="menuitem"> <svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path> </svg> Sign Out </button> </div> </div> </div> </div> `; this.cacheUIElements(authContainer); } cacheUIElements(container) { this.loginButton = container.querySelector('.login-btn'); this.logoutButton = container.querySelector('.logout-btn'); this.userMenuElement = container.querySelector('.user-menu-toggle'); this.guestIndicator = container.querySelector('.guest-indicator'); } setupStoreSubscription() { this.unsubscribeStore = this.appStore.subscribe(state => { this.updateAuthUI(); if (state.auth.showLoginModal && !this.loginModal) { this.showLoginModal(); } else if (!state.auth.showLoginModal && this.loginModal) { this.hideLoginModal(); } }); } updateAuthUI() { const state = this.appStore.getState(); const { isAuthenticated, user } = state.auth; if (isAuthenticated && user) { this.showAuthenticatedUI(user); } else { this.showUnauthenticatedUI(); } } showAuthenticatedUI(user) { this.loginButton?.classList.add('hidden'); this.guestIndicator?.classList.add('hidden'); const userMenu = document.querySelector('.user-menu'); userMenu?.classList.remove('hidden'); const userInitials = this.getUserInitials(user.username); const userInitialsElement = document.querySelector('.user-initials'); const userNameElement = document.querySelector('.user-name'); const userDisplayNameElement = document.querySelector('.user-display-name'); const userEmailElement = document.querySelector('.user-email'); if (userInitialsElement) userInitialsElement.textContent = userInitials; if (userNameElement) userNameElement.textContent = user.username; if (userDisplayNameElement) userDisplayNameElement.textContent = user.username; if (userEmailElement) userEmailElement.textContent = user.email || ''; this.announceToScreenReader(`Signed in as ${user.username}`); } showUnauthenticatedUI() { this.loginButton?.classList.remove('hidden'); const userMenu = document.querySelector('.user-menu'); userMenu?.classList.add('hidden'); this.guestIndicator?.classList.add('hidden'); } showGuestUI() { this.loginButton?.classList.add('hidden'); this.guestIndicator?.classList.remove('hidden'); const userMenu = document.querySelector('.user-menu'); userMenu?.classList.add('hidden'); this.announceToScreenReader('Continuing in guest mode'); } getUserInitials(username) { if (!username) return 'U'; const parts = username.split(/[\s@_.-]+/); if (parts.length >= 2) { return (parts[0][0] + parts[1][0]).toUpperCase(); } return username.substring(0, 2).toUpperCase(); } showLoginModal(options = {}) { if (this.loginModal) return; const modalOptions = { showRegisterLink: true, allowGuestMode: true, onSuccess: user => { if (user) { this.announceToScreenReader(`Welcome back, ${user.username}!`); } else { this.showGuestUI(); } this.hideLoginModal(); options.onSuccess?.(user); }, onCancel: () => { this.hideLoginModal(); options.onCancel?.(); }, ...options, }; this.loginModal = new LoginModal(modalOptions); this.loginModal.show(); this.appStore.showLoginModal(); } hideLoginModal() { if (this.loginModal) { this.loginModal.hide(); this.loginModal = null; } this.appStore.hideLoginModal(); } async handleLogout() { try { await this.appStore.logout(); this.announceToScreenReader('You have been signed out'); this.closeUserMenu(); } catch (error) { console.error('Logout failed:', error); this.announceToScreenReader('Logout failed. Please try again.'); } } toggleUserMenu() { const dropdown = document.querySelector('.user-dropdown'); const toggle = this.userMenuElement; if (!dropdown || !toggle) return; const isOpen = !dropdown.classList.contains('hidden'); if (isOpen) { this.closeUserMenu(); } else { this.openUserMenu(); } } openUserMenu() { const dropdown = document.querySelector('.user-dropdown'); const toggle = this.userMenuElement; if (!dropdown || !toggle) return; dropdown.classList.remove('hidden'); toggle.setAttribute('aria-expanded', 'true'); const firstMenuItem = dropdown.querySelector('[role="menuitem"]'); firstMenuItem?.focus(); setTimeout(() => { document.addEventListener('click', this.handleOutsideClick.bind(this)); }, 0); } closeUserMenu() { const dropdown = document.querySelector('.user-dropdown'); const toggle = this.userMenuElement; if (!dropdown || !toggle) return; dropdown.classList.add('hidden'); toggle.setAttribute('aria-expanded', 'false'); document.removeEventListener('click', this.handleOutsideClick.bind(this)); } handleOutsideClick(event) { const userMenu = document.querySelector('.user-menu'); if (!userMenu?.contains(event.target)) { this.closeUserMenu(); } } async requireAuthentication() { if (this.appStore.isAuthenticated()) { return true; } return new Promise(resolve => { this.showLoginModal({ onSuccess: user => { resolve(!!user); }, onCancel: () => { resolve(false); }, }); }); } isAuthenticated() { return this.appStore.isAuthenticated(); } getCurrentUser() { return this.appStore.getCurrentUser(); } 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() { this.unsubscribeStore?.(); document.removeEventListener('click', this.handleOutsideClick.bind(this)); if (this.loginModal) { this.loginModal.hide(); this.loginModal = null; } } } //# sourceMappingURL=AuthManager.js.map