Add web admin panel for accounts/characters (login, GM rights, live stats)
New "webadmin" service (Flask + PyMySQL, port 8081) backing a small server-rendered admin UI: - Login gated on an existing game account: only accounts with accesslevel >= ADMIN_MIN_ACCESSLEVEL (default 900) get in. Password is MD5-hashed before comparison since the game client hashes it client-side before it ever hits the wire -- accounts.password is always an MD5 hex digest, confirmed against existing DB rows. - Accounts: list/search, create, edit accesslevel/active/password. - Characters: list/search, create (mirrors the real char-creation INSERT in Src/Char Server/charpackets.cpp -- only account_name, char_name, face, hairStyle, sex are set, everything else relies on the table's own defaults), edit core stats (level, zuly, attributes, hp/mp, stat/skill points) and isgm/isdev flags directly. All queries are parameterized (no string-formatted SQL). No CSRF protection or rate limiting yet -- acceptable for a small already-behind-a-high-accesslevel-gate private server, worth revisiting before ever exposing this port publicly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f053a06920
commit
9829688a85
14 files changed
+610
No files matched your search
@@ -34,3 +34,10 @@ LOGIN_CREATE_ACCOUNT=1
|
|||||||
SHARED_LOGIN_PASS=789456
|
SHARED_LOGIN_PASS=789456
|
||||||
SHARED_CHAR_PASS=456123
|
SHARED_CHAR_PASS=456123
|
||||||
SHARED_WORLD_PASS=321654
|
SHARED_WORLD_PASS=321654
|
||||||
|
|
||||||
|
# --- Web admin panel (http://<host>:8081) -----------------------------------
|
||||||
|
# Logs in with an existing game account; only accounts with
|
||||||
|
# accesslevel >= ADMIN_MIN_ACCESSLEVEL are allowed in.
|
||||||
|
# Generate your own with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
ADMIN_SECRET_KEY=change-me-to-a-random-value
|
||||||
|
ADMIN_MIN_ACCESSLEVEL=900
|
||||||
@@ -89,6 +89,24 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- "./webtools:/usr/share/nginx/html:ro"
|
- "./webtools:/usr/share/nginx/html:ro"
|
||||||
|
|
||||||
|
webadmin:
|
||||||
|
build:
|
||||||
|
context: ./webadmin
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: "3306"
|
||||||
|
DB_USER: ${DB_USER:-rose}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-change_me}
|
||||||
|
DB_NAME: ${DB_NAME:-ktrose}
|
||||||
|
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-change-me-too}
|
||||||
|
ADMIN_MIN_ACCESSLEVEL: ${ADMIN_MIN_ACCESSLEVEL:-900}
|
||||||
|
ports:
|
||||||
|
- "8081:8080"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
logs:
|
logs:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py ./
|
||||||
|
COPY templates ./templates
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
|
||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
import os
|
||||||
|
import functools
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
import pymysql.cursors
|
||||||
|
from flask import Flask, request, session, redirect, url_for, render_template, flash
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = os.environ.get("ADMIN_SECRET_KEY", "dev-only-change-me")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plaintext):
|
||||||
|
# The game client MD5-hashes the password client-side before it ever
|
||||||
|
# hits the wire, so accounts.password is always an MD5 hex digest --
|
||||||
|
# match that here so admin-created/changed passwords work in-game.
|
||||||
|
return hashlib.md5(plaintext.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
DB_HOST = os.environ.get("DB_HOST", "db")
|
||||||
|
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
|
||||||
|
DB_USER = os.environ.get("DB_USER", "rose")
|
||||||
|
DB_PASSWORD = os.environ.get("DB_PASSWORD", "change_me")
|
||||||
|
DB_NAME = os.environ.get("DB_NAME", "ktrose")
|
||||||
|
|
||||||
|
# Only accounts with accesslevel >= this may log in to the admin panel.
|
||||||
|
ADMIN_MIN_ACCESSLEVEL = int(os.environ.get("ADMIN_MIN_ACCESSLEVEL", "900"))
|
||||||
|
|
||||||
|
CHAR_STAT_FIELDS = [
|
||||||
|
"level", "zuly", "str", "con", "dex", "_int", "cha", "sen",
|
||||||
|
"curHp", "curMp", "statp", "skillp", "stamina",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
return pymysql.connect(
|
||||||
|
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASSWORD,
|
||||||
|
database=DB_NAME, cursorclass=pymysql.cursors.DictCursor, autocommit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(view):
|
||||||
|
@functools.wraps(view)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
if not session.get("username"):
|
||||||
|
return redirect(url_for("login", next=request.path))
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT username, password, accesslevel FROM accounts WHERE username=%s",
|
||||||
|
(username,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if (
|
||||||
|
row
|
||||||
|
and row["password"].lower() == hash_password(password)
|
||||||
|
and row["accesslevel"] >= ADMIN_MIN_ACCESSLEVEL
|
||||||
|
):
|
||||||
|
session.clear()
|
||||||
|
session["username"] = row["username"]
|
||||||
|
return redirect(request.args.get("next") or url_for("dashboard"))
|
||||||
|
flash("Login fehlgeschlagen (falsches Passwort oder unzureichendes Accesslevel).")
|
||||||
|
return render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.cursor() as cur:
|
||||||
|
cur.execute("SELECT COUNT(*) AS n FROM accounts")
|
||||||
|
accounts_n = cur.fetchone()["n"]
|
||||||
|
cur.execute("SELECT COUNT(*) AS n FROM characters")
|
||||||
|
chars_n = cur.fetchone()["n"]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return render_template("dashboard.html", accounts_n=accounts_n, chars_n=chars_n)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/accounts")
|
||||||
|
@login_required
|
||||||
|
def accounts():
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.cursor() as cur:
|
||||||
|
if q:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, accesslevel, active, online, lastip "
|
||||||
|
"FROM accounts WHERE username LIKE %s ORDER BY id",
|
||||||
|
(f"%{q}%",),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, accesslevel, active, online, lastip "
|
||||||
|
"FROM accounts ORDER BY id"
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return render_template("accounts.html", accounts=rows, q=q)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/accounts/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def new_account():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "").strip()
|
||||||
|
accesslevel = int(request.form.get("accesslevel", "100") or "100")
|
||||||
|
if not username or not password:
|
||||||
|
flash("Username und Passwort sind Pflichtfelder.")
|
||||||
|
return render_template("new_account.html")
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.cursor() as cur:
|
||||||
|
cur.execute("SELECT id FROM accounts WHERE username=%s", (username,))
|
||||||
|
if cur.fetchone():
|
||||||
|
flash(f"Account '{username}' existiert bereits.")
|
||||||
|
return render_template("new_account.html")
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO accounts (username, password, accesslevel, lastlogin, logtime) "
|
||||||
|
"VALUES (%s, %s, %s, '', 0)",
|
||||||
|
(username, hash_password(password), accesslevel),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
flash(f"Account '{username}' angelegt.")
|
||||||
|
return redirect(url_for("accounts"))
|
||||||
|
return render_template("new_account.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/accounts/<int:account_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def edit_account(account_id):
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
if request.method == "POST":
|
||||||
|
accesslevel = int(request.form.get("accesslevel", "100") or "100")
|
||||||
|
active = 1 if request.form.get("active") else 0
|
||||||
|
new_password = request.form.get("password", "").strip()
|
||||||
|
with db.cursor() as cur:
|
||||||
|
if new_password:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE accounts SET accesslevel=%s, active=%s, password=%s WHERE id=%s",
|
||||||
|
(accesslevel, active, hash_password(new_password), account_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE accounts SET accesslevel=%s, active=%s WHERE id=%s",
|
||||||
|
(accesslevel, active, account_id),
|
||||||
|
)
|
||||||
|
flash("Account aktualisiert.")
|
||||||
|
return redirect(url_for("accounts"))
|
||||||
|
with db.cursor() as cur:
|
||||||
|
cur.execute("SELECT * FROM accounts WHERE id=%s", (account_id,))
|
||||||
|
account = cur.fetchone()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, char_name, level FROM characters WHERE account_name=%s",
|
||||||
|
(account["username"],),
|
||||||
|
)
|
||||||
|
chars = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if not account:
|
||||||
|
flash("Account nicht gefunden.")
|
||||||
|
return redirect(url_for("accounts"))
|
||||||
|
return render_template("edit_account.html", account=account, chars=chars)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/characters")
|
||||||
|
@login_required
|
||||||
|
def characters():
|
||||||
|
q = request.args.get("q", "").strip()
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.cursor() as cur:
|
||||||
|
if q:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, char_name, account_name, level, isgm, isdev "
|
||||||
|
"FROM characters WHERE char_name LIKE %s OR account_name LIKE %s ORDER BY id",
|
||||||
|
(f"%{q}%", f"%{q}%"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, char_name, account_name, level, isgm, isdev "
|
||||||
|
"FROM characters ORDER BY id"
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return render_template("characters.html", chars=rows, q=q)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/characters/new", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def new_character():
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
with db.cursor() as cur:
|
||||||
|
cur.execute("SELECT username FROM accounts ORDER BY username")
|
||||||
|
account_names = [r["username"] for r in cur.fetchall()]
|
||||||
|
if request.method == "POST":
|
||||||
|
account_name = request.form.get("account_name", "").strip()
|
||||||
|
char_name = request.form.get("char_name", "").strip()
|
||||||
|
sex = int(request.form.get("sex", "0") or "0")
|
||||||
|
face = int(request.form.get("face", "1") or "1")
|
||||||
|
hairstyle = int(request.form.get("hairstyle", "1") or "1")
|
||||||
|
if not account_name or not char_name:
|
||||||
|
flash("Account und Charaktername sind Pflichtfelder.")
|
||||||
|
return render_template("new_character.html", account_names=account_names)
|
||||||
|
with db.cursor() as cur:
|
||||||
|
cur.execute("SELECT id FROM characters WHERE char_name=%s", (char_name,))
|
||||||
|
if cur.fetchone():
|
||||||
|
flash(f"Charakter '{char_name}' existiert bereits.")
|
||||||
|
return render_template("new_character.html", account_names=account_names)
|
||||||
|
# Mirrors the real character-creation INSERT in
|
||||||
|
# Src/Char Server/charpackets.cpp -- every other column
|
||||||
|
# relies on its table default (level 1, zero stats, etc).
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO characters (account_name, char_name, face, hairStyle, sex) "
|
||||||
|
"VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
(account_name, char_name, face, hairstyle, sex),
|
||||||
|
)
|
||||||
|
flash(f"Charakter '{char_name}' angelegt.")
|
||||||
|
return redirect(url_for("characters"))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return render_template("new_character.html", account_names=account_names)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/characters/<int:char_id>/edit", methods=["GET", "POST"])
|
||||||
|
@login_required
|
||||||
|
def edit_character(char_id):
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
if request.method == "POST":
|
||||||
|
values = []
|
||||||
|
set_clauses = []
|
||||||
|
for field in CHAR_STAT_FIELDS:
|
||||||
|
raw = request.form.get(field, "").strip()
|
||||||
|
if raw == "":
|
||||||
|
continue
|
||||||
|
set_clauses.append(f"`{field}`=%s")
|
||||||
|
values.append(int(raw))
|
||||||
|
isgm = 1 if request.form.get("isgm") else 0
|
||||||
|
isdev = 1 if request.form.get("isdev") else 0
|
||||||
|
set_clauses += ["isgm=%s", "isdev=%s"]
|
||||||
|
values += [isgm, isdev]
|
||||||
|
values.append(char_id)
|
||||||
|
with db.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"UPDATE characters SET {', '.join(set_clauses)} WHERE id=%s",
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
flash("Charakter aktualisiert.")
|
||||||
|
return redirect(url_for("characters"))
|
||||||
|
with db.cursor() as cur:
|
||||||
|
cur.execute("SELECT * FROM characters WHERE id=%s", (char_id,))
|
||||||
|
char = cur.fetchone()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
if not char:
|
||||||
|
flash("Charakter nicht gefunden.")
|
||||||
|
return redirect(url_for("characters"))
|
||||||
|
return render_template("edit_character.html", char=char, stat_fields=CHAR_STAT_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8080, debug=False)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
PyMySQL==1.1.1
|
||||||
|
gunicorn==22.0.0
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Accounts - ROSS Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="searchbar">
|
||||||
|
<form method="get" style="display:flex;gap:10px;flex:1;">
|
||||||
|
<input type="text" name="q" placeholder="Username suchen..." value="{{ q }}">
|
||||||
|
<button type="submit">Suchen</button>
|
||||||
|
</form>
|
||||||
|
<a class="btn" href="{{ url_for('new_account') }}">+ Neuer Account</a>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>ID</th><th>Username</th><th>Accesslevel</th><th>Aktiv</th><th>Online</th><th>Last IP</th><th></th></tr>
|
||||||
|
{% for a in accounts %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ a.id }}</td>
|
||||||
|
<td>{{ a.username }}</td>
|
||||||
|
<td>{{ a.accesslevel }}</td>
|
||||||
|
<td>{% if a.active %}<span class="badge ok">ja</span>{% else %}<span class="badge off">nein</span>{% endif %}</td>
|
||||||
|
<td>{% if a.online %}<span class="badge ok">online</span>{% else %}<span class="badge off">offline</span>{% endif %}</td>
|
||||||
|
<td>{{ a.lastip }}</td>
|
||||||
|
<td class="row-actions"><a class="btn small" href="{{ url_for('edit_account', account_id=a.id) }}">bearbeiten</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{% block title %}ROSS Admin{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #14161c;
|
||||||
|
--panel: #1d2029;
|
||||||
|
--border: #2c3040;
|
||||||
|
--text: #e4e6ec;
|
||||||
|
--muted: #9098ab;
|
||||||
|
--accent: #5b8cff;
|
||||||
|
--danger: #e0556f;
|
||||||
|
--ok: #4fbf7a;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
header a { color: var(--text); text-decoration: none; font-weight: 600; }
|
||||||
|
nav a { color: var(--muted); text-decoration: none; margin-right: 18px; font-size: 14px; }
|
||||||
|
nav a:hover { color: var(--text); }
|
||||||
|
main { max-width: 960px; margin: 0 auto; padding: 24px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||||
|
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||||
|
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; }
|
||||||
|
tr:hover td { background: #ffffff08; }
|
||||||
|
a.btn, button {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a.btn.secondary, button.secondary { background: #33384a; }
|
||||||
|
a.btn.small { padding: 4px 10px; font-size: 12px; }
|
||||||
|
input, select {
|
||||||
|
background: #10121a;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 4px; }
|
||||||
|
form.inline { display: inline; }
|
||||||
|
.flash { background: #3a2f1a; border: 1px solid #6b4f1f; color: #ffd889; padding: 10px 14px; border-radius: 6px; margin-bottom: 16px; }
|
||||||
|
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 20px; margin-bottom: 20px; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 20px; }
|
||||||
|
.searchbar { display: flex; gap: 10px; margin-bottom: 10px; }
|
||||||
|
.searchbar input { flex: 1; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
|
||||||
|
.badge.ok { background: #163a26; color: var(--ok); }
|
||||||
|
.badge.off { background: #3a1620; color: var(--danger); }
|
||||||
|
.row-actions a { margin-right: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<a href="{{ url_for('dashboard') }}">ROSS Admin</a>
|
||||||
|
{% if session.get('username') %}
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('accounts') }}">Accounts</a>
|
||||||
|
<a href="{{ url_for('characters') }}">Charaktere</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Logout ({{ session['username'] }})</a>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for m in messages %}<div class="flash">{{ m }}</div>{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Charaktere - ROSS Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="searchbar">
|
||||||
|
<form method="get" style="display:flex;gap:10px;flex:1;">
|
||||||
|
<input type="text" name="q" placeholder="Charakter- oder Accountname suchen..." value="{{ q }}">
|
||||||
|
<button type="submit">Suchen</button>
|
||||||
|
</form>
|
||||||
|
<a class="btn" href="{{ url_for('new_character') }}">+ Neuer Charakter</a>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>ID</th><th>Name</th><th>Account</th><th>Level</th><th>GM</th><th>Dev</th><th></th></tr>
|
||||||
|
{% for c in chars %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ c.id }}</td>
|
||||||
|
<td>{{ c.char_name }}</td>
|
||||||
|
<td>{{ c.account_name }}</td>
|
||||||
|
<td>{{ c.level }}</td>
|
||||||
|
<td>{% if c.isgm %}<span class="badge ok">GM</span>{% endif %}</td>
|
||||||
|
<td>{% if c.isdev %}<span class="badge ok">Dev</span>{% endif %}</td>
|
||||||
|
<td class="row-actions"><a class="btn small" href="{{ url_for('edit_character', char_id=c.id) }}">bearbeiten</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Dashboard - ROSS Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="margin-top:0;">Accounts</h3>
|
||||||
|
<p style="font-size:32px;margin:0;">{{ accounts_n }}</p>
|
||||||
|
<p><a class="btn small" href="{{ url_for('accounts') }}">verwalten</a>
|
||||||
|
<a class="btn small secondary" href="{{ url_for('new_account') }}">+ neu</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="margin-top:0;">Charaktere</h3>
|
||||||
|
<p style="font-size:32px;margin:0;">{{ chars_n }}</p>
|
||||||
|
<p><a class="btn small" href="{{ url_for('characters') }}">verwalten</a>
|
||||||
|
<a class="btn small secondary" href="{{ url_for('new_character') }}">+ neu</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ account.username }} - ROSS Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="card" style="max-width:480px;">
|
||||||
|
<h2 style="margin-top:0;">Account: {{ account.username }}</h2>
|
||||||
|
<form method="post">
|
||||||
|
<label>Accesslevel (100 = normaler Spieler, 900 = Admin)</label>
|
||||||
|
<input type="number" name="accesslevel" value="{{ account.accesslevel }}">
|
||||||
|
<label><input type="checkbox" name="active" style="width:auto;" {% if account.active %}checked{% endif %}> Aktiv</label>
|
||||||
|
<label>Neues Passwort (leer lassen = unverändert)</label>
|
||||||
|
<input type="password" name="password" placeholder="unverändert">
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<a class="btn secondary" href="{{ url_for('accounts') }}">Zurück</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="margin-top:0;">Charaktere auf diesem Account</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Name</th><th>Level</th><th></th></tr>
|
||||||
|
{% for c in chars %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ c.char_name }}</td>
|
||||||
|
<td>{{ c.level }}</td>
|
||||||
|
<td><a class="btn small" href="{{ url_for('edit_character', char_id=c.id) }}">bearbeiten</a></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="3">Keine Charaktere.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ char.char_name }} - ROSS Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="card" style="max-width:600px;">
|
||||||
|
<h2 style="margin-top:0;">Charakter: {{ char.char_name }} <span class="badge ok">Account: {{ char.account_name }}</span></h2>
|
||||||
|
<form method="post">
|
||||||
|
<div class="grid">
|
||||||
|
{% for field in stat_fields %}
|
||||||
|
<div>
|
||||||
|
<label>{{ field.lstrip('_') }}</label>
|
||||||
|
<input type="number" name="{{ field }}" value="{{ char[field] }}">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<label style="margin-top:16px;"><input type="checkbox" name="isgm" style="width:auto;" {% if char.isgm %}checked{% endif %}> GM</label>
|
||||||
|
<label><input type="checkbox" name="isdev" style="width:auto;" {% if char.isdev %}checked{% endif %}> Dev (Debugmode-fähig)</label>
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<a class="btn secondary" href="{{ url_for('characters') }}">Zurück</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Login - ROSS Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="card" style="max-width:360px;margin:60px auto;">
|
||||||
|
<h2 style="margin-top:0;">ROSS Admin Login</h2>
|
||||||
|
<form method="post">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" name="username" autofocus required>
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Neuer Account - ROSS Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="card" style="max-width:420px;">
|
||||||
|
<h2 style="margin-top:0;">Neuer Account</h2>
|
||||||
|
<form method="post">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" name="username" required autofocus>
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
<label>Accesslevel (100 = normaler Spieler, 900 = Admin)</label>
|
||||||
|
<input type="number" name="accesslevel" value="100">
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
<a class="btn secondary" href="{{ url_for('accounts') }}">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Neuer Charakter - ROSS Admin{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="card" style="max-width:420px;">
|
||||||
|
<h2 style="margin-top:0;">Neuer Charakter</h2>
|
||||||
|
<form method="post">
|
||||||
|
<label>Account</label>
|
||||||
|
<select name="account_name" required>
|
||||||
|
{% for name in account_names %}
|
||||||
|
<option value="{{ name }}">{{ name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label>Charaktername</label>
|
||||||
|
<input type="text" name="char_name" required autofocus>
|
||||||
|
<label>Geschlecht (0 = männlich, 1 = weiblich)</label>
|
||||||
|
<input type="number" name="sex" value="0" min="0" max="1">
|
||||||
|
<label>Face</label>
|
||||||
|
<input type="number" name="face" value="1">
|
||||||
|
<label>Frisur</label>
|
||||||
|
<input type="number" name="hairstyle" value="1">
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
<a class="btn secondary" href="{{ url_for('characters') }}">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in new issue
Block a user