You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
671 lines
32 KiB
671 lines
32 KiB
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
|
)
|
|
|
|
templ ServiceAccounts(data dash.ServiceAccountsData) {
|
|
<div class="container-fluid">
|
|
<!-- Page Header -->
|
|
<div class="d-sm-flex align-items-center justify-content-between mb-4">
|
|
<div>
|
|
<h1 class="h3 mb-0 text-gray-800">
|
|
<i class="fas fa-robot me-2"></i>Service Accounts
|
|
</h1>
|
|
<p class="mb-0 text-muted">Manage application credentials for automated processes</p>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button type="button" class="btn btn-primary"
|
|
data-bs-toggle="modal"
|
|
data-bs-target="#createServiceAccountModal">
|
|
<i class="fas fa-plus me-1"></i>Create Service Account
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary Cards -->
|
|
<div class="row mb-4">
|
|
<div class="col-xl-3 col-md-6 mb-4">
|
|
<div class="card border-left-primary shadow h-100 py-2">
|
|
<div class="card-body">
|
|
<div class="row no-gutters align-items-center">
|
|
<div class="col mr-2">
|
|
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
|
Total Service Accounts
|
|
</div>
|
|
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
|
{fmt.Sprintf("%d", data.TotalAccounts)}
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<i class="fas fa-id-card fa-2x text-gray-300"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-xl-3 col-md-6 mb-4">
|
|
<div class="card border-left-success shadow h-100 py-2">
|
|
<div class="card-body">
|
|
<div class="row no-gutters align-items-center">
|
|
<div class="col mr-2">
|
|
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
|
Active Accounts
|
|
</div>
|
|
<div class="h5 mb-0 font-weight-bold text-gray-800">
|
|
{fmt.Sprintf("%d", data.ActiveAccounts)}
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<i class="fas fa-check-circle fa-2x text-gray-300"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-xl-3 col-md-6 mb-4">
|
|
<div class="card border-left-info shadow h-100 py-2">
|
|
<div class="card-body">
|
|
<div class="row no-gutters align-items-center">
|
|
<div class="col mr-2">
|
|
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
|
Last Updated
|
|
</div>
|
|
<div class="h6 mb-0 font-weight-bold text-gray-800">
|
|
{data.LastUpdated.Format("15:04")}
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Service Accounts Table -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card shadow mb-4">
|
|
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
|
<h6 class="m-0 font-weight-bold text-primary">
|
|
<i class="fas fa-robot me-2"></i>Service Accounts
|
|
</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover" width="100%" cellspacing="0" id="serviceAccountsTable">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Parent User</th>
|
|
<th>Access Key</th>
|
|
<th>Status</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, sa := range data.ServiceAccounts {
|
|
<tr>
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<i class="fas fa-robot me-2 text-muted"></i>
|
|
<code>{sa.ID}</code>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<i class="fas fa-user me-1 text-muted"></i>
|
|
{sa.ParentUser}
|
|
</td>
|
|
<td>
|
|
<code class="text-muted">{sa.AccessKeyId}</code>
|
|
</td>
|
|
<td>
|
|
if sa.Status == "Active" {
|
|
<span class="badge bg-success">Active</span>
|
|
} else {
|
|
<span class="badge bg-secondary">Inactive</span>
|
|
}
|
|
</td>
|
|
<td>{sa.CreateDate.Format("2006-01-02")}</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm" role="group">
|
|
<button type="button" class="btn btn-outline-info"
|
|
data-action="show-sa-details" data-sa-id={ sa.ID }>
|
|
<i class="fas fa-info-circle"></i>
|
|
</button>
|
|
<button type="button" class="btn btn-outline-primary"
|
|
data-action="toggle-sa-status" data-sa-id={ sa.ID } data-current-status={ sa.Status }>
|
|
if sa.Status == "Active" {
|
|
<i class="fas fa-pause"></i>
|
|
} else {
|
|
<i class="fas fa-play"></i>
|
|
}
|
|
</button>
|
|
<button type="button" class="btn btn-outline-danger"
|
|
data-action="delete-sa" data-sa-id={ sa.ID }>
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
if len(data.ServiceAccounts) == 0 {
|
|
<tr>
|
|
<td colspan="6" class="text-center text-muted py-4">
|
|
<i class="fas fa-robot fa-3x mb-3 text-muted"></i>
|
|
<div>
|
|
<h5>No service accounts found</h5>
|
|
<p>Create your first service account for automated processes.</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Last Updated -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<small class="text-muted">
|
|
<i class="fas fa-clock me-1"></i>
|
|
Last updated: {data.LastUpdated.Format("2006-01-02 15:04:05")}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Service Account Modal -->
|
|
<div class="modal fade" id="createServiceAccountModal" tabindex="-1" role="dialog">
|
|
<div class="modal-dialog" role="document">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-plus me-2"></i>Create Service Account
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="createSAForm">
|
|
<div class="mb-3">
|
|
<label for="parentUser" class="form-label">Parent User *</label>
|
|
<select class="form-select" id="parentUser" name="parent_user" required>
|
|
<option value="">-- Select a user --</option>
|
|
for _, user := range data.AvailableUsers {
|
|
<option value={ user }>{ user }</option>
|
|
}
|
|
</select>
|
|
<small class="form-text text-muted">The service account will inherit permissions from this user</small>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="description" class="form-label">Description</label>
|
|
<textarea class="form-control" id="description" name="description" rows="2"
|
|
placeholder="What is this service account used for?"></textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="expiration" class="form-label">Expiration (optional)</label>
|
|
<input type="datetime-local" class="form-control" id="expiration" name="expiration">
|
|
<small class="form-text text-muted">Leave empty for no expiration</small>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="handleCreateServiceAccount()">Create</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Service Account Details Modal -->
|
|
<div class="modal fade" id="saDetailsModal" tabindex="-1" role="dialog">
|
|
<div class="modal-dialog modal-lg" role="document">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-robot me-2"></i>Service Account Details
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body" id="saDetailsContent">
|
|
<!-- Content will be loaded dynamically -->
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Credentials Display Modal -->
|
|
<div class="modal fade" id="credentialsModal" tabindex="-1" role="dialog" data-bs-backdrop="static" data-bs-keyboard="false">
|
|
<div class="modal-dialog modal-lg" role="document">
|
|
<div class="modal-content">
|
|
<div class="modal-header bg-success text-white">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-check-circle me-2"></i>Service Account Created Successfully
|
|
</h5>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="alert alert-warning">
|
|
<i class="fas fa-exclamation-triangle me-2"></i>
|
|
<strong>Important:</strong> This is the only time you will see the secret access key. Please save it securely.
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<h6 class="text-muted mb-3">AWS CLI Configuration</h6>
|
|
<p class="text-muted small">Use these credentials to configure AWS CLI or SDKs:</p>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label fw-bold">AWS_ACCESS_KEY_ID</label>
|
|
<div class="input-group">
|
|
<input type="text" class="form-control font-monospace" id="displayAccessKey" readonly>
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyCredentialToClipboard(this, 'displayAccessKey')">
|
|
<i class="fas fa-copy"></i> Copy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label fw-bold">AWS_SECRET_ACCESS_KEY</label>
|
|
<div class="input-group">
|
|
<input type="text" class="form-control font-monospace" id="displaySecretKey" readonly>
|
|
<button class="btn btn-outline-secondary" type="button" onclick="copyCredentialToClipboard(this, 'displaySecretKey')">
|
|
<i class="fas fa-copy"></i> Copy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-light p-3 rounded">
|
|
<h6 class="text-muted mb-2">Example AWS CLI Usage:</h6>
|
|
<div class="font-monospace small">
|
|
<div>export AWS_ACCESS_KEY_ID=<span id="exampleAccessKey"></span></div>
|
|
<div>export AWS_SECRET_ACCESS_KEY=<span id="exampleSecretKey"></span></div>
|
|
<div>export AWS_ENDPOINT_URL=http://localhost:8333</div>
|
|
<div class="mt-2"># List buckets</div>
|
|
<div>aws s3 ls</div>
|
|
<div class="mt-2"># Upload a file</div>
|
|
<div>aws s3 cp myfile.txt s3://mybucket/</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-primary" onclick="closeCredentialsModal()">
|
|
<i class="fas fa-check me-1"></i>I have saved the credentials
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- JavaScript for service account management -->
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
document.addEventListener('click', function(e) {
|
|
const button = e.target.closest('[data-action]');
|
|
if (!button) return;
|
|
|
|
const action = button.getAttribute('data-action');
|
|
const saId = button.getAttribute('data-sa-id');
|
|
|
|
switch (action) {
|
|
case 'show-sa-details':
|
|
showSADetails(saId);
|
|
break;
|
|
case 'toggle-sa-status':
|
|
toggleSAStatus(saId, button.getAttribute('data-current-status'));
|
|
break;
|
|
case 'delete-sa':
|
|
deleteSA(saId);
|
|
break;
|
|
}
|
|
});
|
|
});
|
|
|
|
async function showSADetails(id) {
|
|
try {
|
|
const response = await fetch(`/api/service-accounts/${id}`);
|
|
if (response.ok) {
|
|
const sa = await response.json();
|
|
document.getElementById('saDetailsContent').innerHTML = createSADetailsContent(sa);
|
|
const modal = new bootstrap.Modal(document.getElementById('saDetailsModal'));
|
|
modal.show();
|
|
} else {
|
|
showErrorMessage('Failed to load service account details');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading service account details:', error);
|
|
showErrorMessage('Failed to load service account details');
|
|
}
|
|
}
|
|
|
|
async function toggleSAStatus(id, currentStatus) {
|
|
const newStatus = currentStatus === 'Active' ? 'Inactive' : 'Active';
|
|
try {
|
|
const response = await fetch(`/api/service-accounts/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: newStatus })
|
|
});
|
|
|
|
if (response.ok) {
|
|
showSuccessMessage(`Service account ${newStatus === 'Active' ? 'activated' : 'deactivated'}`);
|
|
setTimeout(() => window.location.reload(), 1000);
|
|
} else {
|
|
const error = await response.json();
|
|
showErrorMessage('Failed to update status: ' + (error.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating status:', error);
|
|
showErrorMessage('Failed to update status: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function deleteSA(id) {
|
|
if (confirm('Are you sure you want to delete this service account? This action cannot be undone.')) {
|
|
try {
|
|
const response = await fetch(`/api/service-accounts/${id}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
showSuccessMessage('Service account deleted successfully');
|
|
setTimeout(() => window.location.reload(), 1000);
|
|
} else {
|
|
const error = await response.json();
|
|
showErrorMessage('Failed to delete: ' + (error.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error deleting service account:', error);
|
|
showErrorMessage('Failed to delete: ' + error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleCreateServiceAccount() {
|
|
const form = document.getElementById('createSAForm');
|
|
const formData = new FormData(form);
|
|
|
|
const saData = {
|
|
parent_user: formData.get('parent_user'),
|
|
description: formData.get('description')
|
|
};
|
|
|
|
// Handle expiration if set
|
|
const expiration = formData.get('expiration');
|
|
if (expiration) {
|
|
// Validate the date before using it
|
|
const date = new Date(expiration);
|
|
const now = new Date();
|
|
|
|
if (isNaN(date.getTime())) {
|
|
showErrorMessage('Invalid expiration date format');
|
|
return;
|
|
}
|
|
|
|
// Ensure expiration is in the future
|
|
if (date <= now) {
|
|
showErrorMessage('Expiration date must be in the future');
|
|
return;
|
|
}
|
|
|
|
saData.expiration = date.toISOString();
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/service-accounts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(saData)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
|
|
// Hide create modal
|
|
const createModal = bootstrap.Modal.getInstance(document.getElementById('createServiceAccountModal'));
|
|
createModal.hide();
|
|
form.reset();
|
|
|
|
// Show credentials if returned
|
|
if (result.service_account && result.service_account.secret_access_key) {
|
|
showCredentials(result.service_account);
|
|
} else {
|
|
showSuccessMessage('Service account created successfully');
|
|
setTimeout(() => window.location.reload(), 1000);
|
|
}
|
|
} else {
|
|
const error = await response.json();
|
|
showErrorMessage('Failed to create service account: ' + (error.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating service account:', error);
|
|
showErrorMessage('Failed to create service account: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function showCredentials(serviceAccount) {
|
|
// Populate the credentials modal
|
|
document.getElementById('displayAccessKey').value = serviceAccount.access_key_id;
|
|
document.getElementById('displaySecretKey').value = serviceAccount.secret_access_key;
|
|
document.getElementById('exampleAccessKey').textContent = serviceAccount.access_key_id;
|
|
document.getElementById('exampleSecretKey').textContent = serviceAccount.secret_access_key;
|
|
|
|
// Show the modal
|
|
const credentialsModal = new bootstrap.Modal(document.getElementById('credentialsModal'));
|
|
credentialsModal.show();
|
|
}
|
|
|
|
function closeCredentialsModal() {
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('credentialsModal'));
|
|
modal.hide();
|
|
// Reload to show the new service account in the list
|
|
setTimeout(() => window.location.reload(), 500);
|
|
}
|
|
|
|
function copyCredentialToClipboard(button, elementId) {
|
|
const element = document.getElementById(elementId);
|
|
const textToCopy = element.value;
|
|
|
|
// Use modern Clipboard API if available
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(textToCopy).then(() => {
|
|
showSuccessMessage('Copied to clipboard!');
|
|
}).catch(err => {
|
|
console.warn('Clipboard API failed:', err);
|
|
// Fallback
|
|
fallbackCopyTextToClipboard(element);
|
|
});
|
|
} else {
|
|
// Fallback for older browsers or non-secure contexts
|
|
fallbackCopyTextToClipboard(element);
|
|
}
|
|
|
|
// Visual feedback
|
|
const originalHTML = button.innerHTML;
|
|
|
|
button.innerHTML = '<i class="fas fa-check"></i>';
|
|
button.classList.remove('btn-outline-secondary');
|
|
button.classList.add('btn-success');
|
|
|
|
setTimeout(() => {
|
|
button.innerHTML = originalHTML;
|
|
button.classList.remove('btn-success');
|
|
button.classList.add('btn-outline-secondary');
|
|
}, 1000);
|
|
}
|
|
|
|
function fallbackCopyTextToClipboard(element) {
|
|
element.select();
|
|
element.setSelectionRange(0, 99999); // For mobile devices
|
|
|
|
try {
|
|
const successful = document.execCommand('copy');
|
|
if (successful) {
|
|
showSuccessMessage('Copied to clipboard!');
|
|
} else {
|
|
showErrorMessage('Failed to copy to clipboard');
|
|
}
|
|
} catch (err) {
|
|
console.error('Fallback copy failed:', err);
|
|
showErrorMessage('Failed to copy to clipboard');
|
|
}
|
|
|
|
// Clear selection
|
|
window.getSelection().removeAllRanges();
|
|
}
|
|
|
|
|
|
function createSADetailsContent(sa) {
|
|
// Create DOM elements safely to prevent XSS
|
|
const container = document.createElement('div');
|
|
container.className = 'row';
|
|
|
|
// Basic Information column
|
|
const col1 = document.createElement('div');
|
|
col1.className = 'col-md-6';
|
|
|
|
const h6_1 = document.createElement('h6');
|
|
h6_1.className = 'text-muted';
|
|
h6_1.textContent = 'Basic Information';
|
|
col1.appendChild(h6_1);
|
|
|
|
const table1 = document.createElement('table');
|
|
table1.className = 'table table-sm';
|
|
|
|
// ID row
|
|
const idRow = document.createElement('tr');
|
|
idRow.innerHTML = '<td><strong>ID:</strong></td><td><code></code></td>';
|
|
idRow.querySelector('code').textContent = sa.id || '';
|
|
table1.appendChild(idRow);
|
|
|
|
// Parent User row
|
|
const parentRow = document.createElement('tr');
|
|
parentRow.innerHTML = '<td><strong>Parent User:</strong></td><td></td>';
|
|
parentRow.querySelectorAll('td')[1].textContent = sa.parent_user || '';
|
|
table1.appendChild(parentRow);
|
|
|
|
// Access Key row
|
|
const keyRow = document.createElement('tr');
|
|
keyRow.innerHTML = '<td><strong>Access Key:</strong></td><td><code></code></td>';
|
|
keyRow.querySelector('code').textContent = sa.access_key_id || '';
|
|
table1.appendChild(keyRow);
|
|
|
|
// Status row
|
|
const statusRow = document.createElement('tr');
|
|
const statusTd1 = document.createElement('td');
|
|
statusTd1.innerHTML = '<strong>Status:</strong>';
|
|
const statusTd2 = document.createElement('td');
|
|
const statusBadge = document.createElement('span');
|
|
statusBadge.className = sa.status === 'Active' ? 'badge bg-success' : 'badge bg-secondary';
|
|
statusBadge.textContent = sa.status || 'Unknown';
|
|
statusTd2.appendChild(statusBadge);
|
|
statusRow.appendChild(statusTd1);
|
|
statusRow.appendChild(statusTd2);
|
|
table1.appendChild(statusRow);
|
|
|
|
col1.appendChild(table1);
|
|
container.appendChild(col1);
|
|
|
|
// Details column
|
|
const col2 = document.createElement('div');
|
|
col2.className = 'col-md-6';
|
|
|
|
const h6_2 = document.createElement('h6');
|
|
h6_2.className = 'text-muted';
|
|
h6_2.textContent = 'Details';
|
|
col2.appendChild(h6_2);
|
|
|
|
const table2 = document.createElement('table');
|
|
table2.className = 'table table-sm';
|
|
|
|
// Description row
|
|
const descRow = document.createElement('tr');
|
|
descRow.innerHTML = '<td><strong>Description:</strong></td><td></td>';
|
|
descRow.querySelectorAll('td')[1].textContent = sa.description || 'Not set';
|
|
table2.appendChild(descRow);
|
|
|
|
// Created row
|
|
const createdRow = document.createElement('tr');
|
|
createdRow.innerHTML = '<td><strong>Created:</strong></td><td></td>';
|
|
try {
|
|
createdRow.querySelectorAll('td')[1].textContent = new Date(sa.create_date).toLocaleString();
|
|
} catch (e) {
|
|
createdRow.querySelectorAll('td')[1].textContent = 'Invalid date';
|
|
}
|
|
table2.appendChild(createdRow);
|
|
|
|
// Expiration row
|
|
const expRow = document.createElement('tr');
|
|
expRow.innerHTML = '<td><strong>Expires:</strong></td><td></td>';
|
|
expRow.querySelectorAll('td')[1].textContent = sa.expiration || 'Never';
|
|
table2.appendChild(expRow);
|
|
|
|
col2.appendChild(table2);
|
|
container.appendChild(col2);
|
|
|
|
return container.outerHTML;
|
|
}
|
|
|
|
|
|
function showSuccessMessage(message) {
|
|
showToast(message, 'success');
|
|
}
|
|
|
|
function showErrorMessage(message) {
|
|
showToast(message, 'danger');
|
|
}
|
|
|
|
function showToast(message, type) {
|
|
// Create toast container if it doesn't exist
|
|
let toastContainer = document.getElementById('toastContainer');
|
|
if (!toastContainer) {
|
|
toastContainer = document.createElement('div');
|
|
toastContainer.id = 'toastContainer';
|
|
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3';
|
|
toastContainer.style.zIndex = '9999';
|
|
document.body.appendChild(toastContainer);
|
|
}
|
|
|
|
// Create toast element
|
|
const toastId = 'toast-' + Date.now();
|
|
const toastHTML = `
|
|
<div id="${toastId}" class="toast align-items-center text-white bg-${type} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
|
<div class="d-flex">
|
|
<div class="toast-body">
|
|
${escapeHtml(message)}
|
|
</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
|
|
const toastElement = document.getElementById(toastId);
|
|
const toast = new bootstrap.Toast(toastElement, { autohide: true, delay: 5000 });
|
|
toast.show();
|
|
|
|
// Remove toast element after it's hidden
|
|
toastElement.addEventListener('hidden.bs.toast', () => {
|
|
toastElement.remove();
|
|
});
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
</script>
|
|
}
|