mirror of
https://github.com/stellarshenson/stellars-jupyterhub-ds.git
synced 2026-03-08 06:00:29 +00:00
- 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
542 lines
22 KiB
HTML
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 %}
|