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