first commit
This commit is contained in:
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Makes backend a package.
|
||||
165
backend/actions.py
Normal file
165
backend/actions.py
Normal file
@ -0,0 +1,165 @@
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from backend.settings import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _run(cmd: List[str], env: Optional[dict] = None) -> subprocess.CompletedProcess:
|
||||
"""Run a command respecting dry-run mode."""
|
||||
settings = get_settings()
|
||||
if settings.dry_run:
|
||||
logger.info("[dry-run] %s", " ".join(cmd))
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="[dry-run]", stderr="")
|
||||
return subprocess.run(cmd, check=True, capture_output=True, text=True, env=env)
|
||||
|
||||
|
||||
def _user_exists(user: str) -> bool:
|
||||
try:
|
||||
return _run(["id", user]).returncode == 0
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
def _get_uid(user: str) -> Optional[str]:
|
||||
try:
|
||||
result = _run(["id", "-u", user])
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_logged_in(user: str) -> bool:
|
||||
try:
|
||||
result = _run(["who"])
|
||||
for line in result.stdout.splitlines():
|
||||
if line.strip() == "[dry-run]":
|
||||
continue
|
||||
if line.startswith(f"{user} "):
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
logger.warning("Could not determine login status for %s", user)
|
||||
return False
|
||||
|
||||
|
||||
def _play_sound_if_available() -> Optional[str]:
|
||||
settings = get_settings()
|
||||
if not settings.sound_player:
|
||||
return "sound player not configured"
|
||||
if not shutil.which(settings.sound_player):
|
||||
return f"{settings.sound_player} not found"
|
||||
try:
|
||||
_run([settings.sound_player, settings.sound_file])
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.warning("Sound failed: %s", exc)
|
||||
return "sound failed"
|
||||
return None
|
||||
|
||||
|
||||
def _notify_user(user: str, message: str) -> Optional[str]:
|
||||
settings = get_settings()
|
||||
notify = shutil.which(settings.notify_send_path)
|
||||
if not notify:
|
||||
return "notify-send not available"
|
||||
uid = _get_uid(user)
|
||||
if not uid:
|
||||
return "could not resolve uid for notification"
|
||||
|
||||
env = {
|
||||
"DISPLAY": ":0",
|
||||
"DBUS_SESSION_BUS_ADDRESS": f"unix:path=/run/user/{uid}/bus",
|
||||
}
|
||||
cmd = [
|
||||
"sudo",
|
||||
"-u",
|
||||
user,
|
||||
notify,
|
||||
"Safe Kiddo",
|
||||
message,
|
||||
"-t",
|
||||
str(settings.notify_timeout * 1000),
|
||||
]
|
||||
try:
|
||||
_run(cmd, env=env)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.warning("Notification failed: %s", exc)
|
||||
return "notification failed"
|
||||
return None
|
||||
|
||||
|
||||
def disable_user(
|
||||
user: str,
|
||||
countdown: Optional[int] = None,
|
||||
sound: Optional[bool] = None,
|
||||
message: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
settings = get_settings()
|
||||
if not _user_exists(user):
|
||||
raise ActionError(f"user '{user}' not found")
|
||||
|
||||
countdown_seconds = settings.default_countdown if countdown is None else countdown
|
||||
play_sound = settings.default_sound if sound is None else sound
|
||||
steps: List[str] = []
|
||||
|
||||
_run(["sudo", "usermod", "-L", user])
|
||||
steps.append("account locked")
|
||||
|
||||
logged_in = _is_logged_in(user)
|
||||
if logged_in:
|
||||
steps.append("user is logged in")
|
||||
info_message = message or f"Shutdown in {countdown_seconds} seconds. Save your work."
|
||||
|
||||
notify_result = _notify_user(user, info_message)
|
||||
if notify_result:
|
||||
steps.append(notify_result)
|
||||
if play_sound:
|
||||
sound_result = _play_sound_if_available()
|
||||
if sound_result:
|
||||
steps.append(sound_result)
|
||||
|
||||
if countdown_seconds > 0 and not settings.dry_run:
|
||||
for remaining in range(countdown_seconds, 0, -1):
|
||||
time.sleep(1)
|
||||
if remaining % 10 == 0 or remaining <= 5:
|
||||
_notify_user(user, f"Shutdown in {remaining} seconds.")
|
||||
if play_sound:
|
||||
_play_sound_if_available()
|
||||
|
||||
_run(["sudo", "pkill", "-KILL", "-u", user])
|
||||
steps.append("sessions terminated")
|
||||
|
||||
if logged_in:
|
||||
_run(["sudo", "shutdown", "now"])
|
||||
steps.append("system shutdown triggered")
|
||||
else:
|
||||
steps.append("no active session; shutdown skipped")
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
def enable_user(user: str) -> List[str]:
|
||||
if not _user_exists(user):
|
||||
raise ActionError(f"user '{user}' not found")
|
||||
_run(["sudo", "usermod", "-U", user])
|
||||
return ["account unlocked"]
|
||||
|
||||
|
||||
def list_logged_in_users() -> List[str]:
|
||||
try:
|
||||
result = _run(["who"])
|
||||
users: List[str] = []
|
||||
for line in result.stdout.splitlines():
|
||||
if line.strip() in ("", "[dry-run]"):
|
||||
continue
|
||||
users.append(line.split()[0])
|
||||
return users
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
112
backend/app.py
Normal file
112
backend/app.py
Normal file
@ -0,0 +1,112 @@
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from backend import actions
|
||||
from backend.actions import ActionError
|
||||
from backend.auth import authenticate_admin_user, get_current_admin, issue_token, list_manageable_users
|
||||
from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
|
||||
from backend.settings import Settings, get_settings
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("skd")
|
||||
|
||||
app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
|
||||
templates = Jinja2Templates(directory="backend/templates")
|
||||
|
||||
|
||||
def validate_user(username: str, settings: Settings = Depends(get_settings)) -> str:
|
||||
allowed = set(list_manageable_users(settings))
|
||||
if username not in allowed:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not allowed")
|
||||
return username
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health(settings: Settings = Depends(get_settings)) -> dict:
|
||||
return {"status": "ok", "dry_run": settings.dry_run}
|
||||
|
||||
|
||||
@app.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, settings: Settings = Depends(get_settings)) -> LoginResponse:
|
||||
authenticate_admin_user(payload.username, payload.password, settings)
|
||||
token = issue_token(payload.username, settings)
|
||||
return LoginResponse(token=token, expires_in=settings.token_ttl_seconds)
|
||||
|
||||
|
||||
@app.get("/users", response_model=List[UserStatus], dependencies=[Depends(get_current_admin)])
|
||||
def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]:
|
||||
logged_in = set(actions.list_logged_in_users())
|
||||
targets = list_manageable_users(settings)
|
||||
return [UserStatus(user=user, logged_in=user in logged_in) for user in targets]
|
||||
|
||||
|
||||
@app.post(
|
||||
"/users/{username}/disable",
|
||||
response_model=ActionResponse,
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
def disable_user(
|
||||
username: str = Depends(validate_user),
|
||||
payload: ActionRequest | None = Body(default=None),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> ActionResponse:
|
||||
try:
|
||||
steps = actions.disable_user(
|
||||
username,
|
||||
countdown=payload.countdown if payload else None,
|
||||
sound=payload.sound if payload else None,
|
||||
message=payload.message if payload else None,
|
||||
)
|
||||
except ActionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except Exception as exc: # pragma: no cover - safeguard
|
||||
logger.exception("Failed to disable %s", username)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed") from exc
|
||||
|
||||
logged_in = username in actions.list_logged_in_users()
|
||||
return ActionResponse(
|
||||
user=username,
|
||||
action="disable",
|
||||
dry_run=settings.dry_run,
|
||||
steps=steps,
|
||||
logged_in=logged_in,
|
||||
)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/users/{username}/enable",
|
||||
response_model=ActionResponse,
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
def enable_user(
|
||||
username: str = Depends(validate_user),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> ActionResponse:
|
||||
try:
|
||||
steps = actions.enable_user(username)
|
||||
except ActionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except Exception as exc: # pragma: no cover - safeguard
|
||||
logger.exception("Failed to enable %s", username)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed") from exc
|
||||
|
||||
logged_in = username in actions.list_logged_in_users()
|
||||
return ActionResponse(
|
||||
user=username,
|
||||
action="enable",
|
||||
dry_run=settings.dry_run,
|
||||
steps=steps,
|
||||
logged_in=logged_in,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
107
backend/auth.py
Normal file
107
backend/auth.py
Normal file
@ -0,0 +1,107 @@
|
||||
import datetime as dt
|
||||
import grp
|
||||
import pwd
|
||||
from typing import List, Set
|
||||
|
||||
import jwt
|
||||
import pam
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
|
||||
from backend.settings import Settings, get_settings
|
||||
|
||||
|
||||
def _is_member_of(username: str, groups: Set[str]) -> bool:
|
||||
if not groups:
|
||||
return False
|
||||
try:
|
||||
user_entry = pwd.getpwnam(username)
|
||||
except KeyError:
|
||||
return False
|
||||
user_groups = {g.gr_name for g in grp.getgrall() if username in g.gr_mem}
|
||||
primary_group = grp.getgrgid(user_entry.pw_gid).gr_name
|
||||
user_groups.add(primary_group)
|
||||
return bool(user_groups & groups)
|
||||
|
||||
|
||||
def is_authorized_admin(username: str, settings: Settings) -> bool:
|
||||
# UID 0 always allowed
|
||||
try:
|
||||
entry = pwd.getpwnam(username)
|
||||
except KeyError:
|
||||
return False
|
||||
if entry.pw_uid == 0:
|
||||
return True
|
||||
allowed_users = set(settings.auth_allowed_users)
|
||||
allowed_groups = set(settings.auth_allowed_groups)
|
||||
if username in allowed_users:
|
||||
return True
|
||||
if _is_member_of(username, allowed_groups):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def authenticate_admin_user(username: str, password: str, settings: Settings) -> None:
|
||||
if not is_authorized_admin(username, settings):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User not authorized to log in",
|
||||
)
|
||||
pam_client = pam.pam()
|
||||
if not pam_client.authenticate(username, password, service=settings.auth_pam_service):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
|
||||
def issue_token(username: str, settings: Settings) -> str:
|
||||
payload = {
|
||||
"sub": username,
|
||||
"exp": dt.datetime.utcnow() + dt.timedelta(seconds=settings.token_ttl_seconds),
|
||||
"iat": dt.datetime.utcnow(),
|
||||
}
|
||||
return jwt.encode(payload, settings.auth_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str, settings: Settings) -> str:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.auth_secret, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||
|
||||
username = payload.get("sub")
|
||||
if not username or not is_authorized_admin(username, settings):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Unauthorized user")
|
||||
return username
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
request: Request,
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> str:
|
||||
auth_header = request.headers.get("authorization")
|
||||
if not auth_header or not auth_header.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
return decode_token(token, settings)
|
||||
|
||||
|
||||
def list_manageable_users(settings: Settings) -> List[str]:
|
||||
"""List system users we are allowed to manage (uid >= 1000, real shells, optional allowlist)."""
|
||||
allowed = set(settings.allowed_users) if settings.allowed_users else None
|
||||
candidates: List[str] = []
|
||||
for entry in pwd.getpwall():
|
||||
if entry.pw_uid == 0:
|
||||
# Never operate on root accounts
|
||||
continue
|
||||
if entry.pw_uid < 1000:
|
||||
continue
|
||||
if entry.pw_shell in ("/usr/sbin/nologin", "/bin/false"):
|
||||
continue
|
||||
if allowed is not None and entry.pw_name not in allowed:
|
||||
continue
|
||||
candidates.append(entry.pw_name)
|
||||
candidates.sort()
|
||||
return candidates
|
||||
35
backend/models.py
Normal file
35
backend/models.py
Normal file
@ -0,0 +1,35 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ActionRequest(BaseModel):
|
||||
countdown: Optional[int] = Field(default=None, ge=0, description="Seconds for countdown")
|
||||
sound: Optional[bool] = Field(default=None, description="Play sound alongside notification")
|
||||
message: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Optional message to show the user; falls back to default text.",
|
||||
)
|
||||
|
||||
|
||||
class ActionResponse(BaseModel):
|
||||
user: str
|
||||
action: str
|
||||
dry_run: bool
|
||||
steps: List[str]
|
||||
logged_in: bool
|
||||
|
||||
|
||||
class UserStatus(BaseModel):
|
||||
user: str
|
||||
logged_in: bool
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
expires_in: int
|
||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
pydantic==2.7.3
|
||||
jinja2==3.1.4
|
||||
PyJWT==2.9.0
|
||||
python-pam==2.0.2
|
||||
six==1.16.0
|
||||
37
backend/settings.py
Normal file
37
backend/settings.py
Normal file
@ -0,0 +1,37 @@
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import List
|
||||
|
||||
|
||||
class Settings:
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.allowed_users: List[str] = self._parse_list(os.getenv("SKD_ALLOWED_USERS", ""))
|
||||
self.auth_secret: str = os.getenv("SKD_AUTH_SECRET", "change-me-secret")
|
||||
self.token_ttl_seconds: int = int(os.getenv("SKD_TOKEN_TTL_SECONDS", "900"))
|
||||
self.auth_allowed_users: List[str] = self._parse_list(os.getenv("SKD_AUTH_ALLOWED_USERS", ""))
|
||||
self.auth_allowed_groups: List[str] = self._parse_list(
|
||||
os.getenv("SKD_AUTH_ALLOWED_GROUPS", "sudo")
|
||||
)
|
||||
self.auth_pam_service: str = os.getenv("SKD_AUTH_PAM_SERVICE", "login")
|
||||
self.default_countdown: int = int(os.getenv("SKD_DEFAULT_COUNTDOWN", "60"))
|
||||
self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true"
|
||||
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
|
||||
self.dry_run: bool = os.getenv("SKD_DRY_RUN", "false").lower() == "true"
|
||||
# Paths/tools
|
||||
self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send")
|
||||
self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay")
|
||||
self.sound_file: str = os.getenv(
|
||||
"SKD_SOUND_FILE",
|
||||
"/usr/share/sounds/freedesktop/stereo/dialog-warning.oga",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_list(value: str) -> List[str]:
|
||||
return [item for item in (part.strip() for part in value.split(",")) if item]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
181
backend/templates/index.html
Normal file
181
backend/templates/index.html
Normal file
@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Safe Kiddo Control</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
|
||||
<style>
|
||||
body { max-width: 960px; margin: auto; padding: 1.5rem; }
|
||||
.log { white-space: pre-line; }
|
||||
form { margin-bottom: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Safe Kiddo Control</h1>
|
||||
<p>Steuere Nutzerkonten über die lokale API. Stelle sicher, dass der API-Token gesetzt ist.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h3>Login (nur Root-User)</h3>
|
||||
<form id="loginForm">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="loginUser">Benutzer</label>
|
||||
<input id="loginUser" name="loginUser" autocomplete="username" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="loginPass">Passwort</label>
|
||||
<input id="loginPass" name="loginPass" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
<div id="loginStatus" class="log"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Status abrufen</h3>
|
||||
<button id="refreshBtn">Status laden</button>
|
||||
<div id="status" class="log"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Aktion ausführen</h3>
|
||||
<form id="actionForm">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="username">Benutzer</label>
|
||||
<select id="username" name="username" required>
|
||||
<option value="">-- wählen --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="action">Aktion</label>
|
||||
<select id="action" name="action">
|
||||
<option value="disable">Disable</option>
|
||||
<option value="enable">Enable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="countdown">Countdown (Sekunden, optional)</label>
|
||||
<input id="countdown" name="countdown" type="number" min="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="sound">Sound</label>
|
||||
<select id="sound" name="sound">
|
||||
<option value="">Default</option>
|
||||
<option value="true">An</option>
|
||||
<option value="false">Aus</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label for="message">Nachricht (optional)</label>
|
||||
<input id="message" name="message" />
|
||||
<button type="submit">Senden</button>
|
||||
</form>
|
||||
<div id="result" class="log"></div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const statusDiv = document.getElementById('status');
|
||||
const resultDiv = document.getElementById('result');
|
||||
const loginStatus = document.getElementById('loginStatus');
|
||||
const tokenKey = 'skdToken';
|
||||
let currentToken = sessionStorage.getItem(tokenKey) || '';
|
||||
|
||||
function setToken(token) {
|
||||
currentToken = token;
|
||||
if (token) {
|
||||
sessionStorage.setItem(tokenKey, token);
|
||||
loginStatus.textContent = 'Angemeldet';
|
||||
} else {
|
||||
sessionStorage.removeItem(tokenKey);
|
||||
loginStatus.textContent = 'Nicht angemeldet';
|
||||
}
|
||||
}
|
||||
setToken(currentToken);
|
||||
|
||||
function authHeaders() {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (currentToken) headers['Authorization'] = `Bearer ${currentToken}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = { ...authHeaders(), ...(options.headers || {}) };
|
||||
const res = await fetch(path, { ...options, headers });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
statusDiv.textContent = 'Lade...';
|
||||
try {
|
||||
const data = await api('/users');
|
||||
statusDiv.textContent = data.map(u => `${u.user}: ${u.logged_in ? 'eingeloggt' : 'aus'}`).join('\n') || 'Keine Daten';
|
||||
const select = document.getElementById('username');
|
||||
select.innerHTML = '<option value="">-- wählen --</option>';
|
||||
data.forEach(u => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = u.user;
|
||||
opt.textContent = u.user;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
statusDiv.textContent = `Fehler: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
loginStatus.textContent = 'Anmeldung...';
|
||||
const username = document.getElementById('loginUser').value.trim();
|
||||
const password = document.getElementById('loginPass').value;
|
||||
try {
|
||||
const data = await api('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
setToken(data.token);
|
||||
loginStatus.textContent = 'Anmeldung erfolgreich';
|
||||
await refreshUsers();
|
||||
} catch (err) {
|
||||
setToken('');
|
||||
loginStatus.textContent = `Login fehlgeschlagen: ${err.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', refreshUsers);
|
||||
|
||||
document.getElementById('actionForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
resultDiv.textContent = 'Sende...';
|
||||
const username = document.getElementById('username').value;
|
||||
const action = document.getElementById('action').value;
|
||||
const countdown = document.getElementById('countdown').value;
|
||||
const sound = document.getElementById('sound').value;
|
||||
const message = document.getElementById('message').value.trim();
|
||||
|
||||
const body = {};
|
||||
if (countdown) body.countdown = Number(countdown);
|
||||
if (sound === 'true') body.sound = true;
|
||||
if (sound === 'false') body.sound = false;
|
||||
if (message) body.message = message;
|
||||
|
||||
try {
|
||||
const data = await api(`/users/${encodeURIComponent(username)}/${action}`, {
|
||||
method: 'POST',
|
||||
body: Object.keys(body).length ? JSON.stringify(body) : '{}'
|
||||
});
|
||||
resultDiv.textContent = `${data.action} ${data.user}: ${data.steps.join('; ')}`;
|
||||
} catch (err) {
|
||||
resultDiv.textContent = `Fehler: ${err.message}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user