implement activity logging system for user actions and system events
This commit is contained in:
parent
81f7031555
commit
88e38535fc
5 changed files with 958 additions and 500 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,6 +1,8 @@
|
|||
data/
|
||||
venv/
|
||||
.venv/
|
||||
__pycache__/
|
||||
.env
|
||||
*.pyc
|
||||
.DS_Store
|
||||
|
||||
|
|
|
|||
117
main.py
117
main.py
|
|
@ -90,6 +90,16 @@ def init_db():
|
|||
)
|
||||
''')
|
||||
|
||||
# Create activity_logs table
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp REAL NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL
|
||||
)
|
||||
''')
|
||||
|
||||
# Now check for column migrations
|
||||
c.execute("PRAGMA table_info(instances)")
|
||||
columns = [col[1] for col in c.fetchall()]
|
||||
|
|
@ -135,7 +145,20 @@ def init_db():
|
|||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def log_activity(level: str, message: str):
|
||||
try:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('INSERT INTO activity_logs (timestamp, level, message) VALUES (?, ?, ?)',
|
||||
(time.time(), level, message))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"Failed to log activity: {e}")
|
||||
|
||||
init_db()
|
||||
log_activity("INFO", "Database initialized / application startup.")
|
||||
|
||||
|
||||
def is_authenticated(request: Request):
|
||||
if not request.session.get("user_id"):
|
||||
|
|
@ -164,14 +187,20 @@ async def login(request: Request):
|
|||
request.session["user_id"] = user['id']
|
||||
request.session["username"] = user['username']
|
||||
request.session["is_admin"] = bool(user['is_admin'])
|
||||
log_activity("INFO", f"User '{user['username']}' logged in successfully.")
|
||||
return {"status": "ok", "user": {"username": user['username'], "is_admin": bool(user['is_admin'])}}
|
||||
|
||||
log_activity("WARNING", f"Failed login attempt for username '{username}'.")
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
@app.get("/api/logout")
|
||||
async def logout(request: Request):
|
||||
username = request.session.get("username", "Unknown")
|
||||
request.session.clear()
|
||||
log_activity("INFO", f"User '{username}' logged out.")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/auth/status")
|
||||
async def auth_status(request: Request):
|
||||
if request.session.get("user_id"):
|
||||
|
|
@ -213,6 +242,8 @@ async def create_user(request: Request):
|
|||
(username, hashed, is_admin))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
admin_username = request.session.get("username", "Admin")
|
||||
log_activity("INFO", f"Admin '{admin_username}' created user '{username}' (Admin: {bool(is_admin)}).")
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
return {"status": "ok"}
|
||||
|
|
@ -223,11 +254,18 @@ def delete_user(id: int, request: Request):
|
|||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT username FROM users WHERE id=?', (id,))
|
||||
target_user_row = c.fetchone()
|
||||
target_username = target_user_row[0] if target_user_row else f"ID {id}"
|
||||
|
||||
c.execute('DELETE FROM users WHERE id=?', (id,))
|
||||
# Also delete their instances? Let's say yes for cleanliness.
|
||||
c.execute('DELETE FROM instances WHERE user_id=?', (id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
admin_username = request.session.get("username", "Admin")
|
||||
log_activity("INFO", f"Admin '{admin_username}' deleted user '{target_username}' and all associated instances.")
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.put("/api/users/{id}/password", dependencies=[Depends(is_authenticated), Depends(is_admin)])
|
||||
|
|
@ -239,11 +277,19 @@ async def admin_reset_password(id: int, request: Request):
|
|||
hashed = get_password_hash(new_password)
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT username FROM users WHERE id=?', (id,))
|
||||
target_user_row = c.fetchone()
|
||||
target_username = target_user_row[0] if target_user_row else f"ID {id}"
|
||||
|
||||
c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
admin_username = request.session.get("username", "Admin")
|
||||
log_activity("INFO", f"Admin '{admin_username}' reset password for user '{target_username}'.")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# Own Password Change (All Users)
|
||||
@app.put("/api/users/me/password", dependencies=[Depends(is_authenticated)])
|
||||
async def change_own_password(request: Request):
|
||||
|
|
@ -252,12 +298,15 @@ async def change_own_password(request: Request):
|
|||
if not new_password:
|
||||
raise HTTPException(status_code=400, detail="New password required")
|
||||
user_id = request.session.get("user_id")
|
||||
username = request.session.get("username")
|
||||
hashed = get_password_hash(new_password)
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('UPDATE users SET hashed_password = ? WHERE id = ?', (hashed, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
log_activity("INFO", f"User '{username}' updated their password.")
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.put("/api/users/me/username", dependencies=[Depends(is_authenticated)])
|
||||
|
|
@ -267,16 +316,20 @@ async def change_own_username(request: Request):
|
|||
if not new_username:
|
||||
raise HTTPException(status_code=400, detail="New username required")
|
||||
user_id = request.session.get("user_id")
|
||||
old_username = request.session.get("username")
|
||||
try:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('UPDATE users SET username = ? WHERE id = ?', (new_username, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
request.session["username"] = new_username
|
||||
log_activity("INFO", f"User '{old_username}' updated their username to '{new_username}'.")
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
class OTPSecretCreate(BaseModel):
|
||||
name: str
|
||||
secret: str
|
||||
|
|
@ -324,6 +377,7 @@ def get_otp_secrets(request: Request):
|
|||
@app.post("/api/otp-secrets", dependencies=[Depends(is_authenticated)])
|
||||
def create_otp_secret(secret_data: OTPSecretCreate, request: Request):
|
||||
user_id = request.session.get("user_id")
|
||||
username = request.session.get("username")
|
||||
cleaned = clean_secret(secret_data.secret)
|
||||
encrypted = fernet.encrypt(cleaned.encode()).decode()
|
||||
conn = get_db()
|
||||
|
|
@ -334,19 +388,23 @@ def create_otp_secret(secret_data: OTPSecretCreate, request: Request):
|
|||
''', (secret_data.name, encrypted, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log_activity("INFO", f"User '{username}' created OTP secret '{secret_data.name}'.")
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.put("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)])
|
||||
def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request):
|
||||
user_id = request.session.get("user_id")
|
||||
username = request.session.get("username")
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Verify ownership
|
||||
c.execute('SELECT id FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
|
||||
if not c.fetchone():
|
||||
c.execute('SELECT name FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
|
||||
row = c.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="OTP Secret not found")
|
||||
old_name = row[0]
|
||||
|
||||
if secret_data.secret:
|
||||
cleaned = clean_secret(secret_data.secret)
|
||||
|
|
@ -355,11 +413,13 @@ def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request):
|
|||
UPDATE otp_secrets SET name=?, encrypted_secret=?
|
||||
WHERE id=? AND user_id=?
|
||||
''', (secret_data.name, encrypted, id, user_id))
|
||||
log_activity("INFO", f"User '{username}' updated OTP secret '{old_name}' (name updated to '{secret_data.name}' and key value changed).")
|
||||
else:
|
||||
c.execute('''
|
||||
UPDATE otp_secrets SET name=?
|
||||
WHERE id=? AND user_id=?
|
||||
''', (secret_data.name, id, user_id))
|
||||
log_activity("INFO", f"User '{username}' updated OTP secret name '{old_name}' to '{secret_data.name}'.")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
|
@ -368,9 +428,18 @@ def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request):
|
|||
@app.delete("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)])
|
||||
def delete_otp_secret(id: int, request: Request):
|
||||
user_id = request.session.get("user_id")
|
||||
username = request.session.get("username")
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Verify ownership
|
||||
c.execute('SELECT name FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
|
||||
row = c.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="OTP Secret not found")
|
||||
secret_name = row[0]
|
||||
|
||||
# Check if used by any instances
|
||||
c.execute('SELECT COUNT(*) FROM instances WHERE otp_secret_id=?', (id,))
|
||||
if c.fetchone()[0] > 0:
|
||||
|
|
@ -380,6 +449,7 @@ def delete_otp_secret(id: int, request: Request):
|
|||
c.execute('DELETE FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log_activity("INFO", f"User '{username}' deleted OTP secret '{secret_name}'.")
|
||||
return {"status": "ok"}
|
||||
|
||||
async def poll_instances():
|
||||
|
|
@ -419,13 +489,17 @@ async def poll_instances():
|
|||
|
||||
if resp.status_code == 200:
|
||||
status_msg = "Sent"
|
||||
log_activity("INFO", f"Polled instance '{inst['name']}' ({ip}:{port}) - OTP sent successfully.")
|
||||
else:
|
||||
status_msg = f"Failed: {resp.status_code}"
|
||||
log_activity("WARNING", f"Polled instance '{inst['name']}' ({ip}:{port}) - Failed: HTTP status {resp.status_code}.")
|
||||
|
||||
except (httpx.ConnectError, httpx.TimeoutException):
|
||||
status_msg = "Offline"
|
||||
log_activity("INFO", f"Polled instance '{inst['name']}' ({ip}:{port}) - Offline (no response on port {port}).")
|
||||
except Exception as e:
|
||||
status_msg = f"Error: {str(e)}"
|
||||
log_activity("ERROR", f"Polled instance '{inst['name']}' ({ip}:{port}) - Error: {str(e)}.")
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
|
@ -464,6 +538,7 @@ def get_instances(request: Request):
|
|||
@app.post("/api/instances", dependencies=[Depends(is_authenticated)])
|
||||
def create_instance(inst: InstanceCreate, request: Request):
|
||||
user_id = request.session.get("user_id")
|
||||
username = request.session.get("username")
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute('''
|
||||
|
|
@ -472,19 +547,23 @@ def create_instance(inst: InstanceCreate, request: Request):
|
|||
''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, user_id, ""))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log_activity("INFO", f"User '{username}' created instance '{inst.name}' ({inst.ip}:{inst.port}).")
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.put("/api/instances/{id}", dependencies=[Depends(is_authenticated)])
|
||||
def update_instance(id: int, inst: InstanceUpdate, request: Request):
|
||||
user_id = request.session.get("user_id")
|
||||
username = request.session.get("username")
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Verify ownership
|
||||
c.execute('SELECT id FROM instances WHERE id=? AND user_id=?', (id, user_id))
|
||||
if not c.fetchone():
|
||||
c.execute('SELECT name FROM instances WHERE id=? AND user_id=?', (id, user_id))
|
||||
row = c.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Instance not found")
|
||||
old_name = row[0]
|
||||
|
||||
c.execute('''
|
||||
UPDATE instances SET name=?, ip=?, port=?, otp_secret_id=?
|
||||
|
|
@ -493,22 +572,44 @@ def update_instance(id: int, inst: InstanceUpdate, request: Request):
|
|||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log_activity("INFO", f"User '{username}' updated instance '{old_name}' -> '{inst.name}' ({inst.ip}:{inst.port}).")
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/config", dependencies=[Depends(is_authenticated)])
|
||||
def get_config():
|
||||
return {"firewall_host_ip": FIREWALL_HOST_IP}
|
||||
|
||||
@app.delete("/api/instances/{id}", dependencies=[Depends(is_authenticated)])
|
||||
def delete_instance(id: int, request: Request):
|
||||
user_id = request.session.get("user_id")
|
||||
username = request.session.get("username")
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute('SELECT name FROM instances WHERE id=? AND user_id=?', (id, user_id))
|
||||
row = c.fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Instance not found")
|
||||
inst_name = row[0]
|
||||
|
||||
c.execute('DELETE FROM instances WHERE id=? AND user_id=?', (id, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log_activity("INFO", f"User '{username}' deleted instance '{inst_name}'.")
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/config")
|
||||
def get_config():
|
||||
return {"firewall_host_ip": FIREWALL_HOST_IP}
|
||||
|
||||
@app.get("/api/logs", dependencies=[Depends(is_authenticated)])
|
||||
def get_logs():
|
||||
conn = get_db()
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute('SELECT id, timestamp, level, message FROM activity_logs ORDER BY id DESC LIMIT 100')
|
||||
logs = [dict(row) for row in c.fetchall()]
|
||||
conn.close()
|
||||
return logs
|
||||
|
||||
|
||||
# Mount root and static
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
|
|
|
|||
115
static/app.js
115
static/app.js
|
|
@ -31,8 +31,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const addOtpModal = document.getElementById('add-otp-secret-modal');
|
||||
const otpSecretIdSelect = document.getElementById('otp-secret-id');
|
||||
const otpSecretsList = document.getElementById('otp-secrets-list');
|
||||
const logsToggle = document.getElementById('logs-toggle');
|
||||
const logsSection = document.getElementById('logs-section');
|
||||
const logsViewer = document.getElementById('logs-viewer');
|
||||
const logsRefreshBtn = document.getElementById('logs-refresh-btn');
|
||||
|
||||
let config = { firewall_host_ip: null };
|
||||
let logsInterval = null;
|
||||
let currentUser = null;
|
||||
let editId = null;
|
||||
let editOtpId = null;
|
||||
|
|
@ -87,12 +92,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
loginOverlay.style.display = 'flex';
|
||||
logoutBtn.style.display = 'none';
|
||||
mainNav.style.display = 'none';
|
||||
if (logsSection) logsSection.style.display = 'none';
|
||||
stopLogsPolling();
|
||||
};
|
||||
|
||||
const hideLogin = () => {
|
||||
loginOverlay.style.display = 'none';
|
||||
logoutBtn.style.display = 'flex';
|
||||
mainNav.style.display = 'flex';
|
||||
if (logsToggle && logsToggle.checked) {
|
||||
if (logsSection) logsSection.style.display = 'block';
|
||||
startLogsPolling();
|
||||
}
|
||||
};
|
||||
|
||||
// Tab Switching
|
||||
|
|
@ -110,9 +121,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
});
|
||||
|
||||
const updateFirewallCmds = () => {
|
||||
const hostIp = config.firewall_host_ip || window.location.hostname;
|
||||
|
||||
const loginFirewallIp = document.getElementById('login-firewall-ip');
|
||||
if (loginFirewallIp) {
|
||||
loginFirewallIp.textContent = hostIp;
|
||||
}
|
||||
|
||||
if (!ufwCmd || !iptablesCmd || !winCmd) return;
|
||||
const port = portInput.value || '4646';
|
||||
const hostIp = config.firewall_host_ip || window.location.hostname;
|
||||
ufwCmd.textContent = `sudo ufw allow from ${hostIp} to any port ${port} proto tcp`;
|
||||
iptablesCmd.textContent = `sudo iptables -I INPUT -p tcp -s ${hostIp} --dport ${port} -j ACCEPT`;
|
||||
winCmd.textContent = `New-NetFirewallRule -DisplayName "XIVLauncher OTP" -Direction Inbound -RemoteAddress ${hostIp} -LocalPort ${port} -Protocol TCP -Action Allow`;
|
||||
|
|
@ -121,7 +138,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const fetchConfig = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/config');
|
||||
if (res.status === 401) return showLogin();
|
||||
if (res.ok) {
|
||||
config = await res.json();
|
||||
updateFirewallCmds();
|
||||
|
|
@ -193,13 +209,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
</div>
|
||||
<div class="instance-actions">
|
||||
<button class="btn btn-icon" onclick="editInstance(${inst.id})" title="Edit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||||
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><path d="M6 36l3-9 20-20 6 6-20 20-9 3z" fill="url(#pencilYellow)" stroke="#664d00" stroke-width="1.5"/><path d="M6 36l3-9 6 6z" fill="#e5c158"/><path d="M6 36l1.5-4.5 3 3z" fill="#333"/><path d="M29 7l6 6" stroke="#664d00" stroke-width="1.5"/><path d="M35 13l3.5-3.5a3 3 0 0 0 0-4.2l-1.8-1.8a3 3 0 0 0-4.2 0L29 7z" fill="#ff9999" stroke="#803333" stroke-width="1.5"/></svg>
|
||||
</button>
|
||||
<button class="delete-btn" onclick="deleteInstance(${inst.id})" title="Delete">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="21" fill="url(#redBall)" stroke="#7f1d1d" stroke-width="1.5"/><ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)"/><path d="M16 16l16 16M32 16L16 32" stroke="#ffffff" stroke-width="5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -220,10 +233,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
</div>
|
||||
<div class="otp-secret-actions">
|
||||
<button class="btn btn-icon" onclick="editOTPSecret(${sec.id})" title="Edit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
|
||||
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><path d="M6 36l3-9 20-20 6 6-20 20-9 3z" fill="url(#pencilYellow)" stroke="#664d00" stroke-width="1.5"/><path d="M6 36l3-9 6 6z" fill="#e5c158"/><path d="M6 36l1.5-4.5 3 3z" fill="#333"/><path d="M29 7l6 6" stroke="#664d00" stroke-width="1.5"/><path d="M35 13l3.5-3.5a3 3 0 0 0 0-4.2l-1.8-1.8a3 3 0 0 0-4.2 0L29 7z" fill="#ff9999" stroke="#803333" stroke-width="1.5"/></svg>
|
||||
</button>
|
||||
<button class="delete-btn" onclick="deleteOTPSecret(${sec.id})" title="Delete">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2-2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="21" fill="url(#redBall)" stroke="#7f1d1d" stroke-width="1.5"/><ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)"/><path d="M16 16l16 16M32 16L16 32" stroke="#ffffff" stroke-width="5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -238,10 +251,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
</div>
|
||||
<div class="user-actions">
|
||||
<button class="btn btn-icon" onclick="resetUserPassword(${user.id})" title="Reset Password">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3L15.5 7.5z"></path></svg>
|
||||
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><circle cx="15" cy="24" r="9" fill="url(#goldKeyGrad)" stroke="#806600" stroke-width="1.5"/><circle cx="15" cy="24" r="4" fill="#f5f5f5" stroke="#806600" stroke-width="1.5"/><path d="M24 20h18v8h-4v-4h-4v4h-4v-4h-2z" fill="url(#goldKeyGrad)" stroke="#806600" stroke-width="1.5"/></svg>
|
||||
</button>
|
||||
<button class="delete-btn" onclick="deleteUser(${user.id})" title="Delete User">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
|
||||
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="21" fill="url(#redBall)" stroke="#7f1d1d" stroke-width="1.5"/><ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)"/><path d="M16 16l16 16M32 16L16 32" stroke="#ffffff" stroke-width="5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -514,7 +527,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
await navigator.clipboard.writeText(text);
|
||||
const originalHtml = btn.innerHTML;
|
||||
btn.classList.add('copied');
|
||||
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||
btn.innerHTML = '<svg class="icon-oxygen" width="14" height="14" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="21" fill="url(#greenBall)" stroke="#0d3c12" stroke-width="1.5"/><ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)"/><path d="M14 26l7 7 15-15" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('copied');
|
||||
btn.innerHTML = originalHtml;
|
||||
|
|
@ -526,6 +539,79 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
portInput.addEventListener('input', updateFirewallCmds);
|
||||
|
||||
// Bind help button on login overlay
|
||||
const loginShowHelpBtn = document.getElementById('login-show-help-btn');
|
||||
if (loginShowHelpBtn) {
|
||||
loginShowHelpBtn.addEventListener('click', () => openModal(helpModal));
|
||||
}
|
||||
|
||||
// Logging helpers
|
||||
const fetchLogs = async () => {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
const res = await fetch('/api/logs');
|
||||
if (res.status === 401) return showLogin();
|
||||
if (res.ok) {
|
||||
const logs = await res.json();
|
||||
renderLogs(logs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch logs:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const startLogsPolling = () => {
|
||||
stopLogsPolling();
|
||||
fetchLogs();
|
||||
logsInterval = setInterval(fetchLogs, 5000);
|
||||
};
|
||||
|
||||
const stopLogsPolling = () => {
|
||||
if (logsInterval) {
|
||||
clearInterval(logsInterval);
|
||||
logsInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderLogs = (logs) => {
|
||||
if (!logsViewer) return;
|
||||
if (logs.length === 0) {
|
||||
logsViewer.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 1rem;">No logs available.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
logsViewer.innerHTML = logs.slice().reverse().map(log => {
|
||||
const date = new Date(log.timestamp * 1000);
|
||||
const timeStr = date.toLocaleTimeString() + '.' + String(date.getMilliseconds()).padStart(3, '0');
|
||||
const levelClass = `log-level-${log.level.toLowerCase()}`;
|
||||
return `
|
||||
<div class="log-entry">
|
||||
<span class="log-time">[${timeStr}]</span>
|
||||
<span class="log-level ${levelClass}">[${log.level}]</span>
|
||||
<span class="log-message">${escapeHtml(log.message)}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
logsViewer.scrollTop = logsViewer.scrollHeight;
|
||||
};
|
||||
|
||||
if (logsToggle) {
|
||||
logsToggle.addEventListener('change', () => {
|
||||
if (logsToggle.checked) {
|
||||
logsSection.style.display = 'block';
|
||||
startLogsPolling();
|
||||
} else {
|
||||
logsSection.style.display = 'none';
|
||||
stopLogsPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (logsRefreshBtn) {
|
||||
logsRefreshBtn.addEventListener('click', fetchLogs);
|
||||
}
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/status');
|
||||
|
|
@ -534,7 +620,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
currentUser = data.user;
|
||||
setupUIForUser();
|
||||
hideLogin();
|
||||
fetchConfig();
|
||||
updateFirewallCmds();
|
||||
fetchInstances();
|
||||
fetchOTPSecrets();
|
||||
} else {
|
||||
|
|
@ -555,7 +641,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Unconditionally fetch config on startup to show firewall info on login overlay
|
||||
fetchConfig();
|
||||
checkAuth();
|
||||
|
||||
setInterval(() => {
|
||||
if (loginOverlay.style.display === 'none' && document.getElementById('instances-section').classList.contains('active')) {
|
||||
fetchInstances();
|
||||
|
|
|
|||
|
|
@ -9,28 +9,94 @@
|
|||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Global KDE Oxygen SVG Gradient & Filter Defs -->
|
||||
<svg style="display: none;">
|
||||
<defs>
|
||||
<linearGradient id="goldGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffe673" />
|
||||
<stop offset="30%" stop-color="#ffd03b" />
|
||||
<stop offset="70%" stop-color="#e6a817" />
|
||||
<stop offset="100%" stop-color="#b87a00" />
|
||||
</linearGradient>
|
||||
<linearGradient id="silverGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#f5f5f5" />
|
||||
<stop offset="50%" stop-color="#cfcfcf" />
|
||||
<stop offset="100%" stop-color="#8c8c8c" />
|
||||
</linearGradient>
|
||||
<linearGradient id="glassShine" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.85" />
|
||||
<stop offset="50%" stop-color="#ffffff" stop-opacity="0.15" />
|
||||
<stop offset="51%" stop-color="#ffffff" stop-opacity="0.0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="glassShineRed" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9" />
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.0" />
|
||||
</linearGradient>
|
||||
<radialGradient id="blueBall" cx="30%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#a4ebff" />
|
||||
<stop offset="30%" stop-color="#29b6f6" />
|
||||
<stop offset="75%" stop-color="#0288d1" />
|
||||
<stop offset="100%" stop-color="#01579b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="redBall" cx="30%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#ff9999" />
|
||||
<stop offset="30%" stop-color="#ef4444" />
|
||||
<stop offset="75%" stop-color="#dc2626" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</radialGradient>
|
||||
<radialGradient id="greenBall" cx="30%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#c2f0c2" />
|
||||
<stop offset="30%" stop-color="#4caf50" />
|
||||
<stop offset="75%" stop-color="#388e3c" />
|
||||
<stop offset="100%" stop-color="#1b5e20" />
|
||||
</radialGradient>
|
||||
<linearGradient id="blueArrowGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#9ddcff" />
|
||||
<stop offset="100%" stop-color="#1d73c9" />
|
||||
</linearGradient>
|
||||
<linearGradient id="pencilYellow" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#ffe066" />
|
||||
<stop offset="50%" stop-color="#ffcc00" />
|
||||
<stop offset="100%" stop-color="#cc9900" />
|
||||
</linearGradient>
|
||||
<linearGradient id="goldKeyGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ffe680" />
|
||||
<stop offset="40%" stop-color="#ffd42a" />
|
||||
<stop offset="80%" stop-color="#d4aa00" />
|
||||
<stop offset="100%" stop-color="#806600" />
|
||||
</linearGradient>
|
||||
<linearGradient id="paperGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="#e2e8f0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="logo">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
<svg class="icon-oxygen" width="32" height="32" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M14 20V12a10 10 0 1 1 20 0v8" stroke="url(#silverGrad)" stroke-width="6" stroke-linecap="round" />
|
||||
<rect x="8" y="18" width="32" height="26" rx="4" fill="url(#goldGrad)" stroke="#8f5900" stroke-width="1.5" />
|
||||
<rect x="9" y="19" width="30" height="12" rx="3" fill="url(#glassShine)" />
|
||||
<circle cx="24" cy="30" r="3" fill="#201000" />
|
||||
<path d="M22 30h4l2 8h-8z" fill="#201000" />
|
||||
</svg>
|
||||
<h1>XIVLauncher Remote OTP</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="help-btn" class="btn btn-icon" title="Firewall Help">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="21" fill="url(#blueBall)" stroke="#013b68" stroke-width="1.5" />
|
||||
<ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)" />
|
||||
<path d="M20 18c0-3 2-5 5-5s5 2 5 5c0 2-1 3-3 4s-2 2-2 4v1m0 3v2" stroke="#ffffff" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button id="logout-btn" class="btn btn-icon btn-logout" title="Logout" style="display: none;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="21" fill="url(#redBall)" stroke="#7f1d1d" stroke-width="1.5" />
|
||||
<ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)" />
|
||||
<path d="M24 12v12m-8-8a11 11 0 1 0 16 0" stroke="#ffffff" stroke-width="4.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -50,13 +116,15 @@
|
|||
<h2>Managed Instances</h2>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-sm" id="show-add-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
<svg class="icon-oxygen" width="14" height="14" viewBox="0 0 48 48" fill="none" style="margin-right: 4px;">
|
||||
<circle cx="24" cy="24" r="21" fill="url(#greenBall)" stroke="#0d3c12" stroke-width="1.5" />
|
||||
<path d="M24 14v20M14 24h20" stroke="#ffffff" stroke-width="5" stroke-linecap="round" />
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
<button class="btn btn-icon" id="refresh-btn" title="Refresh">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M38 24a14 14 0 1 1-4.1-9.9L38 18M38 10v8h-8" stroke="url(#blueArrowGrad)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -72,7 +140,10 @@
|
|||
<div class="instances-header">
|
||||
<h2>OTP Secrets</h2>
|
||||
<button class="btn btn-primary btn-sm" id="show-add-otp-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
<svg class="icon-oxygen" width="14" height="14" viewBox="0 0 48 48" fill="none" style="margin-right: 4px;">
|
||||
<circle cx="24" cy="24" r="21" fill="url(#greenBall)" stroke="#0d3c12" stroke-width="1.5" />
|
||||
<path d="M24 14v20M14 24h20" stroke="#ffffff" stroke-width="5" stroke-linecap="round" />
|
||||
</svg>
|
||||
Add Secret
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -87,7 +158,10 @@
|
|||
<div class="instances-header">
|
||||
<h2>User Management</h2>
|
||||
<button class="btn btn-primary btn-sm" id="show-add-user-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
<svg class="icon-oxygen" width="14" height="14" viewBox="0 0 48 48" fill="none" style="margin-right: 4px;">
|
||||
<circle cx="24" cy="24" r="21" fill="url(#greenBall)" stroke="#0d3c12" stroke-width="1.5" />
|
||||
<path d="M24 14v20M14 24h20" stroke="#ffffff" stroke-width="5" stroke-linecap="round" />
|
||||
</svg>
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -122,6 +196,28 @@
|
|||
<div id="profile-success" class="success-msg" style="display: none;">Profile updated successfully!</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="logs-toggle-container" class="logs-toggle-container">
|
||||
<label class="switch-container">
|
||||
<input type="checkbox" id="logs-toggle">
|
||||
<span class="switch-slider"></span>
|
||||
<span class="switch-label">Show Detailed System Logs</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="logs-section" class="card logs-card" style="display: none;">
|
||||
<div class="logs-header">
|
||||
<h3>System Logs</h3>
|
||||
<div class="logs-actions">
|
||||
<button class="btn btn-icon btn-sm" id="logs-refresh-btn" title="Refresh Logs">
|
||||
<svg class="icon-oxygen" width="14" height="14" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M38 24a14 14 0 1 1-4.1-9.9L38 18M38 10v8h-8" stroke="url(#blueArrowGrad)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logs-viewer" class="logs-viewer"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
@ -216,7 +312,7 @@
|
|||
<div class="code-wrapper">
|
||||
<code id="ufw-cmd">sudo ufw allow from localhost to any port 4646 proto tcp</code>
|
||||
<button class="btn-copy" onclick="copyToClipboard('ufw-cmd')" title="Copy to clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
<svg class="icon-oxygen" width="14" height="14" viewBox="0 0 48 48" fill="none"><rect x="14" y="6" width="26" height="32" rx="2" fill="url(#paperGrad)" stroke="#2e8bdc" stroke-width="2" /><rect x="8" y="12" width="26" height="32" rx="2" fill="url(#paperGrad)" stroke="#2e8bdc" stroke-width="2" /><line x1="14" y1="20" x2="28" y2="20" stroke="#94a3b8" stroke-width="2" /><line x1="14" y1="26" x2="28" y2="26" stroke="#94a3b8" stroke-width="2" /><line x1="14" y1="32" x2="24" y2="32" stroke="#94a3b8" stroke-width="2" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -225,7 +321,7 @@
|
|||
<div class="code-wrapper">
|
||||
<code id="iptables-cmd">sudo iptables -I INPUT -p tcp -s localhost --dport 4646 -j ACCEPT</code>
|
||||
<button class="btn-copy" onclick="copyToClipboard('iptables-cmd')" title="Copy to clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
<svg class="icon-oxygen" width="14" height="14" viewBox="0 0 48 48" fill="none"><rect x="14" y="6" width="26" height="32" rx="2" fill="url(#paperGrad)" stroke="#2e8bdc" stroke-width="2" /><rect x="8" y="12" width="26" height="32" rx="2" fill="url(#paperGrad)" stroke="#2e8bdc" stroke-width="2" /><line x1="14" y1="20" x2="28" y2="20" stroke="#94a3b8" stroke-width="2" /><line x1="14" y1="26" x2="28" y2="26" stroke="#94a3b8" stroke-width="2" /><line x1="14" y1="32" x2="24" y2="32" stroke="#94a3b8" stroke-width="2" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -234,7 +330,7 @@
|
|||
<div class="code-wrapper">
|
||||
<code id="win-cmd">New-NetFirewallRule -DisplayName "XIVLauncher OTP" -Direction Inbound -RemoteAddress localhost -LocalPort 4646 -Protocol TCP -Action Allow</code>
|
||||
<button class="btn-copy" onclick="copyToClipboard('win-cmd')" title="Copy to clipboard">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
<svg class="icon-oxygen" width="14" height="14" viewBox="0 0 48 48" fill="none"><rect x="14" y="6" width="26" height="32" rx="2" fill="url(#paperGrad)" stroke="#2e8bdc" stroke-width="2" /><rect x="8" y="12" width="26" height="32" rx="2" fill="url(#paperGrad)" stroke="#2e8bdc" stroke-width="2" /><line x1="14" y1="20" x2="28" y2="20" stroke="#94a3b8" stroke-width="2" /><line x1="14" y1="26" x2="28" y2="26" stroke="#94a3b8" stroke-width="2" /><line x1="14" y1="32" x2="24" y2="32" stroke="#94a3b8" stroke-width="2" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -244,9 +340,12 @@
|
|||
<div id="login-overlay" class="login-overlay">
|
||||
<div class="login-card">
|
||||
<div class="logo">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
<svg class="icon-oxygen" width="32" height="32" viewBox="0 0 48 48" fill="none">
|
||||
<path d="M14 20V12a10 10 0 1 1 20 0v8" stroke="url(#silverGrad)" stroke-width="6" stroke-linecap="round" />
|
||||
<rect x="8" y="18" width="32" height="26" rx="4" fill="url(#goldGrad)" stroke="#8f5900" stroke-width="1.5" />
|
||||
<rect x="9" y="19" width="30" height="12" rx="3" fill="url(#glassShine)" />
|
||||
<circle cx="24" cy="30" r="3" fill="#201000" />
|
||||
<path d="M22 30h4l2 8h-8z" fill="#201000" />
|
||||
</svg>
|
||||
<h2>Login</h2>
|
||||
</div>
|
||||
|
|
@ -262,6 +361,23 @@
|
|||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
<div id="login-error" class="login-error" style="display: none;">Invalid username or password</div>
|
||||
<div class="divider" style="margin: 1.5rem 0;"></div>
|
||||
<div class="login-firewall-section">
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.5rem;">Firewall Connection Info</h3>
|
||||
<p class="helper-text" style="font-size: 0.75rem; margin-bottom: 0.75rem;">To allow this server to send OTP codes, ensure your XIVLauncher machine allows connections from:</p>
|
||||
<div class="firewall-ip-display" style="display: flex; align-items: center; justify-content: space-between; background-color: var(--bg-color); border: 1px solid var(--border-color); border-radius: 6px; padding: 0.5rem 0.75rem; margin-bottom: 0.75rem;">
|
||||
<span class="ip-label" style="font-size: 0.75rem; color: var(--text-muted); font-weight: 500;">Host IP</span>
|
||||
<code id="login-firewall-ip" style="font-family: monospace; font-size: 0.875rem; color: #34d399;">Loading...</code>
|
||||
</div>
|
||||
<button type="button" id="login-show-help-btn" class="btn btn-secondary btn-sm" style="width: 100%; display: flex; justify-content: center; align-items: center; gap: 0.375rem; font-size: 0.75rem; padding: 0.5rem;">
|
||||
<svg class="icon-oxygen" width="14" height="14" viewBox="0 0 48 48" fill="none">
|
||||
<circle cx="24" cy="24" r="21" fill="url(#blueBall)" stroke="#013b68" stroke-width="1.5" />
|
||||
<ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)" />
|
||||
<path d="M20 18c0-3 2-5 5-5s5 2 5 5c0 2-1 3-3 4s-2 2-2 4v1m0 3v2" stroke="#ffffff" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
Show Setup Commands
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
1062
static/style.css
1062
static/style.css
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue