⚠ Could not load timezone data: ' + escHtml(err.message) + '
';
return;
}
// Try to auto-detect timezone from browser
var browserTz = null;
try { browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (_) {}
var selectedTz = currentTz || browserTz || "";
var html = '';
// Timezone section
html += '
';
html += '';
html += '';
html += '';
html += '
Current: ' + escHtml(selectedTz || 'Not set') + '
';
html += '
';
// Locale section
html += '
';
html += '';
html += '';
html += '
';
body.innerHTML = html;
// Wire timezone search filter
var tzSearch = document.getElementById('tz-search');
var tzSelect = document.getElementById('tz-select');
var tzCurrentDisplay = document.getElementById('tz-current-display');
if (tzSearch && tzSelect) {
// When typing in search box, filter the dropdown options
tzSearch.addEventListener('input', function() {
var q = tzSearch.value.toLowerCase();
var opts = tzSelect.options;
var firstVisible = null;
for (var i = 0; i < opts.length; i++) {
var match = opts[i].value.toLowerCase().indexOf(q) !== -1;
opts[i].style.display = match ? '' : 'none';
if (match && firstVisible === null) firstVisible = i;
}
if (firstVisible !== null) {
tzSelect.selectedIndex = firstVisible;
if (tzCurrentDisplay) tzCurrentDisplay.textContent = tzSelect.options[firstVisible].value;
}
});
// When selecting from dropdown, update search box
tzSelect.addEventListener('change', function() {
if (tzSelect.value) {
tzSearch.value = tzSelect.value;
if (tzCurrentDisplay) tzCurrentDisplay.textContent = tzSelect.value;
}
});
// Scroll selected option into view
if (tzSelect.selectedIndex >= 0) {
tzSelect.options[tzSelect.selectedIndex].scrollIntoView({ block: 'nearest' });
}
}
}
async function saveStep2() {
var tzSelect = document.getElementById('tz-select');
var tzSearch = document.getElementById('tz-search');
var localeSelect = document.getElementById('locale-select');
// Determine selected timezone: prefer dropdown selection, fall back to search text
var tz = (tzSelect && tzSelect.value) ? tzSelect.value : (tzSearch ? tzSearch.value.trim() : '');
var locale = localeSelect ? localeSelect.value : '';
if (!tz) {
setStatus('step-2-status', '⚠ Please select a timezone.', 'error');
return false;
}
setStatus('step-2-status', 'Saving…', 'info');
var errors = [];
try {
await apiFetch('/api/system/timezone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timezone: tz }),
});
} catch (err) {
errors.push('Timezone: ' + err.message);
}
if (locale) {
try {
await apiFetch('/api/system/locale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale: locale }),
});
} catch (err) {
errors.push('Locale: ' + err.message);
}
}
if (errors.length > 0) {
setStatus('step-2-status', '⚠ ' + errors.join('; '), 'error');
return false;
}
setStatus('step-2-status', '✓ Timezone & locale saved', 'ok');
return true;
}
// ── Step 3: Create Your Password ─────────────────────────────────
async function loadStep3() {
var body = document.getElementById("step-3-body");
if (!body) return;
var nextBtn = document.getElementById("step-3-next");
try {
var result = await apiFetch("/api/security/password-is-default");
_passwordIsDefault = result.is_default !== false;
} catch (_) {
_passwordIsDefault = true;
}
if (_passwordIsDefault) {
// Factory-sealed scenario: password must be set before continuing
if (nextBtn) nextBtn.textContent = "Set Password & Continue \u2192";
body.innerHTML =
'
' +
'' +
'
' +
'' +
'' +
'
' +
'
Minimum 8 characters
' +
'
' +
'
' +
'' +
'
' +
'' +
'' +
'
' +
'
' +
'
⚠️ Write this password down — it cannot be recovered.
';
// Wire show/hide toggles
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
btn.addEventListener("click", function() {
var inp = document.getElementById(btn.dataset.target);
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
});
});
} else {
// DIY install scenario: password already set by installer
if (nextBtn) nextBtn.textContent = "Continue \u2192";
body.innerHTML =
'
✅ Your password was already set during installation.
' +
'' +
'Change it anyway' +
'
' +
'
' +
'' +
'
' +
'' +
'' +
'
' +
'
Minimum 8 characters
' +
'
' +
'
' +
'' +
'
' +
'' +
'' +
'
' +
'
' +
'
⚠️ Write this password down — it cannot be recovered.
' +
'
' +
'';
// Wire show/hide toggles
body.querySelectorAll(".onboarding-password-toggle").forEach(function(btn) {
btn.addEventListener("click", function() {
var inp = document.getElementById(btn.dataset.target);
if (inp) inp.type = (inp.type === "password") ? "text" : "password";
});
});
}
}
async function saveStep3() {
var newPw = document.getElementById("pw-new");
var confirmPw = document.getElementById("pw-confirm");
// If no fields visible or both empty and password already set → skip
if (!newPw || !newPw.value.trim()) {
if (!_passwordIsDefault) return true; // already set, no change requested
setStatus("step-3-status", "⚠ Please enter a password.", "error");
return false;
}
var pw = newPw.value;
var cpw = confirmPw ? confirmPw.value : "";
if (pw.length < 8) {
setStatus("step-3-status", "⚠ Password must be at least 8 characters.", "error");
return false;
}
if (pw !== cpw) {
setStatus("step-3-status", "⚠ Passwords do not match.", "error");
return false;
}
setStatus("step-3-status", "Saving password…", "info");
try {
await apiFetch("/api/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_password: pw, confirm_password: cpw }),
});
} catch (err) {
setStatus("step-3-status", "⚠ " + err.message, "error");
return false;
}
setStatus("step-3-status", "✓ Password saved", "ok");
_passwordIsDefault = false;
return true;
}
// ── Step 4: Domain Configuration ─────────────────────────────────
async function loadStep4() {
var body = document.getElementById("step-4-body");
if (!body) return;
try {
// Fetch services, domains, and network info in parallel
var results = await Promise.all([
apiFetch("/api/services"),
apiFetch("/api/domains/status"),
apiFetch("/api/network"),
]);
_servicesData = results[0];
_domainsData = results[1];
var networkData = results[2];
} catch (err) {
body.innerHTML = '
⚠ Could not load service data: ' + escHtml(err.message) + '
';
return;
}
var externalIp = (networkData && networkData.external_ip) || "Unknown (could not retrieve)";
// Build set of enabled service units
var enabledUnits = new Set();
(_servicesData || []).forEach(function(svc) {
if (svc.enabled) enabledUnits.add(svc.unit);
});
// Filter domain defs to only those whose service is enabled
var relevantDomains = DOMAIN_DEFS.filter(function(d) {
return enabledUnits.has(d.unit);
});
var html = "";
if (relevantDomains.length === 0) {
html += '
No domain-based services are enabled for your role. You can skip this step.
Purchase a new domain on Njal.la, or create a subdomain from a domain you already own. Tip: Subdomains are free to create — you only need to purchase one domain, and you can add as many subdomains as you need at no extra cost.
'
+ '
In the Njal.la web interface, create a Dynamic record pointing to this machine\'s external IP address: '
+ '' + escHtml(externalIp) + '
'
+ '
Njal.la will give you a curl command like: '
+ 'curl "https://njal.la/update/?h=sub.domain.com&k=abc123&auto"
'
+ '
Enter the subdomain and paste that curl command below for each service
'
+ ''
+ '
';
html += '
Enter each fully-qualified subdomain (e.g. matrix.yourdomain.com) and its Njal.la DDNS curl command.
';
relevantDomains.forEach(function(d) {
var currentVal = (_domainsData && _domainsData[d.name]) || "";
html += '
';
html += '';
html += '';
html += '';
html += '';
html += '
ℹ Paste the curl URL from your Njal.la dashboard\'s Dynamic record
';
html += '';
html += '';
html += '
';
});
}
// SSL email section
var emailVal = (_domainsData && _domainsData["sslemail"]) || "";
html += '
';
html += '';
html += '
Let\'s Encrypt uses this for certificate expiry notifications.
';
html += '';
html += '';
html += '';
html += '
';
body.innerHTML = html;
// Wire per-field save buttons for domains
body.querySelectorAll('[data-save-domain]').forEach(function(btn) {
btn.addEventListener('click', async function() {
var domainName = btn.dataset.saveDomain;
var domainInput = document.getElementById('domain-input-' + domainName);
var ddnsInput = document.getElementById('ddns-input-' + domainName);
var statusEl = document.getElementById('domain-save-status-' + domainName);
var domainVal = domainInput ? domainInput.value.trim() : '';
var ddnsVal = ddnsInput ? ddnsInput.value.trim() : '';
if (!domainVal) {
if (statusEl) { statusEl.textContent = '⚠ Enter a domain first'; statusEl.style.color = 'var(--red)'; }
return;
}
btn.disabled = true;
btn.textContent = 'Saving…';
if (statusEl) { statusEl.textContent = ''; }
try {
await apiFetch('/api/domains/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain_name: domainName, domain: domainVal, ddns_url: ddnsVal }),
});
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
} catch (err) {
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
}
btn.disabled = false;
btn.textContent = 'Save';
});
});
// Wire save button for SSL email
body.querySelectorAll('[data-save-email]').forEach(function(btn) {
btn.addEventListener('click', async function() {
var emailInput = document.getElementById('ssl-email-input');
var statusEl = document.getElementById('domain-save-status-email');
var emailVal = emailInput ? emailInput.value.trim() : '';
if (!emailVal) {
if (statusEl) { statusEl.textContent = '⚠ Enter an email first'; statusEl.style.color = 'var(--red)'; }
return;
}
btn.disabled = true;
btn.textContent = 'Saving…';
if (statusEl) { statusEl.textContent = ''; }
try {
await apiFetch('/api/domains/set-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailVal }),
});
if (statusEl) { statusEl.textContent = '✓ Saved'; statusEl.style.color = 'var(--green)'; }
} catch (err) {
if (statusEl) { statusEl.textContent = '⚠ ' + err.message; statusEl.style.color = 'var(--red)'; }
}
btn.disabled = false;
btn.textContent = 'Save';
});
});
}
async function saveStep4() {
setStatus("step-4-status", "Saving domains…", "info");
var errors = [];
// Save each domain input
var domainInputs = document.querySelectorAll("[data-domain]");
for (var i = 0; i < domainInputs.length; i++) {
var inp = domainInputs[i];
var domainName = inp.dataset.domain;
var domainVal = inp.value.trim();
if (!domainVal) continue; // skip empty — not required
var ddnsInput = document.getElementById("ddns-input-" + domainName);
var ddnsVal = ddnsInput ? ddnsInput.value.trim() : "";
try {
await apiFetch("/api/domains/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain_name: domainName, domain: domainVal, ddns_url: ddnsVal }),
});
} catch (err) {
errors.push(domainName + ": " + err.message);
}
}
// Save SSL email
var emailInput = document.getElementById("ssl-email-input");
if (emailInput && emailInput.value.trim()) {
try {
await apiFetch("/api/domains/set-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: emailInput.value.trim() }),
});
} catch (err) {
errors.push("SSL email: " + err.message);
}
}
if (errors.length > 0) {
setStatus("step-4-status", "⚠ Some errors: " + errors.join("; "), "error");
return false;
}
setStatus("step-4-status", "✓ Saved", "ok");
return true;
}
// ── Step 5: Port Forwarding ───────────────────────────────────────
async function loadStep5() {
var body = document.getElementById("step-5-body");
if (!body) return;
body.innerHTML = '
⚠ Could not load network data: ' + escHtml(err.message) + '
';
return;
}
var internalIp = (networkData && networkData.internal_ip) || "unknown";
var ip = escHtml(internalIp);
var html = '
'
+ '⚠ Each port only needs to be forwarded once — all services share the same ports.'
+ '
';
html += '
';
html += ' Forward ports to this machine\'s internal IP:';
html += ' ' + ip + '';
html += '
';
// Required ports table
html += '
';
html += '
Required Ports — open these on your router:
';
html += '
';
html += '
Port
Protocol
Forward to
Purpose
';
html += '';
html += '
80
TCP
' + ip + '
HTTP
';
html += '
443
TCP
' + ip + '
HTTPS
';
html += '
22
TCP
' + ip + '
SSH Remote Access
';
html += '
8448
TCP
' + ip + '
Matrix Federation
';
html += '
';
html += '
';
// Optional ports table
html += '
';
html += '
Optional — Only needed if you enable Element Calling:
';
html += '
These 5 additional port openings are required on top of the 4 required ports above.
';
html += '
';
html += '
Port
Protocol
Forward to
Purpose
';
html += '';
html += '
7881
TCP
' + ip + '
LiveKit WebRTC signalling
';
html += '
7882–7894
UDP
' + ip + '
LiveKit media streams
';
html += '
5349
TCP
' + ip + '
TURN over TLS
';
html += '
3478
UDP
' + ip + '
TURN (STUN/relay)
';
html += '
30000–40000
TCP/UDP
' + ip + '
TURN relay (WebRTC)
';
html += '
';
html += '
';
// Totals
html += '
';
html += 'Total port openings: 4 (without Element Calling) ';
html += 'Total port openings: 9 (with Element Calling — 4 required + 5 optional)';
html += '
';
html += '
'
+ '⚠ Ports 80 and 443 must be forwarded first. '
+ 'Caddy uses these to obtain SSL certificates from Let\'s Encrypt. '
+ 'If they are closed, HTTPS will not work and your services will be unreachable from outside your network.'
+ '
';
html += ''
+ 'How to set up port forwarding'
+ ''
+ '
Open your router\'s admin panel — usually http://192.168.1.1 or http://192.168.0.1
'
+ '
Look for "Port Forwarding", "NAT", or "Virtual Server" in the settings
'
+ '
Create a new rule for each port listed above
'
+ '
Set the destination/internal IP to ' + ip + '
'
+ '
Set both internal and external port to the same number
'
+ '
Save and apply changes
'
+ ''
+ '';
body.innerHTML = html;
}
// ── Step 6: Complete ──────────────────────────────────────────────
async function completeOnboarding() {
var btn = document.getElementById("step-6-finish");
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
try {
await apiFetch("/api/onboarding/complete", { method: "POST" });
} catch (_) {
// Even if this fails, navigate to dashboard
}
window.location.href = "/";
}
// ── Event wiring ──────────────────────────────────────────────────
function wireNavButtons() {
// Step 1 → next
var s1next = document.getElementById("step-1-next");
if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); });
// Step 2 → 3 (save timezone/locale first)
var s2next = document.getElementById("step-2-next");
if (s2next) s2next.addEventListener("click", async function() {
s2next.disabled = true;
var origText = s2next.textContent;
s2next.textContent = "Saving…";
var ok = await saveStep2();
s2next.disabled = false;
s2next.textContent = origText;
if (ok) showStep(nextStep(2));
});
// Step 3 → 4 (save password first)
var s3next = document.getElementById("step-3-next");
if (s3next) s3next.addEventListener("click", async function() {
s3next.disabled = true;
var origText = s3next.textContent;
s3next.textContent = "Saving…";
var ok = await saveStep3();
s3next.disabled = false;
s3next.textContent = origText;
if (ok) showStep(nextStep(3));
});
// Step 4 → 5 (save domains first)
var s4next = document.getElementById("step-4-next");
if (s4next) s4next.addEventListener("click", async function() {
s4next.disabled = true;
s4next.textContent = "Saving…";
await saveStep4();
s4next.disabled = false;
s4next.textContent = "Save & Continue →";
showStep(nextStep(4));
});
// Step 5 → 6 (port forwarding — no save needed)
var s5next = document.getElementById("step-5-next");
if (s5next) s5next.addEventListener("click", function() { showStep(nextStep(5)); });
// Step 6: finish
var s6finish = document.getElementById("step-6-finish");
if (s6finish) s6finish.addEventListener("click", completeOnboarding);
// Back buttons
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
var prev = parseInt(btn.dataset.prev, 10);
btn.addEventListener("click", function() { showStep(prevStep(prev + 1)); });
});
}
// ── Init ──────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", async function() {
// If onboarding is already complete, go to dashboard
try {
var status = await apiFetch("/api/onboarding/status");
if (status.complete) {
window.location.href = "/";
return;
}
} catch (_) {}
// Load role so step-skipping is applied before wiring nav buttons
try {
var cfg = await apiFetch("/api/config");
if (cfg.role) _onboardingRole = cfg.role;
} catch (_) {}
wireNavButtons();
updateProgress(1);
loadStep1();
});