Files
stellars-jupyterhub-ds/services/jupyterhub/html_templates_enhanced/admin.html
stellarshenson a67af7a090 fix: admin volume button for all users
- Find Edit buttons directly instead of looking for td.actions
- Inject button before each Edit User button found
- Works with JupyterHub React admin panel structure
- Walks up DOM to find user container and extract username
2026-01-26 13:31:30 +01:00

542 lines
22 KiB
HTML

{% extends "page.html" %}
{% block main %}
<div id="react-admin-hook">
<script id="jupyterhub-admin-config">
window.api_page_limit = parseInt("{{ api_page_limit|safe }}")
window.base_url = "{{ base_url|safe }}"
</script>
<script src={{ static_url("js/admin-react.js") }}></script>
</div>
<!-- User Credentials Modal -->
<div class="modal fade" id="user-credentials-modal" tabindex="-1" role="dialog" aria-labelledby="userCredentialsModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="userCredentialsModalLabel">
<i class="fa fa-user-plus me-2"></i>New Users Created
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-success">
<strong>Success:</strong> Users created and authorized.
Share the credentials below with each user.
</div>
<div class="mb-3">
<button type="button" class="btn btn-outline-secondary me-2" id="copy-credentials-btn">
<i class="fa fa-copy me-1"></i>Copy to Clipboard
</button>
<button type="button" class="btn btn-primary" id="download-credentials-btn">
<i class="fa fa-download me-1"></i>Download as TXT
</button>
</div>
<div style="max-height: 300px; overflow-y: auto;">
<table class="table table-striped" id="credentials-table">
<thead>
<tr>
<th>Username</th>
<th>Password</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody id="credentials-body">
<!-- Populated dynamically -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Loading Spinner Modal -->
<div class="modal fade" id="loading-modal" tabindex="-1" role="dialog" data-bs-backdrop="static" data-bs-keyboard="false" aria-hidden="true">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body text-center py-4">
<div class="spinner-border text-primary mb-3" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<div id="loading-modal-text">Processing...</div>
</div>
</div>
</div>
</div>
<!-- Manage Volumes Modal (Admin) -->
<div class="modal fade" id="admin-manage-volumes-modal" tabindex="-1" role="dialog" aria-labelledby="adminManageVolumesModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="adminManageVolumesModalLabel">Manage Volumes</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
<strong>Warning:</strong> This action cannot be undone!
</div>
<p>Manage volumes for user: <strong id="admin-volume-username"></strong></p>
<p>Select which volumes to reset. All files in selected volumes will be permanently deleted:</p>
{% for volume_suffix in user_volume_suffixes %}
<div class="form-check mb-2">
<input class="form-check-input admin-volume-checkbox" type="checkbox" id="admin-volume-{{ volume_suffix }}" value="{{ volume_suffix }}">
<label class="form-check-label" for="admin-volume-{{ volume_suffix }}">
<strong>{{ volume_suffix }}</strong>
{% if volume_descriptions and volume_suffix in volume_descriptions %}
<br><small class="text-muted">{{ volume_descriptions[volume_suffix] }}</small>
{% endif %}
</label>
</div>
{% endfor %}
<div id="admin-volume-warning" class="alert alert-warning mt-3" style="display: none;">
<i class="fa fa-exclamation-triangle me-1"></i>
<span id="admin-volume-count">0</span> volume(s) selected for deletion
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="admin-confirm-reset-btn" disabled>
<i class="fa fa-trash me-1"></i>Reset Selected Volumes
</button>
</div>
</div>
</div>
</div>
{% endblock main %}
{% block footer %}
<div class="py-2 px-4 bg-body-tertiary small version_footer">JupyterHub {{ server_version }}</div>
{% endblock footer %}
{% block script %}
{{ super() }}
<script type="text/javascript">
(function() {
const baseUrl = "{{ base_url|safe }}";
// Store for tracking newly created users
let pendingUsernames = [];
// Helper to get CSRF token
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
// Loading spinner functions
let loadingModal = null;
function showLoadingSpinner(message) {
const modalEl = document.getElementById('loading-modal');
const textEl = document.getElementById('loading-modal-text');
if (textEl) {
textEl.textContent = message || 'Processing...';
}
if (!loadingModal) {
loadingModal = new bootstrap.Modal(modalEl);
}
loadingModal.show();
}
function hideLoadingSpinner() {
if (loadingModal) {
loadingModal.hide();
}
}
// Intercept fetch to detect user creation
const originalFetch = window.fetch;
window.fetch = async function(...args) {
const [urlArg, options] = args;
// Convert URL object to string if needed
const url = typeof urlArg === 'string' ? urlArg : urlArg.toString();
const method = options?.method || 'GET';
// console.log('[Admin Fetch]', method, url);
// Check for user creation - POST to api/users (may have ?_xsrf query param)
// Exclude URLs with other query params like include_stopped_servers
const urlPath = url.split('?')[0];
const isUserCreation = method === 'POST' && urlPath.endsWith('api/users');
// Check for user deletion - DELETE to api/users/{username}
const userDeleteMatch = urlPath.match(/api\/users\/([^\/]+)$/);
const isUserDeletion = method === 'DELETE' && userDeleteMatch;
// Show spinner for deletion
if (isUserDeletion) {
const username = decodeURIComponent(userDeleteMatch[1]);
showLoadingSpinner('Deleting user ' + username + '...');
}
// Capture request body before fetch (for batch user creation)
let requestUsernames = [];
if (isUserCreation) {
// console.log('[Admin] User creation POST detected!');
try {
if (options.body) {
const bodyData = JSON.parse(options.body);
if (bodyData.usernames && Array.isArray(bodyData.usernames)) {
requestUsernames = bodyData.usernames;
// console.log('[Admin] Batch user creation request:', requestUsernames);
}
}
} catch (e) {
// Body might not be JSON, ignore
}
}
const response = await originalFetch.apply(this, args);
// console.log('[Admin Fetch] Response status:', response.status);
// Check if this is a POST to create users
if (isUserCreation) {
try {
// Only process successful responses
if (response.ok || response.status === 201) {
// Clone response to read body
const clonedResponse = response.clone();
const responseData = await clonedResponse.json();
// Extract created usernames from response
let createdUsers = [];
if (Array.isArray(responseData)) {
// Batch response: array of user objects
createdUsers = responseData.map(u => u.name);
} else if (responseData.name) {
// Single user response
createdUsers = [responseData.name];
}
if (createdUsers.length > 0) {
// console.log('[Admin] Users created:', createdUsers);
pendingUsernames.push(...createdUsers);
// Show loading spinner
showLoadingSpinner('Generating credentials...');
// Debounce - wait for batch completion then fetch credentials
clearTimeout(window._credentialsFetchTimeout);
window._credentialsFetchTimeout = setTimeout(() => {
if (pendingUsernames.length > 0) {
fetchAndShowCredentials([...pendingUsernames]);
pendingUsernames = [];
}
}, 1000);
}
}
} catch (e) {
// console.error('[Admin] Error processing user creation:', e);
}
}
// Hide spinner after user deletion completes
if (isUserDeletion) {
hideLoadingSpinner();
}
return response;
};
// Fetch credentials for created users and show modal
async function fetchAndShowCredentials(usernames) {
// console.log('[Admin] Fetching credentials for:', usernames);
try {
const response = await originalFetch(`${baseUrl}api/admin/credentials`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRFToken': getCookie('_xsrf')
},
body: JSON.stringify({ usernames: usernames })
});
if (response.ok) {
const data = await response.json();
// console.log('[Admin] Credentials received:', data);
if (data.credentials && data.credentials.length > 0) {
showCredentialsModal(data.credentials);
}
} else {
// console.error('[Admin] Failed to fetch credentials:', response.status);
hideLoadingSpinner();
}
} catch (e) {
// console.error('[Admin] Error fetching credentials:', e);
hideLoadingSpinner();
}
}
// Show modal with credentials
function showCredentialsModal(credentials) {
hideLoadingSpinner();
const tbody = document.getElementById('credentials-body');
tbody.innerHTML = '';
credentials.forEach(cred => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${escapeHtml(cred.username)}</td>
<td>${escapeHtml(cred.password)}</td>
<td><i class="fa fa-copy copy-row-btn" style="opacity: 0.4; cursor: pointer;" title="Copy to clipboard"></i></td>
`;
// Add click handler for row copy
row.querySelector('.copy-row-btn').addEventListener('click', function() {
const text = 'Username: ' + cred.username + '\nPassword: ' + cred.password;
navigator.clipboard.writeText(text).then(() => {
this.classList.remove('fa-copy');
this.classList.add('fa-check');
this.style.opacity = '1';
setTimeout(() => {
this.classList.remove('fa-check');
this.classList.add('fa-copy');
this.style.opacity = '0.4';
}, 1500);
});
});
tbody.appendChild(row);
});
// Store credentials for download/copy
window._currentCredentials = credentials;
// Show modal
const modalEl = document.getElementById('user-credentials-modal');
const modal = new bootstrap.Modal(modalEl);
modal.show();
}
// HTML escape helper
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Format credentials for text output
function formatCredentialsText(credentials) {
let text = 'JupyterHub User Credentials\n';
text += '===========================\n\n';
credentials.forEach(cred => {
text += `Username: ${cred.username}\n`;
text += `Password: ${cred.password}\n\n`;
});
text += `Generated: ${new Date().toISOString()}\n`;
return text;
}
// Copy to clipboard handler
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('copy-credentials-btn')?.addEventListener('click', function() {
if (window._currentCredentials) {
const text = formatCredentialsText(window._currentCredentials);
navigator.clipboard.writeText(text).then(() => {
const btn = this;
const originalText = btn.innerHTML;
btn.innerHTML = '<i class="fa fa-check me-1"></i>Copied!';
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-success');
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-outline-secondary');
}, 2000);
}).catch(err => {
// console.error('[Admin] Failed to copy:', err);
alert('Failed to copy to clipboard');
});
}
});
// Download as TXT handler
document.getElementById('download-credentials-btn')?.addEventListener('click', function() {
if (window._currentCredentials) {
const text = formatCredentialsText(window._currentCredentials);
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `jupyterhub-credentials-${new Date().toISOString().slice(0,10)}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
});
// ============================================================
// Admin Volume Management
// ============================================================
let volumeModal = null;
let currentVolumeUsername = null;
// Inject "Manage Volumes" button into user action areas
function injectVolumeButtons() {
// JupyterHub React admin renders Edit buttons for each user
// Find all Edit buttons and inject our button before them
const editButtons = document.querySelectorAll('button[title="Edit User"], a[title="Edit User"]');
editButtons.forEach(editBtn => {
// Skip if already has our button nearby
const parent = editBtn.parentNode;
if (parent.querySelector('.admin-manage-volumes-btn')) return;
// Find username - look for links in parent containers
let username = null;
// Walk up to find the user row/card container
let container = editBtn.closest('tr') || editBtn.closest('.card') || editBtn.closest('[class*="user"]') || editBtn.parentNode.parentNode.parentNode;
if (container) {
// Method 1: Admin user link (href contains #/users/)
const adminLink = container.querySelector('a[href*="#/users/"]');
if (adminLink) {
const href = adminLink.getAttribute('href');
const match = href.match(/#\/users\/([^\/\?]+)/);
if (match) username = decodeURIComponent(match[1]);
}
// Method 2: Server link (href contains /user/)
if (!username) {
const serverLink = container.querySelector('a[href*="/user/"]');
if (serverLink) {
const href = serverLink.getAttribute('href');
const match = href.match(/\/user\/([^\/\?]+)/);
if (match) username = decodeURIComponent(match[1]);
}
}
// Method 3: Any link that looks like a username (first link text)
if (!username) {
const anyLink = container.querySelector('a');
if (anyLink && anyLink.textContent && !anyLink.textContent.includes('/')) {
username = anyLink.textContent.trim();
}
}
}
if (!username) return;
// Create button with same classes as Edit button
const btn = document.createElement('button');
btn.className = editBtn.className;
btn.classList.add('admin-manage-volumes-btn');
btn.innerHTML = '<i class="fa fa-database"></i>';
btn.title = 'Manage Volumes';
btn.setAttribute('data-username', username);
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
openVolumeModal(username);
});
// Insert before Edit button
parent.insertBefore(btn, editBtn);
});
}
// Open volume modal for a specific user
function openVolumeModal(username) {
currentVolumeUsername = username;
document.getElementById('admin-volume-username').textContent = username;
// Reset checkboxes
document.querySelectorAll('.admin-volume-checkbox').forEach(cb => {
cb.checked = false;
});
updateVolumeSelection();
// Show modal
const modalEl = document.getElementById('admin-manage-volumes-modal');
if (!volumeModal) {
volumeModal = new bootstrap.Modal(modalEl);
}
volumeModal.show();
}
// Update selection UI
function updateVolumeSelection() {
const checkboxes = document.querySelectorAll('.admin-volume-checkbox:checked');
const count = checkboxes.length;
const confirmBtn = document.getElementById('admin-confirm-reset-btn');
const warning = document.getElementById('admin-volume-warning');
const countSpan = document.getElementById('admin-volume-count');
confirmBtn.disabled = count === 0;
warning.style.display = count > 0 ? 'block' : 'none';
countSpan.textContent = count;
}
// Handle checkbox changes
document.querySelectorAll('.admin-volume-checkbox').forEach(cb => {
cb.addEventListener('change', updateVolumeSelection);
});
// Handle confirm button
document.getElementById('admin-confirm-reset-btn')?.addEventListener('click', async function() {
if (!currentVolumeUsername) return;
const selectedVolumes = [];
document.querySelectorAll('.admin-volume-checkbox:checked').forEach(cb => {
selectedVolumes.push(cb.value);
});
if (selectedVolumes.length === 0) return;
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Resetting...';
try {
const response = await originalFetch(`${baseUrl}api/users/${encodeURIComponent(currentVolumeUsername)}/manage-volumes`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-XSRFToken': getCookie('_xsrf')
},
body: JSON.stringify({ volumes: selectedVolumes })
});
if (response.ok) {
const data = await response.json();
volumeModal.hide();
const resetCount = data.reset_volumes ? data.reset_volumes.length : selectedVolumes.length;
alert(`Successfully reset ${resetCount} volume(s) for ${currentVolumeUsername}.`);
} else {
const errorData = await response.json().catch(() => ({}));
alert(`Error: ${errorData.message || response.statusText || 'Failed to reset volumes'}`);
}
} catch (e) {
alert(`Error: ${e.message || 'Failed to reset volumes'}`);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-trash me-1"></i>Reset Selected Volumes';
}
});
// Use MutationObserver to inject buttons as React renders users
const observer = new MutationObserver(function(mutations) {
injectVolumeButtons();
});
// Start observing once DOM is ready
const reactRoot = document.getElementById('react-admin-hook');
if (reactRoot) {
observer.observe(reactRoot, { childList: true, subtree: true });
// Initial injection
setTimeout(injectVolumeButtons, 500);
}
});
})();
</script>
{% endblock script %}