Merge pull request #16 from naturallaw777/copilot/fix-css-theme-bug

[WIP] Fix CSS theme bug in support and feature manager styles
This commit is contained in:
Sovran_Systems
2026-04-03 10:59:35 -05:00
committed by GitHub
3 changed files with 444 additions and 5 deletions

View File

@@ -10,6 +10,8 @@ import os
import re import re
import socket import socket
import subprocess import subprocess
import urllib.error
import urllib.parse
import urllib.request import urllib.request
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
@@ -1110,6 +1112,185 @@ async def api_domains_status():
return {"domains": domains} return {"domains": domains}
# ── Matrix user management ────────────────────────────────────────
MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users"
MATRIX_DOMAINS_FILE = "/var/lib/domains/matrix"
_SAFE_USERNAME_RE = re.compile(r'^[a-z0-9._\-]+$')
def _validate_matrix_username(username: str) -> bool:
"""Return True if username is a valid Matrix localpart."""
return bool(username) and len(username) <= 255 and bool(_SAFE_USERNAME_RE.match(username))
def _parse_matrix_admin_creds() -> tuple[str, str]:
"""Parse admin username and password from the matrix-users credentials file.
Returns (localpart, password) for the admin account.
Raises FileNotFoundError if the file does not exist.
Raises ValueError if the file cannot be parsed.
"""
with open(MATRIX_USERS_FILE, "r") as f:
content = f.read()
admin_user: str | None = None
admin_pass: str | None = None
in_admin_section = False
for line in content.splitlines():
stripped = line.strip()
if stripped == "[ Admin Account ]":
in_admin_section = True
continue
if stripped.startswith("[ ") and in_admin_section:
break
if in_admin_section:
if stripped.startswith("Username:"):
raw = stripped[len("Username:"):].strip()
# Format is @localpart:domain — extract localpart
if raw.startswith("@") and ":" in raw:
admin_user = raw[1:raw.index(":")]
else:
admin_user = raw
elif stripped.startswith("Password:"):
admin_pass = stripped[len("Password:"):].strip()
if not admin_user or not admin_pass:
raise ValueError("Could not parse admin credentials from matrix-users file")
if "(pre-existing" in admin_pass:
raise ValueError(
"Admin password is not stored (user was pre-existing). "
"Please reset the admin password manually before using this feature."
)
return admin_user, admin_pass
def _matrix_get_admin_token(domain: str, admin_user: str, admin_pass: str) -> str:
"""Log in to the local Synapse instance and return an access token."""
url = "http://[::1]:8008/_matrix/client/v3/login"
payload = json.dumps({
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": admin_user},
"password": admin_pass,
}).encode()
req = urllib.request.Request(
url, data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=15) as resp:
body = json.loads(resp.read())
token: str = body.get("access_token", "")
if not token:
raise ValueError("No access_token in Synapse login response")
return token
class MatrixCreateUserRequest(BaseModel):
username: str
password: str
admin: bool = False
@app.post("/api/matrix/create-user")
async def api_matrix_create_user(req: MatrixCreateUserRequest):
"""Create a new Matrix user via register_new_matrix_user."""
if not _validate_matrix_username(req.username):
raise HTTPException(status_code=400, detail="Invalid username. Use only lowercase letters, digits, '.', '_', '-'.")
if not req.password:
raise HTTPException(status_code=400, detail="Password must not be empty.")
admin_flag = ["-a"] if req.admin else ["--no-admin"]
cmd = [
"register_new_matrix_user",
"-c", "/run/matrix-synapse/runtime-config.yaml",
"-u", req.username,
"-p", req.password,
*admin_flag,
"http://localhost:8008",
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except FileNotFoundError:
raise HTTPException(status_code=500, detail="register_new_matrix_user not found on this system.")
except subprocess.TimeoutExpired:
raise HTTPException(status_code=500, detail="Command timed out.")
output = (result.stdout + result.stderr).strip()
if result.returncode != 0:
# Surface the actual error from the tool (e.g. "User ID already taken")
raise HTTPException(status_code=400, detail=output or "Failed to create user.")
return {"ok": True, "username": req.username}
class MatrixChangePasswordRequest(BaseModel):
username: str
new_password: str
@app.post("/api/matrix/change-password")
async def api_matrix_change_password(req: MatrixChangePasswordRequest):
"""Change a Matrix user's password via the Synapse Admin API."""
if not _validate_matrix_username(req.username):
raise HTTPException(status_code=400, detail="Invalid username. Use only lowercase letters, digits, '.', '_', '-'.")
if not req.new_password:
raise HTTPException(status_code=400, detail="New password must not be empty.")
# Read domain
try:
with open(MATRIX_DOMAINS_FILE, "r") as f:
domain = f.read().strip()
except FileNotFoundError:
raise HTTPException(status_code=500, detail="Matrix domain not configured.")
# Parse admin credentials
try:
admin_user, admin_pass = _parse_matrix_admin_creds()
except FileNotFoundError:
raise HTTPException(status_code=500, detail="Matrix credentials file not found.")
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc))
# Obtain admin access token
loop = asyncio.get_event_loop()
try:
token = await loop.run_in_executor(
None, _matrix_get_admin_token, domain, admin_user, admin_pass
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Could not authenticate as admin: {exc}")
# Call Synapse Admin API to reset the password
target_user_id = f"@{req.username}:{domain}"
url = f"http://[::1]:8008/_synapse/admin/v2/users/{urllib.parse.quote(target_user_id, safe='@:')}"
payload = json.dumps({"password": req.new_password}).encode()
api_req = urllib.request.Request(
url, data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
method="PUT",
)
try:
with urllib.request.urlopen(api_req, timeout=15) as resp:
resp.read()
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")
try:
detail = json.loads(body).get("error", body)
except Exception:
detail = body
raise HTTPException(status_code=400, detail=detail)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Admin API call failed: {exc}")
return {"ok": True, "username": req.username}
# ── Startup: seed the internal IP file immediately ─────────────── # ── Startup: seed the internal IP file immediately ───────────────
@app.on_event("startup") @app.on_event("startup")

View File

@@ -154,7 +154,11 @@ function formatDuration(seconds) {
async function apiFetch(path, options) { async function apiFetch(path, options) {
const res = await fetch(path, options || {}); const res = await fetch(path, options || {});
if (!res.ok) throw new Error(res.status + " " + res.statusText); if (!res.ok) {
let detail = res.status + " " + res.statusText;
try { const body = await res.json(); if (body && body.detail) detail = body.detail; } catch (e) {}
throw new Error(detail);
}
return res.json(); return res.json();
} }
@@ -306,6 +310,12 @@ async function openCredsModal(unit, name) {
} }
html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>'; html += '<div class="creds-row"><div class="creds-label">' + escHtml(cred.label) + '</div>' + qrBlock + '<div class="creds-value-wrap"><div class="creds-value" id="' + id + '">' + displayValue + '</div><button class="creds-copy-btn" data-target="' + id + '">Copy</button></div></div>';
} }
if (unit === "matrix-synapse.service") {
html += '<hr class="matrix-actions-divider"><div class="matrix-actions-row">' +
'<button class="matrix-action-btn" id="matrix-add-user-btn"> Add New User</button>' +
'<button class="matrix-action-btn" id="matrix-change-pw-btn">🔑 Change Password</button>' +
'</div>';
}
$credsBody.innerHTML = html; $credsBody.innerHTML = html;
$credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) { $credsBody.querySelectorAll(".creds-copy-btn").forEach(function(btn) {
btn.addEventListener("click", function() { btn.addEventListener("click", function() {
@@ -340,11 +350,125 @@ async function openCredsModal(unit, name) {
} }
}); });
}); });
if (unit === "matrix-synapse.service") {
var addBtn = document.getElementById("matrix-add-user-btn");
var changePwBtn = document.getElementById("matrix-change-pw-btn");
if (addBtn) addBtn.addEventListener("click", function() { openMatrixCreateUserModal(unit, name); });
if (changePwBtn) changePwBtn.addEventListener("click", function() { openMatrixChangePasswordModal(unit, name); });
}
} catch (err) { } catch (err) {
$credsBody.innerHTML = '<p class="creds-empty">Could not load credentials.</p>'; $credsBody.innerHTML = '<p class="creds-empty">Could not load credentials.</p>';
} }
} }
function openMatrixCreateUserModal(unit, name) {
if (!$credsBody) return;
$credsBody.innerHTML =
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-new-username">Username</label>' +
'<input class="matrix-form-input" type="text" id="matrix-new-username" placeholder="alice" autocomplete="off"></div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-new-password">Password</label>' +
'<input class="matrix-form-input" type="password" id="matrix-new-password" placeholder="Strong password" autocomplete="new-password"></div>' +
'<div class="matrix-form-checkbox-row"><input type="checkbox" id="matrix-new-admin"><label class="matrix-form-label" for="matrix-new-admin" style="margin:0">Make admin</label></div>' +
'<div class="matrix-form-actions">' +
'<button class="matrix-form-back" id="matrix-create-back-btn">← Back</button>' +
'<button class="matrix-form-submit" id="matrix-create-submit-btn">Create User</button>' +
'</div>' +
'<div class="matrix-form-result" id="matrix-create-result"></div>';
document.getElementById("matrix-create-back-btn").addEventListener("click", function() {
openCredsModal(unit, name);
});
document.getElementById("matrix-create-submit-btn").addEventListener("click", async function() {
var submitBtn = document.getElementById("matrix-create-submit-btn");
var resultEl = document.getElementById("matrix-create-result");
var username = (document.getElementById("matrix-new-username").value || "").trim();
var password = document.getElementById("matrix-new-password").value || "";
var isAdmin = document.getElementById("matrix-new-admin").checked;
if (!username || !password) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Username and password are required.";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "Creating…";
resultEl.className = "matrix-form-result";
resultEl.textContent = "";
try {
var resp = await apiFetch("/api/matrix/create-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, password: password, admin: isAdmin })
});
resultEl.className = "matrix-form-result success";
resultEl.textContent = "✅ User @" + escHtml(resp.username) + " created successfully.";
submitBtn.textContent = "Create User";
submitBtn.disabled = false;
} catch (err) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "❌ " + (err.message || "Failed to create user.");
submitBtn.textContent = "Create User";
submitBtn.disabled = false;
}
});
}
function openMatrixChangePasswordModal(unit, name) {
if (!$credsBody) return;
$credsBody.innerHTML =
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-chpw-username">Username (localpart only, e.g. <em>alice</em>)</label>' +
'<input class="matrix-form-input" type="text" id="matrix-chpw-username" placeholder="alice" autocomplete="off"></div>' +
'<div class="matrix-form-group"><label class="matrix-form-label" for="matrix-chpw-password">New Password</label>' +
'<input class="matrix-form-input" type="password" id="matrix-chpw-password" placeholder="New strong password" autocomplete="new-password"></div>' +
'<div class="matrix-form-actions">' +
'<button class="matrix-form-back" id="matrix-chpw-back-btn">← Back</button>' +
'<button class="matrix-form-submit" id="matrix-chpw-submit-btn">Change Password</button>' +
'</div>' +
'<div class="matrix-form-result" id="matrix-chpw-result"></div>';
document.getElementById("matrix-chpw-back-btn").addEventListener("click", function() {
openCredsModal(unit, name);
});
document.getElementById("matrix-chpw-submit-btn").addEventListener("click", async function() {
var submitBtn = document.getElementById("matrix-chpw-submit-btn");
var resultEl = document.getElementById("matrix-chpw-result");
var username = (document.getElementById("matrix-chpw-username").value || "").trim();
var newPassword = document.getElementById("matrix-chpw-password").value || "";
if (!username || !newPassword) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "Username and new password are required.";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "Changing…";
resultEl.className = "matrix-form-result";
resultEl.textContent = "";
try {
var resp = await apiFetch("/api/matrix/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username, new_password: newPassword })
});
resultEl.className = "matrix-form-result success";
resultEl.textContent = "✅ Password for @" + escHtml(resp.username) + " changed successfully.";
submitBtn.textContent = "Change Password";
submitBtn.disabled = false;
} catch (err) {
resultEl.className = "matrix-form-result error";
resultEl.textContent = "❌ " + (err.message || "Failed to change password.");
submitBtn.textContent = "Change Password";
submitBtn.disabled = false;
}
});
}
function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); } function closeCredsModal() { if ($credsModal) $credsModal.classList.remove("open"); }
// ── Tech Support modal ──────────────────────────────────────────── // ── Tech Support modal ────────────────────────────────────────────

View File

@@ -592,6 +592,143 @@ button.btn-reboot:hover:not(:disabled) {
color: #defce6; color: #defce6;
} }
/* ── Matrix action buttons ───────────────────────────────────────── */
.matrix-actions-divider {
border: none;
border-top: 1px solid var(--border-color);
margin: 18px 0 14px;
}
.matrix-actions-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.matrix-action-btn {
background-color: var(--accent-color);
color: #0f0f19;
font-size: 0.88rem;
font-weight: 700;
padding: 10px 18px;
border-radius: 8px;
border: none;
cursor: pointer;
flex: 1;
min-width: 140px;
}
.matrix-action-btn:hover {
background-color: #a8c8ff;
}
.matrix-form-group {
margin-bottom: 14px;
}
.matrix-form-label {
display: block;
font-size: 0.82rem;
color: var(--text-secondary);
margin-bottom: 6px;
font-weight: 600;
}
.matrix-form-input {
width: 100%;
background-color: #12121c;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 12px;
font-size: 0.9rem;
box-sizing: border-box;
}
.matrix-form-input:focus {
outline: none;
border-color: var(--accent-color);
}
.matrix-form-checkbox-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.matrix-form-checkbox-row input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-color);
}
.matrix-form-actions {
display: flex;
gap: 10px;
margin-top: 18px;
}
.matrix-form-submit {
background-color: var(--accent-color);
color: #0f0f19;
font-size: 0.88rem;
font-weight: 700;
padding: 10px 20px;
border-radius: 8px;
border: none;
cursor: pointer;
flex: 1;
}
.matrix-form-submit:hover:not(:disabled) {
background-color: #a8c8ff;
}
.matrix-form-submit:disabled {
opacity: 0.6;
cursor: default;
}
.matrix-form-back {
background-color: var(--border-color);
color: var(--text-primary);
font-size: 0.88rem;
font-weight: 600;
padding: 10px 20px;
border-radius: 8px;
border: none;
cursor: pointer;
}
.matrix-form-back:hover {
background-color: #5a5c72;
}
.matrix-form-result {
margin-top: 14px;
padding: 12px 16px;
border-radius: 8px;
font-size: 0.88rem;
line-height: 1.5;
display: none;
}
.matrix-form-result.success {
background-color: rgba(74, 222, 128, 0.12);
border: 1px solid var(--green);
color: var(--green);
display: block;
}
.matrix-form-result.error {
background-color: rgba(239, 68, 68, 0.12);
border: 1px solid #ef4444;
color: #f87171;
display: block;
}
/* ── QR code in credentials modal ────────────────────────────────── */ /* ── QR code in credentials modal ────────────────────────────────── */
.creds-qr-wrap { .creds-qr-wrap {
@@ -756,6 +893,7 @@ button.btn-reboot:hover:not(:disabled) {
width: 200px; width: 200px;
height: 200px; height: 200px;
} }
}
/* ── Tech Support tile ───────────────────────────────────────────── */ /* ── Tech Support tile ───────────────────────────────────────────── */
@@ -955,10 +1093,6 @@ button.btn-reboot:hover:not(:disabled) {
.support-btn-done:hover:not(:disabled) { .support-btn-done:hover:not(:disabled) {
background-color: #5a5c72; background-color: #5a5c72;
}
} }
/* ── Feature Manager ─────────────────────────────────────────────── */ /* ── Feature Manager ─────────────────────────────────────────────── */