Files
seaweedFS/weed/admin/view/app/service_accounts.templ
Chris Lu b6d99f1c9e Admin: Add Service Account Management UI (#7902)
* admin: add Service Account management UI

Add admin UI for managing service accounts:

New files:
- handlers/service_account_handlers.go - HTTP handlers
- dash/service_account_management.go - CRUD operations
- view/app/service_accounts.templ - UI template

Changes:
- dash/types.go - Add ServiceAccount and related types
- handlers/admin_handlers.go - Register routes and handlers
- view/layout/layout.templ - Add sidebar navigation link

Service accounts are stored as special identities with "sa:" prefix
in their name, using ABIA access key prefix. They can be created,
listed, enabled/disabled, and deleted through the admin UI.

Features:
- Create service accounts linked to parent users
- View and manage service account status
- Delete service accounts
- Service accounts inherit parent user permissions

Note: STS configuration is read-only (configured via JSON file).
Full STS integration requires changes from PR #7901.

* admin: use dropdown for parent user selection

Change the Parent User field from text input to dropdown when
creating a service account. The dropdown is populated with all
existing Object Store users.

Changes:
- Add AvailableUsers field to ServiceAccountsData type
- Populate available users in getServiceAccountsData handler
- Update template to use <select> element with user options

* admin: show secret access key on service account creation

Display both access key and secret access key when creating a
service account, with proper AWS CLI usage instructions.

Changes:
- Add SecretAccessKey field to ServiceAccount type (only populated on creation)
- Return secret key from CreateServiceAccount
- Add credentials modal with copy-to-clipboard buttons
- Show AWS CLI usage example with actual credentials
- Modal is non-dismissible until user confirms they saved credentials

The secret key is only shown once during creation for security.
After creation, only the access key ID is visible in the list.

* admin: address code review comments for service account management

- Persist creation dates in identity actions (createdAt:timestamp)
- Replace magic number slicing with len(accessKeyPrefix)
- Add bounds checking after strings.SplitN
- Use accessKeyPrefix constant instead of hardcoded "ABIA"

Creation dates are now stored as actions (e.g., "createdAt:1735473600")
and will persist across restarts. Helper functions getCreationDate()
and setCreationDate() manage the timestamp storage.

Addresses review comments from gemini-code-assist[bot] and coderabbitai[bot]

* admin: fix XSS vulnerabilities in service account details

Replace innerHTML with template literals with safe DOM creation.
The createSADetailsContent function now uses createElement and
textContent to prevent XSS attacks from malicious service account
data (id, description, parent_user, etc.).

Also added try-catch for date parsing to prevent exceptions on
malformed input.

Addresses security review comments from coderabbitai[bot]

* admin: add context.Context to service account management methods

Addressed PR #7902 review feedback:

1. All service account management methods now accept context.Context as
   first parameter to enable cancellation, deadlines, and tracing
2. Removed all context.Background() calls
3. Updated handlers to pass c.Request.Context() from HTTP requests

Methods updated:
- GetServiceAccounts
- GetServiceAccountDetails
- CreateServiceAccount
- UpdateServiceAccount
- DeleteServiceAccount
- GetServiceAccountByAccessKey

Note: Creation date persistence was already implemented using the
createdAt:<timestamp> action pattern as suggested in the review.

* admin: fix render flow to prevent partial HTML writes

Fixed ShowServiceAccounts handler to render template to an in-memory
buffer first before writing to the response. This prevents partial HTML
writes followed by JSON error responses, which would result in invalid
mixed content.

Changes:
- Render to bytes.Buffer first
- Only write to c.Writer if render succeeds
- Use c.AbortWithStatus on error instead of attempting JSON response
- Prevents any additional headers/body writes after partial write

* admin: fix error handling, date validation, and event parameters

Addressed multiple code review issues:

1. Proper 404 vs 500 error handling:
   - Added ErrServiceAccountNotFound sentinel error
   - GetServiceAccountDetails now wraps errors with sentinel
   - Handler uses errors.Is() to distinguish not-found from internal errors
   - Returns 404 only for missing resources, 500 for other errors
   - Logs internal errors before returning 500

2. Date validation in JavaScript:
   - Validate expiration date before using it
   - Check !isNaN(date.getTime()) to ensure valid date
   - Return validation error if date is invalid
   - Prevents invalid Date construction

3. Event parameter handling:
   - copyToClipboard now accepts event parameter
   - Updated onclick attributes to pass event object
   - Prevents reliance on window.event
   - More explicit and reliable event handling

* admin: replace deprecated execCommand with Clipboard API

Replaced deprecated document.execCommand('copy') with modern
navigator.clipboard.writeText() API for better security and UX.

Changes:
- Made copyToClipboard async to support Clipboard API
- Use navigator.clipboard.writeText() as primary method
- Fallback to execCommand if Clipboard API fails (older browsers)
- Added console warning when fallback is used
- Maintains same visual feedback behavior

* admin: improve security and UX for error handling

Addressed code review feedback:

1. Security: Remove sensitive error details from API responses
   - CreateServiceAccount: Return generic error message
   - UpdateServiceAccount: Return generic error message
   - DeleteServiceAccount: Return generic error message
   - Detailed errors still logged server-side via glog.Errorf()
   - Prevents exposure of internal system details to clients

2. UX: Replace alert() with Bootstrap toast notifications
   - Implemented showToast() function using Bootstrap 5 toasts
   - Non-blocking, modern notification system
   - Auto-dismiss after 5 seconds
   - Proper HTML escaping to prevent XSS
   - Toast container positioned at top-right
   - Success (green) and error (red) variants

* admin: complete error handling improvements

Addressed remaining security review feedback:

1. GetServiceAccounts: Remove error details from response
   - Log errors server-side via glog.Errorf()
   - Return generic error message to client

2. UpdateServiceAccount & DeleteServiceAccount:
   - Wrap not-found errors with ErrServiceAccountNotFound sentinel
   - Enables proper 404 vs 500 distinction in handlers

3. Update & Delete handlers:
   - Added errors.Is() check for ErrServiceAccountNotFound
   - Return 404 for missing resources
   - Return 500 for internal errors with logging
   - Consistent with GetServiceAccountDetails behavior

All handlers now properly distinguish not-found (404) from internal
errors (500) and never expose sensitive error details to clients.

* admin: implement expiration support and improve code quality

Addressed final code review feedback:

1. Expiration Support:
   - Added expiration helper functions (getExpiration, setExpiration)
   - Implemented expiration in CreateServiceAccount
   - Implemented expiration in UpdateServiceAccount
   - Added Expiration field to ServiceAccount struct
   - Parse and validate RFC3339 expiration dates

2. Constants for Magic Strings:
   - Added StatusActive, StatusInactive constants
   - Added disabledAction, serviceAccountPrefix constants
   - Replaced all magic strings with constants throughout
   - Improves maintainability and prevents typos

3. Helper Function to Reduce Duplication:
   - Created identityToServiceAccount() helper
   - Reduces code duplication across Get/Update/Delete methods
   - Centralizes ServiceAccount struct building logic

4. Fixed time.Now() Fallback:
   - Changed from time.Now() to time.Time{} for legacy accounts
   - Prevents creation date from changing on each fetch
   - UI can display zero time as "N/A" or blank

All code quality issues addressed!

* admin: fix StatusActive reference in handler

Use dash.StatusActive to properly reference the constant from the dash package.

* admin: regenerate templ files

Regenerated all templ Go files after recent template changes.
The AWS CLI usage example already uses proper <pre><code> formatting
which preserves line breaks for better readability.

* admin: add explicit white-space CSS to AWS CLI example

Added style="white-space: pre-wrap;" to the pre tag to ensure
line breaks are preserved and displayed correctly in all browsers.
This forces the browser to respect the newlines in the code block.

* admin: fix AWS CLI example to display on separate lines

Replaced pre/code block with individual div elements for each line.
This ensures each command displays on its own line regardless of
how templ processes whitespace. Each line is now a separate div
with font-monospace styling for code appearance.

* make

* admin: filter service accounts from parent user dropdown

Service accounts should not appear as selectable parent users when
creating new service accounts. Added filter to GetObjectStoreUsers()
to skip identities with "sa:" prefix, ensuring only actual IAM users
are shown in the parent user dropdown.

* admin: address code review feedback

- Use constants for magic strings in service account management
- Add Expiration field to service account responses
- Add nil checks and context propagation
- Improve templates (date validation, async clipboard, toast notifications)

* Update service_accounts_templ.go
2025-12-29 22:57:02 -08:00

654 lines
31 KiB
Plaintext

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="copyToClipboard(event, '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="copyToClipboard(event, '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 copyToClipboard(event, elementId) {
const element = document.getElementById(elementId);
// Use modern Clipboard API if available
if (navigator.clipboard) {
navigator.clipboard.writeText(element.value).then(() => {
// Success feedback could be added here
}).catch(err => {
console.warn('Clipboard API failed:', err);
// Fallback
element.select();
document.execCommand('copy');
});
} else {
// Fallback for older browsers
element.select();
document.execCommand('copy');
}
// Visual feedback
const button = event.target.closest('button');
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 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>
}