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.

661 lines (588 loc) • 21.5 kB
/** * @fileoverview Authentication Manager - UI Integration Component * @version 1.0.0 * @since 2025-08-04 * @lastUpdated 2025-08-04 * @module AuthManager * @description Component managing authentication UI integration, login modal display, and user status * @contributors Claude Code Agent * @dependencies Component base class, LoginModal, AppStore, AuthService * @requirements REQ-AUTH-014 (Authentication UI integration) * @testCoverage Authentication flows, modal management, state synchronization */ import { AuthService } from '../services/AuthService'; import { AppStore } from '../store/AppStore'; import { User } from '../types/index'; import { Component } from './Component'; import { LoginModal, LoginModalOptions } from './LoginModal'; /** * Authentication Manager Component * * @description Manages authentication UI integration including login modal, * user status display, and authentication state synchronization. * Provides a centralized way to handle authentication throughout the app. * * @example * ```typescript * const authManager = new AuthManager(); * authManager.mount(); * * // Show login when needed * authManager.requireAuthentication(); * * // Check auth status * if (authManager.isAuthenticated()) { * // User is logged in * } * ``` * * @since 2025-08-04 * @author Claude Code Agent * @requirements REQ-AUTH-014 (Authentication UI manager) * @accessibility Manages focus and screen reader announcements for auth state changes */ export class AuthManager extends Component { private static instance: AuthManager; private appStore: AppStore; private authService: AuthService; private loginModal: LoginModal | null = null; private unsubscribeStore: (() => void) | null = null; // UI Elements private userStatusElement: HTMLElement | null = null; private loginButton: HTMLElement | null = null; private logoutButton: HTMLElement | null = null; private userMenuElement: HTMLElement | null = null; private guestIndicator: HTMLElement | null = null; constructor() { // Create auth manager container if it doesn't exist 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.appStore = AppStore.getInstance(); this.authService = AuthService.getInstance(); } /** * Get singleton instance * * @description Returns singleton instance following established pattern * * @returns {AuthManager} Singleton instance * * @since 2025-08-04 * @author Claude Code Agent */ static getInstance(): AuthManager { if (!AuthManager.instance) { AuthManager.instance = new AuthManager(); } return AuthManager.instance; } /** * Initialize authentication manager * * @description Sets up authentication UI and state synchronization * * @since 2025-08-04 * @author Claude Code Agent */ protected onMount(): void { this.setupStoreSubscription(); this.createAuthUI(); this.updateAuthUI(); } /** * Render authentication UI elements * * @description Creates the authentication UI components in the header * * @since 2025-08-04 * @author Claude Code Agent */ protected render(): void { // The auth UI will be injected into existing header elements // This method is required by the Component base class } /** * Bind authentication event listeners * * @description Sets up event listeners for authentication actions * * @since 2025-08-04 * @author Claude Code Agent */ protected bindEvents(): void { // Login button click this.loginButton?.addEventListener('click', () => this.showLoginModal()); // Logout button click this.logoutButton?.addEventListener('click', this.handleLogout.bind(this)); // User menu toggle this.userMenuElement?.addEventListener('click', this.toggleUserMenu.bind(this)); } /** * Create authentication UI elements * * @description Creates and injects auth UI into the header * * @since 2025-08-04 * @author Claude Code Agent */ private createAuthUI(): void { // Find header container (typically in the main navigation) const header = document.querySelector('header') || document.querySelector('.header') || document.querySelector('nav'); if (!header) { console.warn('No header element found for authentication UI'); return; } // Create auth container if it doesn't exist let authContainer = header.querySelector('.auth-container'); if (!authContainer) { authContainer = document.createElement('div'); authContainer.className = 'auth-container flex items-center space-x-3'; // Insert at the end of header header.appendChild(authContainer); } // Create auth UI elements 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> `; // Cache UI elements this.cacheUIElements(authContainer); } /** * Cache UI element references * * @description Stores references to frequently accessed UI elements * * @param {Element} container - Auth container element * * @since 2025-08-04 * @author Claude Code Agent */ private cacheUIElements(container: Element): void { this.loginButton = container.querySelector('.login-btn') as HTMLElement; this.logoutButton = container.querySelector('.logout-btn') as HTMLElement; this.userMenuElement = container.querySelector('.user-menu-toggle') as HTMLElement; this.guestIndicator = container.querySelector('.guest-indicator') as HTMLElement; } /** * Setup app store subscription * * @description Subscribes to app store state changes for auth updates * * @since 2025-08-04 * @author Claude Code Agent */ private setupStoreSubscription(): void { this.unsubscribeStore = this.appStore.subscribe(state => { this.updateAuthUI(); // Handle login modal visibility if (state.auth.showLoginModal && !this.loginModal) { this.showLoginModal(); } else if (!state.auth.showLoginModal && this.loginModal) { this.hideLoginModal(); } }); } /** * Update authentication UI based on current state * * @description Updates UI elements to reflect current authentication state * * @since 2025-08-04 * @author Claude Code Agent */ private updateAuthUI(): void { const state = this.appStore.getState(); const { isAuthenticated, user } = state.auth; if (isAuthenticated && user) { this.showAuthenticatedUI(user); } else { this.showUnauthenticatedUI(); } } /** * Show authenticated user UI * * @description Displays user menu and hides login button * * @param {User} user - Current authenticated user * * @since 2025-08-04 * @author Claude Code Agent */ private showAuthenticatedUI(user: User): void { // Hide login button and guest indicator this.loginButton?.classList.add('hidden'); this.guestIndicator?.classList.add('hidden'); // Show user menu const userMenu = document.querySelector('.user-menu') as HTMLElement; userMenu?.classList.remove('hidden'); // Update user information 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 || ''; // Announce to screen readers this.announceToScreenReader(`Signed in as ${user.username}`); } /** * Show unauthenticated UI * * @description Displays login button and hides user menu * * @since 2025-08-04 * @author Claude Code Author */ private showUnauthenticatedUI(): void { // Show login button this.loginButton?.classList.remove('hidden'); // Hide user menu const userMenu = document.querySelector('.user-menu') as HTMLElement; userMenu?.classList.add('hidden'); // Hide guest indicator by default this.guestIndicator?.classList.add('hidden'); } /** * Show guest mode UI * * @description Displays guest mode indicator * * @since 2025-08-04 * @author Claude Code Agent */ private showGuestUI(): void { this.loginButton?.classList.add('hidden'); this.guestIndicator?.classList.remove('hidden'); const userMenu = document.querySelector('.user-menu') as HTMLElement; userMenu?.classList.add('hidden'); this.announceToScreenReader('Continuing in guest mode'); } /** * Get user initials for avatar * * @description Generates user initials from username * * @param {string} username - User's username * @returns {string} User initials (max 2 characters) * * @since 2025-08-04 * @author Claude Code Agent */ private getUserInitials(username: string): string { 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(); } /** * Show login modal * * @description Creates and displays the login modal * * @param {LoginModalOptions} options - Modal options * * @since 2025-08-04 * @author Claude Code Agent */ public showLoginModal(options: LoginModalOptions = {}): void { if (this.loginModal) return; const modalOptions: LoginModalOptions = { 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(); } /** * Hide login modal * * @description Hides and cleans up the login modal * * @since 2025-08-04 * @author Claude Code Agent */ public hideLoginModal(): void { if (this.loginModal) { this.loginModal.hide(); this.loginModal = null; } this.appStore.hideLoginModal(); } /** * Handle logout action * * @description Logs out user and updates UI * * @since 2025-08-04 * @author Claude Code Agent */ private async handleLogout(): Promise<void> { try { await this.appStore.logout(); this.announceToScreenReader('You have been signed out'); // Close user menu this.closeUserMenu(); } catch (error) { console.error('Logout failed:', error); this.announceToScreenReader('Logout failed. Please try again.'); } } /** * Toggle user menu dropdown * * @description Shows/hides the user dropdown menu * * @since 2025-08-04 * @author Claude Code Agent */ private toggleUserMenu(): void { const dropdown = document.querySelector('.user-dropdown') as HTMLElement; const toggle = this.userMenuElement; if (!dropdown || !toggle) return; const isOpen = !dropdown.classList.contains('hidden'); if (isOpen) { this.closeUserMenu(); } else { this.openUserMenu(); } } /** * Open user menu dropdown * * @description Opens the user dropdown menu with accessibility * * @since 2025-08-04 * @author Claude Code Agent * @accessibility Updates aria-expanded and manages focus */ private openUserMenu(): void { const dropdown = document.querySelector('.user-dropdown') as HTMLElement; const toggle = this.userMenuElement; if (!dropdown || !toggle) return; dropdown.classList.remove('hidden'); toggle.setAttribute('aria-expanded', 'true'); // Focus first menu item const firstMenuItem = dropdown.querySelector('[role="menuitem"]') as HTMLElement; firstMenuItem?.focus(); // Close on outside click setTimeout(() => { document.addEventListener('click', this.handleOutsideClick.bind(this)); }, 0); } /** * Close user menu dropdown * * @description Closes the user dropdown menu * * @since 2025-08-04 * @author Claude Code Agent * @accessibility Updates aria-expanded and manages focus */ private closeUserMenu(): void { const dropdown = document.querySelector('.user-dropdown') as HTMLElement; const toggle = this.userMenuElement; if (!dropdown || !toggle) return; dropdown.classList.add('hidden'); toggle.setAttribute('aria-expanded', 'false'); document.removeEventListener('click', this.handleOutsideClick.bind(this)); } /** * Handle clicks outside user menu * * @description Closes user menu when clicking outside * * @param {Event} event - Click event * * @since 2025-08-04 * @author Claude Code Agent */ private handleOutsideClick(event: Event): void { const userMenu = document.querySelector('.user-menu'); if (!userMenu?.contains(event.target as Node)) { this.closeUserMenu(); } } /** * Require authentication * * @description Forces user to authenticate or shows login modal * * @returns {Promise<boolean>} Whether authentication was successful * * @since 2025-08-04 * @author Claude Code Agent */ public async requireAuthentication(): Promise<boolean> { if (this.appStore.isAuthenticated()) { return true; } return new Promise(resolve => { this.showLoginModal({ onSuccess: user => { resolve(!!user); }, onCancel: () => { resolve(false); }, }); }); } /** * Check if user is authenticated * * @description Returns current authentication status * * @returns {boolean} Authentication status * * @since 2025-08-04 * @author Claude Code Agent */ public isAuthenticated(): boolean { return this.appStore.isAuthenticated(); } /** * Get current user * * @description Returns current authenticated user * * @returns {User | null} Current user * * @since 2025-08-04 * @author Claude Code Agent */ public getCurrentUser(): User | null { return this.appStore.getCurrentUser(); } /** * 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 resources * * @description Removes event listeners and subscriptions * * @since 2025-08-04 * @author Claude Code Agent */ protected onUnmount(): void { this.unsubscribeStore?.(); document.removeEventListener('click', this.handleOutsideClick.bind(this)); if (this.loginModal) { this.loginModal.hide(); this.loginModal = null; } } }