feat: v0.5.0 - Benutzerverwaltung mit Session-Authentifizierung

Rollen-basiertes Zugriffsystem (public/user/admin), Registrierung mit
E-Mail-Verifikation, bcrypt Passwort-Hashing, Admin-Benutzerverwaltung.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-16 19:38:17 +01:00
parent 8f79c197c7
commit 0d6b26f4f8
19 changed files with 1291 additions and 28 deletions

View file

@ -8,29 +8,46 @@
## Project Structure
- Config: `config.yaml` (live-reloaded via file watcher in `meshbot/config.py`)
- Bot: `meshbot/bot.py` - Meshtastic TCP, commands use `config.get("bot.command_prefix")`
- Web: `meshbot/webserver.py` - aiohttp + WebSocket
- DB: `meshbot/database.py` - SQLite via aiosqlite
- Auth: `meshbot/auth.py` - Session-Middleware, Passwort-Hashing, Auth-Routen, Admin-API, Email
- Web: `meshbot/webserver.py` - aiohttp + WebSocket + Auth-Integration
- DB: `meshbot/database.py` - SQLite via aiosqlite (nodes, messages, commands, users, tokens, email_logs)
- Scheduler: `meshbot/scheduler.py` - Cron-based job scheduler
- Frontend: `static/` - Bootstrap 5.3 dark/light theme, AdminLTE-style layout
- Entry: `main.py`
## Pages & Routes
- `/` - Dashboard (`static/index.html`, `static/js/dashboard.js`)
- `/scheduler` - Scheduler (`static/scheduler.html`, `static/js/scheduler.js`)
- `/map` - Leaflet map (`static/map.html`, `static/js/map.js`)
- `/settings` - Node config (`static/settings.html`, `static/js/settings.js`)
- `/scheduler` - Scheduler (`static/scheduler.html`, `static/js/scheduler.js`) - Admin only
- `/map` - Leaflet map (`static/map.html`, `static/js/map.js`) - Public
- `/settings` - Node config (`static/settings.html`, `static/js/settings.js`) - Admin only
- `/login` + `/register` - Auth (`static/login.html`, `static/js/login.js`)
- `/admin` - User management (`static/admin.html`, `static/js/admin.js`) - Admin only
- `/ws` - WebSocket endpoint
- API: `/api/nodes`, `/api/messages`, `/api/stats`, `/api/send`, `/api/node/config`, `/api/scheduler/jobs`
- Auth: `/auth/login`, `/auth/register`, `/auth/logout`, `/auth/verify`, `/auth/set-password`, `/auth/forgot-password`, `/auth/reset-password`
- API: `/api/nodes`, `/api/messages`, `/api/stats`, `/api/send` (user), `/api/node/config` (admin), `/api/scheduler/jobs` (admin)
- API Auth: `/api/auth/me`, `/api/admin/users`, `/api/admin/users/{id}/role`, `/api/admin/users/{id}/verify`
## Rollen & Zugriffsrechte
| Bereich | Public | User | Admin |
|---------|--------|------|-------|
| `/map`, `/` (Nodes, Stats) | Ja | Ja | Ja |
| Dashboard Nachrichten + Senden | Nein | Ja | Ja |
| `/scheduler`, `/settings` | Nein | Nein | Ja |
| `/admin` | Nein | Nein | Ja |
## Frontend Layout Pattern
- All pages use consistent AdminLTE-style: top-navbar (46px), sidebar (200px), content-wrapper
- Sidebar nav with active state, 4 entries: Dashboard, Scheduler, Karte, Einstellungen
- Each JS file has: theme toggle (localStorage), sidebar toggle (mobile), page-specific logic
- Sidebar nav with active state, 5 entries: Dashboard, Scheduler, Karte, Einstellungen, Benutzer
- Admin-only sidebar entries use class `sidebar-admin` (hidden via JS if not admin)
- Navbar: User-Name + Logout button (logged in) or Login button (not logged in)
- Each JS file has: auth check (`/api/auth/me`), updateNavbar(), updateSidebar(), theme toggle, sidebar toggle
- Shared styles in `static/css/style.css`
## Key Details
- Meshtastic host configured in config.yaml, not env vars
- Bot start: `/home/peter/meshdd-bot/venv/bin/python main.py`
- Forgejo remote with token in URL
- Current version: 0.4.0
- Current version: 0.5.0
- Protobuf objects converted via `google.protobuf.json_format.MessageToDict()`
- Auth: bcrypt (12 rounds), aiohttp-session EncryptedCookieStorage, aiosmtplib for emails
- SMTP fallback: if no smtp.host configured, verification links logged to console

View file

@ -1,5 +1,29 @@
# Changelog
## [0.5.0] - 2026-02-16
### Added
- Benutzerverwaltung mit Session-basierter Authentifizierung
- Registrierung mit E-Mail-Verifikation (OTP-Token via aiosmtplib)
- Passwort-Hashing mit bcrypt (12 Rounds, min. 8 Zeichen)
- Benutzerrollen: public (nicht eingeloggt), user, admin
- Login/Register/Passwort-vergessen Seite (`/login`, `/register`)
- Admin-Benutzerverwaltung (`/admin`) mit Rolle aendern, verifizieren, loeschen
- Session-Management via aiohttp-session mit EncryptedCookieStorage
- Auth-Middleware setzt `request['user']` auf allen Routen
- API-Endpoint `GET /api/auth/me` fuer Frontend-Rollenabfrage
- Auth-Routen: login, register, logout, verify, set-password, forgot-password, reset-password
- Admin-API: users CRUD, Rolle aendern, manuell verifizieren
- Navbar zeigt User-Name + Logout oder Login-Button auf allen Seiten
- Sidebar zeigt Scheduler/Settings/Admin nur fuer Admins (JS-gesteuert)
### Changed
- Dashboard: Nachrichten-Card und Sende-Card nur fuer eingeloggte User sichtbar
- API `/api/send` erfordert User-Login
- API `/api/node/config`, `/api/scheduler/*` erfordern Admin-Rolle
- Neue DB-Tabellen: users, tokens, email_logs
- config.yaml: auth + smtp Sektionen hinzugefuegt
- requirements.txt: bcrypt, aiohttp-session, cryptography, aiosmtplib
## [0.4.0] - 2026-02-16
### Summary
- Node-Einstellungen Seite, Karte im Sidebar-Layout

View file

@ -1,4 +1,4 @@
version: "0.4.0"
version: "0.5.0"
bot:
name: "MeshDD-Bot"
@ -14,3 +14,15 @@ web:
database:
path: "meshdd.db"
auth:
secret_key: "change-this-secret-key-32bytes!!"
session_max_age: 86400
smtp:
host: ""
port: 587
user: ""
password: ""
from: "MeshDD-Bot <noreply@example.com>"
app_url: "http://localhost:8080"

380
meshbot/auth.py Normal file
View file

@ -0,0 +1,380 @@
import base64
import logging
import time
import uuid
import bcrypt
from aiohttp import web
import aiohttp_session
from aiohttp_session.cookie_storage import EncryptedCookieStorage
from cryptography.fernet import Fernet
from meshbot import config
logger = logging.getLogger(__name__)
# ── Password hashing ─────────────────────────────────
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
def check_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
# ── Session setup ────────────────────────────────────
def setup_session(app: web.Application):
secret_key = config.get("auth.secret_key", "change-this-secret-key-32bytes!!")
# Fernet requires a 32-byte url-safe base64-encoded key
fernet_key = base64.urlsafe_b64encode(secret_key.encode("utf-8")[:32])
max_age = config.get("auth.session_max_age", 86400)
storage = EncryptedCookieStorage(
fernet_key,
cookie_name="meshdd_session",
max_age=max_age,
)
aiohttp_session.setup(app, storage)
# ── Auth middleware ───────────────────────────────────
@web.middleware
async def auth_middleware(request: web.Request, handler):
request["user"] = None
try:
session = await aiohttp_session.get_session(request)
if session.get("user_id"):
request["user"] = {
"id": session["user_id"],
"email": session.get("email"),
"name": session.get("name"),
"role": session.get("role"),
}
except Exception:
pass
return await handler(request)
# ── Helper checks ────────────────────────────────────
def require_user(request: web.Request):
if not request["user"]:
raise web.HTTPFound("/login")
def require_admin(request: web.Request):
if not request["user"]:
raise web.HTTPFound("/login")
if request["user"]["role"] != "admin":
raise web.HTTPForbidden(text="Admin access required")
def require_user_api(request: web.Request):
if not request["user"]:
raise web.HTTPUnauthorized(text="Login required")
def require_admin_api(request: web.Request):
if not request["user"]:
raise web.HTTPUnauthorized(text="Login required")
if request["user"]["role"] != "admin":
raise web.HTTPForbidden(text="Admin access required")
# ── Email sending ────────────────────────────────────
async def send_verification_email(db, email: str, token: str):
app_url = config.get("smtp.app_url", "http://localhost:8080")
verify_url = f"{app_url}/auth/verify?token={token}"
subject = "MeshDD-Bot - E-Mail verifizieren"
html_body = f"""<html><body>
<h2>MeshDD-Bot Registrierung</h2>
<p>Klicke auf den folgenden Link, um dein Passwort zu setzen und dein Konto zu aktivieren:</p>
<p><a href="{verify_url}">{verify_url}</a></p>
<p>Der Link ist 24 Stunden gueltig.</p>
</body></html>"""
smtp_host = config.get("smtp.host", "")
if not smtp_host:
logger.info("SMTP not configured - verification link: %s", verify_url)
await db.log_email(email, subject, "console", "SMTP not configured")
return
try:
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = config.get("smtp.from", "MeshDD-Bot <noreply@example.com>")
msg["To"] = email
msg.attach(MIMEText(html_body, "html"))
await aiosmtplib.send(
msg,
hostname=smtp_host,
port=config.get("smtp.port", 587),
username=config.get("smtp.user", ""),
password=config.get("smtp.password", ""),
start_tls=True,
)
await db.log_email(email, subject, "sent")
logger.info("Verification email sent to %s", email)
except Exception as e:
logger.error("Failed to send email to %s: %s", email, e)
await db.log_email(email, subject, "error", str(e))
async def send_reset_email(db, email: str, token: str):
app_url = config.get("smtp.app_url", "http://localhost:8080")
reset_url = f"{app_url}/auth/reset-password?token={token}"
subject = "MeshDD-Bot - Passwort zuruecksetzen"
html_body = f"""<html><body>
<h2>Passwort zuruecksetzen</h2>
<p>Klicke auf den folgenden Link, um dein Passwort zurueckzusetzen:</p>
<p><a href="{reset_url}">{reset_url}</a></p>
<p>Der Link ist 24 Stunden gueltig.</p>
</body></html>"""
smtp_host = config.get("smtp.host", "")
if not smtp_host:
logger.info("SMTP not configured - reset link: %s", reset_url)
await db.log_email(email, subject, "console", "SMTP not configured")
return
try:
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = config.get("smtp.from", "MeshDD-Bot <noreply@example.com>")
msg["To"] = email
msg.attach(MIMEText(html_body, "html"))
await aiosmtplib.send(
msg,
hostname=smtp_host,
port=config.get("smtp.port", 587),
username=config.get("smtp.user", ""),
password=config.get("smtp.password", ""),
start_tls=True,
)
await db.log_email(email, subject, "sent")
logger.info("Reset email sent to %s", email)
except Exception as e:
logger.error("Failed to send reset email to %s: %s", email, e)
await db.log_email(email, subject, "error", str(e))
# ── Auth route handlers ──────────────────────────────
def setup_auth_routes(app: web.Application, db):
async def login_post(request: web.Request) -> web.Response:
data = await request.json()
email = data.get("email", "").strip().lower()
password = data.get("password", "")
if not email or not password:
return web.json_response({"error": "E-Mail und Passwort erforderlich"}, status=400)
user = await db.get_user_by_email(email)
if not user or not check_password(password, user["password"]):
return web.json_response({"error": "Ungueltige Anmeldedaten"}, status=401)
if not user["is_verified"]:
return web.json_response({"error": "Konto nicht verifiziert. Bitte pruefe deine E-Mails."}, status=403)
session = await aiohttp_session.new_session(request)
session["user_id"] = user["id"]
session["email"] = user["email"]
session["name"] = user["name"]
session["role"] = user["role"]
return web.json_response({
"id": user["id"],
"email": user["email"],
"name": user["name"],
"role": user["role"],
})
async def register_post(request: web.Request) -> web.Response:
data = await request.json()
name = data.get("name", "").strip()
email = data.get("email", "").strip().lower()
if not name or not email:
return web.json_response({"error": "Name und E-Mail erforderlich"}, status=400)
existing = await db.get_user_by_email(email)
if existing:
return web.json_response({"error": "E-Mail bereits registriert"}, status=409)
# Create user with temporary password hash (will be set during verification)
temp_hash = hash_password(uuid.uuid4().hex)
user = await db.create_user(email=email, password=temp_hash, name=name)
# Generate verification token
token = uuid.uuid4().hex
expires_at = time.time() + 86400 # 24h
await db.create_token(user["id"], token, "verify", expires_at)
# Send email
await send_verification_email(db, email, token)
return web.json_response({"ok": True, "message": "Registrierung erfolgreich. Pruefe deine E-Mails."})
async def logout_get(request: web.Request) -> web.Response:
session = await aiohttp_session.get_session(request)
session.invalidate()
raise web.HTTPFound("/")
async def verify_get(request: web.Request) -> web.Response:
token = request.query.get("token", "")
if not token:
return web.Response(text="Token fehlt", status=400)
token_row = await db.get_valid_token(token, "verify")
if not token_row:
return web.Response(text="Token ungueltig oder abgelaufen", status=400)
# Serve the set-password form
import os
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
return web.FileResponse(os.path.join(static_dir, "login.html"))
async def set_password_post(request: web.Request) -> web.Response:
data = await request.json()
token = data.get("token", "")
password = data.get("password", "")
if not token or not password:
return web.json_response({"error": "Token und Passwort erforderlich"}, status=400)
if len(password) < 8:
return web.json_response({"error": "Passwort muss mindestens 8 Zeichen lang sein"}, status=400)
token_row = await db.get_valid_token(token, "verify")
if not token_row:
return web.json_response({"error": "Token ungueltig oder abgelaufen"}, status=400)
hashed = hash_password(password)
await db.update_user(token_row["user_id"], password=hashed, is_verified=1)
await db.mark_token_used(token_row["id"])
return web.json_response({"ok": True, "message": "Passwort gesetzt. Du kannst dich jetzt anmelden."})
async def forgot_password_post(request: web.Request) -> web.Response:
data = await request.json()
email = data.get("email", "").strip().lower()
if not email:
return web.json_response({"error": "E-Mail erforderlich"}, status=400)
user = await db.get_user_by_email(email)
if not user:
# Don't reveal whether email exists
return web.json_response({"ok": True, "message": "Falls die E-Mail registriert ist, wurde ein Link gesendet."})
token = uuid.uuid4().hex
expires_at = time.time() + 86400 # 24h
await db.create_token(user["id"], token, "reset", expires_at)
await send_reset_email(db, email, token)
return web.json_response({"ok": True, "message": "Falls die E-Mail registriert ist, wurde ein Link gesendet."})
async def reset_password_get(request: web.Request) -> web.Response:
token = request.query.get("token", "")
if not token:
return web.Response(text="Token fehlt", status=400)
token_row = await db.get_valid_token(token, "reset")
if not token_row:
return web.Response(text="Token ungueltig oder abgelaufen", status=400)
import os
static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
return web.FileResponse(os.path.join(static_dir, "login.html"))
async def reset_password_post(request: web.Request) -> web.Response:
data = await request.json()
token = data.get("token", "")
password = data.get("password", "")
if not token or not password:
return web.json_response({"error": "Token und Passwort erforderlich"}, status=400)
if len(password) < 8:
return web.json_response({"error": "Passwort muss mindestens 8 Zeichen lang sein"}, status=400)
token_row = await db.get_valid_token(token, "reset")
if not token_row:
return web.json_response({"error": "Token ungueltig oder abgelaufen"}, status=400)
hashed = hash_password(password)
await db.update_user(token_row["user_id"], password=hashed)
await db.mark_token_used(token_row["id"])
return web.json_response({"ok": True, "message": "Passwort geaendert. Du kannst dich jetzt anmelden."})
async def me_get(request: web.Request) -> web.Response:
user = request["user"]
if not user:
return web.json_response(None, status=401)
return web.json_response(user)
# ── Admin API ────────────────────────────────────
async def admin_users_get(request: web.Request) -> web.Response:
require_admin_api(request)
users = await db.get_all_users()
return web.json_response(users)
async def admin_user_role(request: web.Request) -> web.Response:
require_admin_api(request)
user_id = int(request.match_info["id"])
data = await request.json()
role = data.get("role", "")
if role not in ("user", "admin"):
return web.json_response({"error": "Ungueltige Rolle"}, status=400)
await db.update_user(user_id, role=role)
users = await db.get_all_users()
return web.json_response(users)
async def admin_user_verify(request: web.Request) -> web.Response:
require_admin_api(request)
user_id = int(request.match_info["id"])
await db.update_user(user_id, is_verified=1)
users = await db.get_all_users()
return web.json_response(users)
async def admin_user_delete(request: web.Request) -> web.Response:
require_admin_api(request)
user_id = int(request.match_info["id"])
# Prevent self-deletion
if request["user"]["id"] == user_id:
return web.json_response({"error": "Eigenen Account kann man nicht loeschen"}, status=400)
await db.delete_user(user_id)
users = await db.get_all_users()
return web.json_response(users)
# Register routes
app.router.add_post("/auth/login", login_post)
app.router.add_post("/auth/register", register_post)
app.router.add_get("/auth/logout", logout_get)
app.router.add_get("/auth/verify", verify_get)
app.router.add_post("/auth/set-password", set_password_post)
app.router.add_post("/auth/forgot-password", forgot_password_post)
app.router.add_get("/auth/reset-password", reset_password_get)
app.router.add_post("/auth/reset-password", reset_password_post)
app.router.add_get("/api/auth/me", me_get)
app.router.add_get("/api/admin/users", admin_users_get)
app.router.add_post("/api/admin/users/{id}/role", admin_user_role)
app.router.add_post("/api/admin/users/{id}/verify", admin_user_verify)
app.router.add_delete("/api/admin/users/{id}", admin_user_delete)

View file

@ -57,9 +57,42 @@ class Database:
timestamp REAL,
command TEXT
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_verified INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
expires_at REAL NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS email_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL,
error_message TEXT,
created_at REAL NOT NULL
);
""")
await self.db.commit()
# ── Node methods ──────────────────────────────────
async def upsert_node(self, node_id: str, **kwargs) -> dict:
now = time.time()
existing = await self.get_node(node_id)
@ -107,6 +140,8 @@ class Database:
) as cursor:
return [dict(row) async for row in cursor]
# ── Message methods ───────────────────────────────
async def insert_message(self, from_node: str, to_node: str, channel: int,
portnum: str, payload: str) -> dict:
now = time.time()
@ -127,6 +162,8 @@ class Database:
) as cursor:
return [dict(row) async for row in cursor]
# ── Command methods ───────────────────────────────
async def insert_command(self, command: str):
now = time.time()
await self.db.execute(
@ -152,3 +189,87 @@ class Database:
) as cursor:
stats["command_breakdown"] = {row[0]: row[1] async for row in cursor}
return stats
# ── User methods ──────────────────────────────────
async def create_user(self, email: str, password: str, name: str, role: str = "user", is_verified: int = 0) -> dict:
now = time.time()
cursor = await self.db.execute(
"INSERT INTO users (email, password, name, role, is_verified, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(email, password, name, role, is_verified, now, now),
)
await self.db.commit()
return await self.get_user_by_id(cursor.lastrowid)
async def get_user_by_email(self, email: str) -> dict | None:
async with self.db.execute(
"SELECT * FROM users WHERE email = ?", (email,)
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def get_user_by_id(self, user_id: int) -> dict | None:
async with self.db.execute(
"SELECT * FROM users WHERE id = ?", (user_id,)
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def get_all_users(self) -> list[dict]:
async with self.db.execute(
"SELECT id, email, name, role, is_verified, created_at, updated_at FROM users ORDER BY created_at DESC"
) as cursor:
return [dict(row) async for row in cursor]
async def update_user(self, user_id: int, **kwargs) -> dict | None:
if not kwargs:
return await self.get_user_by_id(user_id)
kwargs["updated_at"] = time.time()
set_clause = ", ".join(f"{k} = ?" for k in kwargs)
values = list(kwargs.values()) + [user_id]
await self.db.execute(
f"UPDATE users SET {set_clause} WHERE id = ?", values
)
await self.db.commit()
return await self.get_user_by_id(user_id)
async def delete_user(self, user_id: int) -> bool:
cursor = await self.db.execute("DELETE FROM users WHERE id = ?", (user_id,))
await self.db.commit()
return cursor.rowcount > 0
# ── Token methods ─────────────────────────────────
async def create_token(self, user_id: int, token: str, token_type: str, expires_at: float) -> dict:
now = time.time()
cursor = await self.db.execute(
"INSERT INTO tokens (user_id, token, type, expires_at, used, created_at) VALUES (?, ?, ?, ?, 0, ?)",
(user_id, token, token_type, expires_at, now),
)
await self.db.commit()
async with self.db.execute("SELECT * FROM tokens WHERE id = ?", (cursor.lastrowid,)) as c:
row = await c.fetchone()
return dict(row) if row else {}
async def get_valid_token(self, token: str, token_type: str) -> dict | None:
now = time.time()
async with self.db.execute(
"SELECT * FROM tokens WHERE token = ? AND type = ? AND used = 0 AND expires_at > ?",
(token, token_type, now),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def mark_token_used(self, token_id: int):
await self.db.execute("UPDATE tokens SET used = 1 WHERE id = ?", (token_id,))
await self.db.commit()
# ── Email log methods ─────────────────────────────
async def log_email(self, recipient: str, subject: str, status: str, error_message: str = None):
now = time.time()
await self.db.execute(
"INSERT INTO email_logs (recipient, subject, status, error_message, created_at) VALUES (?, ?, ?, ?, ?)",
(recipient, subject, status, error_message, now),
)
await self.db.commit()

View file

@ -7,6 +7,7 @@ from aiohttp import web
from meshbot import config
from meshbot.database import Database
from meshbot.auth import setup_session, auth_middleware, setup_auth_routes, require_user_api, require_admin_api
logger = logging.getLogger(__name__)
@ -35,7 +36,10 @@ class WebServer:
self.bot = bot
self.scheduler = scheduler
self.app = web.Application()
setup_session(self.app)
self.app.middlewares.append(auth_middleware)
self._setup_routes()
setup_auth_routes(self.app, db)
def _setup_routes(self):
self.app.router.add_get("/ws", self._ws_handler)
@ -48,6 +52,9 @@ class WebServer:
self.app.router.add_delete("/api/scheduler/jobs/{name}", self._api_scheduler_delete)
self.app.router.add_post("/api/send", self._api_send)
self.app.router.add_get("/api/node/config", self._api_node_config)
self.app.router.add_get("/login", self._serve_login)
self.app.router.add_get("/register", self._serve_login)
self.app.router.add_get("/admin", self._serve_admin)
self.app.router.add_get("/settings", self._serve_settings)
self.app.router.add_get("/scheduler", self._serve_scheduler)
self.app.router.add_get("/map", self._serve_map)
@ -102,6 +109,7 @@ class WebServer:
return web.json_response(stats)
async def _api_send(self, request: web.Request) -> web.Response:
require_user_api(request)
if not self.bot:
return web.json_response({"error": "Bot not available"}, status=503)
data = await request.json()
@ -113,6 +121,7 @@ class WebServer:
return web.json_response({"ok": True})
async def _api_node_config(self, request: web.Request) -> web.Response:
require_admin_api(request)
if not self.bot:
return web.json_response({"error": "Bot not available"}, status=503)
try:
@ -122,6 +131,12 @@ class WebServer:
logger.exception("Error getting node config")
return web.json_response({"error": "Failed to get config"}, status=500)
async def _serve_login(self, request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(STATIC_DIR, "login.html"))
async def _serve_admin(self, request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(STATIC_DIR, "admin.html"))
async def _serve_settings(self, request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(STATIC_DIR, "settings.html"))
@ -140,6 +155,7 @@ class WebServer:
return web.json_response(self.scheduler.get_jobs())
async def _api_scheduler_add(self, request: web.Request) -> web.Response:
require_admin_api(request)
if not self.scheduler:
return web.json_response({"error": "Scheduler not available"}, status=503)
job = await request.json()
@ -149,6 +165,7 @@ class WebServer:
return web.json_response(jobs, status=201)
async def _api_scheduler_update(self, request: web.Request) -> web.Response:
require_admin_api(request)
if not self.scheduler:
return web.json_response({"error": "Scheduler not available"}, status=503)
name = request.match_info["name"]
@ -161,6 +178,7 @@ class WebServer:
return web.json_response(jobs)
async def _api_scheduler_delete(self, request: web.Request) -> web.Response:
require_admin_api(request)
if not self.scheduler:
return web.json_response({"error": "Scheduler not available"}, status=503)
name = request.match_info["name"]

View file

@ -2,3 +2,7 @@ meshtastic>=2.7.7
aiohttp>=3.9.0
aiosqlite>=0.19.0
pyyaml>=6.0
bcrypt>=4.0.0
aiohttp-session>=2.12.0
cryptography>=41.0.0
aiosmtplib>=3.0.0

87
static/admin.html Normal file
View file

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshDD-Bot Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<!-- Top Navbar -->
<nav class="top-navbar d-flex align-items-center px-3">
<button class="btn btn-link text-body p-0 me-2 d-lg-none" id="sidebarToggle">
<i class="bi bi-list fs-5"></i>
</button>
<span class="fw-bold me-auto">
<i class="bi bi-broadcast-pin text-info me-1"></i>MeshDD-Bot
</span>
<div class="d-flex align-items-center gap-2">
<span id="userMenu" class="d-none">
<small class="text-body-secondary me-1"><i class="bi bi-person-fill me-1"></i><span id="userName"></span></small>
<a href="/auth/logout" class="btn btn-sm btn-outline-secondary py-0 px-1" title="Abmelden">
<i class="bi bi-box-arrow-right" style="font-size:.75rem"></i>
</a>
</span>
<a href="/login" id="loginBtn" class="btn btn-sm btn-outline-info py-0 px-1 d-none">
<i class="bi bi-person" style="font-size:.75rem"></i> Login
</a>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
</div>
</nav>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<nav class="sidebar-nav">
<a href="/" class="sidebar-link">
<i class="bi bi-speedometer2"></i><span>Dashboard</span>
</a>
<a href="/scheduler" class="sidebar-link sidebar-admin">
<i class="bi bi-clock-history"></i><span>Scheduler</span>
</a>
<a href="/map" class="sidebar-link">
<i class="bi bi-map"></i><span>Karte</span>
</a>
<a href="/settings" class="sidebar-link sidebar-admin">
<i class="bi bi-gear"></i><span>Einstellungen</span>
</a>
<a href="/admin" class="sidebar-link active sidebar-admin">
<i class="bi bi-people"></i><span>Benutzer</span>
</a>
</nav>
</aside>
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
<!-- Content -->
<main class="content-wrapper">
<h6 class="mb-2"><i class="bi bi-people me-1 text-info"></i>Benutzerverwaltung</h6>
<div class="card card-outline card-info">
<div class="card-body p-0 table-responsive">
<table class="table table-hover table-sm table-striped mb-0 align-middle">
<thead class="table-dark">
<tr>
<th>Name</th>
<th>E-Mail</th>
<th class="text-center">Rolle</th>
<th class="text-center">Verifiziert</th>
<th class="text-end">Registriert</th>
<th class="text-end">Aktionen</th>
</tr>
</thead>
<tbody id="usersTable">
<tr><td colspan="6" class="text-center text-body-secondary py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/admin.js"></script>
</body>
</html>

View file

@ -23,6 +23,15 @@
<span class="status-dot" id="statusDot"></span>
<small class="text-body-secondary" id="statusText">Verbinde...</small>
</span>
<span id="userMenu" class="d-none">
<small class="text-body-secondary me-1"><i class="bi bi-person-fill me-1"></i><span id="userName"></span></small>
<a href="/auth/logout" class="btn btn-sm btn-outline-secondary py-0 px-1" title="Abmelden">
<i class="bi bi-box-arrow-right" style="font-size:.75rem"></i>
</a>
</span>
<a href="/login" id="loginBtn" class="btn btn-sm btn-outline-info py-0 px-1 d-none">
<i class="bi bi-person" style="font-size:.75rem"></i> Login
</a>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
@ -35,15 +44,18 @@
<a href="/" class="sidebar-link active">
<i class="bi bi-speedometer2"></i><span>Dashboard</span>
</a>
<a href="/scheduler" class="sidebar-link">
<a href="/scheduler" class="sidebar-link sidebar-admin">
<i class="bi bi-clock-history"></i><span>Scheduler</span>
</a>
<a href="/map" class="sidebar-link">
<i class="bi bi-map"></i><span>Karte</span>
</a>
<a href="/settings" class="sidebar-link">
<a href="/settings" class="sidebar-link sidebar-admin">
<i class="bi bi-gear"></i><span>Einstellungen</span>
</a>
<a href="/admin" class="sidebar-link sidebar-admin">
<i class="bi bi-people"></i><span>Benutzer</span>
</a>
</nav>
</aside>
@ -97,8 +109,8 @@
</div>
</div>
<!-- Send Message -->
<div class="card card-outline card-success mb-2">
<!-- Send Message (auth-gated) -->
<div class="card card-outline card-success mb-2 d-none" id="sendCard">
<div class="card-body py-2 px-2">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-send-fill"></i></span>
@ -136,8 +148,8 @@
</div>
</div>
<!-- Messages -->
<div class="col-lg-5">
<!-- Messages (auth-gated) -->
<div class="col-lg-5 d-none" id="messagesCard">
<div class="card card-outline card-warning">
<div class="card-header">
<i class="bi bi-chat-dots me-1"></i>Nachrichten

151
static/js/admin.js Normal file
View file

@ -0,0 +1,151 @@
let currentUser = null;
let users = [];
// Auth check
fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => {
currentUser = u;
updateNavbar();
updateSidebar();
if (!u || u.role !== 'admin') {
document.getElementById('usersTable').innerHTML =
'<tr><td colspan="6" class="text-center text-danger py-3">Zugriff verweigert</td></tr>';
return;
}
loadUsers();
});
function updateNavbar() {
if (currentUser) {
document.getElementById('userName').textContent = currentUser.name;
document.getElementById('userMenu').classList.remove('d-none');
document.getElementById('loginBtn').classList.add('d-none');
} else {
document.getElementById('userMenu').classList.add('d-none');
document.getElementById('loginBtn').classList.remove('d-none');
}
}
function updateSidebar() {
const isAdmin = currentUser && currentUser.role === 'admin';
document.querySelectorAll('.sidebar-admin').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
}
async function loadUsers() {
try {
const resp = await fetch('/api/admin/users');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
users = await resp.json();
renderUsers();
} catch (e) {
document.getElementById('usersTable').innerHTML =
'<tr><td colspan="6" class="text-center text-danger py-3">Fehler beim Laden</td></tr>';
}
}
function renderUsers() {
const tbody = document.getElementById('usersTable');
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-body-secondary py-3">Keine Benutzer</td></tr>';
return;
}
tbody.innerHTML = users.map(user => {
const roleBadge = user.role === 'admin'
? '<span class="badge bg-danger">Admin</span>'
: '<span class="badge bg-secondary">User</span>';
const verifiedIcon = user.is_verified
? '<i class="bi bi-check-circle-fill text-success"></i>'
: '<i class="bi bi-x-circle text-danger"></i>';
const created = user.created_at ? new Date(user.created_at * 1000).toLocaleDateString('de-DE') : '-';
const isSelf = currentUser && currentUser.id === user.id;
let actions = '';
if (!isSelf) {
const newRole = user.role === 'admin' ? 'user' : 'admin';
const roleLabel = user.role === 'admin' ? 'User' : 'Admin';
actions += `<button class="btn btn-outline-warning btn-sm py-0 px-1 me-1" onclick="changeRole(${user.id},'${newRole}')" title="Zu ${roleLabel} machen">
<i class="bi bi-arrow-repeat"></i>
</button>`;
if (!user.is_verified) {
actions += `<button class="btn btn-outline-success btn-sm py-0 px-1 me-1" onclick="verifyUser(${user.id})" title="Verifizieren">
<i class="bi bi-check-lg"></i>
</button>`;
}
actions += `<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteUser(${user.id},'${escapeHtml(user.name)}')" title="Loeschen">
<i class="bi bi-trash"></i>
</button>`;
} else {
actions = '<small class="text-body-secondary">Du</small>';
}
return `<tr>
<td class="fw-semibold">${escapeHtml(user.name)}</td>
<td>${escapeHtml(user.email)}</td>
<td class="text-center">${roleBadge}</td>
<td class="text-center">${verifiedIcon}</td>
<td class="text-end text-body-secondary">${created}</td>
<td class="text-end">${actions}</td>
</tr>`;
}).join('');
}
async function changeRole(id, role) {
try {
const resp = await fetch(`/api/admin/users/${id}/role`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role })
});
if (resp.ok) { users = await resp.json(); renderUsers(); }
} catch (e) { console.error('Role change failed:', e); }
}
async function verifyUser(id) {
try {
const resp = await fetch(`/api/admin/users/${id}/verify`, { method: 'POST' });
if (resp.ok) { users = await resp.json(); renderUsers(); }
} catch (e) { console.error('Verify failed:', e); }
}
async function deleteUser(id, name) {
if (!confirm(`Benutzer "${name}" wirklich loeschen?`)) return;
try {
const resp = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
if (resp.ok) { users = await resp.json(); renderUsers(); }
} catch (e) { console.error('Delete failed:', e); }
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
const themeIcon = document.getElementById('themeIcon');
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
themeIcon.className = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill';
localStorage.setItem('theme', theme);
}
applyTheme(localStorage.getItem('theme') || 'dark');
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-bs-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
});
// Sidebar toggle (mobile)
const sidebarToggle = document.getElementById('sidebarToggle');
const sidebar = document.getElementById('sidebar');
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
if (sidebarToggle) {
sidebarToggle.addEventListener('click', () => sidebar.classList.toggle('open'));
sidebarBackdrop.addEventListener('click', () => sidebar.classList.remove('open'));
}

View file

@ -4,11 +4,48 @@ const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const nodeCountBadge = document.getElementById('nodeCountBadge');
let currentUser = null;
let nodes = {};
let channels = {};
let myNodeId = null;
let ws;
// Auth check
fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => {
currentUser = u;
updateNavbar();
updateSidebar();
updateVisibility();
});
function updateNavbar() {
if (currentUser) {
document.getElementById('userName').textContent = currentUser.name;
document.getElementById('userMenu').classList.remove('d-none');
document.getElementById('loginBtn').classList.add('d-none');
} else {
document.getElementById('userMenu').classList.add('d-none');
document.getElementById('loginBtn').classList.remove('d-none');
}
}
function updateSidebar() {
const isAdmin = currentUser && currentUser.role === 'admin';
document.querySelectorAll('.sidebar-admin').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
}
function updateVisibility() {
const loggedIn = !!currentUser;
const sendCard = document.getElementById('sendCard');
const messagesCard = document.getElementById('messagesCard');
if (loggedIn) {
sendCard.classList.remove('d-none');
messagesCard.classList.remove('d-none');
}
}
function connectWebSocket() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);

158
static/js/login.js Normal file
View file

@ -0,0 +1,158 @@
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
const themeIcon = document.getElementById('themeIcon');
function applyTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
themeIcon.className = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill';
localStorage.setItem('theme', theme);
}
applyTheme(localStorage.getItem('theme') || 'dark');
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-bs-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
});
// View switching
const views = {
login: document.getElementById('loginView'),
register: document.getElementById('registerView'),
forgot: document.getElementById('forgotView'),
setPassword: document.getElementById('setPasswordView'),
};
function showView(name) {
Object.values(views).forEach(v => v.classList.add('d-none'));
views[name].classList.remove('d-none');
}
document.getElementById('showRegister').addEventListener('click', (e) => { e.preventDefault(); showView('register'); });
document.getElementById('showLoginFromReg').addEventListener('click', (e) => { e.preventDefault(); showView('login'); });
document.getElementById('showForgot').addEventListener('click', (e) => { e.preventDefault(); showView('forgot'); });
document.getElementById('showLoginFromForgot').addEventListener('click', (e) => { e.preventDefault(); showView('login'); });
// Check URL for token (verify or reset-password)
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const isVerify = window.location.pathname === '/auth/verify';
const isReset = window.location.pathname === '/auth/reset-password';
if (token && (isVerify || isReset)) {
showView('setPassword');
document.getElementById('setPasswordTitle').textContent = isVerify ? 'Passwort setzen' : 'Passwort zuruecksetzen';
}
// Show register view if on /register
if (window.location.pathname === '/register') {
showView('register');
}
function showAlert(id, message, type) {
const el = document.getElementById(id);
el.className = `alert alert-${type} py-1 small`;
el.textContent = message;
}
// Login
document.getElementById('btnLogin').addEventListener('click', async () => {
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value;
if (!email || !password) return;
try {
const resp = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await resp.json();
if (resp.ok) {
window.location.href = '/';
} else {
showAlert('loginAlert', data.error || 'Anmeldung fehlgeschlagen', 'danger');
}
} catch (e) {
showAlert('loginAlert', 'Verbindungsfehler', 'danger');
}
});
document.getElementById('loginPassword').addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById('btnLogin').click();
});
// Register
document.getElementById('btnRegister').addEventListener('click', async () => {
const name = document.getElementById('registerName').value.trim();
const email = document.getElementById('registerEmail').value.trim();
if (!name || !email) return;
try {
const resp = await fetch('/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email })
});
const data = await resp.json();
if (resp.ok) {
showAlert('registerAlert', data.message || 'Registrierung erfolgreich', 'success');
} else {
showAlert('registerAlert', data.error || 'Registrierung fehlgeschlagen', 'danger');
}
} catch (e) {
showAlert('registerAlert', 'Verbindungsfehler', 'danger');
}
});
// Forgot password
document.getElementById('btnForgot').addEventListener('click', async () => {
const email = document.getElementById('forgotEmail').value.trim();
if (!email) return;
try {
const resp = await fetch('/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const data = await resp.json();
showAlert('forgotAlert', data.message || 'Link gesendet', 'success');
} catch (e) {
showAlert('forgotAlert', 'Verbindungsfehler', 'danger');
}
});
// Set password (verify + reset)
document.getElementById('btnSetPassword').addEventListener('click', async () => {
const password = document.getElementById('newPassword').value;
const confirm = document.getElementById('confirmPassword').value;
if (password.length < 8) {
showAlert('setPasswordAlert', 'Passwort muss mindestens 8 Zeichen lang sein', 'danger');
return;
}
if (password !== confirm) {
showAlert('setPasswordAlert', 'Passwoerter stimmen nicht ueberein', 'danger');
return;
}
const endpoint = isReset ? '/auth/reset-password' : '/auth/set-password';
try {
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password })
});
const data = await resp.json();
if (resp.ok) {
showAlert('setPasswordAlert', data.message || 'Passwort gespeichert', 'success');
setTimeout(() => { window.location.href = '/login'; }, 2000);
} else {
showAlert('setPasswordAlert', data.error || 'Fehler', 'danger');
}
} catch (e) {
showAlert('setPasswordAlert', 'Verbindungsfehler', 'danger');
}
});

View file

@ -3,8 +3,34 @@ const nodeData = {};
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const nodeCount = document.getElementById('nodeCount');
let currentUser = null;
let ws;
// Auth check
fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => {
currentUser = u;
updateNavbar();
updateSidebar();
});
function updateNavbar() {
if (currentUser) {
document.getElementById('userName').textContent = currentUser.name;
document.getElementById('userMenu').classList.remove('d-none');
document.getElementById('loginBtn').classList.add('d-none');
} else {
document.getElementById('userMenu').classList.add('d-none');
document.getElementById('loginBtn').classList.remove('d-none');
}
}
function updateSidebar() {
const isAdmin = currentUser && currentUser.role === 'admin';
document.querySelectorAll('.sidebar-admin').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
}
const hopColors = {
0: '#2196F3',
1: '#4CAF50',

View file

@ -1,8 +1,34 @@
const jobsTable = document.getElementById('jobsTable');
const jobModal = new bootstrap.Modal(document.getElementById('jobModal'));
let currentUser = null;
let jobs = [];
let editMode = false;
// Auth check
fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => {
currentUser = u;
updateNavbar();
updateSidebar();
});
function updateNavbar() {
if (currentUser) {
document.getElementById('userName').textContent = currentUser.name;
document.getElementById('userMenu').classList.remove('d-none');
document.getElementById('loginBtn').classList.add('d-none');
} else {
document.getElementById('userMenu').classList.add('d-none');
document.getElementById('loginBtn').classList.remove('d-none');
}
}
function updateSidebar() {
const isAdmin = currentUser && currentUser.role === 'admin';
document.querySelectorAll('.sidebar-admin').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
}
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
const themeIcon = document.getElementById('themeIcon');

View file

@ -1,3 +1,30 @@
let currentUser = null;
// Auth check
fetch('/api/auth/me').then(r => r.ok ? r.json() : null).then(u => {
currentUser = u;
updateNavbar();
updateSidebar();
});
function updateNavbar() {
if (currentUser) {
document.getElementById('userName').textContent = currentUser.name;
document.getElementById('userMenu').classList.remove('d-none');
document.getElementById('loginBtn').classList.add('d-none');
} else {
document.getElementById('userMenu').classList.add('d-none');
document.getElementById('loginBtn').classList.remove('d-none');
}
}
function updateSidebar() {
const isAdmin = currentUser && currentUser.role === 'admin';
document.querySelectorAll('.sidebar-admin').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
}
// Labels for display
const deviceLabels = {
long_name: "Name",

123
static/login.html Normal file
View file

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshDD-Bot Login</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<!-- Top Navbar -->
<nav class="top-navbar d-flex align-items-center px-3">
<span class="fw-bold me-auto">
<i class="bi bi-broadcast-pin text-info me-1"></i>MeshDD-Bot
</span>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
</nav>
<!-- Content (centered, no sidebar) -->
<main style="margin-top:46px;min-height:calc(100vh - 46px);display:flex;align-items:center;justify-content:center;background:rgba(var(--bs-emphasis-color-rgb),0.03)">
<div style="width:100%;max-width:400px;padding:1rem">
<!-- Login Form -->
<div id="loginView">
<div class="card card-outline card-info">
<div class="card-header text-center">
<i class="bi bi-person-lock me-1"></i>Anmelden
</div>
<div class="card-body">
<div id="loginAlert" class="alert alert-danger py-1 small d-none"></div>
<div class="mb-3">
<label for="loginEmail" class="form-label small">E-Mail</label>
<input type="email" class="form-control form-control-sm" id="loginEmail" required>
</div>
<div class="mb-3">
<label for="loginPassword" class="form-label small">Passwort</label>
<input type="password" class="form-control form-control-sm" id="loginPassword" required>
</div>
<button class="btn btn-info btn-sm w-100" id="btnLogin">Anmelden</button>
<div class="mt-2 text-center small">
<a href="#" id="showRegister" class="text-decoration-none">Registrieren</a>
<span class="text-body-secondary mx-1">|</span>
<a href="#" id="showForgot" class="text-decoration-none">Passwort vergessen</a>
</div>
</div>
</div>
</div>
<!-- Register Form -->
<div id="registerView" class="d-none">
<div class="card card-outline card-success">
<div class="card-header text-center">
<i class="bi bi-person-plus me-1"></i>Registrieren
</div>
<div class="card-body">
<div id="registerAlert" class="alert py-1 small d-none"></div>
<div class="mb-3">
<label for="registerName" class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" id="registerName" required>
</div>
<div class="mb-3">
<label for="registerEmail" class="form-label small">E-Mail</label>
<input type="email" class="form-control form-control-sm" id="registerEmail" required>
</div>
<button class="btn btn-success btn-sm w-100" id="btnRegister">Registrieren</button>
<div class="mt-2 text-center small">
<a href="#" id="showLoginFromReg" class="text-decoration-none">Zurueck zum Login</a>
</div>
</div>
</div>
</div>
<!-- Forgot Password Form -->
<div id="forgotView" class="d-none">
<div class="card card-outline card-warning">
<div class="card-header text-center">
<i class="bi bi-key me-1"></i>Passwort vergessen
</div>
<div class="card-body">
<div id="forgotAlert" class="alert py-1 small d-none"></div>
<div class="mb-3">
<label for="forgotEmail" class="form-label small">E-Mail</label>
<input type="email" class="form-control form-control-sm" id="forgotEmail" required>
</div>
<button class="btn btn-warning btn-sm w-100" id="btnForgot">Link senden</button>
<div class="mt-2 text-center small">
<a href="#" id="showLoginFromForgot" class="text-decoration-none">Zurueck zum Login</a>
</div>
</div>
</div>
</div>
<!-- Set Password Form (verify + reset) -->
<div id="setPasswordView" class="d-none">
<div class="card card-outline card-primary">
<div class="card-header text-center">
<i class="bi bi-shield-lock me-1"></i><span id="setPasswordTitle">Passwort setzen</span>
</div>
<div class="card-body">
<div id="setPasswordAlert" class="alert py-1 small d-none"></div>
<div class="mb-3">
<label for="newPassword" class="form-label small">Neues Passwort (min. 8 Zeichen)</label>
<input type="password" class="form-control form-control-sm" id="newPassword" minlength="8" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label small">Passwort wiederholen</label>
<input type="password" class="form-control form-control-sm" id="confirmPassword" minlength="8" required>
</div>
<button class="btn btn-primary btn-sm w-100" id="btnSetPassword">Passwort speichern</button>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/login.js"></script>
</body>
</html>

View file

@ -24,6 +24,15 @@
<small class="text-body-secondary" id="statusText">Verbinde...</small>
</span>
<small class="text-body-secondary" id="nodeCount">0 Nodes</small>
<span id="userMenu" class="d-none">
<small class="text-body-secondary me-1"><i class="bi bi-person-fill me-1"></i><span id="userName"></span></small>
<a href="/auth/logout" class="btn btn-sm btn-outline-secondary py-0 px-1" title="Abmelden">
<i class="bi bi-box-arrow-right" style="font-size:.75rem"></i>
</a>
</span>
<a href="/login" id="loginBtn" class="btn btn-sm btn-outline-info py-0 px-1 d-none">
<i class="bi bi-person" style="font-size:.75rem"></i> Login
</a>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
@ -36,15 +45,18 @@
<a href="/" class="sidebar-link">
<i class="bi bi-speedometer2"></i><span>Dashboard</span>
</a>
<a href="/scheduler" class="sidebar-link">
<a href="/scheduler" class="sidebar-link sidebar-admin">
<i class="bi bi-clock-history"></i><span>Scheduler</span>
</a>
<a href="/map" class="sidebar-link active">
<i class="bi bi-map"></i><span>Karte</span>
</a>
<a href="/settings" class="sidebar-link">
<a href="/settings" class="sidebar-link sidebar-admin">
<i class="bi bi-gear"></i><span>Einstellungen</span>
</a>
<a href="/admin" class="sidebar-link sidebar-admin">
<i class="bi bi-people"></i><span>Benutzer</span>
</a>
</nav>
</aside>

View file

@ -17,9 +17,20 @@
<span class="fw-bold me-auto">
<i class="bi bi-broadcast-pin text-info me-1"></i>MeshDD-Bot
</span>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
<div class="d-flex align-items-center gap-2">
<span id="userMenu" class="d-none">
<small class="text-body-secondary me-1"><i class="bi bi-person-fill me-1"></i><span id="userName"></span></small>
<a href="/auth/logout" class="btn btn-sm btn-outline-secondary py-0 px-1" title="Abmelden">
<i class="bi bi-box-arrow-right" style="font-size:.75rem"></i>
</a>
</span>
<a href="/login" id="loginBtn" class="btn btn-sm btn-outline-info py-0 px-1 d-none">
<i class="bi bi-person" style="font-size:.75rem"></i> Login
</a>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
</div>
</nav>
<!-- Sidebar -->
@ -28,15 +39,18 @@
<a href="/" class="sidebar-link">
<i class="bi bi-speedometer2"></i><span>Dashboard</span>
</a>
<a href="/scheduler" class="sidebar-link active">
<a href="/scheduler" class="sidebar-link active sidebar-admin">
<i class="bi bi-clock-history"></i><span>Scheduler</span>
</a>
<a href="/map" class="sidebar-link">
<i class="bi bi-map"></i><span>Karte</span>
</a>
<a href="/settings" class="sidebar-link">
<a href="/settings" class="sidebar-link sidebar-admin">
<i class="bi bi-gear"></i><span>Einstellungen</span>
</a>
<a href="/admin" class="sidebar-link sidebar-admin">
<i class="bi bi-people"></i><span>Benutzer</span>
</a>
</nav>
</aside>

View file

@ -17,9 +17,20 @@
<span class="fw-bold me-auto">
<i class="bi bi-broadcast-pin text-info me-1"></i>MeshDD-Bot
</span>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
<div class="d-flex align-items-center gap-2">
<span id="userMenu" class="d-none">
<small class="text-body-secondary me-1"><i class="bi bi-person-fill me-1"></i><span id="userName"></span></small>
<a href="/auth/logout" class="btn btn-sm btn-outline-secondary py-0 px-1" title="Abmelden">
<i class="bi bi-box-arrow-right" style="font-size:.75rem"></i>
</a>
</span>
<a href="/login" id="loginBtn" class="btn btn-sm btn-outline-info py-0 px-1 d-none">
<i class="bi bi-person" style="font-size:.75rem"></i> Login
</a>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
</div>
</nav>
<!-- Sidebar -->
@ -28,15 +39,18 @@
<a href="/" class="sidebar-link">
<i class="bi bi-speedometer2"></i><span>Dashboard</span>
</a>
<a href="/scheduler" class="sidebar-link">
<a href="/scheduler" class="sidebar-link sidebar-admin">
<i class="bi bi-clock-history"></i><span>Scheduler</span>
</a>
<a href="/map" class="sidebar-link">
<i class="bi bi-map"></i><span>Karte</span>
</a>
<a href="/settings" class="sidebar-link active">
<a href="/settings" class="sidebar-link active sidebar-admin">
<i class="bi bi-gear"></i><span>Einstellungen</span>
</a>
<a href="/admin" class="sidebar-link sidebar-admin">
<i class="bi bi-people"></i><span>Benutzer</span>
</a>
</nav>
</aside>