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