This commit is contained in:
Slipstream 2025-05-03 17:50:54 -06:00
parent 156125e1db
commit 5246a6151d
Signed by: slipstream
GPG Key ID: 13E498CE010AC6FD
3 changed files with 334 additions and 173 deletions

View File

@ -737,6 +737,51 @@ async def dashboard_logout(request: Request):
log.info(f"Dashboard: User {user_id} logged out.")
return
@dashboard_api_app.get("/auth/status", tags=["Dashboard Authentication"])
async def dashboard_auth_status(request: Request):
"""Checks if the user is authenticated in the dashboard session."""
user_id = request.session.get('user_id')
username = request.session.get('username')
access_token = request.session.get('access_token')
if not user_id or not username or not access_token:
log.debug("Dashboard: Auth status check - user not authenticated")
return {"authenticated": False, "message": "User is not authenticated"}
# Verify the token is still valid with Discord
try:
if not http_session:
log.error("Dashboard: aiohttp session not initialized.")
return {"authenticated": False, "message": "Internal server error: HTTP session not ready"}
user_headers = {'Authorization': f'Bearer {access_token}'}
async with http_session.get(DISCORD_USER_URL, headers=user_headers) as resp:
if resp.status != 200:
log.warning(f"Dashboard: Auth status check - invalid token for user {user_id}")
# Clear the invalid session
request.session.clear()
return {"authenticated": False, "message": "Discord token invalid or expired"}
# Token is valid, get the latest user data
user_data = await resp.json()
# Update session with latest data
request.session['username'] = user_data.get('username')
request.session['avatar'] = user_data.get('avatar')
log.debug(f"Dashboard: Auth status check - user {user_id} is authenticated")
return {
"authenticated": True,
"user": {
"id": user_id,
"username": user_data.get('username'),
"avatar": user_data.get('avatar')
}
}
except Exception as e:
log.exception(f"Dashboard: Error checking auth status: {e}")
return {"authenticated": False, "message": f"Error checking auth status: {str(e)}"}
# --- Dashboard User Endpoints ---
@dashboard_api_app.get("/user/me", tags=["Dashboard User"])
async def dashboard_get_user_me(current_user: dict = Depends(get_dashboard_user)):
@ -745,7 +790,28 @@ async def dashboard_get_user_me(current_user: dict = Depends(get_dashboard_user)
# del user_info['access_token'] # Optional: Don't expose token to frontend
return user_info
@dashboard_api_app.get("/auth/user", tags=["Dashboard Authentication"])
async def dashboard_get_auth_user(request: Request):
"""Returns information about the currently logged-in dashboard user for the frontend."""
user_id = request.session.get('user_id')
username = request.session.get('username')
avatar = request.session.get('avatar')
if not user_id or not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return {
"id": user_id,
"username": username,
"avatar": avatar
}
@dashboard_api_app.get("/user/guilds", tags=["Dashboard User"])
@dashboard_api_app.get("/guilds", tags=["Dashboard Guild Settings"])
async def dashboard_get_user_guilds(current_user: dict = Depends(get_dashboard_user)):
"""Returns a list of guilds the user is an administrator in AND the bot is also in."""
global http_session # Use the global aiohttp session

View File

@ -6,16 +6,16 @@
document.addEventListener('DOMContentLoaded', () => {
// Initialize modals
Modal.init();
// Initialize sidebar toggle
initSidebar();
// Initialize authentication
initAuth();
// Initialize tabs
initTabs();
// Initialize dropdowns
initDropdowns();
});
@ -26,23 +26,23 @@ document.addEventListener('DOMContentLoaded', () => {
function initSidebar() {
const sidebarToggle = document.getElementById('sidebar-toggle');
const sidebar = document.getElementById('sidebar');
if (sidebarToggle && sidebar) {
sidebarToggle.addEventListener('click', () => {
sidebar.classList.toggle('show');
});
// Close sidebar when clicking outside on mobile
document.addEventListener('click', (event) => {
if (window.innerWidth <= 768 &&
sidebar.classList.contains('show') &&
!sidebar.contains(event.target) &&
if (window.innerWidth <= 768 &&
sidebar.classList.contains('show') &&
!sidebar.contains(event.target) &&
event.target !== sidebarToggle) {
sidebar.classList.remove('show');
}
});
}
// Set active nav item based on current page
const currentPath = window.location.pathname;
document.querySelectorAll('.nav-item').forEach(item => {
@ -61,54 +61,100 @@ function initAuth() {
const logoutButton = document.getElementById('logout-button');
const authSection = document.getElementById('auth-section');
const dashboardSection = document.getElementById('dashboard-container');
// Check authentication status
checkAuthStatus();
// Login button event
if (loginButton) {
loginButton.addEventListener('click', () => {
// Show loading state
loginButton.disabled = true;
loginButton.classList.add('btn-loading');
// Redirect to login page
window.location.href = '/dashboard/api/auth/login';
});
}
// Logout button event
if (logoutButton) {
logoutButton.addEventListener('click', () => {
// Show loading state
logoutButton.disabled = true;
logoutButton.classList.add('btn-loading');
// Clear session
fetch('/dashboard/api/auth/logout', { method: 'POST' })
fetch('/dashboard/api/auth/logout', {
method: 'POST',
credentials: 'same-origin' // Important for cookies
})
.then(() => {
// Redirect to login page
window.location.reload();
Toast.success('Logged out successfully');
setTimeout(() => {
window.location.reload();
}, 1000);
})
.catch(error => {
console.error('Logout error:', error);
Toast.error('Failed to logout. Please try again.');
logoutButton.disabled = false;
logoutButton.classList.remove('btn-loading');
});
});
}
/**
* Check if user is authenticated
*/
function checkAuthStatus() {
fetch('/dashboard/api/auth/status')
.then(response => response.json())
// Show loading indicator
const loadingContainer = document.createElement('div');
loadingContainer.className = 'loading-container';
loadingContainer.innerHTML = '<div class="loading-spinner"></div>';
loadingContainer.style.position = 'fixed';
loadingContainer.style.top = '50%';
loadingContainer.style.left = '50%';
loadingContainer.style.transform = 'translate(-50%, -50%)';
document.body.appendChild(loadingContainer);
fetch('/dashboard/api/auth/status', {
credentials: 'same-origin' // Important for cookies
})
.then(response => {
if (!response.ok) {
throw new Error(`Status check failed: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('Auth status:', data);
if (data.authenticated) {
// User is authenticated, show dashboard
if (authSection) authSection.style.display = 'none';
if (dashboardSection) dashboardSection.style.display = 'block';
// Load user info
loadUserInfo();
// If user data is included in the response, use it
if (data.user) {
updateUserDisplay(data.user);
} else {
// Otherwise load user info separately
loadUserInfo();
}
// Load initial data
loadDashboardData();
} else {
// User is not authenticated, show login
if (authSection) authSection.style.display = 'block';
if (dashboardSection) dashboardSection.style.display = 'none';
// Show message if provided
if (data.message) {
console.log('Auth message:', data.message);
}
}
})
.catch(error => {
@ -116,38 +162,69 @@ function initAuth() {
// Assume not authenticated on error
if (authSection) authSection.style.display = 'block';
if (dashboardSection) dashboardSection.style.display = 'none';
// Show error toast
Toast.error('Failed to check authentication status. Please try again.');
})
.finally(() => {
// Remove loading indicator
document.body.removeChild(loadingContainer);
});
}
/**
* Load user information
*/
function loadUserInfo() {
fetch('/dashboard/api/auth/user')
.then(response => response.json())
.then(user => {
// Update username display
const usernameSpan = document.getElementById('username');
if (usernameSpan) {
usernameSpan.textContent = user.username;
}
// Update avatar if available
const userAvatar = document.getElementById('user-avatar');
if (userAvatar) {
if (user.avatar) {
userAvatar.style.backgroundImage = `url(https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png)`;
userAvatar.textContent = '';
} else {
// Set initials as fallback
userAvatar.textContent = user.username.substring(0, 1).toUpperCase();
fetch('/dashboard/api/auth/user', {
credentials: 'same-origin' // Important for cookies
})
.then(response => {
if (!response.ok) {
if (response.status === 401) {
// User is not authenticated, show login
if (authSection) authSection.style.display = 'block';
if (dashboardSection) dashboardSection.style.display = 'none';
throw new Error('Not authenticated');
}
throw new Error(`Failed to load user info: ${response.status}`);
}
return response.json();
})
.then(user => {
updateUserDisplay(user);
})
.catch(error => {
console.error('Error loading user info:', error);
if (error.message !== 'Not authenticated') {
Toast.error('Failed to load user information');
}
});
}
/**
* Update user display with user data
* @param {Object} user - User data object
*/
function updateUserDisplay(user) {
// Update username display
const usernameSpan = document.getElementById('username');
if (usernameSpan) {
usernameSpan.textContent = user.username;
}
// Update avatar if available
const userAvatar = document.getElementById('user-avatar');
if (userAvatar) {
if (user.avatar) {
userAvatar.style.backgroundImage = `url(https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png)`;
userAvatar.textContent = '';
} else {
// Set initials as fallback
userAvatar.textContent = user.username.substring(0, 1).toUpperCase();
}
}
}
}
/**
@ -156,25 +233,25 @@ function initAuth() {
function initTabs() {
document.querySelectorAll('.tabs').forEach(tabContainer => {
const tabs = tabContainer.querySelectorAll('.tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
// Get target content ID
const target = tab.getAttribute('data-target');
if (!target) return;
// Remove active class from all tabs
tabs.forEach(t => t.classList.remove('active'));
// Add active class to clicked tab
tab.classList.add('active');
// Hide all tab content
const tabContents = document.querySelectorAll('.tab-content');
tabContents.forEach(content => {
content.classList.remove('active');
});
// Show target content
const targetContent = document.getElementById(target);
if (targetContent) {
@ -193,22 +270,22 @@ function initDropdowns() {
toggle.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const dropdown = toggle.closest('.dropdown');
const menu = dropdown.querySelector('.dropdown-menu');
// Close all other dropdowns
document.querySelectorAll('.dropdown-menu.show').forEach(openMenu => {
if (openMenu !== menu) {
openMenu.classList.remove('show');
}
});
// Toggle this dropdown
menu.classList.toggle('show');
});
});
// Close dropdowns when clicking outside
document.addEventListener('click', (event) => {
if (!event.target.closest('.dropdown')) {
@ -225,7 +302,7 @@ function initDropdowns() {
function loadDashboardData() {
// Load guilds for server select
loadGuilds();
// Load global settings
loadGlobalSettings();
}
@ -236,17 +313,17 @@ function loadDashboardData() {
function loadGuilds() {
const guildSelect = document.getElementById('guild-select');
if (!guildSelect) return;
// Show loading state
guildSelect.disabled = true;
guildSelect.innerHTML = '<option value="">Loading servers...</option>';
// Fetch guilds from API
API.get('/dashboard/api/guilds')
.then(guilds => {
// Clear loading state
guildSelect.innerHTML = '<option value="">--Please choose a server--</option>';
// Add guilds to select
guilds.forEach(guild => {
const option = document.createElement('option');
@ -254,10 +331,10 @@ function loadGuilds() {
option.textContent = guild.name;
guildSelect.appendChild(option);
});
// Enable select
guildSelect.disabled = false;
// Add change event
guildSelect.addEventListener('change', () => {
const guildId = guildSelect.value;
@ -287,29 +364,29 @@ function loadGuilds() {
function loadGuildSettings(guildId) {
const settingsForm = document.getElementById('settings-form');
if (!settingsForm) return;
// Show loading state
const loadingContainer = document.createElement('div');
loadingContainer.className = 'loading-container';
loadingContainer.innerHTML = '<div class="loading-spinner"></div><p>Loading server settings...</p>';
loadingContainer.style.textAlign = 'center';
loadingContainer.style.padding = '2rem';
settingsForm.style.display = 'none';
settingsForm.parentNode.insertBefore(loadingContainer, settingsForm);
// Fetch guild settings from API
API.get(`/dashboard/api/guilds/${guildId}/settings`)
.then(settings => {
// Remove loading container
loadingContainer.remove();
// Show settings form
settingsForm.style.display = 'block';
// Populate form with settings
populateGuildSettings(settings);
// Load additional data
loadGuildChannels(guildId);
loadGuildRoles(guildId);
@ -332,43 +409,43 @@ function populateGuildSettings(settings) {
if (prefixInput) {
prefixInput.value = settings.prefix || '!';
}
// Welcome settings
const welcomeChannel = document.getElementById('welcome-channel');
const welcomeMessage = document.getElementById('welcome-message');
if (welcomeChannel && welcomeMessage) {
welcomeChannel.value = settings.welcome_channel_id || '';
welcomeMessage.value = settings.welcome_message || '';
}
// Goodbye settings
const goodbyeChannel = document.getElementById('goodbye-channel');
const goodbyeMessage = document.getElementById('goodbye-message');
if (goodbyeChannel && goodbyeMessage) {
goodbyeChannel.value = settings.goodbye_channel_id || '';
goodbyeMessage.value = settings.goodbye_message || '';
}
// Cogs (modules)
const cogsList = document.getElementById('cogs-list');
if (cogsList && settings.enabled_cogs) {
cogsList.innerHTML = '';
Object.entries(settings.enabled_cogs).forEach(([cogName, enabled]) => {
const cogDiv = document.createElement('div');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = `cog-${cogName}`;
checkbox.name = `cog-${cogName}`;
checkbox.checked = enabled;
const label = document.createElement('label');
label.htmlFor = `cog-${cogName}`;
label.textContent = cogName;
cogDiv.appendChild(checkbox);
cogDiv.appendChild(label);
cogsList.appendChild(cogDiv);
@ -383,32 +460,32 @@ function populateGuildSettings(settings) {
function loadGuildChannels(guildId) {
const welcomeChannelSelect = document.getElementById('welcome-channel-select');
const goodbyeChannelSelect = document.getElementById('goodbye-channel-select');
if (!welcomeChannelSelect && !goodbyeChannelSelect) return;
// Fetch channels from API
API.get(`/dashboard/api/guilds/${guildId}/channels`)
.then(channels => {
// Filter text channels
const textChannels = channels.filter(channel => channel.type === 0);
// Populate welcome channel select
if (welcomeChannelSelect) {
welcomeChannelSelect.innerHTML = '<option value="">-- Select Channel --</option>';
textChannels.forEach(channel => {
const option = document.createElement('option');
option.value = channel.id;
option.textContent = `#${channel.name}`;
welcomeChannelSelect.appendChild(option);
});
// Set current value if available
const welcomeChannelInput = document.getElementById('welcome-channel');
if (welcomeChannelInput && welcomeChannelInput.value) {
welcomeChannelSelect.value = welcomeChannelInput.value;
}
// Add change event
welcomeChannelSelect.addEventListener('change', () => {
if (welcomeChannelInput) {
@ -416,24 +493,24 @@ function loadGuildChannels(guildId) {
}
});
}
// Populate goodbye channel select
if (goodbyeChannelSelect) {
goodbyeChannelSelect.innerHTML = '<option value="">-- Select Channel --</option>';
textChannels.forEach(channel => {
const option = document.createElement('option');
option.value = channel.id;
option.textContent = `#${channel.name}`;
goodbyeChannelSelect.appendChild(option);
});
// Set current value if available
const goodbyeChannelInput = document.getElementById('goodbye-channel');
if (goodbyeChannelInput && goodbyeChannelInput.value) {
goodbyeChannelSelect.value = goodbyeChannelInput.value;
}
// Add change event
goodbyeChannelSelect.addEventListener('change', () => {
if (goodbyeChannelInput) {
@ -455,22 +532,22 @@ function loadGuildChannels(guildId) {
function loadGuildRoles(guildId) {
const roleSelect = document.getElementById('role-select');
if (!roleSelect) return;
// Fetch roles from API
API.get(`/dashboard/api/guilds/${guildId}/roles`)
.then(roles => {
roleSelect.innerHTML = '<option value="">-- Select Role --</option>';
roles.forEach(role => {
const option = document.createElement('option');
option.value = role.id;
option.textContent = role.name;
// Set color if available
if (role.color) {
option.style.color = `#${role.color.toString(16).padStart(6, '0')}`;
}
roleSelect.appendChild(option);
});
})
@ -487,25 +564,25 @@ function loadGuildRoles(guildId) {
function loadGuildCommands(guildId) {
const commandSelect = document.getElementById('command-select');
if (!commandSelect) return;
// Fetch commands from API
API.get(`/dashboard/api/guilds/${guildId}/commands`)
.then(commands => {
commandSelect.innerHTML = '<option value="">-- Select Command --</option>';
commands.forEach(command => {
const option = document.createElement('option');
option.value = command.name;
option.textContent = command.name;
// Add description as title attribute
if (command.description) {
option.title = command.description;
}
commandSelect.appendChild(option);
});
// Load command permissions
loadCommandPermissions(guildId);
})
@ -522,48 +599,48 @@ function loadGuildCommands(guildId) {
function loadCommandPermissions(guildId) {
const currentPerms = document.getElementById('current-perms');
if (!currentPerms) return;
// Show loading state
currentPerms.innerHTML = '<div class="loading-spinner-container"><div class="loading-spinner loading-spinner-sm"></div></div>';
// Fetch command permissions from API
API.get(`/dashboard/api/guilds/${guildId}/command-permissions`)
.then(permissions => {
// Clear loading state
currentPerms.innerHTML = '';
if (permissions.length === 0) {
currentPerms.innerHTML = '<div class="text-gray">No custom permissions set.</div>';
return;
}
// Group permissions by command
const permsByCommand = {};
permissions.forEach(perm => {
if (!permsByCommand[perm.command]) {
permsByCommand[perm.command] = [];
}
permsByCommand[perm.command].push(perm);
});
// Create permission elements
Object.entries(permsByCommand).forEach(([command, perms]) => {
const commandDiv = document.createElement('div');
commandDiv.className = 'command-perms';
commandDiv.innerHTML = `<div class="command-name">${command}</div>`;
const rolesList = document.createElement('div');
rolesList.className = 'roles-list';
perms.forEach(perm => {
const roleSpan = document.createElement('span');
roleSpan.className = 'role-badge';
roleSpan.textContent = perm.role_name;
roleSpan.dataset.roleId = perm.role_id;
roleSpan.dataset.command = command;
// Add remove button
const removeBtn = document.createElement('button');
removeBtn.className = 'role-remove';
@ -571,11 +648,11 @@ function loadCommandPermissions(guildId) {
removeBtn.addEventListener('click', () => {
removeCommandPermission(guildId, command, perm.role_id);
});
roleSpan.appendChild(removeBtn);
rolesList.appendChild(roleSpan);
});
commandDiv.appendChild(rolesList);
currentPerms.appendChild(commandDiv);
});
@ -595,17 +672,17 @@ function loadCommandPermissions(guildId) {
function addCommandPermission(guildId, command, roleId) {
const addPermButton = document.getElementById('add-perm-button');
const permsFeedback = document.getElementById('perms-feedback');
if (!command || !roleId) {
permsFeedback.textContent = 'Please select both a command and a role.';
permsFeedback.className = 'error';
return;
}
// Show loading state
addPermButton.disabled = true;
addPermButton.classList.add('btn-loading');
// Send request to API
API.post(`/dashboard/api/guilds/${guildId}/command-permissions`, {
command,
@ -615,10 +692,10 @@ function addCommandPermission(guildId, command, roleId) {
// Show success message
permsFeedback.textContent = 'Permission added successfully.';
permsFeedback.className = '';
// Reload permissions
loadCommandPermissions(guildId);
// Reset form
document.getElementById('command-select').value = '';
document.getElementById('role-select').value = '';
@ -647,7 +724,7 @@ function removeCommandPermission(guildId, command, roleId) {
.then(() => {
// Show success message
Toast.success('Permission removed successfully.');
// Reload permissions
loadCommandPermissions(guildId);
})
@ -663,7 +740,7 @@ function removeCommandPermission(guildId, command, roleId) {
function loadGlobalSettings() {
const aiSettingsSection = document.getElementById('ai-settings-section');
if (!aiSettingsSection) return;
// Fetch global settings from API
API.get('/dashboard/api/settings')
.then(settings => {
@ -672,44 +749,44 @@ function loadGlobalSettings() {
if (modelSelect && settings.model) {
modelSelect.value = settings.model;
}
// Populate temperature slider
const temperatureSlider = document.getElementById('ai-temperature');
const temperatureValue = document.getElementById('temperature-value');
if (temperatureSlider && temperatureValue && settings.temperature) {
temperatureSlider.value = settings.temperature;
temperatureValue.textContent = settings.temperature;
// Add input event for live update
temperatureSlider.addEventListener('input', () => {
temperatureValue.textContent = temperatureSlider.value;
});
}
// Populate max tokens
const maxTokensInput = document.getElementById('ai-max-tokens');
if (maxTokensInput && settings.max_tokens) {
maxTokensInput.value = settings.max_tokens;
}
// Populate character settings
const characterInput = document.getElementById('ai-character');
const characterInfoInput = document.getElementById('ai-character-info');
if (characterInput && settings.character) {
characterInput.value = settings.character;
}
if (characterInfoInput && settings.character_info) {
characterInfoInput.value = settings.character_info;
}
// Populate system prompt
const systemPromptInput = document.getElementById('ai-system-prompt');
if (systemPromptInput && settings.system_message) {
systemPromptInput.value = settings.system_message;
}
// Populate custom instructions
const customInstructionsInput = document.getElementById('ai-custom-instructions');
if (customInstructionsInput && settings.custom_instructions) {

View File

@ -5,7 +5,7 @@
// Toast notification system
const Toast = {
container: null,
/**
* Initialize the toast container
*/
@ -17,7 +17,7 @@ const Toast = {
document.body.appendChild(this.container);
}
},
/**
* Show a toast notification
* @param {string} message - The message to display
@ -27,11 +27,11 @@ const Toast = {
*/
show(message, type = 'info', title = '', duration = 5000) {
this.init();
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
// Create icon based on type
let iconSvg = '';
switch (type) {
@ -47,7 +47,7 @@ const Toast = {
default: // info
iconSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#5865F2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>';
}
// Set toast content
toast.innerHTML = `
<div class="toast-icon">${iconSvg}</div>
@ -57,22 +57,22 @@ const Toast = {
</div>
<button class="toast-close">&times;</button>
`;
// Add to container
this.container.appendChild(toast);
// Add close event
const closeBtn = toast.querySelector('.toast-close');
closeBtn.addEventListener('click', () => this.hide(toast));
// Auto-hide after duration
if (duration) {
setTimeout(() => this.hide(toast), duration);
}
return toast;
},
/**
* Hide a toast notification
* @param {HTMLElement} toast - The toast element to hide
@ -85,7 +85,7 @@ const Toast = {
}
}, 300); // Match animation duration
},
/**
* Show a success toast
* @param {string} message - The message to display
@ -94,7 +94,7 @@ const Toast = {
success(message, title = 'Success') {
return this.show(message, 'success', title);
},
/**
* Show an error toast
* @param {string} message - The message to display
@ -103,7 +103,7 @@ const Toast = {
error(message, title = 'Error') {
return this.show(message, 'error', title);
},
/**
* Show a warning toast
* @param {string} message - The message to display
@ -112,7 +112,7 @@ const Toast = {
warning(message, title = 'Warning') {
return this.show(message, 'warning', title);
},
/**
* Show an info toast
* @param {string} message - The message to display
@ -136,7 +136,10 @@ const API = {
// Set default headers
options.headers = options.headers || {};
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
// Always include credentials for session cookies
options.credentials = options.credentials || 'same-origin';
// Add loading state
if (loadingElement) {
if (loadingElement.tagName === 'BUTTON') {
@ -149,23 +152,25 @@ const API = {
overlay = document.createElement('div');
overlay.className = 'loading-overlay';
overlay.innerHTML = '<div class="loading-spinner"></div>';
// Make sure the element has position relative for absolute positioning
const computedStyle = window.getComputedStyle(loadingElement);
if (computedStyle.position === 'static') {
loadingElement.style.position = 'relative';
}
loadingElement.appendChild(overlay);
} else {
overlay.style.display = 'flex';
}
}
}
try {
console.log(`API Request: ${options.method || 'GET'} ${url}`);
const response = await fetch(url, options);
console.log(`API Response: ${response.status} ${response.statusText}`);
// Parse JSON response
let data;
const contentType = response.headers.get('content-type');
@ -174,7 +179,7 @@ const API = {
} else {
data = await response.text();
}
// Remove loading state
if (loadingElement) {
if (loadingElement.tagName === 'BUTTON') {
@ -187,15 +192,28 @@ const API = {
}
}
}
// Handle error responses
if (!response.ok) {
const error = new Error(data.message || data.error || 'API request failed');
const errorMessage = data.detail || data.message || data.error || 'API request failed';
console.error(`API Error (${response.status}): ${errorMessage}`, data);
const error = new Error(errorMessage);
error.status = response.status;
error.data = data;
// Handle authentication errors
if (response.status === 401) {
console.log('Authentication error detected, redirecting to login');
// Redirect to login after a short delay to show the error
setTimeout(() => {
window.location.href = '/dashboard/api/auth/login';
}, 2000);
}
throw error;
}
return data;
} catch (error) {
// Remove loading state
@ -210,14 +228,14 @@ const API = {
}
}
}
// Show error toast
Toast.error(error.message || 'An error occurred');
throw error;
}
},
/**
* Make a GET request
* @param {string} url - The API endpoint URL
@ -227,7 +245,7 @@ const API = {
async get(url, loadingElement = null) {
return this.request(url, { method: 'GET' }, loadingElement);
},
/**
* Make a POST request
* @param {string} url - The API endpoint URL
@ -245,7 +263,7 @@ const API = {
loadingElement
);
},
/**
* Make a PUT request
* @param {string} url - The API endpoint URL
@ -263,7 +281,7 @@ const API = {
loadingElement
);
},
/**
* Make a DELETE request
* @param {string} url - The API endpoint URL
@ -288,7 +306,7 @@ const Modal = {
document.body.style.overflow = 'hidden'; // Prevent scrolling
}
},
/**
* Close a modal
* @param {string} modalId - The ID of the modal to close
@ -300,7 +318,7 @@ const Modal = {
document.body.style.overflow = ''; // Restore scrolling
}
},
/**
* Initialize modal close buttons
*/
@ -315,7 +333,7 @@ const Modal = {
}
});
});
// Close modal when clicking outside the modal content
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', (event) => {
@ -338,7 +356,7 @@ const Form = {
serialize(form) {
const formData = new FormData(form);
const data = {};
for (const [key, value] of formData.entries()) {
// Handle checkboxes
if (form.elements[key].type === 'checkbox') {
@ -347,10 +365,10 @@ const Form = {
data[key] = value;
}
}
return data;
},
/**
* Populate a form with data
* @param {HTMLFormElement} form - The form element
@ -360,7 +378,7 @@ const Form = {
for (const key in data) {
if (form.elements[key]) {
const element = form.elements[key];
if (element.type === 'checkbox') {
element.checked = Boolean(data[key]);
} else if (element.type === 'radio') {
@ -371,14 +389,14 @@ const Form = {
} else {
element.value = data[key];
}
// Trigger change event for elements like select
const event = new Event('change');
element.dispatchEvent(event);
}
}
},
/**
* Validate a form
* @param {HTMLFormElement} form - The form element
@ -386,12 +404,12 @@ const Form = {
*/
validate(form) {
let isValid = true;
// Remove existing error messages
form.querySelectorAll('.form-error').forEach(error => {
error.remove();
});
// Check required fields
form.querySelectorAll('[required]').forEach(field => {
if (!field.value.trim()) {
@ -399,7 +417,7 @@ const Form = {
this.showError(field, 'This field is required');
}
});
// Check email fields
form.querySelectorAll('input[type="email"]').forEach(field => {
if (field.value && !this.isValidEmail(field.value)) {
@ -407,10 +425,10 @@ const Form = {
this.showError(field, 'Please enter a valid email address');
}
});
return isValid;
},
/**
* Show an error message for a form field
* @param {HTMLElement} field - The form field
@ -424,13 +442,13 @@ const Form = {
error.style.color = 'var(--danger-color)';
error.style.fontSize = '0.875rem';
error.style.marginTop = '0.25rem';
// Add error class to field
field.classList.add('is-invalid');
// Insert error after field
field.parentNode.insertBefore(error, field.nextSibling);
// Add event listener to remove error when field is changed
field.addEventListener('input', () => {
field.classList.remove('is-invalid');
@ -439,7 +457,7 @@ const Form = {
}
}, { once: true });
},
/**
* Check if an email is valid
* @param {string} email - The email to check
@ -462,7 +480,7 @@ const DOM = {
*/
createElement(tag, attrs = {}, children = []) {
const element = document.createElement(tag);
// Set attributes
for (const key in attrs) {
if (key === 'className') {
@ -476,7 +494,7 @@ const DOM = {
element.setAttribute(key, attrs[key]);
}
}
// Add children
if (Array.isArray(children)) {
children.forEach(child => {
@ -489,10 +507,10 @@ const DOM = {
} else if (typeof children === 'string') {
element.textContent = children;
}
return element;
},
/**
* Create a loading spinner
* @param {string} size - The size of the spinner (sm, md, lg)
@ -503,7 +521,7 @@ const DOM = {
spinner.className = `loading-spinner loading-spinner-${size}`;
return spinner;
},
/**
* Show a loading spinner in a container
* @param {HTMLElement} container - The container element
@ -513,18 +531,18 @@ const DOM = {
showSpinner(container, size = 'md') {
// Clear container
container.innerHTML = '';
// Create spinner container
const spinnerContainer = document.createElement('div');
spinnerContainer.className = 'loading-spinner-container';
// Create spinner
const spinner = this.createSpinner(size);
spinnerContainer.appendChild(spinner);
// Add to container
container.appendChild(spinnerContainer);
return spinnerContainer;
}
};