Browse Source

Fix bucket permission persistence in Admin UI (#8049)

Fix bucket permission persistence and security issues (#7226)

Security Fixes:
- Fix XSS vulnerability in showModal by using DOM methods instead of template strings for title
- Add escapeHtmlForAttribute helper to properly escape all HTML entities (&, <, >, ", ')
- Fix XSS in showSecretKey and showNewAccessKeyModal by using proper HTML escaping
- Fix XSS in createAccessKeysContent by replacing inline onclick with data attributes and event delegation

Code Cleanup:
- Remove debug label "(DEBUG)" from page header
- Remove debug console.log statements from buildBucketPermissionsNew
- Remove dead functions: addBucketPermissionRow, removeBucketPermissionRow, parseBucketPermissions, buildBucketPermissions

Validation Improvements:
- Add validation in handleUpdateUser to prevent empty permissions submission
- Update buildBucketPermissionsNew to return null when no buckets selected (instead of empty array)
- Add proper error messages for validation failures

UI Improvements:
- Enhanced access key management with proper modals and copy buttons
- Improved copy-to-clipboard functionality with fallbacks

Fixes #7226
pull/8050/head
Chris Lu 1 day ago
committed by GitHub
parent
commit
dbde8983a7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 509
      weed/admin/static/js/admin.js
  2. 371
      weed/admin/view/app/object_store_users.templ
  3. 2
      weed/admin/view/app/object_store_users_templ.go

509
weed/admin/static/js/admin.js

@ -2095,388 +2095,95 @@ function escapeHtml(text) {
return text.replace(/[&<>"']/g, function (m) { return map[m]; });
}
// ============================================================================
// USER MANAGEMENT FUNCTIONS
// SHARED MODAL UTILITIES FOR ACCESS KEY MANAGEMENT
// ============================================================================
// Global variables for user management
let currentEditingUser = '';
let currentAccessKeysUser = '';
// User Management Functions
async function handleCreateUser() {
const form = document.getElementById('createUserForm');
const formData = new FormData(form);
// Get selected actions
const actionsSelect = document.getElementById('actions');
const selectedActions = Array.from(actionsSelect.selectedOptions).map(option => option.value);
const userData = {
username: formData.get('username'),
email: formData.get('email'),
actions: selectedActions,
generate_key: formData.get('generateKey') === 'on'
};
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
});
if (response.ok) {
const result = await response.json();
showSuccessMessage('User created successfully');
// Show the created access key if generated
if (result.user && result.user.access_key) {
showNewAccessKeyModal(result.user);
}
// Close modal and refresh page
const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal'));
modal.hide();
form.reset();
setTimeout(() => window.location.reload(), 1000);
} else {
const error = await response.json();
showErrorMessage('Failed to create user: ' + (error.error || 'Unknown error'));
}
} catch (error) {
console.error('Error creating user:', error);
showErrorMessage('Failed to create user: ' + error.message);
}
// HTML escaping helper to prevent XSS
function escapeHtmlForAttribute(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
async function editUser(username) {
currentEditingUser = username;
try {
const response = await fetch(`/api/users/${username}`);
if (response.ok) {
const user = await response.json();
// Populate edit form
document.getElementById('editUsername').value = username;
document.getElementById('editEmail').value = user.email || '';
// Set selected actions
const actionsSelect = document.getElementById('editActions');
Array.from(actionsSelect.options).forEach(option => {
option.selected = user.actions && user.actions.includes(option.value);
});
// Set selected policies
const policiesSelect = document.getElementById('editPolicies');
if (policiesSelect) {
Array.from(policiesSelect.options).forEach(option => {
option.selected = user.policy_names && user.policy_names.includes(option.value);
});
}
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editUserModal'));
modal.show();
} else {
showErrorMessage('Failed to load user details');
}
} catch (error) {
console.error('Error loading user:', error);
showErrorMessage('Failed to load user details');
}
}
async function handleUpdateUser() {
const form = document.getElementById('editUserForm');
const formData = new FormData(form);
// Get selected actions
const actionsSelect = document.getElementById('editActions');
const selectedActions = Array.from(actionsSelect.selectedOptions).map(option => option.value);
// Get selected policies
const policiesSelect = document.getElementById('editPolicies');
const selectedPolicies = policiesSelect ? Array.from(policiesSelect.selectedOptions).map(option => option.value) : [];
function showModal(title, content) {
// Create a dynamic modal
const modalId = 'dynamicModal_' + Date.now();
const userData = {
email: formData.get('email'),
actions: selectedActions,
policy_names: selectedPolicies
};
// Create modal structure using DOM to prevent XSS in title
const modalDiv = document.createElement('div');
modalDiv.className = 'modal fade';
modalDiv.id = modalId;
modalDiv.setAttribute('tabindex', '-1');
modalDiv.setAttribute('role', 'dialog');
try {
const response = await fetch(`/api/users/${currentEditingUser}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
});
const modalDialog = document.createElement('div');
modalDialog.className = 'modal-dialog';
modalDialog.setAttribute('role', 'document');
if (response.ok) {
showSuccessMessage('User updated successfully');
const modalContent = document.createElement('div');
modalContent.className = 'modal-content';
// Close modal and refresh page
const modal = bootstrap.Modal.getInstance(document.getElementById('editUserModal'));
modal.hide();
setTimeout(() => window.location.reload(), 1000);
} else {
const error = await response.json();
showErrorMessage('Failed to update user: ' + (error.error || 'Unknown error'));
}
} catch (error) {
console.error('Error updating user:', error);
showErrorMessage('Failed to update user: ' + error.message);
}
}
// Header
const modalHeader = document.createElement('div');
modalHeader.className = 'modal-header';
function confirmDeleteUser(username) {
confirmAction(
`Are you sure you want to delete user "${username}"? This action cannot be undone.`,
() => deleteUserConfirmed(username)
);
}
const modalTitle = document.createElement('h5');
modalTitle.className = 'modal-title';
modalTitle.textContent = title; // Safe - uses textContent
function deleteUser(username) {
confirmDeleteUser(username);
}
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'btn-close';
closeButton.setAttribute('data-bs-dismiss', 'modal');
async function deleteUserConfirmed(username) {
try {
const response = await fetch(`/api/users/${username}`, {
method: 'DELETE'
});
modalHeader.appendChild(modalTitle);
modalHeader.appendChild(closeButton);
if (response.ok) {
showSuccessMessage('User deleted successfully');
setTimeout(() => window.location.reload(), 1000);
} else {
const error = await response.json();
showErrorMessage('Failed to delete user: ' + (error.error || 'Unknown error'));
}
} catch (error) {
console.error('Error deleting user:', error);
showErrorMessage('Failed to delete user: ' + error.message);
}
}
// Body (content may contain HTML, so use innerHTML)
const modalBody = document.createElement('div');
modalBody.className = 'modal-body';
modalBody.innerHTML = content;
async function showUserDetails(username) {
try {
const response = await fetch(`/api/users/${username}`);
if (response.ok) {
const user = await response.json();
// Footer
const modalFooter = document.createElement('div');
modalFooter.className = 'modal-footer';
const content = createUserDetailsContent(user);
document.getElementById('userDetailsContent').innerHTML = content;
const closeFooterButton = document.createElement('button');
closeFooterButton.type = 'button';
closeFooterButton.className = 'btn btn-secondary';
closeFooterButton.setAttribute('data-bs-dismiss', 'modal');
closeFooterButton.textContent = 'Close';
const modal = new bootstrap.Modal(document.getElementById('userDetailsModal'));
modal.show();
} else {
showErrorMessage('Failed to load user details');
}
} catch (error) {
console.error('Error loading user details:', error);
showErrorMessage('Failed to load user details');
}
}
modalFooter.appendChild(closeFooterButton);
function createUserDetailsContent(user) {
return `
<div class="row">
<div class="col-md-6">
<h6 class="text-muted">Basic Information</h6>
<table class="table table-sm">
<tr>
<td><strong>Username:</strong></td>
<td>${escapeHtml(user.username)}</td>
</tr>
<tr>
<td><strong>Email:</strong></td>
<td>${escapeHtml(user.email || 'Not set')}</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h6 class="text-muted">Permissions</h6>
<div class="mb-3">
${user.actions && user.actions.length > 0 ?
user.actions.map(action => `<span class="badge bg-info me-1">${action}</span>`).join('') :
'<span class="text-muted">No permissions assigned</span>'
}
</div>
<h6 class="text-muted">Access Keys</h6>
${user.access_keys && user.access_keys.length > 0 ?
createAccessKeysTable(user.access_keys) :
'<p class="text-muted">No access keys</p>'
}
</div>
</div>
`;
}
// Assemble modal
modalContent.appendChild(modalHeader);
modalContent.appendChild(modalBody);
modalContent.appendChild(modalFooter);
modalDialog.appendChild(modalContent);
modalDiv.appendChild(modalDialog);
function createAccessKeysTable(accessKeys) {
return `
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Access Key</th>
<th>Created</th>
</tr>
</thead>
<tbody>
${accessKeys.map(key => `
<tr>
<td><code>${key.access_key}</code></td>
<td>${new Date(key.created_at).toLocaleDateString()}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
async function manageAccessKeys(username) {
currentAccessKeysUser = username;
document.getElementById('accessKeysUsername').textContent = username;
await loadAccessKeys(username);
// Add modal to body
document.body.appendChild(modalDiv);
const modal = new bootstrap.Modal(document.getElementById('accessKeysModal'));
// Show modal
const modal = new bootstrap.Modal(document.getElementById(modalId));
modal.show();
}
async function loadAccessKeys(username) {
try {
const response = await fetch(`/api/users/${username}`);
if (response.ok) {
const user = await response.json();
const content = createAccessKeysManagementContent(user.access_keys || []);
document.getElementById('accessKeysContent').innerHTML = content;
} else {
document.getElementById('accessKeysContent').innerHTML = '<p class="text-muted">Failed to load access keys</p>';
}
} catch (error) {
console.error('Error loading access keys:', error);
document.getElementById('accessKeysContent').innerHTML = '<p class="text-muted">Error loading access keys</p>';
}
}
function createAccessKeysManagementContent(accessKeys) {
if (accessKeys.length === 0) {
return '<p class="text-muted">No access keys found. Create one to get started.</p>';
}
return `
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Access Key</th>
<th>Secret Key</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${accessKeys.map(key => `
<tr>
<td>
<code>${key.access_key}</code>
<button class="btn btn-sm btn-outline-secondary ms-2" onclick="adminCopyToClipboard('${key.access_key}')">
<i class="fas fa-copy"></i>
</button>
</td>
<td>
<code class="text-muted"></code>
<button class="btn btn-sm btn-outline-secondary ms-2" onclick="showSecretKey('${key.access_key}', '${key.secret_key}')">
<i class="fas fa-eye"></i>
</button>
</td>
<td>${new Date(key.created_at).toLocaleDateString()}</td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="confirmDeleteAccessKey('${key.access_key}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
async function createAccessKey() {
if (!currentAccessKeysUser) {
showErrorMessage('No user selected');
return;
}
try {
const response = await fetch(`/api/users/${currentAccessKeysUser}/access-keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
const result = await response.json();
showSuccessMessage('Access key created successfully');
// Show the new access key
showNewAccessKeyModal(result.access_key);
// Reload access keys
await loadAccessKeys(currentAccessKeysUser);
} else {
const error = await response.json();
showErrorMessage('Failed to create access key: ' + (error.error || 'Unknown error'));
}
} catch (error) {
console.error('Error creating access key:', error);
showErrorMessage('Failed to create access key: ' + error.message);
}
}
function confirmDeleteAccessKey(accessKeyId) {
confirmAction(
`Are you sure you want to delete access key "${accessKeyId}"? This action cannot be undone.`,
() => deleteAccessKeyConfirmed(accessKeyId)
);
}
async function deleteAccessKeyConfirmed(accessKeyId) {
try {
const response = await fetch(`/api/users/${currentAccessKeysUser}/access-keys/${accessKeyId}`, {
method: 'DELETE'
});
if (response.ok) {
showSuccessMessage('Access key deleted successfully');
// Reload access keys
await loadAccessKeys(currentAccessKeysUser);
} else {
const error = await response.json();
showErrorMessage('Failed to delete access key: ' + (error.error || 'Unknown error'));
}
} catch (error) {
console.error('Error deleting access key:', error);
showErrorMessage('Failed to delete access key: ' + error.message);
}
// Remove modal from DOM when hidden
document.getElementById(modalId).addEventListener('hidden.bs.modal', function () {
this.remove();
});
}
function showSecretKey(accessKey, secretKey) {
const modalId = 'secretKeyModal_' + Date.now();
const escapedAccessKey = escapeHtmlForAttribute(accessKey);
const escapedSecretKey = escapeHtmlForAttribute(secretKey);
const content = `
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
@ -2485,8 +2192,8 @@ function showSecretKey(accessKey, secretKey) {
<div class="mb-3">
<label class="form-label"><strong>Access Key:</strong></label>
<div class="input-group">
<input type="text" class="form-control" value="${accessKey}" readonly>
<button class="btn btn-outline-secondary" onclick="adminCopyToClipboard('${accessKey}')">
<input type="text" id="${modalId}_accessKey" class="form-control" value="${escapedAccessKey}" readonly>
<button class="btn btn-outline-secondary" onclick="copyFromInput('${modalId}_accessKey')">
<i class="fas fa-copy"></i>
</button>
</div>
@ -2494,8 +2201,8 @@ function showSecretKey(accessKey, secretKey) {
<div class="mb-3">
<label class="form-label"><strong>Secret Key:</strong></label>
<div class="input-group">
<input type="text" class="form-control" value="${secretKey}" readonly>
<button class="btn btn-outline-secondary" onclick="adminCopyToClipboard('${secretKey}')">
<input type="text" id="${modalId}_secretKey" class="form-control" value="${escapedSecretKey}" readonly>
<button class="btn btn-outline-secondary" onclick="copyFromInput('${modalId}_secretKey')">
<i class="fas fa-copy"></i>
</button>
</div>
@ -2506,20 +2213,20 @@ function showSecretKey(accessKey, secretKey) {
}
function showNewAccessKeyModal(accessKeyData) {
const modalId = 'newKeyModal_' + Date.now();
const escapedAccessKey = escapeHtmlForAttribute(accessKeyData.access_key);
const escapedSecretKey = escapeHtmlForAttribute(accessKeyData.secret_key);
const content = `
<div class="alert alert-success">
<i class="fas fa-check-circle me-2"></i>
<strong>Success!</strong> Your new access key has been created.
</div>
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Important:</strong> These credentials provide access to your object storage. Keep them secure and don't share them. You can view them again through the user management interface if needed.
</div>
<div class="mb-3">
<label class="form-label"><strong>Access Key:</strong></label>
<div class="input-group">
<input type="text" class="form-control" value="${accessKeyData.access_key}" readonly>
<button class="btn btn-outline-secondary" onclick="adminCopyToClipboard('${accessKeyData.access_key}')">
<input type="text" id="${modalId}_accessKey" class="form-control" value="${escapedAccessKey}" readonly>
<button class="btn btn-outline-secondary" onclick="copyFromInput('${modalId}_accessKey')">
<i class="fas fa-copy"></i>
</button>
</div>
@ -2527,8 +2234,8 @@ function showNewAccessKeyModal(accessKeyData) {
<div class="mb-3">
<label class="form-label"><strong>Secret Key:</strong></label>
<div class="input-group">
<input type="text" class="form-control" value="${accessKeyData.secret_key}" readonly>
<button class="btn btn-outline-secondary" onclick="adminCopyToClipboard('${accessKeyData.secret_key}')">
<input type="text" id="${modalId}_secretKey" class="form-control" value="${escapedSecretKey}" readonly>
<button class="btn btn-outline-secondary" onclick="copyFromInput('${modalId}_secretKey')">
<i class="fas fa-copy"></i>
</button>
</div>
@ -2538,40 +2245,32 @@ function showNewAccessKeyModal(accessKeyData) {
showModal('New Access Key Created', content);
}
function showModal(title, content) {
// Create a dynamic modal
const modalId = 'dynamicModal_' + Date.now();
const modalHtml = `
<div class="modal fade" id="${modalId}" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">${title}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
${content}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
`;
// Add modal to body
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById(modalId));
modal.show();
// Helper function to copy from an input field
function copyFromInput(inputId) {
const input = document.getElementById(inputId);
if (input) {
input.select();
input.setSelectionRange(0, 99999); // For mobile devices
// Remove modal from DOM when hidden
document.getElementById(modalId).addEventListener('hidden.bs.modal', function () {
this.remove();
});
try {
const successful = document.execCommand('copy');
if (successful) {
showAlert('success', 'Copied to clipboard!');
} else {
// Try modern clipboard API as fallback
navigator.clipboard.writeText(input.value).then(() => {
showAlert('success', 'Copied to clipboard!');
}).catch(() => {
showAlert('danger', 'Failed to copy');
});
}
} catch (err) {
// Try modern clipboard API as fallback
navigator.clipboard.writeText(input.value).then(() => {
showAlert('success', 'Copied to clipboard!');
}).catch(() => {
showAlert('danger', 'Failed to copy');
});
}
}
}

371
weed/admin/view/app/object_store_users.templ

@ -223,6 +223,30 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
</select>
<small class="form-text text-muted">Hold Ctrl/Cmd to select multiple permissions</small>
</div>
<div class="mb-3">
<label class="form-label">Bucket Scope</label>
<small class="form-text text-muted d-block mb-2">Apply selected permissions to specific buckets or all buckets</small>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="bucketScope" id="allBuckets" value="all" checked onchange="toggleBucketList()">
<label class="form-check-label" for="allBuckets">
All Buckets
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="bucketScope" id="specificBuckets" value="specific" onchange="toggleBucketList()">
<label class="form-check-label" for="specificBuckets">
Specific Buckets
</label>
</div>
<div id="bucketSelectionList" class="mt-2" style="display: none;">
<select multiple class="form-select" id="selectedBuckets" size="5">
<!-- Options loaded dynamically -->
</select>
<small class="form-text text-muted">Hold Ctrl/Cmd to select multiple buckets</small>
</div>
</div>
<div class="mb-3">
<label for="policies" class="form-label">Attached Policies</label>
<select multiple class="form-control" id="policies" name="policies" size="5">
@ -282,6 +306,30 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
</optgroup>
</select>
</div>
<div class="mb-3">
<label class="form-label">Bucket Scope</label>
<small class="form-text text-muted d-block mb-2">Apply selected permissions to specific buckets or all buckets</small>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="editBucketScope" id="editAllBuckets" value="all" checked onchange="toggleBucketList('edit')">
<label class="form-check-label" for="editAllBuckets">
All Buckets
</label>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="editBucketScope" id="editSpecificBuckets" value="specific" onchange="toggleBucketList('edit')">
<label class="form-check-label" for="editSpecificBuckets">
Specific Buckets
</label>
</div>
<div id="editBucketSelectionList" class="mt-2" style="display: none;">
<select multiple class="form-select" id="editSelectedBuckets" size="5">
<!-- Options loaded dynamically -->
</select>
<small class="form-text text-muted">Hold Ctrl/Cmd to select multiple buckets</small>
</div>
</div>
<div class="mb-3">
<label for="editPolicies" class="form-label">Attached Policies</label>
<select multiple class="form-control" id="editPolicies" name="policies" size="5">
@ -386,8 +434,35 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
// Load policies for dropdowns
loadPolicies();
// Load buckets for bucket permissions
loadBuckets();
});
// Global variable to store available buckets
var availableBuckets = [];
var bucketPermissionCounter = 0;
// Load buckets
async function loadBuckets() {
try {
const response = await fetch('/api/s3/buckets');
if (response.ok) {
const data = await response.json();
availableBuckets = data.buckets || [];
console.log('Loaded', availableBuckets.length, 'buckets');
// Populate bucket selection dropdowns
populateBucketSelections();
} else {
console.warn('Failed to load buckets');
availableBuckets = [];
}
} catch (error) {
console.error('Error loading buckets:', error);
availableBuckets = [];
}
}
// Load policies
async function loadPolicies() {
try {
@ -434,6 +509,170 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
}
}
// Toggle bucket permission fields when Admin checkbox changes
function toggleBucketPermissionFields(mode) {
mode = mode || 'create';
const adminCheckbox = document.getElementById(mode === 'edit' ? 'editBucketAdmin' : 'bucketAdmin');
const permissionFields = document.getElementById(mode === 'edit' ? 'editBucketPermissionFields' : 'bucketPermissionFields');
if (adminCheckbox && permissionFields) {
permissionFields.style.display = adminCheckbox.checked ? 'none' : 'block';
}
}
// Toggle bucket list visibility when bucket scope changes
function toggleBucketList(mode) {
mode = mode || 'create';
const specificRadio = document.getElementById(mode === 'edit' ? 'editSpecificBuckets' : 'specificBuckets');
const bucketList = document.getElementById(mode === 'edit' ? 'editBucketSelectionList' : 'bucketSelectionList');
if (specificRadio && bucketList) {
bucketList.style.display = specificRadio.checked ? 'block' : 'none';
}
}
// Populate bucket selection dropdowns
function populateBucketSelections() {
const createSelect = document.getElementById('selectedBuckets');
const editSelect = document.getElementById('editSelectedBuckets');
[createSelect, editSelect].forEach(select => {
if (select) {
select.innerHTML = '';
availableBuckets.forEach(bucket => {
const option = document.createElement('option');
option.value = bucket.name;
option.textContent = bucket.name;
select.appendChild(option);
});
}
});
}
// Parse bucket permissions from actions array for new UI
function parseBucketPermissions(actions) {
const result = {
isAdmin: false,
permissions: [],
applyToAll: false,
specificBuckets: []
};
// Check if user has Admin permission
if (actions.includes('Admin')) {
result.isAdmin = true;
return result;
}
// Separate bucket-scoped from global actions
const bucketActions = [];
const globalBucketPerms = [];
actions.forEach(action => {
if (action.includes(':')) {
const parts = action.split(':');
const perm = parts[0];
const bucket = parts.slice(1).join(':').replace(/\/\*$/, '');
bucketActions.push({ permission: perm, bucket: bucket });
} else {
globalBucketPerms.push(action);
}
});
// If we have global bucket permissions (no colon), they apply to all buckets
if (globalBucketPerms.length > 0) {
result.permissions = globalBucketPerms;
result.applyToAll = true;
} else if (bucketActions.length > 0) {
// Get unique permissions and buckets
const perms = [...new Set(bucketActions.map(ba => ba.permission))];
const buckets = [...new Set(bucketActions.map(ba => ba.bucket))];
result.permissions = perms;
result.applyToAll = false;
result.specificBuckets = buckets;
}
return result;
}
// Build bucket permission action strings using original permissions dropdown
/**
* Builds bucket permission strings based on selected permissions and bucket scope.
* @param {string} mode - The operation mode, either 'create' or 'edit'.
* @returns {string[]|null} Array of permission strings (e.g., ['Read:bucket1']) or null if validation fails (specific scope selected but no buckets).
*/
function buildBucketPermissions(mode) {
mode = mode || 'create';
const selectId = mode === 'edit' ? 'editActions' : 'actions';
const permSelect = document.getElementById(selectId);
if (!permSelect) return [];
// Get selected permissions from the original multi-select
const selectedPerms = Array.from(permSelect.selectedOptions).map(opt => opt.value);
// If Admin is selected, return just Admin (it overrides everything)
if (selectedPerms.includes('Admin')) {
return ['Admin'];
}
if (selectedPerms.length === 0) {
return [];
}
// Check if applying to all buckets or specific ones
// Use querySelector to find the checked radio button by name group
const scopeName = mode === 'edit' ? 'editBucketScope' : 'bucketScope';
// Try multiple methods to find the checked radio
let checkedRadio = document.querySelector(`input[name="${scopeName}"]:checked`);
// Fallback: check both radio buttons explicitly
if (!checkedRadio) {
const allBucketsId = mode === 'edit' ? 'editAllBuckets' : 'allBuckets';
const specificBucketsId = mode === 'edit' ? 'editSpecificBuckets' : 'specificBuckets';
const allBucketsRadio = document.getElementById(allBucketsId);
const specificBucketsRadio = document.getElementById(specificBucketsId);
if (specificBucketsRadio && specificBucketsRadio.checked) {
checkedRadio = specificBucketsRadio;
} else if (allBucketsRadio && allBucketsRadio.checked) {
checkedRadio = allBucketsRadio;
}
}
// Default to 'all' if nothing is checked (shouldn't happen) or if 'all' is checked
const applyToAll = !checkedRadio || checkedRadio.value === 'all';
if (applyToAll) {
// Return global permissions (no bucket specification)
return selectedPerms;
} else {
// Get selected specific buckets
const bucketSelect = document.getElementById(mode === 'edit' ? 'editSelectedBuckets' : 'selectedBuckets');
if (!bucketSelect) return null;
const selectedBuckets = Array.from(bucketSelect.selectedOptions).map(opt => opt.value);
// Return null to signal validation failure if no buckets selected
if (selectedBuckets.length === 0) {
return null;
}
// Build bucket-scoped permissions
const actions = [];
selectedPerms.forEach(perm => {
selectedBuckets.forEach(bucket => {
actions.push(perm + ':' + bucket);
});
});
return actions;
}
}
// Show user details modal
async function showUserDetails(username) {
try {
@ -477,6 +716,44 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
});
}
// Populate bucket permissions using original permissions dropdown
if (user.actions && user.actions.length > 0) {
const bucketPerms = parseBucketPermissions(user.actions);
// Set permissions in the original multi-select
const actionsSelect = document.getElementById('editActions');
if (actionsSelect) {
Array.from(actionsSelect.options).forEach(option => {
if (bucketPerms.isAdmin && option.value === 'Admin') {
option.selected = true;
} else if (!bucketPerms.isAdmin && bucketPerms.permissions.includes(option.value)) {
option.selected = true;
}
});
}
// Set bucket scope (all or specific)
const allBucketsRadio = document.getElementById('editAllBuckets');
const specificBucketsRadio = document.getElementById('editSpecificBuckets');
if (!bucketPerms.isAdmin) {
if (bucketPerms.applyToAll) {
if (allBucketsRadio) allBucketsRadio.checked = true;
} else if (bucketPerms.specificBuckets.length > 0) {
if (specificBucketsRadio) specificBucketsRadio.checked = true;
toggleBucketList('edit');
// Select specific buckets
const bucketSelect = document.getElementById('editSelectedBuckets');
if (bucketSelect) {
Array.from(bucketSelect.options).forEach(option => {
option.selected = bucketPerms.specificBuckets.includes(option.value);
});
}
}
}
}
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editUserModal'));
modal.show();
@ -535,10 +812,13 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
const form = document.getElementById('createUserForm');
const formData = new FormData(form);
// Get permissions with bucket scope applied
const allActions = buildBucketPermissions('create');
const userData = {
username: formData.get('username'),
email: formData.get('email'),
actions: Array.from(document.getElementById('actions').selectedOptions).map(option => option.value),
actions: allActions,
policy_names: Array.from(document.getElementById('policies').selectedOptions).map(option => option.value),
generate_key: document.getElementById('generateKey').checked
};
@ -577,6 +857,62 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
}
// Handle update user form submission
async function handleUpdateUser() {
const username = document.getElementById('editUsername').value;
if (!username) {
showErrorMessage('Username is required');
return;
}
// Get permissions with bucket scope applied
const allActions = buildBucketPermissions('edit');
// Validate that permissions are not empty
if (!allActions || allActions.length === 0) {
showErrorMessage('At least one permission must be selected');
return;
}
// Check for null (validation failure from buildBucketPermissionsNew)
if (allActions === null) {
showErrorMessage('Please select at least one bucket when using specific bucket permissions');
return;
}
const userData = {
email: document.getElementById('editEmail').value,
actions: allActions,
policy_names: Array.from(document.getElementById('editPolicies').selectedOptions).map(option => option.value)
};
try {
const response = await fetch(`/api/users/${username}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
});
if (response.ok) {
showSuccessMessage('User updated successfully');
// Close modal and refresh page
const modal = bootstrap.Modal.getInstance(document.getElementById('editUserModal'));
modal.hide();
setTimeout(() => window.location.reload(), 1000);
} else {
const error = await response.json();
showErrorMessage('Failed to update user: ' + (error.error || 'Unknown error'));
}
} catch (error) {
console.error('Error updating user:', error);
showErrorMessage('Failed to update user: ' + error.message);
}
}
// Create user details content
function createUserDetailsContent(user) {
var detailsHtml = '<div class="row">';
@ -639,6 +975,11 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
keysHtml += '<td><code>' + escapeHtml(key.access_key) + '</code></td>';
keysHtml += '<td><span class="badge bg-success">Active</span></td>';
keysHtml += '<td>';
// Add "View Secret" button with data attributes
keysHtml += '<button class="btn btn-outline-secondary btn-sm me-2 view-secret-btn" data-access-key="' + escapeHtml(key.access_key) + '" data-secret-key="' + escapeHtml(key.secret_key) + '">';
keysHtml += '<i class="fas fa-eye"></i> View Secret';
keysHtml += '</button>';
// Delete button
keysHtml += '<button class="btn btn-outline-danger btn-sm delete-access-key-btn" data-username="' + escapeHtml(user.username) + '" data-access-key="' + escapeHtml(key.access_key) + '">';
keysHtml += '<i class="fas fa-trash"></i> Delete';
keysHtml += '</button>';
@ -649,6 +990,18 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
keysHtml += '</tbody>';
keysHtml += '</table>';
keysHtml += '</div>';
// Add delegated event listener for view secret buttons
setTimeout(() => {
document.querySelectorAll('.view-secret-btn').forEach(btn => {
btn.addEventListener('click', function() {
const accessKey = this.getAttribute('data-access-key');
const secretKey = this.getAttribute('data-secret-key');
showSecretKey(accessKey, secretKey);
});
});
}, 100);
return keysHtml;
}
@ -667,6 +1020,12 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
if (response.ok) {
const result = await response.json();
// Show the new access key details (IMPORTANT: secret key is only shown once!)
if (result.access_key) {
showNewAccessKeyModal(result.access_key);
}
showSuccessMessage('Access key created successfully');
// Refresh access keys display
@ -713,16 +1072,6 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
}
}
// Show new access key modal (when user is created with generated key)
function showNewAccessKeyModal(user) {
// Create a simple alert for now - could be enhanced with a dedicated modal
var message = 'New user created!\n\n';
message += 'Username: ' + user.username + '\n';
message += 'Access Key: ' + user.access_key + '\n';
message += 'Secret Key: ' + user.secret_key + '\n\n';
message += 'Please save these credentials securely.';
alert(message);
}
// Utility functions
function showSuccessMessage(message) {

2
weed/admin/view/app/object_store_users_templ.go
File diff suppressed because it is too large
View File

Loading…
Cancel
Save