diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index aaaf9b9..d228f68 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -3,7 +3,9 @@ from __future__ import annotations import asyncio +import base64 import hashlib +import io import json import os import re @@ -31,6 +33,7 @@ UPDATE_STATUS = "/var/log/sovran-hub-update.status" UPDATE_UNIT = "sovran-hub-update.service" INTERNAL_IP_FILE = "/var/lib/secrets/internal-ip" +ZEUS_CONNECT_FILE = "/var/lib/secrets/zeus-connect-url" REBOOT_COMMAND = ["reboot"] @@ -185,6 +188,24 @@ def _get_external_ip() -> str: return "unavailable" +# ── QR code helper ──────────────────────────────────────────────── + +def _generate_qr_base64(data: str) -> str | None: + """Generate a QR code PNG and return it as a base64-encoded data URI. + Uses qrencode CLI (available on the system via credentials-pdf.nix).""" + try: + result = subprocess.run( + ["qrencode", "-o", "-", "-t", "PNG", "-s", "6", "-m", "2", "-l", "H", data], + capture_output=True, timeout=10, + ) + if result.returncode == 0 and result.stdout: + b64 = base64.b64encode(result.stdout).decode("ascii") + return f"data:image/png;base64,{b64}" + except Exception: + pass + return None + + # ── Update helpers (file-based, no systemctl) ──────────────────── def _read_update_status() -> str: @@ -224,16 +245,22 @@ def _read_log(offset: int = 0) -> tuple[str, int]: # ── Credentials helpers ────────────────────────────────────────── def _resolve_credential(cred: dict) -> dict | None: - """Resolve a single credential entry to {label, value}.""" + """Resolve a single credential entry to {label, value, ...}.""" label = cred.get("label", "") prefix = cred.get("prefix", "") suffix = cred.get("suffix", "") extract = cred.get("extract", "") multiline = cred.get("multiline", False) + qrcode = cred.get("qrcode", False) # Static value if "value" in cred: - return {"label": label, "value": prefix + cred["value"] + suffix, "multiline": multiline} + result = {"label": label, "value": prefix + cred["value"] + suffix, "multiline": multiline} + if qrcode: + qr_data = _generate_qr_base64(result["value"]) + if qr_data: + result["qrcode"] = qr_data + return result # File-based value filepath = cred.get("file", "") @@ -255,7 +282,14 @@ def _resolve_credential(cred: dict) -> dict | None: return None value = prefix + raw + suffix - return {"label": label, "value": value, "multiline": multiline} + result = {"label": label, "value": value, "multiline": multiline} + + if qrcode: + qr_data = _generate_qr_base64(value) + if qr_data: + result["qrcode"] = qr_data + + return result # ── Routes ─────────────────────────────────────────────────────── diff --git a/app/sovran_systemsos_web/static/app.js b/app/sovran_systemsos_web/static/app.js index 72b0160..616cef6 100644 --- a/app/sovran_systemsos_web/static/app.js +++ b/app/sovran_systemsos_web/static/app.js @@ -269,7 +269,7 @@ async function refreshServices() { } } -// ── Network IPs ─────────────────────────────────────────────────── +// ── Network IPs ──────────────────────────────────��──────────────── async function loadNetwork() { try { @@ -319,9 +319,22 @@ async function openCredsModal(unit, name) { for (const cred of data.credentials) { const id = "cred-" + Math.random().toString(36).substring(2, 8); const displayValue = linkify(cred.value); + + // QR code block (if present) + let qrBlock = ""; + if (cred.qrcode) { + qrBlock = ` +
+ QR Code for ${escHtml(cred.label)} +
Scan with Zeus app on your phone
+
+ `; + } + html += `
${escHtml(cred.label)}
+ ${qrBlock}
${displayValue}
diff --git a/app/sovran_systemsos_web/static/style.css b/app/sovran_systemsos_web/static/style.css index 7dc40d0..dc655a2 100644 --- a/app/sovran_systemsos_web/static/style.css +++ b/app/sovran_systemsos_web/static/style.css @@ -1,6 +1,6 @@ /* Sovran_SystemsOS Hub — Web UI Stylesheet Dark theme matching the Adwaita dark aesthetic - v4 — credentials info modal */ + v5 — QR code support in credentials modal */ *, *::before, *::after { box-sizing: border-box; @@ -530,7 +530,7 @@ button.btn-reboot:hover:not(:disabled) { background-color: #5a5c72; } -/* ── Credentials info modal ──────────────────────────────────────── */ +/* ── Credentials info modal ──────────────────────────────────────��─ */ .creds-dialog { background-color: var(--surface-color); @@ -670,137 +670,14 @@ button.btn-reboot:hover:not(:disabled) { color: #defce6; } -/* ── Reboot overlay ─────────────────────────────────────────────── */ +/* ── QR code in credentials modal ────────────────────────────────── */ -.reboot-overlay { - display: none; - position: fixed; - inset: 0; - background-color: rgba(15, 15, 25, 0.92); - z-index: 999; - align-items: center; - justify-content: center; -} - -.reboot-overlay.visible { - display: flex; -} - -.reboot-card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 20px; - padding: 48px 56px; - text-align: center; - max-width: 480px; - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); - animation: reboot-fade-in 0.4s ease-out; -} - -@keyframes reboot-fade-in { - from { opacity: 0; transform: scale(0.92) translateY(12px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} - -.reboot-icon { - font-size: 3rem; - color: var(--accent-color); - margin-bottom: 16px; - animation: reboot-spin 2s linear infinite; - display: inline-block; -} - -@keyframes reboot-spin { - to { transform: rotate(360deg); } -} - -.reboot-title { - font-size: 1.35rem; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 12px; -} - -.reboot-message { - font-size: 0.92rem; - color: var(--text-secondary); - line-height: 1.6; - margin-bottom: 24px; -} - -.reboot-dots { +.creds-qr-wrap { display: flex; + flex-direction: column; align-items: center; - justify-content: center; - gap: 8px; - margin-bottom: 16px; + padding: 20px 0; + margin-bottom: 10px; } -.reboot-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background-color: var(--accent-color); - animation: reboot-bounce 1.4s ease-in-out infinite; -} - -.reboot-dot:nth-child(2) { animation-delay: 0.2s; } -.reboot-dot:nth-child(3) { animation-delay: 0.4s; } - -@keyframes reboot-bounce { - 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } - 40% { opacity: 1; transform: scale(1.2); } -} - -.reboot-submessage { - font-size: 0.82rem; - color: var(--text-dim); - font-style: italic; -} - -/* ── Empty state ────────────────────────────────────────────────── */ - -.empty-state { - text-align: center; - padding: 64px 24px; - color: var(--text-dim); -} - -.empty-state p { - font-size: 1rem; - margin-bottom: 8px; -} - -/* ── Responsive ─────────────────────────────────────────────────── */ - -@media (max-width: 600px) { - .header-bar { - padding: 10px 14px; - gap: 10px; - } - .header-bar .title { - font-size: 0.95rem; - } - .ip-bar { - gap: 16px; - flex-wrap: wrap; - padding: 8px 14px; - } - .main-content { - padding: 16px 12px 40px; - } - .tiles-grid { - justify-content: center; - } - .service-tile { - width: 160px; - min-height: 200px; - } - .reboot-card { - padding: 36px 28px; - margin: 0 16px; - } - .creds-dialog { - margin: 0 12px; - } -} \ No newline at end of file +. \ No newline at end of file