From d9fba842433695f54495d14ad1bad8b25d29d4f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:08:33 +0000 Subject: [PATCH 1/2] Initial plan From 9e081bec057d1a1299c6b784c1e4888b0527e1a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:13:44 +0000 Subject: [PATCH 2/2] Add timezone/locale onboarding step (new Step 2), renumber existing steps 2-5 to 3-6 Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/47f2ee8f-bd6c-4151-bd2d-3e9283cb02c0 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 163 ++++++++++++ .../static/css/onboarding.css | 57 ++++- app/sovran_systemsos_web/static/onboarding.js | 236 +++++++++++++++--- .../templates/onboarding.html | 65 +++-- 4 files changed, 462 insertions(+), 59 deletions(-) diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 0b5c2cf..d37d3d3 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -3117,6 +3117,169 @@ async def api_change_password(req: ChangePasswordRequest): 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_USERS_FILE = "/var/lib/secrets/matrix-users" diff --git a/app/sovran_systemsos_web/static/css/onboarding.css b/app/sovran_systemsos_web/static/css/onboarding.css index 23cb8f3..041d8b8 100644 --- a/app/sovran_systemsos_web/static/css/onboarding.css +++ b/app/sovran_systemsos_web/static/css/onboarding.css @@ -566,7 +566,62 @@ 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 { display: flex; diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index 2b262d6..6be1c39 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -1,16 +1,16 @@ /* 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"; // ── Constants ───────────────────────────────────────────────────── -const TOTAL_STEPS = 5; +const TOTAL_STEPS = 6; -// Steps to skip per role (steps 3 and 4 involve domain/port setup) -// Step 2 (password) is NEVER skipped — all roles need it. +// Steps to skip per role (steps 4 and 5 involve domain/port setup) +// Steps 2 (timezone/locale) and 3 (password) are NEVER skipped — all roles need them. const ROLE_SKIP_STEPS = { - "desktop": [3, 4], - "node": [3, 4], + "desktop": [4, 5], + "node": [4, 5], }; // ── Role state (loaded at init) ─────────────────────────────────── @@ -96,7 +96,8 @@ function showStep(step) { if (step === 2) loadStep2(); if (step === 3) loadStep3(); 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 @@ -125,13 +126,160 @@ async function loadStep1() { } catch (_) {} } -// ── Step 2: Create Your Password ───────────────────────────────── +// ── Step 2: Timezone & Locale ───────────────────────────────────── async function loadStep2() { var body = document.getElementById("step-2-body"); + if (!body || body._tzLoaded) return; + body._tzLoaded = true; + + body.innerHTML = '
Loading timezone data…
'; + + 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 = '⚠ 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 += 'Current: ' + escHtml(selectedTz || 'Not set') + '
'; + html += 'Checking ports…
'; @@ -527,10 +675,10 @@ async function loadStep4() { body.innerHTML = html; } -// ── Step 5: Complete ────────────────────────────────────────────── +// ── Step 6: Complete ────────────────────────────────────────────── 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…"; } try { @@ -549,7 +697,7 @@ function wireNavButtons() { var s1next = document.getElementById("step-1-next"); 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"); if (s2next) s2next.addEventListener("click", async function() { s2next.disabled = true; @@ -561,24 +709,36 @@ function wireNavButtons() { if (ok) showStep(nextStep(2)); }); - // Step 3 → 4 (save domains first) + // 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…"; - await saveStep3(); + var ok = await saveStep3(); s3next.disabled = false; - s3next.textContent = "Save & Continue →"; - showStep(nextStep(3)); + s3next.textContent = origText; + if (ok) showStep(nextStep(3)); }); - // Step 4 → 5 (Complete) + // Step 4 → 5 (save domains first) 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 - var s5finish = document.getElementById("step-5-finish"); - if (s5finish) s5finish.addEventListener("click", completeOnboarding); + // 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) { diff --git a/app/sovran_systemsos_web/templates/onboarding.html b/app/sovran_systemsos_web/templates/onboarding.html index f0d1427..e33481e 100644 --- a/app/sovran_systemsos_web/templates/onboarding.html +++ b/app/sovran_systemsos_web/templates/onboarding.html @@ -36,6 +36,8 @@ 4 5 + + 6 @@ -72,8 +74,30 @@ - + + + +