Fix CSS media query, add Matrix user management UI and API endpoints
Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/84f10dbb-7db4-4f3f-b9b4-0f20455ac3e0 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
e90fbccde0
commit
fc2c7e7928
@@ -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")
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────── */
|
||||||
|
|||||||
Reference in New Issue
Block a user