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:
parent
8f79c197c7
commit
0d6b26f4f8
|
|
@ -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
|
||||
|
|
|
|||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
14
config.yaml
14
config.yaml
|
|
@ -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
380
meshbot/auth.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
87
static/admin.html
Normal 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>
|
||||
|
|
@ -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
151
static/js/admin.js
Normal 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'));
|
||||
}
|
||||
|
|
@ -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
158
static/js/login.js
Normal 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');
|
||||
}
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
123
static/login.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue