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 = `
+
+

+
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