DockFlare/dockflare/app/static/js/main.js
2026-04-16 11:48:21 +02:00

3034 lines
133 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// app/static/js/main.js - painful .. javascript
const maxLogLines = 250;
let initialConnectMessageCleared = false;
let activeLogSource = null;
let eventSourceHealthCheck = null;
let logsEnabled = false;
let pingInterval = null;
let manualTunnelTomSelect = null;
let cachedTunnels = null;
let cachedZones = null;
let manualZoneDetectionTimeout = null;
let servicesSnapshotPromise = null;
let servicesSnapshotQueued = false;
let activeStateEventSource = null;
function getMasterApiKey() {
const meta = document.querySelector('meta[name="dockflare-api-key"]');
return meta && meta.content ? meta.content : null;
}
function buildApiHeaders(initial = {}) {
const headers = { ...initial };
const key = getMasterApiKey();
if (key) {
headers['Authorization'] = `Bearer ${key}`;
}
return headers;
}
function initializeAllTomSelects() {
const multiCheckboxOptions = {
plugins: {
'checkbox_options': {},
'remove_button': {
title: 'Remove this item',
}
},
create: false,
sortField: {
field: "text",
direction: "asc"
}
};
const countrySelectOptions = {
...multiCheckboxOptions,
maxOptions: null,
};
const singleSelectOptions = {
create: false,
sortField: {
field: 'text',
direction: 'asc'
}
};
const addGroupSelect = document.getElementById('manual_access_group');
if (addGroupSelect) {
new TomSelect(addGroupSelect, multiCheckboxOptions);
}
const editGroupSelect = document.getElementById('edit_manual_access_group');
if (editGroupSelect) {
new TomSelect(editGroupSelect, multiCheckboxOptions);
}
const manualTunnelSelect = document.getElementById('manual_tunnel_id');
if (manualTunnelSelect) {
manualTunnelTomSelect = new TomSelect(manualTunnelSelect, singleSelectOptions);
}
}
const themeManager = (function() {
let themeMenuScoped;
const htmlElementScoped = document.documentElement;
const availableThemes = [
"light", "dark", "cupcake", "bumblebee", "emerald", "corporate",
"synthwave", "retro", "cyberpunk", "valentine", "halloween", "garden",
"forest", "aqua", "lofi", "pastel", "fantasy", "wireframe", "black",
"luxury", "dracula", "cmyk", "autumn", "business", "acid",
"lemonade", "night", "coffee", "winter"
];
function setTheme(theme) {
if (!availableThemes.includes(theme)) {
console.warn(t('js.text.theme_not_available', {theme: theme}));
theme = 'light';
}
localStorage.setItem('theme', theme);
htmlElementScoped.setAttribute('data-theme', theme);
if (themeMenuScoped) updateSelectedThemeInMenu(theme);
}
function populateThemeMenu() {
if (!themeMenuScoped) return;
themeMenuScoped.innerHTML = '';
availableThemes.forEach(themeName => {
const listItem = document.createElement('li');
listItem.classList.add('w-full');
const link = document.createElement('a');
link.textContent = themeName.charAt(0).toUpperCase() + themeName.slice(1);
link.setAttribute('data-theme-value', themeName);
link.href = "#";
link.classList.add('flex', 'items-center', 'flex-grow', 'w-full', 'px-4', 'py-2');
link.addEventListener('click', (e) => {
e.preventDefault();
const selectedTheme = e.target.getAttribute('data-theme-value');
setTheme(selectedTheme);
if (document.activeElement && typeof document.activeElement.blur === 'function') {
document.activeElement.blur();
}
});
listItem.appendChild(link);
themeMenuScoped.appendChild(listItem);
});
}
function updateSelectedThemeInMenu(currentTheme) {
if (!themeMenuScoped) return;
themeMenuScoped.querySelectorAll('li a').forEach(a => {
if (a.getAttribute('data-theme-value') === currentTheme) {
a.parentElement.classList.add('font-bold', 'text-primary');
a.classList.add('active');
} else {
a.parentElement.classList.remove('font-bold', 'text-primary');
a.classList.remove('active');
}
});
}
function initTheme() {
const savedTheme = localStorage.getItem('theme');
const defaultTheme = 'light';
setTheme(savedTheme || defaultTheme);
}
return {
initialize: function() {
themeMenuScoped = document.getElementById('theme-menu');
const themeSelectorBtn = document.getElementById('theme-selector-btn');
if (themeMenuScoped && themeSelectorBtn) {
populateThemeMenu();
initTheme();
} else {
console.error("DockFlare Theme Error: UI elements for theme selector not found.");
}
}
};
})();
function initializeEditRuleModal() {
const editButtons = document.querySelectorAll('.edit-rule-btn');
const modal = document.getElementById('edit_manual_rule_modal');
if (!editButtons.length || !modal) {
return;
}
editButtons.forEach(button => {
button.addEventListener('click', async function() {
try {
const ruleKey = this.dataset.ruleKey;
const details = JSON.parse(this.dataset.ruleDetails);
modal.querySelector('#edit_original_rule_key').value = ruleKey;
const hostname = details.hostname || '';
const parts = hostname.split('.');
if (parts.length > 2 && !hostname.startsWith('*.')) {
modal.querySelector('#edit_manual_subdomain').value = parts.slice(0, -2).join('.');
modal.querySelector('#edit_manual_domain_name').value = parts.slice(-2).join('.');
} else {
modal.querySelector('#edit_manual_subdomain').value = '';
modal.querySelector('#edit_manual_domain_name').value = hostname;
}
const path = details.path || '';
modal.querySelector('#edit_manual_path_display').value = path.startsWith('/') ? path.substring(1) : path;
modal.querySelector('#edit_manual_path').value = path;
const service = details.service || '';
const serviceParts = service.split('://');
if (serviceParts.length === 2) {
modal.querySelector('#edit_manual_service_type').value = serviceParts[0];
modal.querySelector('#edit_manual_service_address').value = serviceParts[1];
} else if (service.startsWith('http_status:')) {
modal.querySelector('#edit_manual_service_type').value = 'http_status';
modal.querySelector('#edit_manual_service_address').value = service.split(':')[1];
}
const accessGroupSelect = modal.querySelector('#edit_manual_access_group');
const manualPolicySelect = modal.querySelector('#edit_manual_access_policy_type');
const selectedGroups = Array.isArray(details.access_group_id)
? details.access_group_id
: (details.access_group_id ? [details.access_group_id] : []);
if (accessGroupSelect) {
if (accessGroupSelect.tomselect) {
accessGroupSelect.tomselect.clear();
if (selectedGroups.length) {
accessGroupSelect.tomselect.setValue(selectedGroups, true);
}
} else {
accessGroupSelect.value = selectedGroups.length ? selectedGroups[0] : '';
}
}
if (manualPolicySelect) {
manualPolicySelect.value = details.access_policy_type || 'none';
manualPolicySelect.dispatchEvent(new Event('change'));
}
if (accessGroupSelect) {
accessGroupSelect.dispatchEvent(new Event('change'));
}
const authEmailField = modal.querySelector('#edit_manual_auth_email');
if (authEmailField) authEmailField.value = details.auth_email || '';
const zoneOverrideField = modal.querySelector('#edit_manual_zone_name_override');
if (zoneOverrideField) zoneOverrideField.value = '';
const noTlsVerifyField = modal.querySelector('#edit_manual_no_tls_verify');
if (noTlsVerifyField) noTlsVerifyField.checked = details.no_tls_verify || false;
const originServerNameField = modal.querySelector('#edit_manual_origin_server_name');
if (originServerNameField) originServerNameField.value = details.origin_server_name || '';
const httpHostHeaderField = modal.querySelector('#edit_manual_http_host_header');
if (httpHostHeaderField) httpHostHeaderField.value = details.http_host_header || '';
const http2OriginField = modal.querySelector('#edit_http2_origin');
if (http2OriginField) http2OriginField.checked = details.http2_origin || false;
const disableChunkedEncodingField = modal.querySelector('#edit_disable_chunked_encoding');
if (disableChunkedEncodingField) disableChunkedEncodingField.checked = details.disable_chunked_encoding || false;
const matchSniToHostField = modal.querySelector('#edit_match_sni_to_host');
if (matchSniToHostField) matchSniToHostField.checked = details.match_sni_to_host || false;
const tunnelDisplay = modal.querySelector('#edit_rule_tunnel_value');
const zoneDisplay = modal.querySelector('#edit_rule_zone_value');
const agentHint = modal.querySelector('#edit_rule_agent_hint');
if (tunnelDisplay) {
const tunnelId = details.tunnel_id;
const tunnelName = details.tunnel_name || (tunnelId ? 'Tunnel' : 'N/A');
if (tunnelId) {
const shortId = tunnelId.length > 12 ? `${tunnelId.slice(0, 12)}` : tunnelId;
tunnelDisplay.textContent = `${tunnelName} (${shortId})`;
} else {
tunnelDisplay.textContent = tunnelName;
}
}
if (zoneDisplay) {
zoneDisplay.textContent = details.zone_name || details.zone_id || '—';
}
if (agentHint) {
agentHint.classList.toggle('hidden', details.source !== 'agent');
}
modal.showModal();
} catch (e) {
console.error("Error populating edit modal:", e);
await dfAlert(t('js.alert.edit_dialog_error'), t('js.alert.error_title'));
}
});
});
}
function fixResourcesAndBase() {
const currentProtocol = window.location.protocol;
const currentHost = window.location.host;
document.querySelectorAll('link[rel="stylesheet"]').forEach(function(link) {
const href = link.getAttribute('href');
if (href && href.startsWith('http:') && currentProtocol === 'https:') {
link.setAttribute('href', href.replace('http:', 'https:'));
}
});
document.querySelectorAll('script[src]').forEach(function(script) {
const src = script.getAttribute('src');
if (src && src.startsWith('http:') && currentProtocol === 'https:') {
script.setAttribute('src', src.replace('http:', 'https:'));
}
});
document.querySelectorAll('link[rel="preconnect"]').forEach(function(link) {
const href = link.getAttribute('href');
if (href && href.startsWith('http:') && currentProtocol === 'https:') {
const urlObj = new URL(href);
link.setAttribute('href', currentProtocol + '//' + urlObj.host + (urlObj.pathname || '') + (urlObj.search || ''));
}
});
let baseTag = document.querySelector('base');
if (!baseTag) {
baseTag = document.createElement('base');
document.head.insertBefore(baseTag, document.head.firstChild);
}
baseTag.href = currentProtocol + '//' + currentHost + '/';
const origFetch = window.fetch;
window.fetch = function(url, options) {
let processedUrl = url;
if (url && typeof url === 'string') {
try {
const urlObj = new URL(url, document.baseURI);
if (urlObj.host === currentHost && urlObj.protocol !== currentProtocol) {
urlObj.protocol = currentProtocol;
processedUrl = urlObj.toString();
}
} catch (e) {}
}
return origFetch.call(this, processedUrl, options);
};
}
function fetchServicesSnapshot() {
const url = `${document.baseURI}api/v2/services?t=${Date.now()}`;
return fetch(url, { headers: buildApiHeaders() })
.then(response => {
if (!response.ok) {
throw new Error(`Snapshot request failed: ${response.status}`);
}
return response.json();
})
.then(payload => Array.isArray(payload.services) ? payload.services : []);
}
function updateRowFromService(row, service) {
if (!row || !service) return;
row.dataset.ruleStatus = service.status || '';
row.dataset.ruleSource = service.source || '';
const statusCell = row.querySelector('[data-role="status-cell"]');
const statusBadge = statusCell ? statusCell.querySelector('.status-badge') : null;
if (statusBadge) {
if (service.source === 'manual') {
statusBadge.textContent = 'Manual';
statusBadge.className = 'badge badge-info badge-sm status-badge';
} else {
const normalizedStatus = (service.status || 'unknown').replace(/_/g, ' ');
statusBadge.textContent = normalizedStatus;
let badgeClass = 'badge-success';
if (service.status && service.status.includes('pending')) {
badgeClass = 'badge-warning';
} else if (service.status && service.status.includes('error')) {
badgeClass = 'badge-error';
}
statusBadge.className = `badge ${badgeClass} badge-sm status-badge`;
}
}
const expiresCell = row.querySelector('[data-role="expires-cell"]');
if (expiresCell) {
if (service.status === 'pending_deletion' && service.delete_at) {
let container = expiresCell.querySelector('[data-delete-at]');
if (!container) {
expiresCell.innerHTML = '';
container = document.createElement('div');
expiresCell.appendChild(container);
}
container.setAttribute('data-delete-at', service.delete_at);
let absoluteSpan = container.querySelector('.absolute-time-display');
if (!absoluteSpan) {
absoluteSpan = document.createElement('span');
absoluteSpan.className = 'absolute-time-display';
container.appendChild(absoluteSpan);
}
let countdownSpan = container.querySelector('.countdown-timer');
if (!countdownSpan) {
countdownSpan = document.createElement('span');
countdownSpan.className = 'countdown-timer block text-xs opacity-80';
container.appendChild(countdownSpan);
}
} else {
expiresCell.innerHTML = '<span class="text-xs opacity-60">N/A</span>';
}
}
}
function removeServiceRow(ruleId) {
if (!ruleId) return false;
let removed = false;
document.querySelectorAll('tr[data-rule-key]').forEach(row => {
if (row.dataset.ruleKey === ruleId) {
row.remove();
removed = true;
}
});
return removed;
}
function applyServicesSnapshot(services) {
const servicesById = new Map();
services.forEach(service => {
if (service && service.id) {
servicesById.set(service.id, service);
}
});
const rows = Array.from(document.querySelectorAll('tr[data-rule-key]'));
rows.forEach(row => {
const key = row.dataset.ruleKey;
if (!servicesById.has(key)) {
row.remove();
return;
}
const service = servicesById.get(key);
updateRowFromService(row, service);
servicesById.delete(key);
});
if (servicesById.size > 0) {
window.location.reload();
return;
}
updateCountdowns();
}
function scheduleServicesSnapshotRefresh() {
if (!document.querySelector('tr[data-rule-key]')) {
return;
}
if (servicesSnapshotPromise) {
servicesSnapshotQueued = true;
return;
}
servicesSnapshotPromise = fetchServicesSnapshot()
.then(applyServicesSnapshot)
.catch(error => {
console.warn('Failed to refresh services snapshot:', error);
})
.finally(() => {
servicesSnapshotPromise = null;
if (servicesSnapshotQueued) {
servicesSnapshotQueued = false;
scheduleServicesSnapshotRefresh();
}
});
}
function findRowByRuleKey(ruleId) {
if (!ruleId) return null;
const rows = document.querySelectorAll('tr[data-rule-key]');
for (const row of rows) {
if (row.dataset.ruleKey === ruleId) {
return row;
}
}
return null;
}
const emailProgressState = {
steps: [],
timers: [],
autoCloseTimer: null,
activeIndex: -1,
};
const EMAIL_OPERATION_STEPS = {
setup_domain: [
{ key: 'email.progress.step_enable_routing', ms: 3000 },
{ key: 'email.progress.step_dns_records', ms: 4000 },
{ key: 'email.progress.step_r2_bucket', ms: 3000 },
{ key: 'email.progress.step_r2_credentials', ms: 2000 },
{ key: 'email.progress.step_generate_keys', ms: 2000 },
{ key: 'email.progress.step_deploy_inbound', ms: 6000 },
{ key: 'email.progress.step_deploy_outbound',ms: 6000 },
{ key: 'email.progress.step_restart_container', ms: 3000 },
],
create_mailbox: [
{ key: 'email.progress.step_create_rule', ms: 3000 },
{ key: 'email.progress.step_redeploy_inbound', ms: 6000 },
{ key: 'email.progress.step_restart_container', ms: 3000 },
],
delete_mailbox: [
{ key: 'email.progress.step_remove_rule', ms: 3000 },
{ key: 'email.progress.step_redeploy_inbound', ms: 6000 },
{ key: 'email.progress.step_restart_container', ms: 3000 },
],
repair_dns: [
{ key: 'email.progress.step_repair_dns', ms: 5000 },
],
teardown_domain: [
{ key: 'email.progress.step_remove_rule', ms: 3000 },
{ key: 'email.progress.step_delete_workers', ms: 4000 },
{ key: 'email.progress.step_remove_dns', ms: 3000 },
{ key: 'email.progress.step_restart_container', ms: 2000 },
],
teardown_all: [
{ key: 'email.progress.step_remove_rule', ms: 5000 },
{ key: 'email.progress.step_delete_workers', ms: 5000 },
{ key: 'email.progress.step_remove_dns', ms: 4000 },
{ key: 'email.progress.step_restart_container', ms: 2000 },
],
update_r2: [
{ key: 'email.progress.step_save_credentials', ms: 2000 },
{ key: 'email.progress.step_restart_container', ms: 3000 },
],
redeploy_workers: [
{ key: 'email.progress.step_deploy_inbound', ms: 6000 },
{ key: 'email.progress.step_deploy_outbound',ms: 6000 },
],
};
function emailProgressShowPanel(title) {
const panel = document.getElementById('emailProgressPanel');
if (!panel) return;
const titleEl = document.getElementById('emailProgressTitle');
if (titleEl) titleEl.textContent = title;
document.getElementById('emailProgressSteps').innerHTML = '';
panel.classList.remove('translate-y-4', 'opacity-0', 'pointer-events-none');
panel.classList.add('translate-y-0', 'opacity-100', 'pointer-events-auto');
}
function emailProgressHidePanel() {
const panel = document.getElementById('emailProgressPanel');
if (!panel) return;
emailProgressState.timers.forEach(t => clearTimeout(t));
emailProgressState.timers = [];
if (emailProgressState.autoCloseTimer) {
clearTimeout(emailProgressState.autoCloseTimer);
emailProgressState.autoCloseTimer = null;
}
emailProgressState.steps = [];
emailProgressState.activeIndex = -1;
panel.classList.add('translate-y-4', 'opacity-0', 'pointer-events-none');
panel.classList.remove('translate-y-0', 'opacity-100', 'pointer-events-auto');
}
function emailProgressRenderSteps() {
const list = document.getElementById('emailProgressSteps');
if (!list) return;
list.innerHTML = '';
for (const step of emailProgressState.steps) {
const li = document.createElement('li');
const iconSpan = document.createElement('span');
iconSpan.className = 'w-4 text-center shrink-0 inline-flex items-center justify-center';
if (step.status === 'done') {
li.className = 'flex items-center gap-2 text-success';
iconSpan.textContent = '✓';
} else if (step.status === 'error') {
li.className = 'flex items-center gap-2 text-error';
iconSpan.textContent = '✗';
} else if (step.status === 'active') {
li.className = 'flex items-center gap-2 text-primary';
const spinner = document.createElement('span');
spinner.className = 'loading loading-spinner loading-xs';
iconSpan.appendChild(spinner);
} else {
li.className = 'flex items-center gap-2 opacity-40';
iconSpan.textContent = '·';
}
const labelSpan = document.createElement('span');
labelSpan.textContent = t(step.key) || step.key;
li.appendChild(iconSpan);
li.appendChild(labelSpan);
list.appendChild(li);
}
}
function _emailProgressActivateStep(index, stepDefs) {
if (index >= emailProgressState.steps.length) return;
emailProgressState.activeIndex = index;
emailProgressState.steps[index].status = 'active';
emailProgressRenderSteps();
const timer = setTimeout(() => {
if (emailProgressState.steps[index]) {
emailProgressState.steps[index].status = 'done';
emailProgressRenderSteps();
}
_emailProgressActivateStep(index + 1, stepDefs);
}, stepDefs[index].ms);
emailProgressState.timers.push(timer);
}
function emailProgressStart(stepDefs, title) {
emailProgressHidePanel();
emailProgressState.steps = stepDefs.map(s => ({ key: s.key, status: 'pending' }));
emailProgressShowPanel(title);
emailProgressRenderSteps();
_emailProgressActivateStep(0, stepDefs);
}
function emailProgressFinish(success) {
emailProgressState.timers.forEach(t => clearTimeout(t));
emailProgressState.timers = [];
if (success) {
let delay = 0;
for (let i = 0; i < emailProgressState.steps.length; i++) {
if (emailProgressState.steps[i].status !== 'done') {
const idx = i;
const timer = setTimeout(() => {
if (emailProgressState.steps[idx]) {
emailProgressState.steps[idx].status = 'done';
emailProgressRenderSteps();
}
}, delay);
emailProgressState.timers.push(timer);
delay += 150;
}
}
emailProgressState.autoCloseTimer = setTimeout(() => {
emailProgressHidePanel();
location.reload();
}, delay + 1500);
} else {
const active = emailProgressState.steps.findIndex(s => s.status === 'active');
if (active >= 0) emailProgressState.steps[active].status = 'error';
for (let i = active + 1; i < emailProgressState.steps.length; i++) {
emailProgressState.steps[i].status = 'pending';
}
emailProgressRenderSteps();
}
}
function handleStructuredStateEvent(message) {
const eventType = message.type;
const data = message.data || {};
const ruleId = data.id;
switch (eventType) {
case 'snapshot_refresh':
scheduleServicesSnapshotRefresh();
break;
case 'service_deleted':
if (!removeServiceRow(ruleId)) {
scheduleServicesSnapshotRefresh();
}
break;
case 'service_pending_deletion':
case 'service_updated':
const targetRow = findRowByRuleKey(ruleId);
if (targetRow && data) {
updateRowFromService(targetRow, data);
updateCountdowns();
} else {
scheduleServicesSnapshotRefresh();
}
break;
case 'service_created':
default:
scheduleServicesSnapshotRefresh();
break;
}
}
function connectStateUpdateSource() {
if (!window.EventSource) {
console.error(t('js.text.state_sse_not_supported'));
return;
}
const streamUrl = `${window.location.origin}/stream-state-updates`;
if (activeStateEventSource) {
activeStateEventSource.close();
}
activeStateEventSource = new EventSource(streamUrl);
const eventSource = activeStateEventSource;
eventSource.onmessage = function(event) {
if (!event.data) {
return;
}
if (event.data === 'update') {
scheduleServicesSnapshotRefresh();
return;
}
if (event.data.trim().length === 0) {
return;
}
try {
const message = JSON.parse(event.data);
if (message && message.type) {
handleStructuredStateEvent(message);
} else {
scheduleServicesSnapshotRefresh();
}
} catch (error) {
console.warn('Failed to parse state stream payload:', error);
scheduleServicesSnapshotRefresh();
}
};
eventSource.onerror = function(err) {
console.error("State update stream connection error. It will be retried automatically by the browser.", err);
eventSource.close();
setTimeout(connectStateUpdateSource, 5000); // Reconnect after 5 seconds
};
}
function addLogLine(message, type = 'log') {
const logOutput = document.getElementById('log-output');
if (!logOutput) {
return;
}
if (!initialConnectMessageCleared && logOutput.textContent.includes(t('js.text.connecting_logs'))) {
logOutput.textContent = '';
initialConnectMessageCleared = true;
}
const newLogLine = document.createElement('div');
newLogLine.textContent = message;
if (type === 'status') newLogLine.classList.add('text-neutral-content', 'opacity-70', 'italic');
else if (type === 'error') newLogLine.classList.add('text-red-400', 'font-semibold');
else if (type === 'connected') newLogLine.classList.add('text-green-400');
const isScrolledToBottom = logOutput.scrollHeight - logOutput.clientHeight <= logOutput.scrollTop + 10;
logOutput.appendChild(newLogLine);
while (logOutput.childNodes.length > maxLogLines) {
logOutput.removeChild(logOutput.firstChild);
}
if (isScrolledToBottom) {
logOutput.scrollTop = logOutput.scrollHeight;
}
}
function setupLogControls() {
const enableBtn = document.getElementById('enable-logs-btn');
const disableBtn = document.getElementById('disable-logs-btn');
const clearBtn = document.getElementById('clear-logs-btn');
const logOutput = document.getElementById('log-output');
if (!enableBtn || !disableBtn || !clearBtn || !logOutput) return;
enableBtn.addEventListener('click', () => {
logsEnabled = true;
logOutput.classList.remove('hidden');
logOutput.textContent = t('js.text.connecting_logs');
connectEventSource();
enableBtn.classList.add('hidden');
disableBtn.classList.remove('hidden');
});
disableBtn.addEventListener('click', () => {
logsEnabled = false;
disconnectEventSource();
logOutput.classList.add('hidden');
enableBtn.classList.remove('hidden');
disableBtn.classList.add('hidden');
});
clearBtn.addEventListener('click', () => {
if (logOutput) {
logOutput.textContent = activeLogSource ? t('js.text.log_cleared') + '\n' : t('js.text.enable_logs_prompt');
}
});
}
function disconnectEventSource() {
if (activeLogSource) {
try {
activeLogSource.close();
} catch (e) {
console.error("Error closing log stream:", e);
}
activeLogSource = null;
}
if (eventSourceHealthCheck) {
clearInterval(eventSourceHealthCheck);
eventSourceHealthCheck = null;
}
}
function connectEventSource() {
const logOutput = document.getElementById('log-output');
if (!logOutput) {
return;
}
if (!window.EventSource) {
addLogLine(t('js.text.browser_sse_not_supported'), 'error');
return;
}
if (activeLogSource) {
try {
activeLogSource.close();
} catch (e) {
console.error("Error closing existing log stream:", e);
}
activeLogSource = null;
}
const streamUrl = `${document.baseURI}stream-logs?t=${Date.now()}`;
try {
activeLogSource = new EventSource(streamUrl);
let connectionTimeout;
const resetConnectionTimeout = () => {
if (connectionTimeout) clearTimeout(connectionTimeout);
connectionTimeout = setTimeout(() => {
if (activeLogSource) {
activeLogSource.close();
activeLogSource = null;
addLogLine(t('js.text.log_connection_timeout'), 'error');
setTimeout(connectEventSource, 2000);
}
}, 10000);
};
resetConnectionTimeout();
activeLogSource.onopen = function() {
if (connectionTimeout) clearTimeout(connectionTimeout);
addLogLine(t('js.text.log_connected'), 'connected');
};
activeLogSource.onmessage = function(event) {
resetConnectionTimeout();
if (event.data === "heartbeat" || event.data === ": keepalive") {
return;
}
addLogLine(event.data, 'log');
};
let retryAttempt = 0;
activeLogSource.onerror = function(err) {
if (connectionTimeout) clearTimeout(connectionTimeout);
if (activeLogSource && activeLogSource.readyState !== EventSource.CLOSED) {
addLogLine(t('js.text.log_connection_error'), 'error');
}
if (activeLogSource) {
activeLogSource.close();
activeLogSource = null;
}
if (logsEnabled) {
retryAttempt++;
const delay = Math.min(5000 * Math.pow(1.5, Math.min(retryAttempt - 1, 5)), 30000);
setTimeout(connectEventSource, delay);
}
};
} catch (e) {
addLogLine(t('js.text.log_connection_failed', {error: e.message}), 'error');
if (logsEnabled) {
setTimeout(connectEventSource, 5000);
}
}
if (eventSourceHealthCheck) clearInterval(eventSourceHealthCheck);
eventSourceHealthCheck = setInterval(() => {
if (logsEnabled && (!activeLogSource || activeLogSource.readyState === EventSource.CLOSED)) {
addLogLine(t('js.text.log_health_check_error'), 'status');
connectEventSource();
}
}, 15000);
}
function formatTimeDifference(diffMillis) {
const totalSeconds = Math.round(Math.abs(diffMillis / 1000));
if (totalSeconds < 60) return diffMillis >= 0 ? 'in <1m' : '<1m ago';
const days = Math.floor(totalSeconds / (3600 * 24));
const hours = Math.floor((totalSeconds % (3600 * 24)) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
let parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0 || (days === 0 && hours === 0)) parts.push(`${minutes}m`);
const timeString = parts.join(' ');
return diffMillis >= 0 ? `in ${timeString}` : `${timeString} ago`;
}
function updateCountdowns() {
document.querySelectorAll('div[data-delete-at]').forEach(div => {
const deleteAtISO = div.dataset.deleteAt;
if (!deleteAtISO) return;
const absoluteTimeSpan = div.querySelector('.absolute-time-display');
const countdownSpan = div.querySelector('.countdown-timer');
if (!absoluteTimeSpan || !countdownSpan) return;
try {
const targetDate = new Date(deleteAtISO);
if (isNaN(targetDate.getTime())) throw new Error("Invalid date");
const now = new Date();
const diffMs = targetDate - now;
const diffSeconds = Math.floor(diffMs / 1000);
if (diffMs < 0) {
absoluteTimeSpan.textContent = t('js.text.countdown_expired');
countdownSpan.textContent = "";
absoluteTimeSpan.className = 'absolute-time-display text-error font-bold';
} else if (diffSeconds < 3600) {
const minutes = Math.floor(diffSeconds / 60);
const seconds = diffSeconds % 60;
const timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`;
absoluteTimeSpan.textContent = t('js.text.countdown_expires_in', {time: timeStr});
countdownSpan.textContent = "";
if (diffSeconds <= 10) {
absoluteTimeSpan.className = 'absolute-time-display text-error font-bold animate-pulse';
} else if (diffSeconds <= 30) {
absoluteTimeSpan.className = 'absolute-time-display text-error font-semibold';
} else if (diffSeconds <= 120) {
absoluteTimeSpan.className = 'absolute-time-display text-warning font-semibold';
} else {
absoluteTimeSpan.className = 'absolute-time-display text-success';
}
} else {
const hours = Math.floor(diffSeconds / 3600);
const minutes = Math.floor((diffSeconds % 3600) / 60);
let timeStr = '';
if (hours > 0) {
timeStr = minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
} else {
timeStr = `${minutes}m`;
}
absoluteTimeSpan.textContent = t('js.text.countdown_expires_in', {time: timeStr});
countdownSpan.textContent = "";
absoluteTimeSpan.className = 'absolute-time-display text-base-content opacity-70';
}
const fullTimestamp = targetDate.toLocaleString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: 'short',
year: 'numeric'
});
div.setAttribute('title', `Exact time: ${fullTimestamp}`);
} catch (e) {
absoluteTimeSpan.textContent = t('js.text.invalid_date');
countdownSpan.textContent = "";
console.error("Error processing date for countdown:", deleteAtISO, e);
}
});
}
function startServerPing() {
if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => {
fetch(`${document.baseURI}ping?t=${Date.now()}`)
.then(response => response.ok ? response.json() : Promise.reject(`Ping failed: ${response.status}`))
.then(data => {})
.catch(error => console.warn("Server ping failed:", error));
}, 30000);
}
function updateReconciliationStatus() {
fetch(`${document.baseURI}reconciliation-status?t=${Date.now()}`)
.then(response => response.json())
.then(data => {
const statusElement = document.getElementById('reconciliation-status');
const messageElement = document.getElementById('reconciliation-status-message');
if (!statusElement || !messageElement) return;
if (data.status) {
messageElement.textContent = data.status;
messageElement.style.display = data.in_progress ? 'block' : 'none';
}
if (data.in_progress) {
statusElement.innerHTML = `<div role="alert" class="alert alert-warning shadow-md text-sm"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6 animate-spin"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6l4 2M21.56 10.5A10.001 10.001 0 0012 2a10 10 0 100 20 9.974 9.974 0 005.201-1.71l-.001-.001z"></path></svg><div><h3 class="font-bold">${t('js.text.reconciliation_progress', {progress: data.progress})}</h3><div class="text-xs">${t('js.text.reconciliation_processing', {processed: data.processed_items, total: data.total_items})}</div></div></div>`;
} else {
if (statusElement.innerHTML.includes('Reconciliation:') || statusElement.innerHTML.includes(t('js.text.reconciliation_progress', {progress: ''}))) {
statusElement.innerHTML = `<div role="alert" class="alert alert-success shadow-md text-sm"><svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg><span>${t('js.text.reconciliation_complete')}</span></div>`;
setTimeout(() => {
if (statusElement.innerHTML.includes(t('js.text.reconciliation_complete'))) {
statusElement.innerHTML = '';
if (messageElement) messageElement.style.display = 'none';
}
}, 5000);
}
}
}).catch(err => console.warn("Failed to fetch reconciliation status:", err));
}
function setupPathInput(displayElement, hiddenElement) {
if (!displayElement || !hiddenElement) return;
displayElement.addEventListener('input', function() {
let displayValue = this.value.trim();
if (displayValue) {
let pathSegment = displayValue.replace(/^\/+/, '');
hiddenElement.value = '/' + pathSegment;
} else {
hiddenElement.value = '';
}
});
}
function buildManualHostname(subdomain, domain) {
const domainPart = (domain || '').trim();
const subdomainPart = (subdomain || '').trim();
if (!domainPart) return '';
return subdomainPart ? `${subdomainPart}.${domainPart}` : domainPart;
}
async function fetchAccountTunnels() {
if (cachedTunnels !== null) return cachedTunnels;
try {
const response = await fetch('/api/v2/tunnels/account', {
headers: buildApiHeaders()
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
cachedTunnels = Array.isArray(data.tunnels) ? data.tunnels : [];
} catch (error) {
console.error('Failed to load account tunnels', error);
cachedTunnels = [];
}
return cachedTunnels;
}
async function fetchAccountZones() {
if (cachedZones !== null) return cachedZones;
try {
const response = await fetch('/api/v2/zones', {
headers: buildApiHeaders()
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
cachedZones = Array.isArray(data) ? data : [];
} catch (error) {
console.error('Failed to load account zones', error);
cachedZones = [];
}
return cachedZones;
}
function getZoneById(zoneId) {
if (!Array.isArray(cachedZones)) return null;
return cachedZones.find(zone => zone && zone.id === zoneId) || null;
}
function detectZoneForHostname(hostname) {
if (!hostname) return { status: 'empty' };
if (!Array.isArray(cachedZones) || cachedZones.length === 0) {
return { status: 'not_found', candidates: [] };
}
let normalizedHost = hostname.toLowerCase();
if (normalizedHost.startsWith('*.')) {
normalizedHost = normalizedHost.slice(2);
}
const matches = [];
cachedZones.forEach(zone => {
const zoneName = (zone && zone.name ? zone.name : '').toLowerCase();
if (!zoneName) return;
if (normalizedHost === zoneName || normalizedHost.endsWith(`.${zoneName}`)) {
matches.push(zone);
}
});
if (matches.length === 0) {
return { status: 'not_found', candidates: [] };
}
const longestLength = Math.max(...matches.map(z => (z.name || '').length));
const topMatches = matches.filter(z => (z.name || '').length === longestLength);
if (topMatches.length === 1) {
return { status: 'ok', zone: topMatches[0] };
}
return { status: 'ambiguous', candidates: topMatches };
}
function populateZoneSelector(selector, zones, placeholderText = 'Select a zone...') {
if (!selector) return;
selector.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = placeholderText;
placeholder.disabled = true;
placeholder.selected = true;
selector.appendChild(placeholder);
(zones || []).forEach(zone => {
if (!zone || !zone.id) return;
const option = document.createElement('option');
option.value = zone.id;
option.textContent = zone.name ? `${zone.name} (${zone.id.slice(0, 8)}${zone.id.length > 8 ? '…' : ''})` : zone.id;
selector.appendChild(option);
});
}
function setZoneBadge(badgeEl, text, variant) {
if (!badgeEl) return;
badgeEl.textContent = text;
badgeEl.classList.remove('hidden', 'badge-success', 'badge-warning', 'badge-error', 'badge-info');
const variantClass = variant ? `badge-${variant}` : 'badge-info';
badgeEl.classList.add(variantClass);
badgeEl.classList.remove('hidden');
}
function updateManualZoneUI(state, elements) {
if (!elements) return;
const { zoneIdInput, selectorWrapper, selectorEl, messageEl, badgeEl } = elements;
if (!zoneIdInput || !messageEl) return;
const hideBadge = () => {
if (badgeEl) {
badgeEl.classList.add('hidden');
}
};
switch (state.status) {
case 'empty':
zoneIdInput.value = '';
if (selectorWrapper) selectorWrapper.classList.add('hidden');
hideBadge();
messageEl.textContent = t('js.text.zone_enter_hostname');
break;
case 'override':
zoneIdInput.value = '';
if (selectorWrapper) selectorWrapper.classList.add('hidden');
if (state.zoneName) {
setZoneBadge(badgeEl, t('js.text.zone_badge_override'), 'info');
messageEl.textContent = t('js.text.zone_override', {zoneName: state.zoneName});
} else {
hideBadge();
messageEl.textContent = t('js.text.zone_override', {zoneName: ''});
}
break;
case 'ok':
zoneIdInput.value = state.zone && state.zone.id ? state.zone.id : '';
if (selectorWrapper) selectorWrapper.classList.add('hidden');
setZoneBadge(badgeEl, t('js.text.zone_badge_detected'), 'success');
messageEl.textContent = state.zone && state.zone.name ? t('js.text.zone_detected', {zoneName: state.zone.name}) : t('js.text.zone_detected', {zoneName: ''});
break;
case 'ambiguous':
zoneIdInput.value = '';
if (selectorWrapper && selectorEl) {
populateZoneSelector(selectorEl, state.candidates, 'Select a matching zone...');
selectorWrapper.classList.remove('hidden');
}
setZoneBadge(badgeEl, t('js.text.zone_badge_select'), 'warning');
messageEl.textContent = t('js.text.zone_select_multiple');
break;
case 'not_found':
zoneIdInput.value = '';
if (selectorWrapper && selectorEl) {
populateZoneSelector(selectorEl, cachedZones || [], 'Select a zone...');
selectorWrapper.classList.remove('hidden');
}
setZoneBadge(badgeEl, t('js.text.zone_badge_required'), 'warning');
messageEl.textContent = t('js.text.zone_not_found');
break;
case 'selected':
zoneIdInput.value = state.zone && state.zone.id ? state.zone.id : '';
if (selectorWrapper) selectorWrapper.classList.remove('hidden');
setZoneBadge(badgeEl, t('js.text.zone_badge_selected'), 'success');
messageEl.textContent = state.zone && state.zone.name ? t('js.text.zone_selected', {zoneName: state.zone.name}) : t('js.text.zone_selected', {zoneName: ''});
break;
default:
break;
}
}
async function populateManualTunnelOptions(feedbackEl) {
if (!manualTunnelTomSelect) return;
const tunnels = await fetchAccountTunnels();
manualTunnelTomSelect.clearOptions();
if (Array.isArray(tunnels) && tunnels.length > 0) {
tunnels.forEach(tunnel => {
if (!tunnel || !tunnel.id) return;
const label = tunnel.name ? `${tunnel.name} (${tunnel.id.slice(0, 8)}${tunnel.id.length > 8 ? '…' : ''})` : tunnel.id;
manualTunnelTomSelect.addOption({ value: tunnel.id, text: label });
});
manualTunnelTomSelect.refreshOptions(false);
const defaultId = manualTunnelTomSelect.input.dataset.defaultTunnelId;
if (defaultId && tunnels.some(t => t && t.id === defaultId)) {
manualTunnelTomSelect.setValue(defaultId, true);
} else {
manualTunnelTomSelect.clear(true);
}
if (feedbackEl) {
feedbackEl.classList.add('hidden');
feedbackEl.classList.remove('alert-warning', 'alert-error', 'alert-success');
}
} else {
manualTunnelTomSelect.refreshOptions(false);
if (feedbackEl) {
feedbackEl.textContent = t('js.text.no_tunnels_found');
feedbackEl.classList.remove('hidden');
feedbackEl.classList.remove('alert-success', 'alert-error');
feedbackEl.classList.add('alert-warning');
}
}
}
async function initializeManualRuleForm() {
const form = document.getElementById('add_manual_rule_form');
if (!form) return;
const subdomainInput = document.getElementById('manual_subdomain');
const domainInput = document.getElementById('manual_domain_name');
const zoneIdInput = document.getElementById('manual_zone_id');
const zoneMessageEl = document.getElementById('manual_zone_message');
const zoneBadgeEl = document.getElementById('manual_zone_status_badge');
const zoneSelectorWrapper = document.getElementById('manual_zone_selector_wrapper');
const zoneSelectorEl = document.getElementById('manual_zone_selector');
const zoneOverrideInput = document.getElementById('manual_zone_name_override');
const feedbackEl = document.getElementById('manual_rule_feedback');
const elements = {
zoneIdInput,
selectorWrapper: zoneSelectorWrapper,
selectorEl: zoneSelectorEl,
messageEl: zoneMessageEl,
badgeEl: zoneBadgeEl
};
await populateManualTunnelOptions(feedbackEl);
await fetchAccountZones();
const triggerDetection = async () => {
const overrideValue = zoneOverrideInput ? zoneOverrideInput.value.trim() : '';
if (overrideValue) {
updateManualZoneUI({ status: 'override', zoneName: overrideValue }, elements);
return;
}
const hostname = buildManualHostname(subdomainInput ? subdomainInput.value : '', domainInput ? domainInput.value : '');
if (!hostname) {
updateManualZoneUI({ status: 'empty' }, elements);
return;
}
const detectionResult = detectZoneForHostname(hostname);
updateManualZoneUI(detectionResult, elements);
};
const scheduleDetection = () => {
if (manualZoneDetectionTimeout) {
clearTimeout(manualZoneDetectionTimeout);
}
manualZoneDetectionTimeout = setTimeout(triggerDetection, 200);
};
if (subdomainInput) subdomainInput.addEventListener('input', scheduleDetection);
if (domainInput) domainInput.addEventListener('input', scheduleDetection);
if (zoneOverrideInput) {
zoneOverrideInput.addEventListener('input', () => {
if (zoneOverrideInput.value.trim()) {
updateManualZoneUI({ status: 'override', zoneName: zoneOverrideInput.value.trim() }, elements);
} else {
scheduleDetection();
}
});
}
if (zoneSelectorEl) {
zoneSelectorEl.addEventListener('change', () => {
const selectedId = zoneSelectorEl.value;
if (!selectedId) {
zoneIdInput.value = '';
return;
}
const zone = getZoneById(selectedId) || { id: selectedId };
updateManualZoneUI({ status: 'selected', zone }, elements);
});
}
scheduleDetection();
}
function openCreateAccessGroupModal() {
const modal = document.getElementById('access_group_modal');
if (!modal) return;
const form = document.getElementById('access_group_form');
const title = document.getElementById('access_group_modal_title');
const groupIdInput = document.getElementById('group_id');
form.reset();
form.action = `${document.baseURI}ui/access-groups/create`;
title.textContent = t('js.text.create_access_group_title');
groupIdInput.disabled = false;
document.getElementById('original_group_id').value = '';
const countrySelect = document.getElementById('group_countries');
if (countrySelect && countrySelect.tomselect) {
countrySelect.tomselect.clear();
countrySelect.tomselect.sync();
if (window.enhancedCountrySelector && window.enhancedCountrySelector.updateSelectionCounter) {
window.enhancedCountrySelector.updateSelectionCounter();
}
}
if (window.idpTomSelect) {
window.idpTomSelect.clear();
}
modal.showModal();
}
async function openEditAccessGroupModal(groupId, details) {
const modal = document.getElementById('access_group_modal');
if (!modal) return;
const form = document.getElementById('access_group_form');
const title = document.getElementById('access_group_modal_title');
const groupIdInput = document.getElementById('group_id');
form.reset();
form.action = `${document.baseURI}ui/access-groups/edit/${encodeURIComponent(groupId)}`;
title.textContent = t('js.text.edit_access_group_title', {displayName: details.display_name});
document.getElementById('original_group_id').value = groupId;
groupIdInput.value = groupId;
groupIdInput.disabled = true;
document.getElementById('group_display_name').value = details.display_name || '';
document.getElementById('group_session_duration').value = details.session_duration || '24h';
document.getElementById('group_app_launcher_visible').checked = details.app_launcher_visible || false;
document.getElementById('group_auto_redirect').checked = details.auto_redirect_to_identity || false;
const isPublicMode = details.public_mode === true;
document.getElementById('public_mode').value = isPublicMode ? 'true' : 'false';
if (typeof window.switchToMode === 'function') {
if (isPublicMode) {
window.switchToMode('public');
} else {
window.switchToMode('authenticated');
}
}
let emailText = '';
let ipRangeText = '';
let selectedCountries = [];
let selectedIdpIds = [];
if (details.policies && Array.isArray(details.policies)) {
const emails = [];
const ipRanges = [];
details.policies.forEach(policy => {
if (policy.include) {
policy.include.forEach(rule => {
if (rule.email && rule.email.email) emails.push(rule.email.email);
else if (rule.email_domain && rule.email_domain.domain) emails.push(`@${rule.email_domain.domain}`);
else if (rule.ip && rule.ip.ip) ipRanges.push(rule.ip.ip);
else if (rule['login_method'] && rule['login_method'].id) selectedIdpIds.push(rule['login_method'].id);
});
}
if (policy.exclude && Array.isArray(policy.exclude)) {
policy.exclude.forEach(rule => {
if (rule.geo && rule.geo.country_code) {
selectedCountries.push(rule.geo.country_code);
}
});
}
});
emailText = [...new Set(emails)].join(', ');
ipRangeText = [...new Set(ipRanges)].join(', ');
selectedCountries = [...new Set(selectedCountries)];
}
document.getElementById('group_emails').value = emailText;
document.getElementById('group_ip_ranges').value = ipRangeText;
const countrySelect = document.getElementById('group_countries');
if (countrySelect && countrySelect.tomselect) {
countrySelect.tomselect.setValue(selectedCountries);
if (window.enhancedCountrySelector && window.enhancedCountrySelector.updateSelectionCounter) {
window.enhancedCountrySelector.updateSelectionCounter();
}
} else if (countrySelect) {
Array.from(countrySelect.options).forEach(option => {
option.selected = selectedCountries.includes(option.value);
});
}
if (window.idpTomSelect && selectedIdpIds.length > 0) {
try {
const response = await fetch('/api/v2/idp/list');
const data = await response.json();
if (data.success && data.identity_providers) {
const idToFriendlyName = {};
for (const [friendlyName, idpData] of Object.entries(data.identity_providers)) {
idToFriendlyName[idpData.cloudflare_id] = friendlyName;
}
const selectedIdpFriendlyNames = selectedIdpIds
.map(id => idToFriendlyName[id])
.filter(name => name);
window.idpTomSelect.setValue(selectedIdpFriendlyNames);
}
} catch (err) {
console.error('Failed to load IdPs for edit modal:', err);
}
}
modal.showModal();
}
function updateManualRuleServiceFields() {
const manualServiceTypeSelect = document.getElementById('manual_service_type');
if (!manualServiceTypeSelect) return;
const selectedType = manualServiceTypeSelect.value.toLowerCase();
const noTlsVerifyDiv = document.getElementById('manual_no_tls_verify_div');
const originServerNameDiv = document.getElementById('manual_origin_server_name_div');
const manualServiceAddressInput = document.getElementById('manual_service_address');
const manualServiceAddressLabel = document.getElementById('manual_service_address_label');
const manualServiceHelpText = document.getElementById('manual_service_help');
const manualServicePrefixSpan = document.getElementById('manual_service_prefix_span');
const matchSniToHostDiv = document.getElementById('manual_match_sni_to_host_div');
let showNoTlsVerify = false;
let showOriginServerName = false;
if (manualServiceAddressInput) manualServiceAddressInput.style.display = '';
if (manualServicePrefixSpan) manualServicePrefixSpan.classList.add('hidden');
if (manualServiceAddressInput) manualServiceAddressInput.placeholder = 'host:port or status code';
if (manualServiceAddressLabel) manualServiceAddressLabel.textContent = 'URL (Required for most types)';
if (manualServiceHelpText) manualServiceHelpText.textContent = 'e.g., 192.168.1.10:8000 or my-service.local:3000 for HTTP/S/TCP etc.';
if (selectedType === 'http' || selectedType === 'https') {
if (manualServicePrefixSpan) {
manualServicePrefixSpan.textContent = selectedType + '://';
manualServicePrefixSpan.classList.remove('hidden');
}
if (manualServiceAddressInput) manualServiceAddressInput.placeholder = 'host:port or resolvable hostname';
if (manualServiceAddressLabel) manualServiceAddressLabel.textContent = 'Origin URL (Required)';
if (manualServiceHelpText) manualServiceHelpText.textContent = 'e.g., 192.168.1.10:8000 or my-service.local:3000';
showNoTlsVerify = true;
showOriginServerName = true;
} else if (selectedType === 'tcp' || selectedType === 'ssh' || selectedType === 'rdp') {
if (manualServicePrefixSpan) {
manualServicePrefixSpan.textContent = selectedType + '://';
manualServicePrefixSpan.classList.remove('hidden');
}
if (manualServiceAddressInput) manualServiceAddressInput.placeholder = 'host:port';
if (manualServiceAddressLabel) manualServiceAddressLabel.textContent = `Origin Address for ${selectedType.toUpperCase()} (host:port)`;
if (manualServiceHelpText) manualServiceHelpText.textContent = `e.g., my-internal-server:22`;
} else if (selectedType === 'http_status') {
if (manualServiceAddressInput) manualServiceAddressInput.placeholder = 'e.g., 404';
if (manualServiceAddressLabel) manualServiceAddressLabel.textContent = 'HTTP Status Code (e.g., 200, 404, 503)';
if (manualServiceHelpText) manualServiceHelpText.textContent = 'Enter a valid HTTP status code (100-599).';
} else if (selectedType === 'bastion') {
if (manualServiceAddressInput) manualServiceAddressInput.style.display = 'none';
if (manualServicePrefixSpan) manualServicePrefixSpan.classList.add('hidden');
if (manualServiceAddressLabel) manualServiceAddressLabel.textContent = 'Origin URL (Not Required)';
if (manualServiceHelpText) manualServiceHelpText.textContent = 'Bastion mode routes based on the public hostname directly.';
showNoTlsVerify = false;
showOriginServerName = false;
}
if (noTlsVerifyDiv) {
noTlsVerifyDiv.style.display = showNoTlsVerify ? '' : 'none';
}
if (originServerNameDiv) {
originServerNameDiv.style.display = showOriginServerName ? '' : 'none';
}
if (matchSniToHostDiv) {
matchSniToHostDiv.style.display = showOriginServerName ? '' : 'none';
}
}
document.addEventListener('DOMContentLoaded', function() {
fixResourcesAndBase();
themeManager.initialize();
initializeAllTomSelects();
const manualServiceTypeSelect = document.getElementById('manual_service_type');
if (manualServiceTypeSelect) {
manualServiceTypeSelect.addEventListener('change', updateManualRuleServiceFields);
updateManualRuleServiceFields(); // Run once on load
setupPathInput(document.getElementById('manual_path_display'), document.getElementById('manual_path'));
setupPathInput(document.getElementById('edit_manual_path_display'), document.getElementById('edit_manual_path'));
initializeEditRuleModal();
initializeManualRuleForm();
}
const deleteTunnelModal = document.getElementById('delete_tunnel_modal');
if (deleteTunnelModal) {
const confirmInput = document.getElementById('delete_tunnel_confirm_input');
const confirmButton = document.getElementById('delete_tunnel_confirm_button');
const tunnelIdField = document.getElementById('delete_tunnel_id');
const warningText = document.getElementById('delete_tunnel_warning_text');
const updateConfirmState = () => {
if (!confirmButton) return;
confirmButton.disabled = confirmInput.value.trim().toLowerCase() !== 'delete';
};
if (confirmInput) {
confirmInput.addEventListener('input', updateConfirmState);
}
document.querySelectorAll('.delete-tunnel-btn').forEach(button => {
button.addEventListener('click', () => {
const tunnelId = button.dataset.tunnelId || '';
const tunnelName = button.dataset.tunnelName || '';
const friendlyName = tunnelName || tunnelId;
const safeName = friendlyName
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
if (tunnelIdField) {
tunnelIdField.value = tunnelId;
}
if (warningText) {
warningText.innerHTML = `Deleting <span class="font-semibold">${safeName}</span> will disconnect any agents currently connected to this Cloudflare Tunnel. This action cannot be undone.`;
}
if (confirmInput) {
confirmInput.value = '';
updateConfirmState();
}
deleteTunnelModal.showModal();
});
});
}
const manualAccessGroupSelect = document.getElementById('manual_access_group');
const manualPolicyOptionsWrapper = document.getElementById('manual_policy_options_wrapper');
if (manualAccessGroupSelect && manualPolicyOptionsWrapper) {
manualAccessGroupSelect.addEventListener('change', function() {
const isDisabled = !!this.value;
manualPolicyOptionsWrapper.style.opacity = isDisabled ? '0.5' : '1';
manualPolicyOptionsWrapper.querySelectorAll('select, input').forEach(el => {
el.disabled = isDisabled;
});
});
manualAccessGroupSelect.dispatchEvent(new Event('change'));
}
const editManualAccessGroupSelect = document.getElementById('edit_manual_access_group');
const editManualPolicyOptionsWrapper = document.getElementById('edit_manual_policy_options_wrapper');
if (editManualAccessGroupSelect && editManualPolicyOptionsWrapper) {
editManualAccessGroupSelect.addEventListener('change', function() {
const isDisabled = !!this.value;
editManualPolicyOptionsWrapper.style.opacity = isDisabled ? '0.5' : '1';
editManualPolicyOptionsWrapper.querySelectorAll('select, input').forEach(el => {
el.disabled = isDisabled;
});
});
}
document.querySelectorAll('.policy-type-select').forEach(select => {
const container = select.closest('.dropdown-content, .modal-box, form');
if (!container) {
console.warn('Could not find container for policy select:', select);
return;
}
const emailField = container.querySelector('.auth-email-field');
if (!emailField) {
return;
}
const toggleEmailField = () => {
if (select.value === 'authenticate_email') {
emailField.classList.remove('hidden');
} else {
emailField.classList.add('hidden');
}
};
const clearAccessGroupOnPolicyChange = () => {
if (select.value && select.value !== 'none') {
const accessGroupSelect = container.querySelector('#manual_access_group, #edit_manual_access_group');
if (accessGroupSelect) {
if (accessGroupSelect.tomselect) {
accessGroupSelect.tomselect.clear();
} else {
accessGroupSelect.value = '';
}
accessGroupSelect.dispatchEvent(new Event('change'));
}
}
};
select.addEventListener('change', () => {
toggleEmailField();
clearAccessGroupOnPolicyChange();
});
});
document.querySelectorAll('.tunnel-dns-toggle').forEach(button => {
button.addEventListener('click', async function() {
const tunnelId = this.dataset.tunnelId;
const dnsRecordsDisplayRow = this.closest('tr').nextElementSibling;
const targetDiv = document.getElementById(this.getAttribute('aria-controls'));
const isExpanded = this.getAttribute('aria-expanded') === 'true';
const expandIcon = this.querySelector('.expand-icon');
const collapseIcon = this.querySelector('.collapse-icon');
if (!dnsRecordsDisplayRow || !targetDiv) return;
if (isExpanded) {
dnsRecordsDisplayRow.classList.add('hidden');
this.setAttribute('aria-expanded', 'false');
if (expandIcon) expandIcon.classList.remove('hidden');
if (collapseIcon) collapseIcon.classList.add('hidden');
} else {
this.setAttribute('aria-expanded', 'true');
if (expandIcon) expandIcon.classList.add('hidden');
if (collapseIcon) collapseIcon.classList.remove('hidden');
if (targetDiv.dataset.loaded !== 'true' || targetDiv.dataset.loaded === 'error') {
targetDiv.innerHTML = `<p class="opacity-60 italic animate-pulse p-2">${t('js.text.loading_dns')}</p>`;
dnsRecordsDisplayRow.classList.remove('hidden');
try {
const fetchUrl = `${document.baseURI}tunnel-dns-records/${encodeURIComponent(tunnelId)}?t=${Date.now()}`;
const response = await fetch(fetchUrl);
if (!response.ok) {
let errorDetail = `HTTP error ${response.status}`;
try { const errorData = await response.json(); errorDetail = errorData.error || errorData.message || errorDetail; } catch (e) {}
throw new Error(errorDetail);
}
const data = await response.json();
const currentTargetDiv = document.getElementById(`dns-records-${tunnelId}`);
if (!currentTargetDiv) return;
if (data.dns_records && data.dns_records.length > 0) {
let dnsHtml = '<ul class="list-none pl-4 space-y-1.5">';
data.dns_records.forEach(record => {
const recordUrl = `https://${record.name}`;
const zoneDisplay = record.zone_name ? record.zone_name : record.zone_id;
dnsHtml += `<li class="opacity-90 text-xs"><svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 inline-block mr-1 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg> <a href="${recordUrl}" target="_blank" rel="noopener noreferrer" class="link link-hover">${record.name}</a> <span class="ml-2 opacity-60">(Zone: ${zoneDisplay})</span></li>`;
});
dnsHtml += '</ul>';
currentTargetDiv.innerHTML = dnsHtml;
currentTargetDiv.dataset.loaded = 'true';
} else {
currentTargetDiv.innerHTML = `<p class="opacity-60 italic p-2">${data.message || t('js.text.no_cname_records')}</p>`;
currentTargetDiv.dataset.loaded = 'true';
}
} catch (error) {
const errorTargetDiv = document.getElementById(`dns-records-${tunnelId}`);
if (errorTargetDiv) {
errorTargetDiv.innerHTML = `<p class="text-error p-2">${t('js.text.error_loading_dns', {error: error.message})}</p>`;
errorTargetDiv.dataset.loaded = 'error';
}
}
}
dnsRecordsDisplayRow.classList.remove('hidden');
}
});
});
document.querySelectorAll('.edit-access-group-btn').forEach(button => {
button.addEventListener('click', function() {
const groupId = this.dataset.groupId;
const details = JSON.parse(this.dataset.groupDetails);
openEditAccessGroupModal(groupId, details);
});
});
const createAccessGroupBtn = document.getElementById('create-access-group-btn');
if (createAccessGroupBtn) {
createAccessGroupBtn.addEventListener('click', function() {
openCreateAccessGroupModal();
});
}
document.querySelectorAll('form.protocol-aware-form').forEach(form => {
if (form.getAttribute('action')) {
try {
const fullActionUrl = new URL(form.getAttribute('action'), document.baseURI);
form.setAttribute('action', fullActionUrl.toString());
} catch (e) {}
}
});
updateCountdowns();
setInterval(updateCountdowns, 1000);
if (document.getElementById('log-output')) {
setupLogControls();
}
if (document.getElementById('reconciliation-status')) {
updateReconciliationStatus();
setInterval(updateReconciliationStatus, 2000);
}
connectStateUpdateSource();
scheduleServicesSnapshotRefresh();
document.getElementById('emailProgressClose')?.addEventListener('click', emailProgressHidePanel);
startServerPing();
if (document.getElementById('idp-table-container')) {
loadIdentityProviders();
document.getElementById('sync-idps-btn')?.addEventListener('click', syncIdentityProviders);
document.getElementById('create-idp-btn')?.addEventListener('click', () => {
showIdPModal('create');
});
document.getElementById('idp-form')?.addEventListener('submit', handleIdPFormSubmit);
document.getElementById('idp-type')?.addEventListener('change', updateIdPFormFields);
}
window.addEventListener('beforeunload', function() {
if (activeLogSource) activeLogSource.close();
if (activeStateEventSource) activeStateEventSource.close();
if (eventSourceHealthCheck) clearInterval(eventSourceHealthCheck);
if (pingInterval) clearInterval(pingInterval);
});
});
let idpTypes = {};
async function loadIdentityProviders() {
try {
const [typesResponse, idpsResponse] = await Promise.all([
fetch('/api/v2/idp/types'),
fetch('/api/v2/idp/list')
]);
if (typesResponse.ok) {
const typesData = await typesResponse.json();
idpTypes = typesData.types || {};
}
if (idpsResponse.ok) {
const data = await idpsResponse.json();
renderIdPTable(data.identity_providers || {});
} else {
document.getElementById('idp-table-container').innerHTML =
`<p class="text-center text-error py-8">${t('js.table.idp_failed_to_load')}</p>`;
}
} catch (error) {
console.error('Error loading IdPs:', error);
document.getElementById('idp-table-container').innerHTML =
`<p class="text-center text-error py-8">${t('js.table.idp_error_loading')}</p>`;
}
}
function renderIdPTable(idps) {
const container = document.getElementById('idp-table-container');
if (!idps || Object.keys(idps).length === 0) {
container.innerHTML = `<p class="text-center opacity-70 py-8">${t('js.table.idp_empty')}</p>`;
return;
}
const typeIcons = {
'google': '<svg class="w-6 h-6" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>',
'google-apps': '<svg class="w-6 h-6" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>',
'azureAD': '<svg class="w-6 h-6" viewBox="0 0 24 24"><path fill="#00A4EF" d="M0 0h11.377v11.372H0z"/><path fill="#FFB900" d="M12.623 0H24v11.372H12.623z"/><path fill="#7FBA00" d="M0 12.628h11.377V24H0z"/><path fill="#F25022" d="M12.623 12.628H24V24H12.623z"/></svg>',
'okta': '<svg class="w-6 h-6" viewBox="0 0 24 24"><path fill="#007DC1" d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 18c-3.314 0-6-2.686-6-6s2.686-6 6-6 6 2.686 6 6-2.686 6-6 6z"/></svg>',
'github': '<svg class="w-6 h-6" viewBox="0 0 24 24"><path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>',
'oidc': '<svg class="w-6 h-6" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg>',
'onetimepin': '<svg class="w-6 h-6" viewBox="0 0 32 32"><path d="M8.16 23h21.177v-5.86l-4.023-2.307-.694-.3-16.46.113z" fill="#fff"/><path d="M22.012 22.222c.197-.675.122-1.294-.206-1.754-.3-.422-.807-.666-1.416-.694l-11.545-.15c-.075 0-.14-.038-.178-.094s-.047-.13-.028-.206c.038-.113.15-.197.272-.206l11.648-.15c1.38-.066 2.88-1.182 3.404-2.55l.666-1.735a.38.38 0 0 0 .02-.225c-.75-3.395-3.78-5.927-7.4-5.927-3.34 0-6.17 2.157-7.184 5.15-.657-.488-1.5-.75-2.392-.666-1.604.16-2.9 1.444-3.048 3.048a3.58 3.58 0 0 0 .084 1.191A4.84 4.84 0 0 0 0 22.1c0 .234.02.47.047.703.02.113.113.197.225.197H21.58a.29.29 0 0 0 .272-.206l.16-.572z" fill="#f38020"/><path d="M25.688 14.803l-.32.01c-.075 0-.14.056-.17.13l-.45 1.566c-.197.675-.122 1.294.206 1.754.3.422.807.666 1.416.694l2.457.15c.075 0 .14.038.178.094s.047.14.028.206c-.038.113-.15.197-.272.206l-2.56.15c-1.388.066-2.88 1.182-3.404 2.55l-.188.478c-.038.094.028.188.13.188h8.797a.23.23 0 0 0 .225-.169A6.41 6.41 0 0 0 32 21.106a6.32 6.32 0 0 0-6.312-6.302" fill="#faae40"/></svg>'
};
let tableHTML = `
<table class="table table-zebra table-sm policy-table w-full table-responsive">
<colgroup class="hidden md:table-column-group">
<col class="col-primary">
<col class="col-secondary">
<col class="col-tertiary">
<col class="col-status">
<col class="col-actions">
</colgroup>
<thead>
<tr>
<th class="p-3">${t('js.table.provider')}</th>
<th class="p-3">${t('js.table.cloudflare_id')}</th>
<th class="p-3">${t('js.table.connector')}</th>
<th class="p-3">${t('js.table.status')}</th>
<th class="p-3 text-right">${t('js.table.actions')}</th>
</tr>
</thead>
<tbody>`;
for (const [friendlyName, idpData] of Object.entries(idps)) {
const icon = typeIcons[idpData.type] || '<svg class="w-6 h-6" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg>';
const isSystem = idpData.system_managed || false;
const statusBadge = isSystem ?
`<span class="badge badge-sm badge-success gap-2"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg>${t('js.table.system_managed')}</span>` :
`<span class="badge badge-sm badge-info gap-2"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg>${t('js.table.user_configured')}</span>`;
tableHTML += `
<tr>
<td class="p-3" data-label="${t('js.table.provider')}">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center">${icon}</span>
<div class="font-medium">${idpData.name}</div>
</div>
</td>
<td class="p-3 text-xs opacity-70" data-label="${t('js.table.cloudflare_id')}">
${idpData.cloudflare_id ? `<span class="tooltip" data-tip="${idpData.cloudflare_id}"><code>${idpData.cloudflare_id.slice(0, 8)}...</code></span>` : '-'}
</td>
<td class="p-3 text-sm opacity-80" data-label="${t('js.table.connector')}">${idpData.type}</td>
<td class="p-3" data-label="${t('js.table.status')}">${statusBadge}</td>
<td class="p-3 text-right" data-label="${t('js.table.actions')}">
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z" />
</svg>
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">`;
if (!isSystem) {
tableHTML += `
<li><a onclick="showIdPModal('edit', '${friendlyName}')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
${t('js.table.idp_edit')}
</a></li>`;
}
if (idpData.cloudflare_id) {
tableHTML += `
<li><a onclick="testIdP('${idpData.cloudflare_id}')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
${t('js.table.idp_test')}
</a></li>`;
}
if (!isSystem) {
tableHTML += `
<li><a onclick="deleteIdP('${friendlyName}')" class="text-error">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
${t('js.table.idp_delete')}
</a></li>`;
}
tableHTML += `
</ul>
</div>
</td>
</tr>`;
}
tableHTML += `
</tbody>
</table>`;
container.innerHTML = tableHTML;
}
async function syncIdentityProviders() {
const btn = document.getElementById('sync-idps-btn');
btn.disabled = true;
btn.innerHTML = `<span class="loading loading-spinner loading-sm"></span> ${t('js.sync.syncing')}`;
try {
const response = await fetch('/api/v2/idp/sync', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
if (data.success) {
await loadIdentityProviders();
} else {
await dfAlert(t('js.alert.sync_error', {error: data.error || t('js.alert.sync_error_generic')}), t('js.alert.sync_error_title'));
}
} catch (error) {
console.error('Error syncing IdPs:', error);
await dfAlert(t('js.alert.sync_error_generic'), t('js.alert.error_title'));
} finally {
btn.disabled = false;
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-1"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /></svg> ${t('js.sync.default_text')}`;
}
}
function showIdPModal(mode, friendlyName = null) {
const modal = document.getElementById('idp-modal');
const form = document.getElementById('idp-form');
const title = document.getElementById('idp-modal-title');
const submitBtn = document.getElementById('idp-submit-btn');
document.getElementById('idp-mode').value = mode;
form.reset();
document.getElementById('idp-config-fields').innerHTML = `<div class="alert alert-warning"><svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg><span>${t('js.modal.idp_select_type')}</span></div>`;
if (mode === 'create') {
title.textContent = t('js.modal.idp_title_create');
submitBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 mr-1"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> ${t('js.modal.idp_btn_create')}`;
document.getElementById('idp-friendly-name').disabled = false;
} else if (mode === 'edit' && friendlyName) {
title.textContent = t('js.modal.idp_title_edit');
submitBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 mr-1"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> ${t('js.modal.idp_btn_update')}`;
document.getElementById('idp-friendly-name').disabled = true;
document.getElementById('idp-edit-name').value = friendlyName;
fetch(`/api/v2/idp/${friendlyName}`)
.then(r => r.json())
.then(data => {
if (data.success && data.identity_provider) {
const idp = data.identity_provider;
document.getElementById('idp-friendly-name').value = friendlyName;
document.getElementById('idp-display-name').value = idp.name || '';
document.getElementById('idp-type').value = idp.type || '';
updateIdPFormFields();
}
});
}
modal.showModal();
}
function updateIdPFormFields() {
const type = document.getElementById('idp-type').value;
const container = document.getElementById('idp-config-fields');
const redirectInfo = document.getElementById('redirect-url-info');
if (!type || !idpTypes[type]) {
container.innerHTML = `<div class="alert alert-warning"><svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg><span>${t('js.modal.idp_select_type')}</span></div>`;
redirectInfo.classList.add('hidden');
return;
}
const typeConfig = idpTypes[type];
let fieldsHTML = '';
for (const [fieldName, fieldConfig] of Object.entries(typeConfig.fields)) {
const required = fieldConfig.required ? '<span class="label-text-alt text-error">*</span>' : '';
const inputType = fieldConfig.type === 'password' ? 'password' : 'text';
const placeholder = fieldConfig.placeholder || '';
fieldsHTML += `
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">${fieldConfig.label}</span>
${required}
</label>
<input type="${inputType}"
id="idp-config-${fieldName}"
name="${fieldName}"
placeholder="${placeholder}"
class="input input-bordered w-full"
${fieldConfig.required ? 'required' : ''}>
</div>`;
}
container.innerHTML = fieldsHTML;
redirectInfo.classList.remove('hidden');
document.getElementById('redirect-url-display').textContent = 'https://[your-team].cloudflareaccess.com/cdn-cgi/access/callback';
}
async function handleIdPFormSubmit(e) {
e.preventDefault();
const mode = document.getElementById('idp-mode').value;
const friendlyName = document.getElementById('idp-friendly-name').value.trim();
const displayName = document.getElementById('idp-display-name').value.trim();
const type = document.getElementById('idp-type').value;
const config = {};
const configFields = document.querySelectorAll('#idp-config-fields input');
configFields.forEach(input => {
if (input.name && input.value) {
config[input.name] = input.value;
}
});
const submitBtn = document.getElementById('idp-submit-btn');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="loading loading-spinner loading-sm"></span> Saving...';
try {
let response;
if (mode === 'create') {
response = await fetch('/api/v2/idp/create', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
friendly_name: friendlyName,
name: displayName,
type: type,
config: config
})
});
} else {
const editName = document.getElementById('idp-edit-name').value;
response = await fetch(`/api/v2/idp/${editName}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: displayName,
config: config
})
});
}
const data = await response.json();
if (data.success) {
document.getElementById('idp-modal').close();
await loadIdentityProviders();
if (data.test_url && mode === 'create') {
const shouldTest = await dfConfirm(t('js.confirm.idp_test_success'), t('js.confirm.idp_test_title'));
if (shouldTest) {
window.open(data.test_url, '_blank');
}
}
} else {
await dfAlert(t('js.alert.save_error', {error: data.error || t('js.alert.save_error_generic')}), t('js.alert.save_error_title'));
}
} catch (error) {
console.error('Error saving IdP:', error);
await dfAlert(t('js.alert.save_error_generic'), t('js.alert.error_title'));
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = mode === 'create' ?
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 mr-1"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> ${t('js.modal.idp_btn_create')}` :
`<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 mr-1"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> ${t('js.modal.idp_btn_update')}`;
}
}
async function testIdP(idpId) {
try {
const response = await fetch('/api/v2/idp/list');
const data = await response.json();
if (data.success) {
for (const idp of Object.values(data.identity_providers)) {
if (idp.cloudflare_id === idpId) {
const testUrl = `https://dataverse.cloudflareaccess.com/cdn-cgi/access/test-idp/${idpId}`;
window.open(testUrl, '_blank');
return;
}
}
}
} catch (error) {
console.error('Error testing IdP:', error);
await dfAlert(t('js.alert.test_url_error'), t('js.alert.error_title'));
}
}
async function deleteIdP(friendlyName) {
const confirmed = await dfConfirm(t('js.confirm.idp_delete', {friendlyName: friendlyName}), t('js.confirm.idp_delete_title'));
if (!confirmed) {
return;
}
try {
const response = await fetch(`/api/v2/idp/${friendlyName}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
await loadIdentityProviders();
} else {
await dfAlert(t('js.alert.delete_error', {error: data.error || t('js.alert.delete_error_generic')}), t('js.alert.delete_error_title'));
}
} catch (error) {
console.error('Error deleting IdP:', error);
await dfAlert(t('js.alert.delete_error_generic'), t('js.alert.error_title'));
}
}
async function emailCheckPermissions() {
try {
const response = await fetch('/email/check-permissions', {
method: 'POST',
headers: buildApiHeaders()
});
const data = await response.json();
if (data.success) {
const p = data.permissions;
document.getElementById('permEmailRouting').innerText = p.email_routing ? '✅' : '❌';
document.getElementById('permWorkers').innerText = p.workers ? '✅' : '❌';
const r2Label = p.r2 ? '✅' : (p.r2_note ? '❌ ' + p.r2_note : '❌');
document.getElementById('permR2').innerText = r2Label;
document.getElementById('permKv').innerText = p.workers_kv ? '✅' : '❌';
const allGranted = p.email_routing && p.workers && p.r2 && p.workers_kv;
const banner = document.getElementById('emailPermissionsBanner');
if (banner) {
banner.classList.toggle('hidden', allGranted);
}
const setupBtn = document.getElementById('emailSetupBtn');
if (setupBtn) {
setupBtn.disabled = !allGranted;
}
}
} catch (e) {
console.error(e);
}
}
async function emailSetupDomain(event) {
const select = document.getElementById('emailZoneSelect');
if (!select || !select.value) return;
const zoneId = select.value;
const zoneName = select.options[select.selectedIndex].text;
const btn = event?.currentTarget;
const originalHTML = btn?.innerHTML;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
const title = (t('email.progress.setup_domain') || 'Setting up {zone}').replace('{zone}', zoneName);
emailProgressStart(EMAIL_OPERATION_STEPS.setup_domain, title);
try {
const response = await fetch('/email/setup-domain', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ zone_id: zoneId, zone_name: zoneName })
});
const data = await response.json();
if (data.success) {
emailProgressFinish(true);
} else {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
emailProgressFinish(false);
await dfAlert(data.error || 'Error', 'Error');
}
} catch (e) {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
emailProgressFinish(false);
console.error(e);
}
}
async function dfConfirmTyped(message, expectedValue, title) {
title = title || t('common.confirm');
return new Promise((resolve) => {
const modal = document.getElementById('dockflare-prompt-modal');
const titleEl = document.getElementById('dockflare-prompt-title');
const messageEl = document.getElementById('dockflare-prompt-message');
const inputEl = document.getElementById('dockflare-prompt-input');
const okBtn = document.getElementById('dockflare-prompt-ok');
const cancelBtn = document.getElementById('dockflare-prompt-cancel');
titleEl.textContent = title;
messageEl.textContent = message;
inputEl.value = '';
okBtn.disabled = true;
okBtn.classList.add('btn-disabled');
const checkInput = () => {
const match = inputEl.value === expectedValue;
okBtn.disabled = !match;
okBtn.classList.toggle('btn-disabled', !match);
};
const handleOk = () => {
if (inputEl.value !== expectedValue) return;
modal.close();
cleanup();
resolve(true);
};
const handleCancel = () => {
modal.close();
cleanup();
resolve(false);
};
const handleEnter = (e) => {
if (e.key === 'Enter' && inputEl.value === expectedValue) handleOk();
};
const cleanup = () => {
okBtn.disabled = false;
okBtn.classList.remove('btn-disabled');
okBtn.removeEventListener('click', handleOk);
cancelBtn.removeEventListener('click', handleCancel);
inputEl.removeEventListener('input', checkInput);
inputEl.removeEventListener('keypress', handleEnter);
};
okBtn.addEventListener('click', handleOk);
cancelBtn.addEventListener('click', handleCancel);
inputEl.addEventListener('input', checkInput);
inputEl.addEventListener('keypress', handleEnter);
modal.showModal();
setTimeout(() => inputEl.focus(), 100);
});
}
async function emailTeardownPartial(domain, event) {
if (!await dfConfirm(t('email.teardown_confirm_remote').replace('{domain}', domain), t('email.teardown_partial'))) return;
await _emailTeardown(domain, false, event);
}
async function emailTeardownComplete(domain, event) {
if (!await dfConfirm(t('email.teardown_confirm_local').replace('{domain}', domain), t('email.teardown_complete'))) return;
if (!await dfConfirmTyped(t('email.teardown_type_to_confirm').replace('{domain}', domain), domain, t('email.teardown_complete'))) return;
await _emailTeardown(domain, true, event);
}
async function _emailTeardown(domain, includeLocalData, event) {
const btn = event?.currentTarget;
const originalHTML = btn?.innerHTML;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
const title = (t('email.progress.teardown_domain') || 'Tearing down {zone}').replace('{zone}', domain);
emailProgressStart(EMAIL_OPERATION_STEPS.teardown_domain, title);
try {
const response = await fetch('/email/teardown-domain', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ zone_name: domain, include_local_data: includeLocalData })
});
const data = await response.json();
if (data.success) {
emailProgressFinish(true);
if (data.errors && data.errors.length > 0) {
await dfAlert(t('email.teardown_errors') + '\n' + data.errors.join('\n'), t('email.teardown_complete'));
}
} else {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
emailProgressFinish(false);
}
} catch (e) {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
emailProgressFinish(false);
console.error(e);
}
}
async function emailNukeAll(includeLocalData, event) {
if (includeLocalData) {
if (!await dfConfirm(t('email.nuke_all_confirm_1'), t('email.nuke_all_complete'))) return;
if (!await dfConfirm(t('email.nuke_all_confirm_2'), t('email.nuke_all_complete'))) return;
if (!await dfConfirmTyped(t('email.nuke_all_type_to_confirm'), 'NUKE ALL', t('email.nuke_all_complete'))) return;
} else {
if (!await dfConfirm(t('email.nuke_all_confirm_1'), t('email.nuke_all_partial'))) return;
}
const btn = event?.currentTarget;
const originalHTML = btn?.innerHTML;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
emailProgressStart(EMAIL_OPERATION_STEPS.teardown_all, t('email.progress.teardown_all') || 'Removing all domains');
try {
const response = await fetch('/email/teardown-all', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ include_local_data: includeLocalData })
});
const data = await response.json();
if (data.success) {
emailProgressFinish(true);
if (data.errors && data.errors.length > 0) {
const feedback = document.getElementById('emailNukeFeedback');
feedback.classList.remove('hidden', 'text-error', 'text-success');
feedback.classList.add('text-error');
feedback.textContent = t('email.teardown_errors') + data.errors.join(', ');
}
} else {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
emailProgressFinish(false);
}
} catch (e) {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
emailProgressFinish(false);
const feedback = document.getElementById('emailNukeFeedback');
feedback.classList.remove('hidden', 'text-error', 'text-success');
feedback.classList.add('text-error');
feedback.textContent = e.message;
console.error(e);
}
}
async function emailLoadOrphanedDomains() {
try {
const response = await fetch('/email/local-domains', { headers: buildApiHeaders() });
if (!response.ok) return;
const data = await response.json();
if (!data.orphaned || data.orphaned.length === 0) return;
const section = document.getElementById('emailOrphanedSection');
const list = document.getElementById('emailOrphanedList');
section.classList.remove('hidden');
list.innerHTML = data.orphaned.map(d => `
<div class="flex items-center gap-4 py-2">
<div class="flex-1">
<span class="font-mono font-semibold">${d.domain}</span>
<span class="text-sm opacity-70 ml-2">${d.mailbox_count} mailbox(es), ${d.message_count} message(s)</span>
</div>
<a href="/email/backup" class="btn btn-xs btn-outline">${t('email.download_backup')}</a>
<button class="btn btn-xs btn-error" onclick="emailWipeLocal('${d.domain}')">${t('email.wipe_local_data')}</button>
</div>
`).join('');
} catch (e) {
console.error(e);
}
}
async function emailWipeLocal(domain) {
if (!await dfConfirmTyped(t('email.wipe_local_confirm').replace('{domain}', domain), domain, t('email.wipe_local_data'))) return;
try {
const response = await fetch('/email/wipe-local', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ domain })
});
const data = await response.json();
if (data.success) location.reload();
else await dfAlert('Error: ' + (data.error || 'Unknown error'));
} catch (e) {
console.error(e);
}
}
const QUOTA_STEPS = [
{ label: '100 MB', bytes: 104857600 },
{ label: '250 MB', bytes: 262144000 },
{ label: '500 MB', bytes: 524288000 },
{ label: '1 GB', bytes: 1073741824 },
{ label: '2 GB', bytes: 2147483648 },
{ label: '5 GB', bytes: 5368709120 },
{ label: '10 GB', bytes: 10737418240 },
{ label: '25 GB', bytes: 26843545600 },
{ label: '50 GB', bytes: 53687091200 },
{ label: '100 GB', bytes: 107374182400 },
{ label: 'Unlimited', bytes: 0 },
];
function _fmtBytes(b) {
if (!b || b === 0) return '0 B';
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (b >= 1024 && i < 4) { b /= 1024; i++; }
return b.toFixed(1) + '\u00a0' + u[i];
}
function _calcGrace(quotaBytes) {
if (!quotaBytes || quotaBytes <= 0) return 0;
return Math.max(Math.round(quotaBytes * 0.15), 10 * 1024 * 1024);
}
function emailUpdateQuotaLabel(labelId, stepIndex, graceInfoId) {
const el = document.getElementById(labelId);
const step = QUOTA_STEPS[parseInt(stepIndex)];
if (el) el.textContent = step?.label ?? '10 GB';
if (graceInfoId) {
const graceEl = document.getElementById(graceInfoId);
if (graceEl) {
const quota = step?.bytes ?? 0;
if (quota > 0) {
const grace = _calcGrace(quota);
graceEl.textContent = `Hard limit: ${_fmtBytes(quota + grace)} (includes ${_fmtBytes(grace)} grace buffer)`;
} else {
graceEl.textContent = '';
}
}
}
}
function emailLoadStats() {
fetch('/email/mail-stats')
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d) return;
const r = document.getElementById('statReceived');
const s = document.getElementById('statSent');
const st = document.getElementById('statStorage');
const m = document.getElementById('statMailboxes');
if (r) r.textContent = d.total_messages ?? 0;
if (s) s.textContent = d.total_sent ?? 0;
if (st) st.textContent = _fmtBytes(d.disk_used_bytes);
if (m) m.textContent = d.mailbox_count ?? 0;
})
.catch(() => {});
}
async function emailLoadMailboxStats() {
try {
const resp = await fetch('/email/mailbox-stats', { headers: buildApiHeaders({}) });
if (!resp.ok) return;
const list = await resp.json();
for (const mb of list) {
const row = document.querySelector(`tr[data-mailbox="${CSS.escape(mb.address)}"]`);
if (!row) continue;
const rcv = row.querySelector('.mb-received');
const snt = row.querySelector('.mb-sent');
const bar = row.querySelector('.mb-storage-progress');
const txt = row.querySelector('.mb-storage-text');
const badge = row.querySelector('.mb-quota-badge');
if (rcv) { rcv.textContent = mb.received_count ?? 0; rcv.classList.remove('opacity-50'); }
if (snt) { snt.textContent = mb.sent_count ?? 0; snt.classList.remove('opacity-50'); }
const used = mb.storage_bytes ?? 0;
const quota = mb.quota_bytes ?? 0;
if (txt) txt.textContent = quota > 0 ? `${_fmtBytes(used)} / ${_fmtBytes(quota)}` : `${_fmtBytes(used)} / ∞`;
if (bar) {
const pct = quota > 0 ? Math.min(100, Math.round(used / quota * 100)) : 0;
bar.value = pct;
bar.className = 'progress w-28 block mb-storage-progress ' + (pct >= 90 ? 'progress-error' : pct >= 75 ? 'progress-warning' : 'progress-success');
}
if (badge) {
if (mb.quota_exceeded_count > 0) {
badge.textContent = `⚠ Over quota (${mb.quota_exceeded_count}×)`;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
}
} catch (e) { console.error(e); }
}
async function emailCreateMailbox(event) {
const address = document.getElementById('newMailboxAddress').value.trim();
const domain = document.getElementById('newMailboxDomain').value;
const name = document.getElementById('newMailboxName').value.trim();
const quotaStep = parseInt(document.getElementById('newMailboxQuota')?.value ?? '6');
const quota_bytes = QUOTA_STEPS[quotaStep]?.bytes ?? 10737418240;
if (!address || !domain) return;
const btn = event?.currentTarget;
const originalHTML = btn?.innerHTML;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
document.getElementById('emailAddMailboxModal')?.close();
emailProgressStart(EMAIL_OPERATION_STEPS.create_mailbox, t('email.progress.create_mailbox') || 'Creating mailbox');
try {
const response = await fetch('/email/mailbox/create', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ address: address + '@' + domain, domain: domain, display_name: name, quota_bytes })
});
const data = await response.json();
if (data.success) {
emailProgressFinish(true);
} else {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
emailProgressFinish(false);
if (data.error) await dfAlert(data.error, 'Error');
}
} catch (e) {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
emailProgressFinish(false);
console.error(e);
}
}
async function emailDeleteMailbox(address, domain, event) {
if (!await dfConfirm('Delete mailbox?', 'Delete')) return;
const btn = event?.currentTarget;
const originalHTML = btn?.innerHTML;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
emailProgressStart(EMAIL_OPERATION_STEPS.delete_mailbox, t('email.progress.delete_mailbox') || 'Deleting mailbox');
try {
const response = await fetch('/email/mailbox/delete', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ address: address, domain: domain })
});
const data = await response.json();
if (data.success) {
emailProgressFinish(true);
} else {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
emailProgressFinish(false);
}
} catch (e) {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
emailProgressFinish(false);
console.error(e);
}
}
async function emailVerifyDns(domain, event) {
const btn = event?.currentTarget;
const originalHTML = btn?.innerHTML;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
try {
const response = await fetch('/email/verify-dns', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ zone_name: domain })
});
const data = await response.json();
if (data.success) {
await dfAlert(JSON.stringify(data.status), 'DNS Status');
}
} catch (e) {
console.error(e);
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
}
}
async function emailUpdateR2(domain, event) {
const accessKeyId = prompt('R2 Access Key ID (from CF Dashboard → R2 → Manage R2 API Tokens):');
if (!accessKeyId) return;
const secretAccessKey = prompt('R2 Secret Access Key:');
if (!secretAccessKey) return;
const btn = event?.currentTarget;
const originalHTML = btn?.innerHTML;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
emailProgressStart(EMAIL_OPERATION_STEPS.update_r2, t('email.progress.update_r2') || 'Updating R2 credentials');
try {
const response = await fetch('/email/update-r2-credentials', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ zone_name: domain, r2_access_key_id: accessKeyId, r2_secret_access_key: secretAccessKey })
});
const data = await response.json();
if (data.success) {
emailProgressFinish(true);
} else {
emailProgressFinish(false);
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Failed');
}
} catch (e) {
emailProgressFinish(false);
console.error(e);
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
}
}
async function emailLoadAllCatchAllStatuses() {
const statusEls = document.querySelectorAll('[id^="catchAllStatus_"]');
for (const el of statusEls) {
const domain = el.id.replace('catchAllStatus_', '').replace(/_/g, '.');
try {
const resp = await fetch(`/email/catch-all/status?domain=${encodeURIComponent(domain)}`, { headers: buildApiHeaders({}) });
if (!resp.ok) { el.textContent = 'Catch-All: —'; continue; }
const data = await resp.json();
el.textContent = data.catch_all_mailbox ? `Catch-All → ${data.catch_all_mailbox}` : 'Catch-All: disabled';
} catch { el.textContent = 'Catch-All: —'; }
}
}
async function emailOpenCatchAllModal(domain) {
document.getElementById('catchAllDomain').value = domain;
document.getElementById('catchAllDomainLabel').textContent = domain;
const sel = document.getElementById('catchAllTarget');
sel.innerHTML = '';
const rows = document.querySelectorAll('tr[data-mailbox]');
let current = '';
try {
const resp = await fetch(`/email/catch-all/status?domain=${encodeURIComponent(domain)}`, { headers: buildApiHeaders({}) });
if (resp.ok) { const d = await resp.json(); current = d.catch_all_mailbox || ''; }
} catch {}
rows.forEach(row => {
const addr = row.dataset.mailbox;
const opt = document.createElement('option');
opt.value = addr;
opt.textContent = addr;
if (addr === current) opt.selected = true;
sel.appendChild(opt);
});
document.getElementById('catchAllModal').showModal();
}
async function emailSaveCatchAll() {
const domain = document.getElementById('catchAllDomain').value;
const target = document.getElementById('catchAllTarget').value;
try {
const resp = await fetch('/email/catch-all/enable', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ domain, target_address: target }),
});
const data = await resp.json();
document.getElementById('catchAllModal').close();
emailLoadAllCatchAllStatuses();
if (!data.status && !data.catch_all_mailbox) await dfAlert(data.error || 'Failed', 'Error');
} catch (e) { console.error(e); }
}
async function emailDisableCatchAll() {
const domain = document.getElementById('catchAllDomain').value;
try {
await fetch('/email/catch-all/disable', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ domain }),
});
document.getElementById('catchAllModal').close();
emailLoadAllCatchAllStatuses();
} catch (e) { console.error(e); }
}
async function emailLoadAutoResponderBadges() {
try {
const resp = await fetch('/email/auto-responders', { headers: buildApiHeaders({}) });
if (!resp.ok) return;
const data = await resp.json();
const active = new Set((data.auto_responders || []).filter(r => r.is_active).map(r => r.mailbox_address));
document.querySelectorAll('tr[data-mailbox]').forEach(row => {
const badge = row.querySelector('.mb-ar-badge');
if (badge) badge.classList.toggle('hidden', !active.has(row.dataset.mailbox));
});
} catch {}
}
async function emailOpenAutoResponderModal(address, domain) {
document.getElementById('arAddress').value = address;
document.getElementById('arDomain').value = domain;
document.getElementById('autoResponderMailboxLabel').textContent = address;
document.getElementById('arFeedback').classList.add('hidden');
document.getElementById('arSubject').value = 'Auto Reply';
document.getElementById('arBody').value = '';
document.getElementById('arStartDate').value = '';
document.getElementById('arEndDate').value = '';
document.getElementById('arInterval').value = '24';
document.getElementById('arIsActive').checked = true;
document.getElementById('arDeleteBtn').classList.add('hidden');
try {
const resp = await fetch(`/email/mailbox/auto-responder?address=${encodeURIComponent(address)}`, { headers: buildApiHeaders({}) });
if (resp.ok) {
const data = await resp.json();
if (data.auto_responder) {
const ar = data.auto_responder;
document.getElementById('arSubject').value = ar.subject || 'Auto Reply';
document.getElementById('arBody').value = ar.message_body || '';
document.getElementById('arStartDate').value = ar.start_date || '';
document.getElementById('arEndDate').value = ar.end_date || '';
document.getElementById('arInterval').value = ar.reply_interval_hours || 24;
document.getElementById('arIsActive').checked = !!ar.is_active;
document.getElementById('arDeleteBtn').classList.remove('hidden');
}
}
} catch {}
document.getElementById('autoResponderModal').showModal();
}
async function emailSaveAutoResponder() {
const address = document.getElementById('arAddress').value;
const feedback = document.getElementById('arFeedback');
const payload = {
address,
subject: document.getElementById('arSubject').value.trim() || 'Auto Reply',
message_body: document.getElementById('arBody').value.trim(),
start_date: document.getElementById('arStartDate').value || null,
end_date: document.getElementById('arEndDate').value || null,
reply_interval_hours: parseInt(document.getElementById('arInterval').value) || 24,
is_active: document.getElementById('arIsActive').checked,
};
if (!payload.message_body) {
feedback.textContent = 'Message body is required.';
feedback.className = 'text-sm font-semibold mb-2 text-error';
feedback.classList.remove('hidden');
return;
}
try {
const resp = await fetch('/email/mailbox/auto-responder', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify(payload),
});
const data = await resp.json();
if (data.auto_responder) {
document.getElementById('autoResponderModal').close();
emailLoadAutoResponderBadges();
} else {
feedback.textContent = data.error || 'Failed to save.';
feedback.className = 'text-sm font-semibold mb-2 text-error';
feedback.classList.remove('hidden');
}
} catch (e) { console.error(e); }
}
async function emailDeleteAutoResponder() {
const address = document.getElementById('arAddress').value;
try {
await fetch('/email/mailbox/auto-responder', {
method: 'DELETE',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ address }),
});
document.getElementById('autoResponderModal').close();
emailLoadAutoResponderBadges();
} catch (e) { console.error(e); }
}
let _logCurrentTab = 'outbound';
let _logCurrentPage = 1;
function emailOpenLogsModal() {
_logCurrentTab = 'outbound';
_logCurrentPage = 1;
document.getElementById('logTabOutbound')?.classList.add('tab-active');
document.getElementById('logTabBounce')?.classList.remove('tab-active');
document.getElementById('logPanelOutbound')?.classList.remove('hidden');
document.getElementById('logPanelBounce')?.classList.add('hidden');
document.getElementById('logStatusFilterWrap')?.classList.remove('hidden');
document.getElementById('logBounceTypeFilterWrap')?.classList.add('hidden');
sessionStorage.setItem('logsModalOpen', '1');
document.getElementById('logsModal')?.showModal();
emailLoadSendLog(1);
}
function emailSwitchLogTab(tab) {
_logCurrentTab = tab;
_logCurrentPage = 1;
['outbound', 'bounce'].forEach(t => {
document.getElementById('logTab' + t.charAt(0).toUpperCase() + t.slice(1))?.classList.toggle('tab-active', t === tab);
const panel = document.getElementById('logPanel' + t.charAt(0).toUpperCase() + t.slice(1));
if (panel) panel.classList.toggle('hidden', t !== tab);
});
document.getElementById('logStatusFilterWrap')?.classList.toggle('hidden', tab !== 'outbound');
document.getElementById('logBounceTypeFilterWrap')?.classList.toggle('hidden', tab !== 'bounce');
emailLoadCurrentLog();
}
function emailLoadCurrentLog() {
if (_logCurrentTab === 'outbound') emailLoadSendLog(_logCurrentPage);
else if (_logCurrentTab === 'bounce') emailLoadBounceLog(_logCurrentPage);
}
function emailLogPrevPage() {
if (_logCurrentPage > 1) { _logCurrentPage--; emailLoadCurrentLog(); }
}
function emailLogNextPage() {
_logCurrentPage++;
emailLoadCurrentLog();
}
function _logFmtDate(iso) {
if (!iso) return '';
try { return new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }); }
catch { return iso; }
}
function _logUpdatePagination(total, page, limit) {
const pages = Math.max(1, Math.ceil(total / limit));
const info = document.getElementById('logPageInfo');
if (info) info.textContent = `Page ${page} of ${pages} (${total} total)`;
const prev = document.getElementById('logPrevBtn');
const next = document.getElementById('logNextBtn');
if (prev) prev.disabled = page <= 1;
if (next) next.disabled = page >= pages;
document.getElementById('logPagination')?.classList.remove('hidden');
}
async function emailLoadSendLog(page) {
page = page || 1;
const params = new URLSearchParams({ page, limit: 50 });
const dateFrom = document.getElementById('logDateFrom')?.value;
const dateTo = document.getElementById('logDateTo')?.value;
const status = document.getElementById('logStatus')?.value;
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
if (status) params.set('status', status);
try {
const resp = await fetch('/email/logs/send-log?' + params.toString(), { headers: buildApiHeaders({}) });
if (!resp.ok) return;
const data = await resp.json();
const tbody = document.getElementById('logBodyOutbound');
const empty = document.getElementById('logEmptyOutbound');
if (!tbody) return;
tbody.innerHTML = '';
if (!data.logs || data.logs.length === 0) {
if (empty) empty.classList.remove('hidden');
_logUpdatePagination(0, page, 50);
return;
}
if (empty) empty.classList.add('hidden');
for (const row of data.logs) {
const toList = Array.isArray(row.to_addresses) ? row.to_addresses.join(', ') : (row.to_addresses || '');
const statusBadge = row.status === 'sent'
? '<span class="badge badge-success badge-sm">sent</span>'
: '<span class="badge badge-error badge-sm">failed</span>';
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="text-xs opacity-70 whitespace-nowrap">${_logFmtDate(row.sent_at)}</td>
<td class="text-xs max-w-32 truncate">${row.from_address || ''}</td>
<td class="text-xs max-w-32 truncate">${toList}</td>
<td class="text-xs max-w-40 truncate">${row.subject || ''}</td>
<td>${statusBadge}</td>
<td class="text-xs max-w-40 truncate opacity-60">${row.error_message || ''}</td>
`;
tbody.appendChild(tr);
}
_logUpdatePagination(data.total, page, data.limit);
} catch (e) { console.error(e); }
}
async function emailLoadBounceLog(page) {
page = page || 1;
const params = new URLSearchParams({ page, limit: 50 });
const dateFrom = document.getElementById('logDateFrom')?.value;
const dateTo = document.getElementById('logDateTo')?.value;
const bounceType = document.getElementById('logBounceType')?.value;
if (dateFrom) params.set('date_from', dateFrom);
if (dateTo) params.set('date_to', dateTo);
if (bounceType) params.set('bounce_type', bounceType);
try {
const resp = await fetch('/email/logs/bounce-log?' + params.toString(), { headers: buildApiHeaders({}) });
if (!resp.ok) return;
const data = await resp.json();
const tbody = document.getElementById('logBodyBounce');
const empty = document.getElementById('logEmptyBounce');
if (!tbody) return;
tbody.innerHTML = '';
if (!data.logs || data.logs.length === 0) {
if (empty) empty.classList.remove('hidden');
_logUpdatePagination(0, page, 50);
return;
}
if (empty) empty.classList.add('hidden');
for (const row of data.logs) {
const typeBadge = row.bounce_type === 'permanent'
? '<span class="badge badge-error badge-sm">permanent</span>'
: '<span class="badge badge-warning badge-sm">temporary</span>';
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="text-xs opacity-70 whitespace-nowrap">${_logFmtDate(row.received_at)}</td>
<td class="text-xs max-w-40 truncate">${row.recipient || ''}</td>
<td>${typeBadge}</td>
<td class="text-xs max-w-60 truncate opacity-70">${row.reason || ''}</td>
`;
tbody.appendChild(tr);
}
_logUpdatePagination(data.total, page, data.limit);
} catch (e) { console.error(e); }
}
async function emailLoadDeliveryStats() {
try {
const resp = await fetch('/email/logs/stats', { headers: buildApiHeaders({}) });
if (!resp.ok) return;
const d = await resp.json();
const el = id => document.getElementById(id);
if (el('dlStatSent')) el('dlStatSent').textContent = d.total_sent ?? 0;
if (el('dlStatFailed')) el('dlStatFailed').textContent = d.total_failed ?? 0;
if (el('dlStatBounced')) el('dlStatBounced').textContent = d.total_bounced ?? 0;
if (el('dlStatRate')) el('dlStatRate').textContent = (d.bounce_rate ?? 0) + '%';
const reasons = document.getElementById('dlTopReasons');
if (reasons && d.top_bounce_reasons?.length > 0) {
reasons.innerHTML = '<p class="text-sm font-semibold mb-2 opacity-70">Top bounce reasons</p>' +
d.top_bounce_reasons.map(r =>
`<div class="flex justify-between text-sm py-1 border-b border-base-300 opacity-70"><span class="truncate mr-4">${r.reason}</span><span class="shrink-0">${r.count}</span></div>`
).join('');
} else if (reasons) {
reasons.innerHTML = '<p class="text-sm opacity-50">No bounce data yet.</p>';
}
} catch (e) { console.error(e); }
}
function emailOpenWebmail() {
window.location.href = '/email/sso/callback';
}
async function emailRepairDns(domain, event) {
if (!confirm(`Re-apply all required DNS records for ${domain}? Missing records (including DKIM) will be added.`)) return;
const btn = event?.currentTarget;
const originalHTML = btn?.innerHTML;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>'; }
emailProgressStart(EMAIL_OPERATION_STEPS.repair_dns, t('email.progress.repair_dns') || 'Repairing DNS');
try {
const response = await fetch('/email/repair-dns', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ zone_name: domain })
});
const data = await response.json();
if (data.success) {
emailProgressFinish(true);
} else {
emailProgressFinish(false);
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Failed');
}
} catch (e) {
emailProgressFinish(false);
console.error(e);
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = originalHTML; }
}
}
async function emailSetPassword(address, domain) {
const modal = document.getElementById('emailSetPasswordModal');
if (!modal) return;
const targetEl = document.getElementById('emailSetPasswordTarget');
const newPwEl = document.getElementById('emailSetPasswordNew');
const confirmEl = document.getElementById('emailSetPasswordConfirm');
const errorEl = document.getElementById('emailSetPasswordError');
const submitBtn = document.getElementById('emailSetPasswordSubmitBtn');
if (targetEl) targetEl.textContent = address;
if (newPwEl) newPwEl.value = '';
if (confirmEl) confirmEl.value = '';
if (errorEl) errorEl.classList.add('hidden');
modal.showModal();
setTimeout(() => newPwEl?.focus(), 100);
const handler = async () => {
submitBtn.removeEventListener('click', handler);
const password = newPwEl.value;
const confirmed = confirmEl.value;
errorEl.classList.add('hidden');
if (password.length < 8) {
errorEl.textContent = 'Password must be at least 8 characters.';
errorEl.classList.remove('hidden');
submitBtn.addEventListener('click', handler);
return;
}
if (password !== confirmed) {
errorEl.textContent = 'Passwords do not match.';
errorEl.classList.remove('hidden');
submitBtn.addEventListener('click', handler);
return;
}
modal.close();
try {
const response = await fetch('/email/mailbox/set-password', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ address, domain, password })
});
const data = await response.json();
if (data.success) {
await dfAlert(`Password updated for ${address}.`, 'Success');
} else {
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Error');
}
} catch (e) {
console.error(e);
}
};
submitBtn.addEventListener('click', handler);
}
async function emailRedeployWorkers() {
if (!confirm('Redeploy all inbound and outbound workers? This will push the latest worker code and bindings to Cloudflare.')) return;
emailProgressStart(EMAIL_OPERATION_STEPS.redeploy_workers, t('email.progress.redeploy_workers') || 'Redeploying workers');
try {
const response = await fetch('/email/redeploy-workers', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'})
});
const data = await response.json();
if (data.success) {
emailProgressFinish(true);
} else {
emailProgressFinish(false);
await dfAlert('Error: ' + (data.error || 'Unknown'), 'Failed');
}
} catch (e) {
emailProgressFinish(false);
console.error(e);
}
}
async function emailRestoreBackup() {
const fileInput = document.getElementById('emailRestoreFile');
if (!fileInput.files || fileInput.files.length === 0) {
return;
}
if (!await dfConfirm(t('email.restore_confirm'), t('email.restore_title'))) {
return;
}
const btn = document.getElementById('emailRestoreBtn');
const feedback = document.getElementById('emailRestoreFeedback');
btn.disabled = true;
btn.innerHTML = '<span class="loading loading-spinner loading-sm"></span> ' + t('common.loading');
feedback.classList.remove('hidden', 'text-error', 'text-success');
feedback.textContent = 'Uploading and restoring...';
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const response = await fetch('/email/restore', {
method: 'POST',
headers: buildApiHeaders(),
body: formData
});
const data = await response.json();
if (data.success) {
feedback.classList.add('text-success');
feedback.textContent = t('email.restore_success');
setTimeout(() => {
location.reload();
}, 3000);
} else {
feedback.classList.add('text-error');
feedback.textContent = 'Error: ' + (data.error || 'Unknown error');
btn.disabled = false;
btn.textContent = t('email.restore_backup');
}
} catch (e) {
feedback.classList.add('text-error');
feedback.textContent = 'Error: ' + e.message;
btn.disabled = false;
btn.textContent = t('email.restore_backup');
console.error(e);
}
}
function emailEditQuota(address, domain, currentQuotaBytes) {
const modal = document.getElementById('emailEditQuotaModal');
if (!modal) return;
document.getElementById('emailEditQuotaTarget').textContent = address;
const row = document.querySelector(`tr[data-mailbox="${CSS.escape(address)}"]`);
const usageText = row?.querySelector('.mb-storage-text')?.textContent ?? '';
document.getElementById('emailEditQuotaUsage').textContent = usageText ? `Current usage: ${usageText}` : '';
const stepIndex = QUOTA_STEPS.findIndex(s => s.bytes === currentQuotaBytes);
const slider = document.getElementById('editMailboxQuota');
slider.value = stepIndex >= 0 ? stepIndex : 6;
emailUpdateQuotaLabel('editMailboxQuotaLabel', slider.value, 'emailEditQuotaGraceInfo');
const submitBtn = document.getElementById('emailEditQuotaSubmitBtn');
const handler = async () => {
submitBtn.removeEventListener('click', handler);
const quota_bytes = QUOTA_STEPS[parseInt(slider.value)]?.bytes ?? 10737418240;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="loading loading-spinner loading-sm"></span>';
try {
const resp = await fetch('/email/mailbox/set-quota', {
method: 'POST',
headers: buildApiHeaders({'Content-Type': 'application/json'}),
body: JSON.stringify({ address, domain, quota_bytes }),
});
const data = await resp.json();
modal.close();
if (data.success) {
await emailLoadMailboxStats();
} else {
await dfAlert(data.error || 'Failed to update quota', 'Error');
}
} catch (e) {
modal.close();
console.error(e);
}
submitBtn.disabled = false;
submitBtn.innerHTML = 'Save';
};
submitBtn.addEventListener('click', handler);
modal.showModal();
}