Files
dashcaddy/status/js/core/service-crud.js
Sami b4022288dc fix: service edit, CSRF token stability, and license restore (v1.1.1)
- Fix service edit double-write bug (was creating duplicate entries)
- Add editable display name field to service edit modal
- Backend update endpoint now accepts name, logo, and recalculates url
- Fix CSRF token regeneration breaking all POST requests (nonce was
  being regenerated on every request, invalidating cached tokens)
- CSRF nonce now persists across requests, rotated only on TOTP login
- Frontend secureFetch auto-retries on CSRF failure with fresh token
- Restore lifetime license activation on DNS2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 13:39:05 -07:00

412 lines
15 KiB
JavaScript

// ========== SERVICE CRUD ==========
// Edit, delete, and update operations for existing services.
(function () {
// ===== SERVICE EDIT MODAL =====
let currentEditService = null;
function openServiceEditModal(service) {
currentEditService = service;
const modal = document.getElementById('service-edit-modal');
document.getElementById('service-edit-title').textContent = `Edit ${service.name}`;
document.getElementById('edit-service-name').value = service.name;
document.getElementById('edit-service-url-display').textContent = service.url || buildServiceUrl(service.id);
document.getElementById('edit-service-logo-preview').src = service.logo || `/assets/${service.id}.png`;
document.getElementById('edit-subdomain').value = service.id;
document.getElementById('edit-port').value = service.port || '';
document.getElementById('edit-ip').value = service.ip || 'localhost';
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
document.getElementById('edit-logo-url').value = service.logo || '';
modal.classList.add('show');
}
function closeServiceEditModal() {
closeModal('service-edit-modal');
currentEditService = null;
}
async function saveServiceChanges() {
if (!currentEditService) return;
const newSubdomain = document.getElementById('edit-subdomain').value.trim().toLowerCase();
const newName = document.getElementById('edit-service-name').value.trim();
const newPort = document.getElementById('edit-port').value.trim();
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
const newLogo = document.getElementById('edit-logo-url').value.trim();
if (!newSubdomain) {
showNotification('Subdomain is required', 'warning');
return;
}
const oldSubdomain = currentEditService.id;
const changes = [];
if (newSubdomain !== oldSubdomain) changes.push('subdomain');
if (newName && newName !== currentEditService.name) changes.push('name');
if (newPort && newPort !== String(currentEditService.port)) changes.push('port');
if (newIp !== currentEditService.ip) changes.push('ip');
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
if (newLogo && newLogo !== currentEditService.logo) changes.push('logo');
if (changes.length === 0) {
closeServiceEditModal();
return;
}
const saveBtn = document.getElementById('service-edit-save');
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
const response = await secureFetch('/api/v1/services/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oldSubdomain,
newSubdomain,
name: newName || currentEditService.name,
port: newPort || currentEditService.port,
ip: newIp,
tailscaleOnly,
logo: newLogo || undefined
})
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to update service');
}
// Update local APPS array to match
const appIndex = window.APPS.findIndex(a => a.id === oldSubdomain);
if (appIndex !== -1) {
window.APPS[appIndex] = {
...window.APPS[appIndex],
id: newSubdomain,
name: newName || window.APPS[appIndex].name,
port: newPort || window.APPS[appIndex].port,
ip: newIp,
tailscaleOnly,
logo: newLogo || window.APPS[appIndex].logo
};
}
closeServiceEditModal();
window.buildGrid();
window.refreshAll();
} catch (error) {
console.error('Error saving service changes:', error);
showNotification(`Error saving changes: ${error.message}`, 'error');
} finally {
saveBtn.textContent = 'Save Changes';
saveBtn.disabled = false;
}
}
// Logo file upload handler
document.getElementById('edit-logo-file')?.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
showNotification('Please select an image file', 'warning');
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
const dataUrl = event.target.result;
document.getElementById('edit-service-logo-preview').src = dataUrl;
document.getElementById('edit-logo-url').value = dataUrl;
if (currentEditService) {
try {
const response = await secureFetch('/api/v1/assets/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: `${currentEditService.id}.png`,
data: dataUrl
})
});
const result = await response.json();
if (result.success && result.path) {
document.getElementById('edit-logo-url').value = result.path;
}
} catch (err) {
// Fallback to data URL
}
}
};
reader.readAsDataURL(file);
});
// Service edit modal event listeners
document.getElementById('service-edit-cancel')?.addEventListener('click', closeServiceEditModal);
document.getElementById('service-edit-save')?.addEventListener('click', saveServiceChanges);
document.getElementById('service-edit-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'service-edit-modal') closeServiceEditModal();
});
// ===== DELETE SERVICE MODAL =====
function showDeleteModal(serviceName, hasContainer, containerId) {
return new Promise((resolve) => {
const modal = document.getElementById('delete-service-modal');
const title = document.getElementById('delete-modal-title');
const message = document.getElementById('delete-modal-message');
const containerInfo = document.getElementById('delete-modal-container-info');
const containerName = document.getElementById('delete-modal-container-name');
const help = document.getElementById('delete-modal-help');
const cancelBtn = document.getElementById('delete-modal-cancel');
const removeBtn = document.getElementById('delete-modal-remove');
const deleteBtn = document.getElementById('delete-modal-delete');
title.textContent = `Delete "${serviceName}"`;
if (hasContainer) {
message.innerHTML = 'This service has an associated Docker container.<br>Choose how to proceed:';
containerInfo.style.display = 'block';
containerName.textContent = `Container ID: ${containerId?.slice(0, 12) || 'Unknown'}`;
help.style.display = 'block';
deleteBtn.style.display = 'block';
} else {
message.textContent = 'Remove this service from the dashboard?';
containerInfo.style.display = 'none';
help.style.display = 'none';
deleteBtn.style.display = 'none';
}
const cleanup = () => {
modal.classList.remove('show');
cancelBtn.removeEventListener('click', handleCancel);
removeBtn.removeEventListener('click', handleRemove);
deleteBtn.removeEventListener('click', handleDelete);
modal.removeEventListener('click', handleBackdrop);
};
const handleCancel = () => { cleanup(); resolve(null); };
const handleRemove = () => { cleanup(); resolve(false); };
const handleDelete = () => { cleanup(); resolve(true); };
const handleBackdrop = (e) => { if (e.target === modal) { cleanup(); resolve(null); } };
cancelBtn.addEventListener('click', handleCancel);
removeBtn.addEventListener('click', handleRemove);
deleteBtn.addEventListener('click', handleDelete);
modal.addEventListener('click', handleBackdrop);
modal.classList.add('show');
});
}
// ===== UPDATE CONTAINER =====
async function updateContainer(containerId, serviceName, serviceId) {
const updateBtn = document.getElementById(`update-btn-${serviceId}`);
const originalText = updateBtn?.textContent;
if (!confirm(`Update ${serviceName} to the latest version?\n\nThis will:\n1. Pull the latest image\n2. Stop the container\n3. Recreate with same settings\n\nThe service will be briefly unavailable.`)) {
return;
}
try {
if (updateBtn) {
updateBtn.textContent = '\u{1F504}';
updateBtn.disabled = true;
updateBtn.title = 'Updating...';
}
const response = await secureFetch(`/api/v1/containers/${containerId}/update`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
const service = window.APPS.find(app => app.id === serviceId);
if (service && result.newContainerId) {
service.containerId = result.newContainerId;
}
if (updateBtn) {
updateBtn.textContent = '\u{2705}';
updateBtn.title = 'Updated successfully!';
setTimeout(() => {
updateBtn.textContent = originalText;
updateBtn.disabled = false;
updateBtn.title = 'Update container to latest version';
}, 3000);
}
setTimeout(() => window.refreshAll(), 2000);
showNotification(`${serviceName} updated successfully!`, 'success');
} else {
throw new Error(result.error || 'Update failed');
}
} catch (error) {
console.error('Update error:', error);
if (updateBtn) {
updateBtn.textContent = '\u{274C}';
updateBtn.title = 'Update failed';
setTimeout(() => {
updateBtn.textContent = originalText;
updateBtn.disabled = false;
updateBtn.title = 'Update container to latest version';
}, 3000);
}
showNotification(`Failed to update ${serviceName}: ${error.message}`, 'error');
}
}
// ===== DELETE SERVICE =====
async function deleteService(serviceId, serviceName) {
const service = window.APPS.find(app => app.id === serviceId);
const domain = service ? buildDomain(service.id) : null;
const hasContainer = service?.containerId;
const deleteContainer = await showDeleteModal(serviceName || serviceId, hasContainer, service?.containerId);
if (deleteContainer === null) {
return; // User cancelled
}
let results = {
dashboard: false,
container: null,
dns: null,
caddy: null,
service: null
};
// Full removal with container
if (deleteContainer && hasContainer) {
try {
const params = new URLSearchParams({
containerId: service.containerId,
subdomain: service.id,
ip: service.ip || 'localhost',
deleteContainer: 'true'
});
const response = await secureFetch(`/api/v1/apps/${encodeURIComponent(service.id)}?${params.toString()}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
results = { ...results, ...result.results, dashboard: false };
} else {
console.error('App removal failed:', result.error);
}
} catch (error) {
console.error('App removal error:', error);
}
} else if (deleteContainer && domain) {
// Fallback for manually added services
try {
const serviceIP = service?.ip || 'localhost';
const dnsResponse = await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(domain)}&type=A&ipAddress=${encodeURIComponent(serviceIP)}&server=${SITE.dnsIp}`, {
method: 'DELETE'
});
const dnsResult = await dnsResponse.json();
results.dns = dnsResult.success ? 'deleted' : (dnsResult.error || 'failed');
} catch (e) {
results.dns = e.message;
}
try {
const caddyResponse = await secureFetch(`/api/v1/site/${encodeURIComponent(domain)}`, {
method: 'DELETE'
});
const caddyResult = await caddyResponse.json();
results.caddy = (caddyResult.success || (caddyResult.error && caddyResult.error.includes('not found'))) ? 'removed' : (caddyResult.error || 'failed');
} catch (e) {
results.caddy = e.message;
}
}
// Remove from APPS array
const index = window.APPS.findIndex(app => app.id === serviceId);
if (index > -1) {
window.APPS.splice(index, 1);
results.dashboard = true;
}
// Remove from localStorage
try {
const customApps = safeGetJSON('custom-apps', []);
const localIndex = customApps.findIndex(app => app.id === serviceId);
if (localIndex > -1) {
customApps.splice(localIndex, 1);
safeSet('custom-apps', JSON.stringify(customApps));
}
} catch (e) {
// Ignore localStorage errors
}
// Remove from services.json via API
try {
const serviceResponse = await secureFetch(`/api/v1/services/${encodeURIComponent(serviceId)}`, {
method: 'DELETE'
});
const serviceResult = await serviceResponse.json();
results.service = serviceResult.success ? 'removed' : (serviceResult.error || 'failed');
} catch (e) {
results.service = e.message;
}
window.buildGrid();
window.refreshAll();
// Only show alert if there are actual errors
let hasErrors = false;
let errorMessages = [];
if (!results.dashboard) {
hasErrors = true;
errorMessages.push('\u{2717} Failed to remove from dashboard');
}
const successStates = ['removed', 'already removed', 'not found', 'deleted', 'kept (user choice)', 'skipped', 'no such record', 'does not exist'];
const isSuccess = (val) => !val || successStates.some(s => val.toLowerCase().includes(s.toLowerCase()));
if (results.container && !isSuccess(results.container)) {
hasErrors = true;
errorMessages.push(`\u{26A0} Container: ${results.container}`);
}
if (results.dns && !isSuccess(results.dns)) {
hasErrors = true;
errorMessages.push(`\u{26A0} DNS Record: ${results.dns}`);
}
if (results.caddy && !isSuccess(results.caddy)) {
hasErrors = true;
errorMessages.push(`\u{26A0} Caddy Config: ${results.caddy}`);
}
if (results.service && !isSuccess(results.service)) {
hasErrors = true;
errorMessages.push(`\u{26A0} Service File: ${results.service}`);
}
if (hasErrors) {
showNotification(`Error deleting "${serviceName || serviceId}": ${errorMessages.join(', ')}`, 'error', 6000);
}
}
// ===== WINDOW EXPORTS =====
window.openServiceEditModal = openServiceEditModal;
window.showDeleteModal = showDeleteModal;
window.updateContainer = updateContainer;
window.deleteService = deleteService;
})();