Merge pull request #161 from naturallaw777/copilot/add-timezone-locale-onboarding-step
[WIP] Add timezone and locale selection to onboarding wizard
This commit is contained in:
@@ -3117,6 +3117,169 @@ async def api_change_password(req: ChangePasswordRequest):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Timezone / Locale endpoints ───────────────────────────────────
|
||||||
|
|
||||||
|
SUPPORTED_LOCALES = [
|
||||||
|
"en_US.UTF-8",
|
||||||
|
"en_GB.UTF-8",
|
||||||
|
"es_ES.UTF-8",
|
||||||
|
"fr_FR.UTF-8",
|
||||||
|
"de_DE.UTF-8",
|
||||||
|
"pt_BR.UTF-8",
|
||||||
|
"ja_JP.UTF-8",
|
||||||
|
"zh_CN.UTF-8",
|
||||||
|
"ko_KR.UTF-8",
|
||||||
|
"ru_RU.UTF-8",
|
||||||
|
"ar_SA.UTF-8",
|
||||||
|
"hi_IN.UTF-8",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_timezone() -> str | None:
|
||||||
|
"""Return the currently configured timezone string, or None if unset."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["timedatectl", "show", "--property=Timezone", "--value"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
tz = result.stdout.strip()
|
||||||
|
return tz if tz and tz != "n/a" else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_current_locale() -> str | None:
|
||||||
|
"""Return the currently configured LANG locale, or None if unset."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["localectl", "status"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if "LANG=" in line:
|
||||||
|
return line.split("LANG=", 1)[1].strip()
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/system/timezones")
|
||||||
|
async def api_system_timezones():
|
||||||
|
"""Return list of available timezones and the currently configured one."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: subprocess.run(
|
||||||
|
["timedatectl", "list-timezones"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
timezones = [tz for tz in result.stdout.splitlines() if tz.strip()]
|
||||||
|
except Exception:
|
||||||
|
# Fallback: read from /usr/share/zoneinfo
|
||||||
|
timezones = []
|
||||||
|
zoneinfo_dir = "/usr/share/zoneinfo"
|
||||||
|
if os.path.isdir(zoneinfo_dir):
|
||||||
|
for root, dirs, files in os.walk(zoneinfo_dir):
|
||||||
|
# Skip posix/right sub-directories and non-timezone files
|
||||||
|
dirs[:] = [d for d in dirs if d not in ("posix", "right")]
|
||||||
|
for fname in files:
|
||||||
|
full = os.path.join(root, fname)
|
||||||
|
rel = os.path.relpath(full, zoneinfo_dir)
|
||||||
|
if "/" in rel:
|
||||||
|
timezones.append(rel)
|
||||||
|
timezones.sort()
|
||||||
|
|
||||||
|
current_tz = await loop.run_in_executor(None, _get_current_timezone)
|
||||||
|
return {"timezones": timezones, "current": current_tz}
|
||||||
|
|
||||||
|
|
||||||
|
class TimezoneRequest(BaseModel):
|
||||||
|
timezone: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/system/timezone")
|
||||||
|
async def api_system_set_timezone(req: TimezoneRequest):
|
||||||
|
"""Set the system timezone using timedatectl."""
|
||||||
|
tz = req.timezone.strip()
|
||||||
|
if not tz:
|
||||||
|
raise HTTPException(status_code=400, detail="Timezone must not be empty.")
|
||||||
|
# Basic validation: only allow characters valid in timezone names
|
||||||
|
if not re.match(r'^[A-Za-z0-9/_\-+]+$', tz):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid timezone format.")
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: subprocess.run(
|
||||||
|
["sudo", "timedatectl", "set-timezone", tz],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip() or "timedatectl failed."
|
||||||
|
raise HTTPException(status_code=500, detail=detail)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to set timezone: {exc}")
|
||||||
|
|
||||||
|
return {"ok": True, "timezone": tz}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/system/locales")
|
||||||
|
async def api_system_locales():
|
||||||
|
"""Return the list of supported locales and the currently configured one."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
current_locale = await loop.run_in_executor(None, _get_current_locale)
|
||||||
|
return {"locales": SUPPORTED_LOCALES, "current": current_locale}
|
||||||
|
|
||||||
|
|
||||||
|
class LocaleRequest(BaseModel):
|
||||||
|
locale: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/system/locale")
|
||||||
|
async def api_system_set_locale(req: LocaleRequest):
|
||||||
|
"""Set the system locale using localectl."""
|
||||||
|
locale = req.locale.strip()
|
||||||
|
if not locale:
|
||||||
|
raise HTTPException(status_code=400, detail="Locale must not be empty.")
|
||||||
|
if locale not in SUPPORTED_LOCALES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported locale: {locale}")
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: subprocess.run(
|
||||||
|
["sudo", "localectl", "set-locale", f"LANG={locale}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = (result.stderr or result.stdout).strip() or "localectl failed."
|
||||||
|
raise HTTPException(status_code=500, detail=detail)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to set locale: {exc}")
|
||||||
|
|
||||||
|
return {"ok": True, "locale": locale}
|
||||||
|
|
||||||
|
|
||||||
# ── Matrix user management ────────────────────────────────────────
|
# ── Matrix user management ────────────────────────────────────────
|
||||||
|
|
||||||
MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users"
|
MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users"
|
||||||
|
|||||||
@@ -566,7 +566,62 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Password step (Step 2) ─────────────────────────────────────── */
|
/* ── Timezone / Locale step (Step 2) ────────────────────────────── */
|
||||||
|
|
||||||
|
.onboarding-tz-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tz-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background-color: var(--card-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tz-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tz-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-btn);
|
||||||
|
background-color: var(--card-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-family: 'Cantarell', 'Inter', 'Segoe UI', sans-serif;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tz-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-tz-select option {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background-color: var(--card-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-locale-select {
|
||||||
|
height: auto;
|
||||||
|
size: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Password step (Step 3) ─────────────────────────────────────── */
|
||||||
|
|
||||||
.onboarding-password-group {
|
.onboarding-password-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
|
/* Sovran_SystemsOS Hub — First-Boot Onboarding Wizard
|
||||||
Drives the 5-step post-install setup flow. */
|
Drives the 6-step post-install setup flow. */
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TOTAL_STEPS = 5;
|
const TOTAL_STEPS = 6;
|
||||||
|
|
||||||
// Steps to skip per role (steps 3 and 4 involve domain/port setup)
|
// Steps to skip per role (steps 4 and 5 involve domain/port setup)
|
||||||
// Step 2 (password) is NEVER skipped — all roles need it.
|
// Steps 2 (timezone/locale) and 3 (password) are NEVER skipped — all roles need them.
|
||||||
const ROLE_SKIP_STEPS = {
|
const ROLE_SKIP_STEPS = {
|
||||||
"desktop": [3, 4],
|
"desktop": [4, 5],
|
||||||
"node": [3, 4],
|
"node": [4, 5],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Role state (loaded at init) ───────────────────────────────────
|
// ── Role state (loaded at init) ───────────────────────────────────
|
||||||
@@ -96,7 +96,8 @@ function showStep(step) {
|
|||||||
if (step === 2) loadStep2();
|
if (step === 2) loadStep2();
|
||||||
if (step === 3) loadStep3();
|
if (step === 3) loadStep3();
|
||||||
if (step === 4) loadStep4();
|
if (step === 4) loadStep4();
|
||||||
// Step 5 (Complete) is static — no lazy-load needed
|
if (step === 5) loadStep5();
|
||||||
|
// Step 6 (Complete) is static — no lazy-load needed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the next step number, skipping over role-excluded steps
|
// Return the next step number, skipping over role-excluded steps
|
||||||
@@ -125,13 +126,160 @@ async function loadStep1() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 2: Create Your Password ─────────────────────────────────
|
// ── Step 2: Timezone & Locale ─────────────────────────────────────
|
||||||
|
|
||||||
async function loadStep2() {
|
async function loadStep2() {
|
||||||
var body = document.getElementById("step-2-body");
|
var body = document.getElementById("step-2-body");
|
||||||
|
if (!body || body._tzLoaded) return;
|
||||||
|
body._tzLoaded = true;
|
||||||
|
|
||||||
|
body.innerHTML = '<p class="onboarding-loading">Loading timezone data…</p>';
|
||||||
|
|
||||||
|
var timezones = [];
|
||||||
|
var currentTz = null;
|
||||||
|
var locales = [];
|
||||||
|
var currentLocale = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var results = await Promise.all([
|
||||||
|
apiFetch("/api/system/timezones"),
|
||||||
|
apiFetch("/api/system/locales"),
|
||||||
|
]);
|
||||||
|
timezones = results[0].timezones || [];
|
||||||
|
currentTz = results[0].current || null;
|
||||||
|
locales = results[1].locales || [];
|
||||||
|
currentLocale = results[1].current || null;
|
||||||
|
} catch (err) {
|
||||||
|
body.innerHTML = '<p class="onboarding-error">⚠ Could not load timezone data: ' + escHtml(err.message) + '</p>';
|
||||||
|
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 += '<div class="onboarding-tz-group">';
|
||||||
|
html += '<label class="onboarding-domain-label" for="tz-search">🕐 Timezone</label>';
|
||||||
|
html += '<input class="onboarding-tz-search" type="text" id="tz-search" placeholder="Search timezones…" autocomplete="off" value="' + escHtml(selectedTz) + '" />';
|
||||||
|
html += '<select class="onboarding-tz-select" id="tz-select" size="5">';
|
||||||
|
timezones.forEach(function(tz) {
|
||||||
|
var sel = tz === selectedTz ? ' selected' : '';
|
||||||
|
html += '<option value="' + escHtml(tz) + '"' + sel + '>' + escHtml(tz) + '</option>';
|
||||||
|
});
|
||||||
|
html += '</select>';
|
||||||
|
html += '<p class="onboarding-hint">Current: <span id="tz-current-display">' + escHtml(selectedTz || 'Not set') + '</span></p>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
// Locale section
|
||||||
|
html += '<div class="onboarding-tz-group">';
|
||||||
|
html += '<label class="onboarding-domain-label" for="locale-select">🌐 Language / Locale</label>';
|
||||||
|
html += '<select class="onboarding-tz-select onboarding-locale-select" id="locale-select">';
|
||||||
|
locales.forEach(function(loc) {
|
||||||
|
var sel = loc === (currentLocale || 'en_US.UTF-8') ? ' selected' : '';
|
||||||
|
html += '<option value="' + escHtml(loc) + '"' + sel + '>' + escHtml(loc) + '</option>';
|
||||||
|
});
|
||||||
|
html += '</select>';
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
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;
|
if (!body) return;
|
||||||
|
|
||||||
var nextBtn = document.getElementById("step-2-next");
|
var nextBtn = document.getElementById("step-3-next");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var result = await apiFetch("/api/security/password-is-default");
|
var result = await apiFetch("/api/security/password-is-default");
|
||||||
@@ -208,14 +356,14 @@ async function loadStep2() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveStep2() {
|
async function saveStep3() {
|
||||||
var newPw = document.getElementById("pw-new");
|
var newPw = document.getElementById("pw-new");
|
||||||
var confirmPw = document.getElementById("pw-confirm");
|
var confirmPw = document.getElementById("pw-confirm");
|
||||||
|
|
||||||
// If no fields visible or both empty and password already set → skip
|
// If no fields visible or both empty and password already set → skip
|
||||||
if (!newPw || !newPw.value.trim()) {
|
if (!newPw || !newPw.value.trim()) {
|
||||||
if (!_passwordIsDefault) return true; // already set, no change requested
|
if (!_passwordIsDefault) return true; // already set, no change requested
|
||||||
setStatus("step-2-status", "⚠ Please enter a password.", "error");
|
setStatus("step-3-status", "⚠ Please enter a password.", "error");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,15 +371,15 @@ async function saveStep2() {
|
|||||||
var cpw = confirmPw ? confirmPw.value : "";
|
var cpw = confirmPw ? confirmPw.value : "";
|
||||||
|
|
||||||
if (pw.length < 8) {
|
if (pw.length < 8) {
|
||||||
setStatus("step-2-status", "⚠ Password must be at least 8 characters.", "error");
|
setStatus("step-3-status", "⚠ Password must be at least 8 characters.", "error");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (pw !== cpw) {
|
if (pw !== cpw) {
|
||||||
setStatus("step-2-status", "⚠ Passwords do not match.", "error");
|
setStatus("step-3-status", "⚠ Passwords do not match.", "error");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("step-2-status", "Saving password…", "info");
|
setStatus("step-3-status", "Saving password…", "info");
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/change-password", {
|
await apiFetch("/api/change-password", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -239,19 +387,19 @@ async function saveStep2() {
|
|||||||
body: JSON.stringify({ new_password: pw, confirm_password: cpw }),
|
body: JSON.stringify({ new_password: pw, confirm_password: cpw }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus("step-2-status", "⚠ " + err.message, "error");
|
setStatus("step-3-status", "⚠ " + err.message, "error");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("step-2-status", "✓ Password saved", "ok");
|
setStatus("step-3-status", "✓ Password saved", "ok");
|
||||||
_passwordIsDefault = false;
|
_passwordIsDefault = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 3: Domain Configuration ─────────────────────────────────
|
// ── Step 4: Domain Configuration ─────────────────────────────────
|
||||||
|
|
||||||
async function loadStep3() {
|
async function loadStep4() {
|
||||||
var body = document.getElementById("step-3-body");
|
var body = document.getElementById("step-4-body");
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -394,8 +542,8 @@ async function loadStep3() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveStep3() {
|
async function saveStep4() {
|
||||||
setStatus("step-3-status", "Saving domains…", "info");
|
setStatus("step-4-status", "Saving domains…", "info");
|
||||||
var errors = [];
|
var errors = [];
|
||||||
|
|
||||||
// Save each domain input
|
// Save each domain input
|
||||||
@@ -435,18 +583,18 @@ async function saveStep3() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
setStatus("step-3-status", "⚠ Some errors: " + errors.join("; "), "error");
|
setStatus("step-4-status", "⚠ Some errors: " + errors.join("; "), "error");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("step-3-status", "✓ Saved", "ok");
|
setStatus("step-4-status", "✓ Saved", "ok");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 4: Port Forwarding ───────────────────────────────────────
|
// ── Step 5: Port Forwarding ───────────────────────────────────────
|
||||||
|
|
||||||
async function loadStep4() {
|
async function loadStep5() {
|
||||||
var body = document.getElementById("step-4-body");
|
var body = document.getElementById("step-5-body");
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
|
body.innerHTML = '<p class="onboarding-loading">Checking ports…</p>';
|
||||||
|
|
||||||
@@ -527,10 +675,10 @@ async function loadStep4() {
|
|||||||
body.innerHTML = html;
|
body.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5: Complete ──────────────────────────────────────────────
|
// ── Step 6: Complete ──────────────────────────────────────────────
|
||||||
|
|
||||||
async function completeOnboarding() {
|
async function completeOnboarding() {
|
||||||
var btn = document.getElementById("step-5-finish");
|
var btn = document.getElementById("step-6-finish");
|
||||||
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
|
if (btn) { btn.disabled = true; btn.textContent = "Finishing…"; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -549,7 +697,7 @@ function wireNavButtons() {
|
|||||||
var s1next = document.getElementById("step-1-next");
|
var s1next = document.getElementById("step-1-next");
|
||||||
if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); });
|
if (s1next) s1next.addEventListener("click", function() { showStep(nextStep(1)); });
|
||||||
|
|
||||||
// Step 2 → 3 (save password first)
|
// Step 2 → 3 (save timezone/locale first)
|
||||||
var s2next = document.getElementById("step-2-next");
|
var s2next = document.getElementById("step-2-next");
|
||||||
if (s2next) s2next.addEventListener("click", async function() {
|
if (s2next) s2next.addEventListener("click", async function() {
|
||||||
s2next.disabled = true;
|
s2next.disabled = true;
|
||||||
@@ -561,24 +709,36 @@ function wireNavButtons() {
|
|||||||
if (ok) showStep(nextStep(2));
|
if (ok) showStep(nextStep(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 3 → 4 (save domains first)
|
// Step 3 → 4 (save password first)
|
||||||
var s3next = document.getElementById("step-3-next");
|
var s3next = document.getElementById("step-3-next");
|
||||||
if (s3next) s3next.addEventListener("click", async function() {
|
if (s3next) s3next.addEventListener("click", async function() {
|
||||||
s3next.disabled = true;
|
s3next.disabled = true;
|
||||||
|
var origText = s3next.textContent;
|
||||||
s3next.textContent = "Saving…";
|
s3next.textContent = "Saving…";
|
||||||
await saveStep3();
|
var ok = await saveStep3();
|
||||||
s3next.disabled = false;
|
s3next.disabled = false;
|
||||||
s3next.textContent = "Save & Continue →";
|
s3next.textContent = origText;
|
||||||
showStep(nextStep(3));
|
if (ok) showStep(nextStep(3));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4 → 5 (Complete)
|
// Step 4 → 5 (save domains first)
|
||||||
var s4next = document.getElementById("step-4-next");
|
var s4next = document.getElementById("step-4-next");
|
||||||
if (s4next) s4next.addEventListener("click", function() { showStep(nextStep(4)); });
|
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: finish
|
// Step 5 → 6 (port forwarding — no save needed)
|
||||||
var s5finish = document.getElementById("step-5-finish");
|
var s5next = document.getElementById("step-5-next");
|
||||||
if (s5finish) s5finish.addEventListener("click", completeOnboarding);
|
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
|
// Back buttons
|
||||||
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
document.querySelectorAll(".onboarding-btn-back").forEach(function(btn) {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
<span class="onboarding-step-dot" data-step="4">4</span>
|
<span class="onboarding-step-dot" data-step="4">4</span>
|
||||||
<span class="onboarding-step-connector"></span>
|
<span class="onboarding-step-connector"></span>
|
||||||
<span class="onboarding-step-dot" data-step="5">5</span>
|
<span class="onboarding-step-dot" data-step="5">5</span>
|
||||||
|
<span class="onboarding-step-connector"></span>
|
||||||
|
<span class="onboarding-step-dot" data-step="6">6</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step panels -->
|
<!-- Step panels -->
|
||||||
@@ -72,8 +74,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Step 2: Create Your Password ── -->
|
<!-- ── Step 2: Timezone & Locale ── -->
|
||||||
<div class="onboarding-panel" id="step-2" style="display:none">
|
<div class="onboarding-panel" id="step-2" style="display:none">
|
||||||
|
<div class="onboarding-step-header">
|
||||||
|
<span class="onboarding-step-icon">🌍</span>
|
||||||
|
<h2 class="onboarding-step-title">Timezone & Locale</h2>
|
||||||
|
<p class="onboarding-step-desc">
|
||||||
|
Select your timezone and preferred language so your system clock, logs,
|
||||||
|
and services display the correct time and format.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="onboarding-card" id="step-2-body">
|
||||||
|
<p class="onboarding-loading">Loading timezone data…</p>
|
||||||
|
</div>
|
||||||
|
<div id="step-2-status" class="onboarding-save-status"></div>
|
||||||
|
<div class="onboarding-footer">
|
||||||
|
<button class="btn btn-close-modal onboarding-btn-back" data-prev="1">← Back</button>
|
||||||
|
<button class="btn btn-primary onboarding-btn-next" id="step-2-next">
|
||||||
|
Save & Continue →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Step 3: Create Your Password ── -->
|
||||||
|
<div class="onboarding-panel" id="step-3" style="display:none">
|
||||||
<div class="onboarding-step-header">
|
<div class="onboarding-step-header">
|
||||||
<span class="onboarding-step-icon">🔒</span>
|
<span class="onboarding-step-icon">🔒</span>
|
||||||
<h2 class="onboarding-step-title">Create Your Password</h2>
|
<h2 class="onboarding-step-title">Create Your Password</h2>
|
||||||
@@ -81,20 +105,20 @@
|
|||||||
Choose a strong password for your <strong>'free'</strong> user account.
|
Choose a strong password for your <strong>'free'</strong> user account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card" id="step-2-body">
|
<div class="onboarding-card" id="step-3-body">
|
||||||
<p class="onboarding-loading">Checking password status…</p>
|
<p class="onboarding-loading">Checking password status…</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="step-2-status" class="onboarding-save-status"></div>
|
<div id="step-3-status" class="onboarding-save-status"></div>
|
||||||
<div class="onboarding-footer">
|
<div class="onboarding-footer">
|
||||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="1">← Back</button>
|
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
|
||||||
<button class="btn btn-primary onboarding-btn-next" id="step-2-next">
|
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
|
||||||
Set Password & Continue →
|
Set Password & Continue →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Step 3: Domain Configuration ── -->
|
<!-- ── Step 4: Domain Configuration ── -->
|
||||||
<div class="onboarding-panel" id="step-3" style="display:none">
|
<div class="onboarding-panel" id="step-4" style="display:none">
|
||||||
<div class="onboarding-step-header">
|
<div class="onboarding-step-header">
|
||||||
<span class="onboarding-step-icon">🌐</span>
|
<span class="onboarding-step-icon">🌐</span>
|
||||||
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
<h2 class="onboarding-step-title">Domain Configuration</h2>
|
||||||
@@ -105,20 +129,20 @@
|
|||||||
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
|
Finally, paste the DDNS curl command from your Njal.la dashboard for each service below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card" id="step-3-body">
|
<div class="onboarding-card" id="step-4-body">
|
||||||
<p class="onboarding-loading">Loading service information…</p>
|
<p class="onboarding-loading">Loading service information…</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="step-3-status" class="onboarding-save-status"></div>
|
<div id="step-4-status" class="onboarding-save-status"></div>
|
||||||
<div class="onboarding-footer">
|
<div class="onboarding-footer">
|
||||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="2">← Back</button>
|
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
||||||
<button class="btn btn-primary onboarding-btn-next" id="step-3-next">
|
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
|
||||||
Save & Continue →
|
Save & Continue →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Step 4: Port Forwarding ── -->
|
<!-- ── Step 5: Port Forwarding ── -->
|
||||||
<div class="onboarding-panel" id="step-4" style="display:none">
|
<div class="onboarding-panel" id="step-5" style="display:none">
|
||||||
<div class="onboarding-step-header">
|
<div class="onboarding-step-header">
|
||||||
<span class="onboarding-step-icon">🔌</span>
|
<span class="onboarding-step-icon">🔌</span>
|
||||||
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
<h2 class="onboarding-step-title">Port Forwarding Check</h2>
|
||||||
@@ -127,19 +151,19 @@
|
|||||||
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
<strong>Ports 80 and 443 must be open for SSL certificates to work.</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-card" id="step-4-body">
|
<div class="onboarding-card" id="step-5-body">
|
||||||
<p class="onboarding-loading">Checking ports…</p>
|
<p class="onboarding-loading">Checking ports…</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-footer">
|
<div class="onboarding-footer">
|
||||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="3">← Back</button>
|
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
|
||||||
<button class="btn btn-primary onboarding-btn-next" id="step-4-next">
|
<button class="btn btn-primary onboarding-btn-next" id="step-5-next">
|
||||||
Continue →
|
Continue →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Step 5: Complete ── -->
|
<!-- ── Step 6: Complete ── -->
|
||||||
<div class="onboarding-panel" id="step-5" style="display:none">
|
<div class="onboarding-panel" id="step-6" style="display:none">
|
||||||
<div class="onboarding-hero">
|
<div class="onboarding-hero">
|
||||||
<div class="onboarding-logo">✅</div>
|
<div class="onboarding-logo">✅</div>
|
||||||
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
<h1 class="onboarding-title">Your Sovran_SystemsOS is Ready!</h1>
|
||||||
@@ -151,14 +175,15 @@
|
|||||||
monitor your services, manage credentials, and make changes at any time.
|
monitor your services, manage credentials, and make changes at any time.
|
||||||
</p>
|
</p>
|
||||||
<ul class="onboarding-checklist" id="onboarding-checklist">
|
<ul class="onboarding-checklist" id="onboarding-checklist">
|
||||||
|
<li>✅ Timezone & locale configured</li>
|
||||||
<li>✅ Password configured</li>
|
<li>✅ Password configured</li>
|
||||||
<li>✅ Domain configuration saved</li>
|
<li>✅ Domain configuration saved</li>
|
||||||
<li>✅ Port forwarding reviewed</li>
|
<li>✅ Port forwarding reviewed</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="onboarding-footer">
|
<div class="onboarding-footer">
|
||||||
<button class="btn btn-close-modal onboarding-btn-back" data-prev="4">← Back</button>
|
<button class="btn btn-close-modal onboarding-btn-back" data-prev="5">← Back</button>
|
||||||
<button class="btn btn-primary" id="step-5-finish">
|
<button class="btn btn-primary" id="step-6-finish">
|
||||||
Go to Dashboard →
|
Go to Dashboard →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user