feat: wallet privacy control and audit logging for tech support sessions
- Add dedicated `sovran-support` restricted user (non-root) for SSH sessions - Apply POSIX ACLs via setfacl to block support user from wallet directories (LND, Sparrow, Bisq, nix-bitcoin-secrets) by default - Graceful fallback to root authorized_keys if user creation fails (with UI warning) - Add time-limited wallet unlock consent: POST /api/support/wallet-unlock - Add wallet re-lock: POST /api/support/wallet-lock - Add audit log: GET /api/support/audit-log (append-only, all events logged) - Expand /api/support/status with wallet_protected, wallet_unlocked, wallet_unlocked_until, protected_paths, acl_applied fields - Update frontend to show wallet protection status box with protected path list - Show wallet unlock/re-lock controls with duration selector (30min/1h/2h) - Show audit log viewer in support modal (toggleable) - Add wallet unlock expiry auto-refresh timer in JS - Add CSS styles for wallet protection box, unlock/lock buttons, audit log Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/70330ce3-1ed7-46b1-ac66-4cdc50de6017 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
87529b0d3f
commit
dd3a20ed00
@@ -7,9 +7,11 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pwd
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -63,6 +65,30 @@ SOVRAN_SUPPORT_PUBKEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFLY8hjksaWzQmIQVut
|
|||||||
|
|
||||||
SUPPORT_KEY_COMMENT = "sovransystemsos-support"
|
SUPPORT_KEY_COMMENT = "sovransystemsos-support"
|
||||||
|
|
||||||
|
# Dedicated restricted support user (non-root) for wallet privacy
|
||||||
|
SUPPORT_USER = "sovran-support"
|
||||||
|
SUPPORT_USER_HOME = "/var/lib/sovran-support"
|
||||||
|
SUPPORT_USER_SSH_DIR = "/var/lib/sovran-support/.ssh"
|
||||||
|
SUPPORT_USER_AUTH_KEYS = "/var/lib/sovran-support/.ssh/authorized_keys"
|
||||||
|
|
||||||
|
# Audit log for all support session events
|
||||||
|
SUPPORT_AUDIT_LOG = "/var/log/sovran-support-audit.log"
|
||||||
|
|
||||||
|
# Time-limited wallet unlock state
|
||||||
|
WALLET_UNLOCK_FILE = "/var/lib/secrets/support-wallet-unlock"
|
||||||
|
WALLET_UNLOCK_DURATION_DEFAULT = 3600 # seconds (1 hour)
|
||||||
|
|
||||||
|
# Wallet paths protected by default from the support user
|
||||||
|
PROTECTED_WALLET_PATHS: list[str] = [
|
||||||
|
"/var/lib/lnd",
|
||||||
|
"/root/.lnd",
|
||||||
|
"/var/lib/sparrow",
|
||||||
|
"/root/.sparrow",
|
||||||
|
"/root/.bisq",
|
||||||
|
"/etc/nix-bitcoin-secrets",
|
||||||
|
"/var/lib/bitcoind",
|
||||||
|
]
|
||||||
|
|
||||||
CATEGORY_ORDER = [
|
CATEGORY_ORDER = [
|
||||||
("infrastructure", "Infrastructure"),
|
("infrastructure", "Infrastructure"),
|
||||||
("bitcoin-base", "Bitcoin Base"),
|
("bitcoin-base", "Bitcoin Base"),
|
||||||
@@ -714,7 +740,15 @@ def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
|||||||
# ── Tech Support helpers ──────────────────────────────────────────
|
# ── Tech Support helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
def _is_support_active() -> bool:
|
def _is_support_active() -> bool:
|
||||||
"""Check if the support key is currently in authorized_keys."""
|
"""Check if the support key is currently in authorized_keys or support user's authorized_keys."""
|
||||||
|
# Check support user's authorized_keys first
|
||||||
|
try:
|
||||||
|
with open(SUPPORT_USER_AUTH_KEYS, "r") as f:
|
||||||
|
if SUPPORT_KEY_COMMENT in f.read():
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
# Fall back to root authorized_keys
|
||||||
try:
|
try:
|
||||||
with open(AUTHORIZED_KEYS, "r") as f:
|
with open(AUTHORIZED_KEYS, "r") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
@@ -732,48 +766,222 @@ def _get_support_session_info() -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _enable_support() -> bool:
|
def _log_support_audit(event: str, details: str = "") -> None:
|
||||||
"""Add the Sovran support public key to root's authorized_keys."""
|
"""Append a timestamped event to the support audit log."""
|
||||||
|
timestamp = time.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||||
|
line = f"[{timestamp}] {event}"
|
||||||
|
if details:
|
||||||
|
line += f": {details}"
|
||||||
|
line += "\n"
|
||||||
try:
|
try:
|
||||||
os.makedirs("/root/.ssh", mode=0o700, exist_ok=True)
|
os.makedirs(os.path.dirname(SUPPORT_AUDIT_LOG), exist_ok=True)
|
||||||
|
with open(SUPPORT_AUDIT_LOG, "a") as f:
|
||||||
|
f.write(line)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Write the key to the dedicated support key file
|
|
||||||
with open(SUPPORT_KEY_FILE, "w") as f:
|
|
||||||
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
|
|
||||||
os.chmod(SUPPORT_KEY_FILE, 0o600)
|
|
||||||
|
|
||||||
# Append to authorized_keys if not already present
|
def _get_support_audit_log(max_lines: int = 100) -> list:
|
||||||
existing = ""
|
"""Return the last N lines from the audit log."""
|
||||||
|
try:
|
||||||
|
with open(SUPPORT_AUDIT_LOG, "r") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
return [l.rstrip("\n") for l in lines[-max_lines:]]
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_existing_wallet_paths() -> list:
|
||||||
|
"""Return the subset of PROTECTED_WALLET_PATHS that actually exist on disk."""
|
||||||
|
return [p for p in PROTECTED_WALLET_PATHS if os.path.exists(p)]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_support_user() -> bool:
|
||||||
|
"""Ensure the sovran-support restricted user exists. Returns True on success."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["id", SUPPORT_USER], capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"useradd",
|
||||||
|
"--system",
|
||||||
|
"--no-create-home",
|
||||||
|
"--home-dir", SUPPORT_USER_HOME,
|
||||||
|
"--shell", "/bin/bash",
|
||||||
|
"--comment", "Sovran Systems Support (restricted)",
|
||||||
|
SUPPORT_USER,
|
||||||
|
],
|
||||||
|
check=True, capture_output=True, timeout=15,
|
||||||
|
)
|
||||||
|
os.makedirs(SUPPORT_USER_HOME, mode=0o700, exist_ok=True)
|
||||||
|
os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True)
|
||||||
|
pw = pwd.getpwnam(SUPPORT_USER)
|
||||||
|
os.chown(SUPPORT_USER_HOME, pw.pw_uid, pw.pw_gid)
|
||||||
|
os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_wallet_acls() -> bool:
|
||||||
|
"""Apply POSIX ACLs to deny the support user access to wallet directories.
|
||||||
|
|
||||||
|
Sets a deny-all ACL entry (u:sovran-support:---) on each existing protected
|
||||||
|
path. Returns True if all existing paths were handled without error.
|
||||||
|
setfacl is tried; if it is not available the function returns False without
|
||||||
|
raising so callers can warn the user appropriately.
|
||||||
|
"""
|
||||||
|
existing = _get_existing_wallet_paths()
|
||||||
|
if not existing:
|
||||||
|
return True
|
||||||
|
success = True
|
||||||
|
for path in existing:
|
||||||
try:
|
try:
|
||||||
with open(AUTHORIZED_KEYS, "r") as f:
|
result = subprocess.run(
|
||||||
existing = f.read()
|
["setfacl", "-R", "-m", f"u:{SUPPORT_USER}:---", path],
|
||||||
|
capture_output=True, timeout=15,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
success = False
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
# setfacl not installed
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
success = False
|
||||||
|
return success
|
||||||
|
|
||||||
if SUPPORT_KEY_COMMENT not in existing:
|
|
||||||
with open(AUTHORIZED_KEYS, "a") as f:
|
def _revoke_wallet_acls() -> bool:
|
||||||
|
"""Remove the support user's deny ACL from wallet directories."""
|
||||||
|
existing = _get_existing_wallet_paths()
|
||||||
|
if not existing:
|
||||||
|
return True
|
||||||
|
success = True
|
||||||
|
for path in existing:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["setfacl", "-R", "-x", f"u:{SUPPORT_USER}", path],
|
||||||
|
capture_output=True, timeout=15,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
success = False
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
success = False
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
def _is_wallet_unlocked() -> bool:
|
||||||
|
"""Return True if the user has granted time-limited wallet access and it has not expired."""
|
||||||
|
try:
|
||||||
|
with open(WALLET_UNLOCK_FILE, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return time.time() < data.get("expires_at", 0)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_wallet_unlock_info() -> dict:
|
||||||
|
"""Read wallet unlock state. Re-locks and returns {} if the grant has expired."""
|
||||||
|
try:
|
||||||
|
with open(WALLET_UNLOCK_FILE, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if time.time() >= data.get("expires_at", 0):
|
||||||
|
try:
|
||||||
|
os.remove(WALLET_UNLOCK_FILE)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
_apply_wallet_acls()
|
||||||
|
_log_support_audit("WALLET_RELOCKED", "auto-expired")
|
||||||
|
return {}
|
||||||
|
return data
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_support() -> bool:
|
||||||
|
"""Add the Sovran support public key to the restricted support user's authorized_keys.
|
||||||
|
|
||||||
|
Falls back to root's authorized_keys if the support user cannot be created.
|
||||||
|
Applies POSIX ACLs to wallet directories to prevent access by the support
|
||||||
|
user without explicit user consent.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
use_restricted_user = _ensure_support_user()
|
||||||
|
|
||||||
|
if use_restricted_user:
|
||||||
|
os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True)
|
||||||
|
with open(SUPPORT_USER_AUTH_KEYS, "w") as f:
|
||||||
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
|
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
|
||||||
os.chmod(AUTHORIZED_KEYS, 0o600)
|
os.chmod(SUPPORT_USER_AUTH_KEYS, 0o600)
|
||||||
|
try:
|
||||||
|
pw = pwd.getpwnam(SUPPORT_USER)
|
||||||
|
os.chown(SUPPORT_USER_AUTH_KEYS, pw.pw_uid, pw.pw_gid)
|
||||||
|
os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Fallback: add key to root's authorized_keys
|
||||||
|
os.makedirs("/root/.ssh", mode=0o700, exist_ok=True)
|
||||||
|
with open(SUPPORT_KEY_FILE, "w") as f:
|
||||||
|
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
|
||||||
|
os.chmod(SUPPORT_KEY_FILE, 0o600)
|
||||||
|
|
||||||
|
existing = ""
|
||||||
|
try:
|
||||||
|
with open(AUTHORIZED_KEYS, "r") as f:
|
||||||
|
existing = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if SUPPORT_KEY_COMMENT not in existing:
|
||||||
|
with open(AUTHORIZED_KEYS, "a") as f:
|
||||||
|
f.write(SOVRAN_SUPPORT_PUBKEY + "\n")
|
||||||
|
os.chmod(AUTHORIZED_KEYS, 0o600)
|
||||||
|
|
||||||
|
acl_applied = _apply_wallet_acls() if use_restricted_user else False
|
||||||
|
wallet_paths = _get_existing_wallet_paths()
|
||||||
|
|
||||||
# Write session metadata
|
|
||||||
import time
|
|
||||||
session_info = {
|
session_info = {
|
||||||
"enabled_at": time.time(),
|
"enabled_at": time.time(),
|
||||||
"enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"),
|
"enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"),
|
||||||
|
"use_restricted_user": use_restricted_user,
|
||||||
|
"wallet_protected": use_restricted_user,
|
||||||
|
"acl_applied": acl_applied,
|
||||||
|
"protected_paths": wallet_paths,
|
||||||
}
|
}
|
||||||
os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True)
|
os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True)
|
||||||
with open(SUPPORT_STATUS_FILE, "w") as f:
|
with open(SUPPORT_STATUS_FILE, "w") as f:
|
||||||
json.dump(session_info, f)
|
json.dump(session_info, f)
|
||||||
|
|
||||||
|
_log_support_audit(
|
||||||
|
"SUPPORT_ENABLED",
|
||||||
|
f"restricted_user={use_restricted_user} acl_applied={acl_applied} "
|
||||||
|
f"protected_paths={len(wallet_paths)}",
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _disable_support() -> bool:
|
def _disable_support() -> bool:
|
||||||
"""Remove the Sovran support public key from authorized_keys."""
|
"""Remove the Sovran support public key and revoke all wallet access."""
|
||||||
try:
|
try:
|
||||||
# Remove from authorized_keys
|
# Remove from support user's authorized_keys
|
||||||
|
try:
|
||||||
|
os.remove(SUPPORT_USER_AUTH_KEYS)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Remove from root's authorized_keys (fallback / legacy)
|
||||||
try:
|
try:
|
||||||
with open(AUTHORIZED_KEYS, "r") as f:
|
with open(AUTHORIZED_KEYS, "r") as f:
|
||||||
lines = f.readlines()
|
lines = f.readlines()
|
||||||
@@ -790,19 +998,35 @@ def _disable_support() -> bool:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Revoke any outstanding wallet unlock
|
||||||
|
try:
|
||||||
|
os.remove(WALLET_UNLOCK_FILE)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Re-apply ACLs to ensure wallet access is revoked
|
||||||
|
_revoke_wallet_acls()
|
||||||
|
|
||||||
# Remove session metadata
|
# Remove session metadata
|
||||||
try:
|
try:
|
||||||
os.remove(SUPPORT_STATUS_FILE)
|
os.remove(SUPPORT_STATUS_FILE)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
_log_support_audit("SUPPORT_DISABLED")
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _verify_support_removed() -> bool:
|
def _verify_support_removed() -> bool:
|
||||||
"""Verify the support key is truly gone from authorized_keys."""
|
"""Verify the support key is truly gone from all authorized_keys files."""
|
||||||
|
try:
|
||||||
|
with open(SUPPORT_USER_AUTH_KEYS, "r") as f:
|
||||||
|
if SUPPORT_KEY_COMMENT in f.read():
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
with open(AUTHORIZED_KEYS, "r") as f:
|
with open(AUTHORIZED_KEYS, "r") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
@@ -1164,10 +1388,18 @@ async def api_support_status():
|
|||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
active = await loop.run_in_executor(None, _is_support_active)
|
active = await loop.run_in_executor(None, _is_support_active)
|
||||||
session = await loop.run_in_executor(None, _get_support_session_info)
|
session = await loop.run_in_executor(None, _get_support_session_info)
|
||||||
|
unlock_info = await loop.run_in_executor(None, _get_wallet_unlock_info)
|
||||||
|
wallet_unlocked = bool(unlock_info)
|
||||||
return {
|
return {
|
||||||
"active": active,
|
"active": active,
|
||||||
"enabled_at": session.get("enabled_at"),
|
"enabled_at": session.get("enabled_at"),
|
||||||
"enabled_at_human": session.get("enabled_at_human"),
|
"enabled_at_human": session.get("enabled_at_human"),
|
||||||
|
"wallet_protected": session.get("wallet_protected", False),
|
||||||
|
"acl_applied": session.get("acl_applied", False),
|
||||||
|
"protected_paths": session.get("protected_paths", []),
|
||||||
|
"wallet_unlocked": wallet_unlocked,
|
||||||
|
"wallet_unlocked_until": unlock_info.get("expires_at") if wallet_unlocked else None,
|
||||||
|
"wallet_unlocked_until_human": unlock_info.get("expires_at_human") if wallet_unlocked else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1194,6 +1426,77 @@ async def api_support_disable():
|
|||||||
return {"ok": True, "verified": verified, "message": "Support access removed and verified"}
|
return {"ok": True, "verified": verified, "message": "Support access removed and verified"}
|
||||||
|
|
||||||
|
|
||||||
|
class WalletUnlockRequest(BaseModel):
|
||||||
|
duration: int = WALLET_UNLOCK_DURATION_DEFAULT # seconds
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/support/wallet-unlock")
|
||||||
|
async def api_support_wallet_unlock(req: WalletUnlockRequest):
|
||||||
|
"""Grant the support user time-limited access to wallet directories.
|
||||||
|
|
||||||
|
Removes the deny ACL for the support user on all protected wallet paths.
|
||||||
|
Access is automatically revoked when the timer expires (checked lazily on
|
||||||
|
next status call) or when the support session is ended.
|
||||||
|
"""
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
active = await loop.run_in_executor(None, _is_support_active)
|
||||||
|
if not active:
|
||||||
|
raise HTTPException(status_code=400, detail="No active support session")
|
||||||
|
|
||||||
|
duration = max(300, min(req.duration, 14400)) # clamp: 5 min – 4 hours
|
||||||
|
expires_at = time.time() + duration
|
||||||
|
expires_human = time.strftime("%Y-%m-%d %H:%M:%S %Z", time.localtime(expires_at))
|
||||||
|
|
||||||
|
# Remove ACL restrictions
|
||||||
|
await loop.run_in_executor(None, _revoke_wallet_acls)
|
||||||
|
|
||||||
|
unlock_info = {
|
||||||
|
"unlocked_at": time.time(),
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"expires_at_human": expires_human,
|
||||||
|
"duration": duration,
|
||||||
|
}
|
||||||
|
os.makedirs(os.path.dirname(WALLET_UNLOCK_FILE), exist_ok=True)
|
||||||
|
with open(WALLET_UNLOCK_FILE, "w") as f:
|
||||||
|
json.dump(unlock_info, f)
|
||||||
|
|
||||||
|
_log_support_audit(
|
||||||
|
"WALLET_UNLOCKED",
|
||||||
|
f"duration={duration}s expires={expires_human}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"expires_at_human": expires_human,
|
||||||
|
"message": f"Wallet access granted for {duration // 60} minutes",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/support/wallet-lock")
|
||||||
|
async def api_support_wallet_lock():
|
||||||
|
"""Revoke wallet access and re-apply ACL protections."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.remove(WALLET_UNLOCK_FILE)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await loop.run_in_executor(None, _apply_wallet_acls)
|
||||||
|
_log_support_audit("WALLET_LOCKED", "user-initiated")
|
||||||
|
return {"ok": True, "message": "Wallet access revoked"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/support/audit-log")
|
||||||
|
async def api_support_audit_log(limit: int = 100):
|
||||||
|
"""Return the last N lines of the support audit log."""
|
||||||
|
limit = max(1, min(limit, 500))
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
lines = await loop.run_in_executor(None, _get_support_audit_log, limit)
|
||||||
|
return {"entries": lines}
|
||||||
|
|
||||||
|
|
||||||
# ── Feature Manager endpoints ─────────────────────────────────────
|
# ── Feature Manager endpoints ─────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/features")
|
@app.get("/api/features")
|
||||||
|
|||||||
@@ -42,8 +42,10 @@ let _updatePollTimer = null;
|
|||||||
let _updateLogOffset = 0;
|
let _updateLogOffset = 0;
|
||||||
let _serverWasDown = false;
|
let _serverWasDown = false;
|
||||||
let _updateFinished = false;
|
let _updateFinished = false;
|
||||||
let _supportTimerInt = null;
|
let _supportTimerInt = null;
|
||||||
let _supportEnabledAt = null;
|
let _supportEnabledAt = null;
|
||||||
|
let _supportStatus = null; // last fetched /api/support/status payload
|
||||||
|
let _walletUnlockTimerInt = null;
|
||||||
let _cachedExternalIp = null;
|
let _cachedExternalIp = null;
|
||||||
|
|
||||||
// Feature Manager state
|
// Feature Manager state
|
||||||
@@ -572,7 +574,8 @@ async function openSupportModal() {
|
|||||||
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
|
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
|
||||||
try {
|
try {
|
||||||
var status = await apiFetch("/api/support/status");
|
var status = await apiFetch("/api/support/status");
|
||||||
if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(); }
|
_supportStatus = status;
|
||||||
|
if (status.active) { _supportEnabledAt = status.enabled_at; renderSupportActive(status); }
|
||||||
else { renderSupportInactive(); }
|
else { renderSupportInactive(); }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>';
|
$supportBody.innerHTML = '<p class="creds-empty">Could not check support status.</p>';
|
||||||
@@ -582,19 +585,114 @@ async function openSupportModal() {
|
|||||||
function renderSupportInactive() {
|
function renderSupportInactive() {
|
||||||
stopSupportTimer();
|
stopSupportTimer();
|
||||||
var ip = _cachedExternalIp || "loading…";
|
var ip = _cachedExternalIp || "loading…";
|
||||||
$supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big">🛟</div><h3 class="support-heading">Need help from Sovran Systems?</h3><p class="support-desc">This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.</p><div class="support-info-box"><div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div><div class="support-info-hint">This IP will be shared with Sovran Systems support</div></div><div class="support-steps"><div class="support-steps-title">What happens:</div><ol><li>Our public SSH key is added to your machine</li><li>We connect and help fix the issue</li><li>You click "End Session" to remove our access</li></ol></div><button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button><p class="support-fine-print">You can revoke access at any time</p></div>';
|
$supportBody.innerHTML = [
|
||||||
|
'<div class="support-section">',
|
||||||
|
'<div class="support-icon-big">🛟</div>',
|
||||||
|
'<h3 class="support-heading">Need help from Sovran Systems?</h3>',
|
||||||
|
'<p class="support-desc">This will temporarily grant our support team SSH access to your machine so we can help diagnose and fix issues.</p>',
|
||||||
|
'<div class="support-info-box">',
|
||||||
|
'<div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div>',
|
||||||
|
'<div class="support-info-hint">This IP will be shared with Sovran Systems support</div>',
|
||||||
|
'</div>',
|
||||||
|
'<div class="support-wallet-box support-wallet-protected">',
|
||||||
|
'<div class="support-wallet-header"><span class="support-wallet-icon">🔒</span><span class="support-wallet-title">Wallet Protection</span></div>',
|
||||||
|
'<p class="support-wallet-desc">Wallet files (LND, Sparrow, Bisq) are <strong>protected by default</strong>. Support staff cannot access your private keys unless you explicitly grant access.</p>',
|
||||||
|
'</div>',
|
||||||
|
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
||||||
|
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
||||||
|
'<li>Our SSH key is added only to that restricted account</li>',
|
||||||
|
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
||||||
|
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
||||||
|
'<li>All session events are logged for your audit</li>',
|
||||||
|
'</ol></div>',
|
||||||
|
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
||||||
|
'<p class="support-fine-print">You can revoke access at any time. Wallet files are protected unless you unlock them.</p>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
document.getElementById("btn-support-enable").addEventListener("click", enableSupport);
|
document.getElementById("btn-support-enable").addEventListener("click", enableSupport);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSupportActive() {
|
function renderSupportActive(status) {
|
||||||
var ip = _cachedExternalIp || "loading…";
|
var ip = _cachedExternalIp || "loading…";
|
||||||
$supportBody.innerHTML = '<div class="support-section"><div class="support-icon-big support-active-icon">🔓</div><h3 class="support-heading support-active-heading">Support Access is Active</h3><p class="support-active-note">Sovran Systems can currently connect to your machine via SSH.</p><div class="support-info-box support-active-box"><div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div><div class="support-info-row"><span class="support-info-label">Duration</span><span class="support-info-value" id="support-timer">…</span></div></div><button class="btn support-btn-disable" id="btn-support-disable">End Support Session</button><p class="support-fine-print">This will remove the SSH key immediately</p></div>';
|
var walletProtected = status && status.wallet_protected;
|
||||||
|
var walletUnlocked = status && status.wallet_unlocked;
|
||||||
|
var unlockUntil = status && status.wallet_unlocked_until_human ? status.wallet_unlocked_until_human : "";
|
||||||
|
var protectedPaths = (status && status.protected_paths && status.protected_paths.length)
|
||||||
|
? status.protected_paths : [];
|
||||||
|
|
||||||
|
var walletSection;
|
||||||
|
if (walletProtected) {
|
||||||
|
if (walletUnlocked) {
|
||||||
|
walletSection = [
|
||||||
|
'<div class="support-wallet-box support-wallet-unlocked">',
|
||||||
|
'<div class="support-wallet-header"><span class="support-wallet-icon">🔓</span><span class="support-wallet-title">Wallet Access: UNLOCKED</span></div>',
|
||||||
|
'<p class="support-wallet-desc">You have granted support temporary access to wallet files' + (unlockUntil ? ' until <strong>' + escHtml(unlockUntil) + '</strong>' : '') + '.</p>',
|
||||||
|
'<button class="btn support-btn-wallet-lock" id="btn-wallet-lock">Re-lock Wallet Now</button>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
} else {
|
||||||
|
var pathList = protectedPaths.length
|
||||||
|
? '<ul class="support-wallet-paths">' + protectedPaths.map(function(p){ return '<li>' + escHtml(p) + '</li>'; }).join("") + '</ul>'
|
||||||
|
: '';
|
||||||
|
walletSection = [
|
||||||
|
'<div class="support-wallet-box support-wallet-protected">',
|
||||||
|
'<div class="support-wallet-header"><span class="support-wallet-icon">🔒</span><span class="support-wallet-title">Wallet Files: Protected</span></div>',
|
||||||
|
'<p class="support-wallet-desc">Support cannot access your wallet files. Grant temporary access only if needed for wallet troubleshooting.</p>',
|
||||||
|
pathList,
|
||||||
|
'<div class="support-wallet-unlock-row">',
|
||||||
|
'<select id="wallet-unlock-duration" class="support-unlock-select">',
|
||||||
|
'<option value="3600">1 hour</option>',
|
||||||
|
'<option value="1800">30 minutes</option>',
|
||||||
|
'<option value="7200">2 hours</option>',
|
||||||
|
'</select>',
|
||||||
|
'<button class="btn support-btn-wallet-unlock" id="btn-wallet-unlock">Grant Wallet Access</button>',
|
||||||
|
'</div>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
walletSection = [
|
||||||
|
'<div class="support-wallet-box support-wallet-warning">',
|
||||||
|
'<div class="support-wallet-header"><span class="support-wallet-icon">⚠️</span><span class="support-wallet-title">Wallet Protection Unavailable</span></div>',
|
||||||
|
'<p class="support-wallet-desc">The restricted support user could not be created. Support is running with root access — wallet files may be accessible. End the session if you are concerned.</p>',
|
||||||
|
'</div>',
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
$supportBody.innerHTML = [
|
||||||
|
'<div class="support-section">',
|
||||||
|
'<div class="support-icon-big support-active-icon">🔓</div>',
|
||||||
|
'<h3 class="support-heading support-active-heading">Support Access is Active</h3>',
|
||||||
|
'<p class="support-active-note">Sovran Systems can currently connect to your machine via SSH.</p>',
|
||||||
|
'<div class="support-info-box support-active-box">',
|
||||||
|
'<div class="support-info-row"><span class="support-info-label">Your IP</span><span class="support-info-value">' + escHtml(ip) + '</span></div>',
|
||||||
|
'<div class="support-info-row"><span class="support-info-label">Duration</span><span class="support-info-value" id="support-timer">…</span></div>',
|
||||||
|
'</div>',
|
||||||
|
walletSection,
|
||||||
|
'<button class="btn support-btn-disable" id="btn-support-disable">End Support Session</button>',
|
||||||
|
'<p class="support-fine-print">This will remove the SSH key and revoke all wallet access immediately.</p>',
|
||||||
|
'<button class="btn support-btn-auditlog" id="btn-support-audit">View Audit Log</button>',
|
||||||
|
'</div>',
|
||||||
|
'<div id="support-audit-container" class="support-audit-container" style="display:none;"></div>',
|
||||||
|
].join("");
|
||||||
|
|
||||||
document.getElementById("btn-support-disable").addEventListener("click", disableSupport);
|
document.getElementById("btn-support-disable").addEventListener("click", disableSupport);
|
||||||
|
document.getElementById("btn-support-audit").addEventListener("click", toggleAuditLog);
|
||||||
|
if (walletProtected && !walletUnlocked) {
|
||||||
|
document.getElementById("btn-wallet-unlock").addEventListener("click", walletUnlock);
|
||||||
|
}
|
||||||
|
if (walletProtected && walletUnlocked) {
|
||||||
|
document.getElementById("btn-wallet-lock").addEventListener("click", walletLock);
|
||||||
|
}
|
||||||
startSupportTimer();
|
startSupportTimer();
|
||||||
|
if (walletUnlocked && status.wallet_unlocked_until) {
|
||||||
|
startWalletUnlockTimer(status.wallet_unlocked_until);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSupportRemoved(verified) {
|
function renderSupportRemoved(verified) {
|
||||||
stopSupportTimer();
|
stopSupportTimer();
|
||||||
|
stopWalletUnlockTimer();
|
||||||
var icon = verified ? "✅" : "⚠️";
|
var icon = verified ? "✅" : "⚠️";
|
||||||
var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone.";
|
var msg = verified ? "The Sovran Systems SSH key has been completely removed from your machine. We no longer have any access." : "The key removal was requested but could not be fully verified. Please reboot to ensure it is gone.";
|
||||||
var vclass = verified ? "verified-gone" : "verify-warning";
|
var vclass = verified ? "verified-gone" : "verify-warning";
|
||||||
@@ -609,8 +707,9 @@ async function enableSupport() {
|
|||||||
try {
|
try {
|
||||||
await apiFetch("/api/support/enable", { method: "POST" });
|
await apiFetch("/api/support/enable", { method: "POST" });
|
||||||
var status = await apiFetch("/api/support/status");
|
var status = await apiFetch("/api/support/status");
|
||||||
|
_supportStatus = status;
|
||||||
_supportEnabledAt = status.enabled_at;
|
_supportEnabledAt = status.enabled_at;
|
||||||
renderSupportActive();
|
renderSupportActive(status);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
||||||
alert("Failed to enable support access. Please try again.");
|
alert("Failed to enable support access. Please try again.");
|
||||||
@@ -629,6 +728,63 @@ async function disableSupport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function walletUnlock() {
|
||||||
|
var btn = document.getElementById("btn-wallet-unlock");
|
||||||
|
var sel = document.getElementById("wallet-unlock-duration");
|
||||||
|
var duration = sel ? parseInt(sel.value, 10) : 3600;
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = "Unlocking…"; }
|
||||||
|
try {
|
||||||
|
var result = await apiFetch("/api/support/wallet-unlock", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ duration: duration }),
|
||||||
|
});
|
||||||
|
var status = await apiFetch("/api/support/status");
|
||||||
|
_supportStatus = status;
|
||||||
|
renderSupportActive(status);
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = "Grant Wallet Access"; }
|
||||||
|
alert("Failed to unlock wallet access: " + (err.message || "Unknown error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walletLock() {
|
||||||
|
var btn = document.getElementById("btn-wallet-lock");
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = "Locking…"; }
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/support/wallet-lock", { method: "POST" });
|
||||||
|
var status = await apiFetch("/api/support/status");
|
||||||
|
_supportStatus = status;
|
||||||
|
renderSupportActive(status);
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = "Re-lock Wallet Now"; }
|
||||||
|
alert("Failed to re-lock wallet: " + (err.message || "Unknown error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAuditLog() {
|
||||||
|
var container = document.getElementById("support-audit-container");
|
||||||
|
if (!container) return;
|
||||||
|
if (container.style.display !== "none") {
|
||||||
|
container.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.style.display = "block";
|
||||||
|
container.innerHTML = '<p class="creds-loading">Loading audit log…</p>';
|
||||||
|
try {
|
||||||
|
var data = await apiFetch("/api/support/audit-log");
|
||||||
|
if (!data.entries || data.entries.length === 0) {
|
||||||
|
container.innerHTML = '<p class="support-audit-empty">No audit events recorded yet.</p>';
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="support-audit-log">' +
|
||||||
|
data.entries.map(function(e) { return '<div class="support-audit-entry">' + escHtml(e) + '</div>'; }).join("") +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = '<p class="creds-empty">Could not load audit log.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startSupportTimer() {
|
function startSupportTimer() {
|
||||||
stopSupportTimer();
|
stopSupportTimer();
|
||||||
updateSupportTimer();
|
updateSupportTimer();
|
||||||
@@ -646,9 +802,28 @@ function updateSupportTimer() {
|
|||||||
el.textContent = formatDuration(Math.max(0, elapsed));
|
el.textContent = formatDuration(Math.max(0, elapsed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startWalletUnlockTimer(expiresAt) {
|
||||||
|
stopWalletUnlockTimer();
|
||||||
|
_walletUnlockTimerInt = setInterval(function() {
|
||||||
|
if (Date.now() / 1000 >= expiresAt) {
|
||||||
|
stopWalletUnlockTimer();
|
||||||
|
// Refresh the support modal to show re-locked state
|
||||||
|
apiFetch("/api/support/status").then(function(status) {
|
||||||
|
_supportStatus = status;
|
||||||
|
renderSupportActive(status);
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWalletUnlockTimer() {
|
||||||
|
if (_walletUnlockTimerInt) { clearInterval(_walletUnlockTimerInt); _walletUnlockTimerInt = null; }
|
||||||
|
}
|
||||||
|
|
||||||
function closeSupportModal() {
|
function closeSupportModal() {
|
||||||
if ($supportModal) $supportModal.classList.remove("open");
|
if ($supportModal) $supportModal.classList.remove("open");
|
||||||
stopSupportTimer();
|
stopSupportTimer();
|
||||||
|
stopWalletUnlockTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Update modal ──────────────────────────────────────────────────
|
// ── Update modal ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1316,7 +1316,168 @@ button.btn-reboot:hover:not(:disabled) {
|
|||||||
background-color: #5a5c72;
|
background-color: #5a5c72;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Feature Manager ─────────────────────────────────────────────── */
|
/* ── Tech Support — wallet protection ────────────────────────────── */
|
||||||
|
|
||||||
|
.support-wallet-box {
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
max-width: 460px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-protected {
|
||||||
|
border-color: var(--green);
|
||||||
|
background-color: rgba(30, 150, 96, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-unlocked {
|
||||||
|
border-color: var(--yellow);
|
||||||
|
background-color: rgba(230, 180, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-warning {
|
||||||
|
border-color: var(--red);
|
||||||
|
background-color: rgba(220, 38, 38, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-desc {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-paths {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-paths li {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-paths li::before {
|
||||||
|
content: "🗂 ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-wallet-unlock-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-unlock-select {
|
||||||
|
background-color: #1c1c2e;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-wallet-unlock {
|
||||||
|
background-color: var(--yellow);
|
||||||
|
color: #111;
|
||||||
|
padding: 7px 18px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-wallet-unlock:hover:not(:disabled) {
|
||||||
|
background-color: #c9a200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-wallet-lock {
|
||||||
|
background-color: var(--green);
|
||||||
|
color: #fff;
|
||||||
|
padding: 7px 18px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-wallet-lock:hover:not(:disabled) {
|
||||||
|
background-color: #1a8557;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-auditlog {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--accent-color);
|
||||||
|
border: 1px solid var(--accent-color);
|
||||||
|
padding: 6px 18px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-btn-auditlog:hover:not(:disabled) {
|
||||||
|
background-color: rgba(100, 130, 220, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tech Support — audit log ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.support-audit-container {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 520px;
|
||||||
|
padding: 0 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-audit-log {
|
||||||
|
background-color: #0d0d1a;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-audit-entry {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Source Code Pro', monospace;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.7;
|
||||||
|
border-bottom: 1px solid #1e1e30;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-audit-entry:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-audit-empty {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.feature-manager-section {
|
.feature-manager-section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
|
|||||||
Reference in New Issue
Block a user