Compare commits
66 Commits
4eb20e2449
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c4d0bb3894 | |||
| 806d768bbc | |||
| bf7d1e466a | |||
| e13e51531d | |||
| b6d81885de | |||
| ab58a163a6 | |||
| 7f5c8f0b7b | |||
| 63ee0b40b5 | |||
| 9ad9501722 | |||
| 5850ef8358 | |||
| 835244b7e0 | |||
| fb096f7897 | |||
| c748181de2 | |||
| 8c55fd4954 | |||
| f513d46d08 | |||
| 55e452b6a2 | |||
| 1888f59c05 | |||
| b68ce13be0 | |||
| a732a5afc4 | |||
| 10ec58f744 | |||
| c41482a7b3 | |||
| 589f9595f4 | |||
| 78594c5bca | |||
| 96d559f819 | |||
| a3a7298447 | |||
| b2edb400aa | |||
| 47290d2d8f | |||
| fde2825112 | |||
| f639e3c56a | |||
| e75a989c54 | |||
| 044203e332 | |||
| 725b67d734 | |||
| f27e423ef3 | |||
| b12e0bf4d5 | |||
| 5e7eb6a6e7 | |||
| 49adf77808 | |||
| 73ade70f16 | |||
| efd006276d | |||
| f7b1a7987d | |||
| d799c0f042 | |||
| 107cdabe8d | |||
| b64cc5981c | |||
| fde207a3e6 | |||
| 2fc189a467 | |||
| 312365a5f6 | |||
| 132604de2d | |||
| 37087e7925 | |||
| c41a4560c1 | |||
| 2d829b74dd | |||
| b508f5c58c | |||
| 8a16a8633b | |||
| 2aa04795b3 | |||
| c004aeffa7 | |||
| 2c36744ea8 | |||
| 3caff1f6e2 | |||
| 2d1ca05bf0 | |||
| b566055d0a | |||
| 5c9ef1ddcb | |||
| 098ba7b187 | |||
| a4f043ea96 | |||
| dffc0d79e4 | |||
| e380288755 | |||
| 97ea7070cc | |||
| 4809027227 | |||
| 98d51655ba | |||
| a9c57caaf1 |
2
.gitignore
vendored
@ -2,6 +2,8 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
upload.token
|
||||
update-addon.env
|
||||
venv/
|
||||
ENV/
|
||||
.env
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `bash -n sk.sh` — Syntax check to catch parsing errors early.
|
||||
- `sudo ./scripts/install.sh` — On Ubuntu/Debian creates `/etc/pam.d/skd` and sets `SKD_AUTH_PAM_SERVICE=skd` if unset.
|
||||
- `shellcheck sk.sh` — Linting for style, safety, and portability; fix or suppress with clear rationale.
|
||||
- `./sk.sh <user> disable|enable [countdown] [sound] [seconds]` — Run the tool; requires root. Use a test account when iterating.
|
||||
- `sudo ./sk.sh demo_user disable countdown sound 90` — Example invocation combining optional modes.
|
||||
|
||||
92
CHANGELOG.md
@ -1,4 +1,4 @@
|
||||
ID: DOC_000001 | Version: 0.1.0 | Status: Final
|
||||
ID: DOC_000001 | Version: 0.2.3 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Projekt-Logbuch (Changelog)
|
||||
@ -10,6 +10,96 @@ By: Codex (GPT-5)
|
||||
| 28.12.2025 | 🏗️ Planning | ID: EPIC_000007 und US_000020-US_000023 dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: US_000024 dokumentiert; OIDC- und Login-Stories praezisiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: US_000024 Theme-Assets unter assets/design vorbereitet. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: US_000025 und TASK_000025-TASK_000026 fuer OIDC-Validierung gestartet. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 📝 Req | ID: US_000025 Runbook/Validierungsschritte dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 📝 Req | ID: US_000025 Validation blocked (Service/IdP nicht bereit). By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: PR-Vorbereitung fuer feature/oidc-validation geplant. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: PAM immer aktiv; OIDC optional mit deaktivierter UI-Option dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | ⚙️ Code | ID: Installer setzt PAM-Defaults in /etc/skd/env. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | ⚙️ Code | ID: Root-Service auf Port 80, Account-Status und Self-Disable-Schutz. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: EPIC_000008 und US_000026-US_000028 fuer Client-Updates dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: Update-Format festgelegt (JSON + tar.gz). By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: Update-Service URL auf update.wlkns.org festgelegt. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: EPIC_000009 Update Webservice (External Team) dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: TASK_000027-TASK_000029 fuer EPIC_000008 ausgearbeitet. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | ⚙️ Code | ID: Update-Config Keys in Settings/ENV/README definiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 📝 Req | ID: Update-Flow fuer Client dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 📝 Req | ID: Update-Flow Prototyp dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 📝 Req | ID: Update-Status-Schema dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | ⚙️ Code | ID: Update-Status-URL und Report im Client-Prototyp. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: US_000029-US_000032 fuer Update-UI dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: US_000033 Rollback-UI dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: Update-UI Anforderungen um Backup-Voraussetzung ergaenzt. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: TASK_000030-TASK_000034 fuer Update-UI ausgearbeitet. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 📝 Req | ID: Update-API Endpunkte dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 📝 Req | ID: Update-Status/Log Dateien dokumentiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | ⚙️ Code | ID: Update-Client Prototyp-Skript hinzugefuegt. By: Codex (GPT-5) |
|
||||
| 29.12.2025 | ⚙️ Code | ID: Update-Backend Logik implementiert (backend/update.py). By: Codex (GPT-5) |
|
||||
| 29.12.2025 | ⚙️ Code | ID: Update-Models und Settings erweitert (Models, Status-File Paths). By: Codex (GPT-5) |
|
||||
| 29.12.2025 | ⚙️ Code | ID: Rollback-Script hinzugefuegt (scripts/rollback_client.sh). By: Codex (GPT-5) |
|
||||
| 30.12.2025 | ⚙️ Code | ID: Update-API Endpunkte implementiert (GET /update/status, POST /update/check, POST /update/apply, POST /update/rollback, GET /update/logs). By: Claude Sonnet 4.5 |
|
||||
| 30.12.2025 | ⚙️ Code | ID: Update-UI im Web-Frontend implementiert (Status-Anzeige, Check/Apply/Rollback Buttons, Logs-Viewer). By: Claude Sonnet 4.5 |
|
||||
| 30.12.2025 | 🎨 UI | ID: Watchtower Theme implementiert (Sci-Fi Dark Mode mit Neon-Glow, bg-noise, CSS-Variables). By: Claude Sonnet 4.5 |
|
||||
| 30.12.2025 | 🎨 UI | ID: Web-UI modernisiert (Lucide Icons, Metrics Dashboard, User Table mit Badges, Toast Notifications, Loading States, Action Modals). By: Claude Sonnet 4.5 |
|
||||
| 30.12.2025 | 🎨 UI | ID: Watchtower Theme ersetzt durch OIDC Theme (Professional Design mit Dark Mode Toggle, Light/Dark Theme). By: Claude Sonnet 4.5 |
|
||||
| 30.12.2025 | 🎨 UI | ID: TASK_000035 Login-Landing und Versionsanzeige im Header der Web-UI. By: Codex (GPT-5) |
|
||||
| 30.12.2025 | 🎨 UI | ID: TASK_000036 Login-/Dashboard-URLs und Redirect nach Login. By: Codex (GPT-5) |
|
||||
| 30.12.2025 | 🎨 UI | ID: TASK_000037 OIDC-Button neben Anmelden und nur aktiv bei erreichbarem Server. By: Codex (GPT-5) |
|
||||
| 30.12.2025 | 🎨 UI | ID: TASK_000038 Logo im Header und Favicon eingebunden. By: Codex (GPT-5) |
|
||||
| 30.12.2025 | 🎨 UI | ID: TASK_000039 Login-Text reduziert, Buttons symmetrisch, Panels/Metrics harmonisiert. By: Codex (GPT-5) |
|
||||
| 30.12.2025 | 🏗️ Planning | ID: EPIC_000010/US_000034/US_000035 Update-Service v1 Migration dokumentiert. By: Codex (GPT-5) |
|
||||
| 30.12.2025 | 🏗️ Planning | ID: TASK_000040/TASK_000041 fuer Enrollment und v1 Endpunkte angelegt. By: Codex (GPT-5) |
|
||||
| 12.01.2026 | ⚙️ Code | ID: TASK_000040 Enrollment-Skript bereinigt und als scripts/enroll_local.py hinzugefuegt. By: Gemini CLI |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: EPIC_000011 und US_000036-US_000038 dokumentiert. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 📝 Req | ID: Update-Doku und ENV-Beispiele an Code-Stand angeglichen. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: Makefile-Healthcheck auf Authorization: Bearer angepasst. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: US_000039 und TASK_000042 fuer Doku-Audit angelegt. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 📝 Req | ID: US_000039 Doku-Audit abgeschlossen (Header-Versionen konsolidiert, Update-Docs korrigiert). By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: EPIC_000012 und US_000040 sowie TASK_000043-TASK_000044 fuer Doku-Overhaul angelegt. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 📝 Req | ID: US_000040 Doku-Overhaul umgesetzt (neue Struktur, README, Archivierung). By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 📝 Req | ID: Externe Service-Links (Update/OIDC) in Doku ergaenzt. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: US_000041/TASK_000045 Einbindung externer Services dokumentieren. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 📝 Req | ID: US_000041 Einbindungsschritte fuer Update- und OIDC-Service dokumentiert. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: US_000042/TASK_000046-TASK_000048 Consumer-Doku und Audience-Split ergaenzen. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 📝 Req | ID: US_000042 Consumer-Doku, Audience-Split, External Dependencies und Power-User-Ergaenzungen umgesetzt. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 📝 Req | ID: Makefile als bevorzugter Einstieg in Doku ergaenzt. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: US_000043/TASK_000049 Enrollment-Token Script geplant. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: US_000043 Enrollment-Script fuer Update-Service hinzugefuegt. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: EPIC_000013/US_000044/TASK_000050-TASK_000051 System Telemetry im Dashboard geplant. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: EPIC_000014/US_000045/TASK_000052-TASK_000054 Login-Zeitfenster und Parent-Control Regeln geplant. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: US_000045 Kriterien konkretisiert (lokale Zeit, OS-Login, Auto-Reaktivierung). By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: US_000046/TASK_000055 Update-Service Erreichbarkeit anzeigen. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: US_000046 Update-Service Statusanzeige implementiert. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: Update-Service Status zeigt Dev/Prod und nutzt konsistente Panel-Styles. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: US_000047/TASK_000056 Update-ENV getrennt. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: Update-Config in env.update.example ausgelagert. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: US_000048/TASK_000057 Release-Upload automatisieren. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: Release-Upload Script hinzugefuegt (tar.gz + curl). By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: Upload-Script toleriert fehlende Leserechte fuer /etc/skd/*. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🏗️ Planning | ID: US_000049/TASK_000058 deployment.env fuer lokale Uploads. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: deployment.env.example hinzugefuegt und Scripts angepasst. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: deployment.env als lokale Quelle fuer Enrollment/Upload Scripts. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: EPIC_000013 System Telemetry Endpoint und UI umgesetzt. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 📝 Req | ID: EPIC_000009 Update Webservice als erledigt markiert. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🚀 Release | ID: VERSION auf 0.2.2 erhoeht. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🐞 Fix | ID: BUG_000001 Update-Apply scheitert nicht mehr an WorkingDirectory. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🎨 UI | ID: System Information Kacheln mit Progressbars verbessert. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | 🚀 Release | ID: VERSION auf 0.2.3 erhoeht. By: Codex (GPT-5) |
|
||||
| 15.01.2026 | ⚙️ Code | ID: Makefile restart-Target hinzugefuegt. By: Codex (GPT-5) |
|
||||
| 16.01.2026 | ✨ Feat | ID: EPIC_000014/US_000045 Login-Regeln und Scheduler implementiert (UI + Backend). By: Gemini CLI |
|
||||
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.0 erhoeht. By: Gemini CLI |
|
||||
| 16.01.2026 | 🐞 Fix | ID: Update-Prozess via systemd-run entkoppelt, damit Service-Stop den Updater nicht killt. By: Gemini CLI |
|
||||
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.1 erhoeht. By: Gemini CLI |
|
||||
| 16.01.2026 | 🧪 Test | ID: Patch-Bump auf 0.3.2 zur Verifizierung des Update-Fixes. By: Gemini CLI |
|
||||
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.2 erhoeht. By: Gemini CLI |
|
||||
| 16.01.2026 | 🐞 Fix | ID: Update-Token wird nun bei jedem Update-Vorgang frisch geladen (Fix fuer Caching-Problem). By: Gemini CLI |
|
||||
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.3 erhoeht. By: Gemini CLI |
|
||||
| 16.01.2026 | 🧪 Test | ID: Patch-Bump auf 0.3.4 zur finalen Verifizierung nach manuellem Git-Pull am Client. By: Gemini CLI |
|
||||
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.4 erhoeht. By: Gemini CLI |
|
||||
| 16.01.2026 | 🐞 Fix | ID: Environment-Variablen explizit via --setenv an systemd-run uebergeben (Fix fuer fehlenden Token im Updater). By: Gemini CLI |
|
||||
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.5 erhoeht. By: Gemini CLI |
|
||||
| 16.01.2026 | 🧪 Test | ID: Patch-Bump auf 0.3.6 zur finalen Verifizierung des systemd-run Env-Fixes. By: Gemini CLI |
|
||||
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.6 erhoeht. By: Gemini CLI |
|
||||
|
||||
---
|
||||
## Legende
|
||||
|
||||
32
CHANGES.md
Normal file
@ -0,0 +1,32 @@
|
||||
# Changes
|
||||
|
||||
## Kontext
|
||||
- Problem: PAM-Login schlug fehl und spaeter wurden keine Benutzer im UI angezeigt.
|
||||
- Ursache: Der Dienst lief als User `skd`. PAM kann nur den aktuellen Nutzer pruefen; deshalb schlug die Authentifizierung fuer andere Nutzer fehl.
|
||||
- UI-Eindruck: Benutzerliste erscheint erst nach "Status laden" (kein Auto-Refresh nach erfolgreichem Login, nur Token setzen).
|
||||
|
||||
## Service/Deploy-Anpassungen
|
||||
- `skd.service` wird als `root` gestartet, damit PAM andere Nutzer authentifizieren kann. (Systemd Unit unter `/etc/systemd/system/skd.service`)
|
||||
- `SKD_ALLOWED_USERS` in `/etc/skd/env` auf leer gesetzt, damit alle "echten" Nutzer (uid >= 1000, mit Shell) gelistet werden.
|
||||
|
||||
## Code-Aenderungen
|
||||
- `backend/actions.py`
|
||||
- `pkill`-Exit-Code 1 (keine Prozesse) wird jetzt als normaler Zustand behandelt, kein 500-Fehler mehr.
|
||||
- `backend/auth.py`
|
||||
- `is_account_locked()` hinzugefuegt, liest `/etc/shadow` und erkennt gesperrte Accounts.
|
||||
- `backend/models.py`
|
||||
- `UserStatus` um `account_locked: bool` erweitert.
|
||||
- `backend/app.py`
|
||||
- `/users` liefert jetzt `account_locked`.
|
||||
- Schutz: der aktuell angemeldete User kann sich nicht selbst deaktivieren.
|
||||
- `backend/templates/index.html`
|
||||
- Statusanzeige zeigt jetzt "aktiv/deaktiviert" pro Benutzer.
|
||||
|
||||
## Client-Seite / Verhalten
|
||||
- Benutzerliste erscheint erst nach "Status laden". Das UI setzt nach dem Login zwar den Token, laedt aber nicht automatisch die Benutzerliste, es sei denn die Funktion wird explizit aufgerufen.
|
||||
- Workaround: nach Login einmal "Status laden" klicken.
|
||||
|
||||
## Relevante Logs/Beobachtungen
|
||||
- Vor Umstellung auf root: `unix_chkpwd: check pass; user unknown` und 401 bei PAM-Login.
|
||||
- Nach Umstellung: Login erfolgreich, `/users` liefert 200.
|
||||
|
||||
145
CLAUDE.md
Normal file
@ -0,0 +1,145 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Safe Kiddo Daemon (SKD) is a FastAPI-based service for managing local user accounts on kids' laptops. It provides account locking/unlocking with countdown notifications, optional sound alerts, and shutdown capabilities. The service exposes a REST API with bearer token auth (PAM or OIDC) and serves a minimal web UI.
|
||||
|
||||
## Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Run the service manually (uses .venv, binds to 0.0.0.0:80)
|
||||
./scripts/run.sh
|
||||
|
||||
# Install service and dependencies
|
||||
sudo make install
|
||||
|
||||
# Service management
|
||||
sudo make up # Start service
|
||||
sudo make down # Stop service
|
||||
sudo make update # Pull latest from git, reinstall deps, restart
|
||||
|
||||
# Generate/set API token
|
||||
make token
|
||||
|
||||
# Health check (requires token)
|
||||
make healthcheck
|
||||
```
|
||||
|
||||
### Installation & Deployment
|
||||
```bash
|
||||
# Full install (creates service user, venv, systemd unit, PAM config)
|
||||
sudo ./scripts/install.sh
|
||||
|
||||
# Deploy to remote host (requires deploy_hosts.yml)
|
||||
./scripts/deploy.sh <host-name>
|
||||
|
||||
# Manual update on target
|
||||
ssh user@target 'cd /opt/sk && ./scripts/update.sh'
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Python syntax validation
|
||||
python -m py_compile backend/*.py
|
||||
|
||||
# Run specific tests (no formal test runner yet; tests/ is empty)
|
||||
# Use curl for API testing:
|
||||
token=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d '{"username":"root","password":"..."}' \
|
||||
http://localhost/login | jq -r .token)
|
||||
curl -H "Authorization: Bearer $token" http://localhost/users
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Structure
|
||||
- `backend/`: FastAPI application
|
||||
- `app.py`: Main FastAPI app with route handlers
|
||||
- `actions.py`: User management actions (lock/unlock, notifications, shutdown logic)
|
||||
- `auth.py`: PAM authentication, JWT tokens, user/group authorization checks
|
||||
- `oidc.py`: OIDC client (dynamic discovery, token exchange, claims validation)
|
||||
- `update.py`: Update client logic (check/status/logs, triggers async update/rollback scripts)
|
||||
- `settings.py`: Environment-based configuration (Settings class, singleton via lru_cache)
|
||||
- `models.py`: Pydantic models for API requests/responses
|
||||
- `templates/`: Jinja2 templates for web UI
|
||||
- `scripts/`: Deployment and lifecycle scripts
|
||||
- `install.sh`: System setup (user, venv, systemd, PAM config)
|
||||
- `run.sh`: Manual service start
|
||||
- `update.sh`: Local git pull and service restart
|
||||
- `update_client.sh`: Full update flow with backup/rollback
|
||||
- `rollback_client.sh`: Restore from backup if update fails
|
||||
- `deploy.sh`: SSH-based deployment to remote hosts
|
||||
- `register_oidc_client.sh`: OIDC dynamic client registration helper
|
||||
- `sk.sh`: Legacy bash script (CLI fallback for direct SSH use)
|
||||
- `src/`: Hexagonal architecture skeleton (core/ports/adapters/ui) - currently empty placeholders
|
||||
- `docs/`: Detailed specs for OIDC validation, update API, status/log formats
|
||||
- `Makefile`: Convenience targets for install, service control, updates, token management
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
**Dual Authentication**: PAM-based local auth (root/sudo users) is always available; OIDC is optional if `SKD_OIDC_ISSUER`, `SKD_OIDC_CLIENT_ID`, and `SKD_OIDC_CLIENT_SECRET` are configured. Both modes issue JWT bearer tokens.
|
||||
|
||||
**Settings Management**: All config via environment variables (loaded from `/etc/skd/env` in production). `settings.py` provides a singleton `Settings` instance via `get_settings()` using `lru_cache`. FastAPI dependencies inject settings into route handlers.
|
||||
|
||||
**Action Execution**: `actions.py` wraps all privileged operations (usermod, pkill, shutdown) via `_run()` helper. Dry-run mode (`SKD_DRY_RUN=true`) logs commands without executing them.
|
||||
|
||||
**Update Flow**: `update.py` checks remote update service for new versions, writes status to JSON files, and triggers async scripts (`update_client.sh`, `rollback_client.sh`) that create backups, apply updates, and handle rollbacks on failure.
|
||||
|
||||
**Authorization**: `auth.py` checks both user allowlists (`SKD_AUTH_ALLOWED_USERS`) and group membership (`SKD_AUTH_ALLOWED_GROUPS`, defaults to `sudo`). UID 0 (root) always allowed for PAM. OIDC validates against `preferred_username`, `email`, or `sub` claims.
|
||||
|
||||
**Manageable Users**: Only system users with UID >= 1000, real shells (not nologin/false), and optional allowlist (`SKD_ALLOWED_USERS`) are exposed via API. Root accounts are never manageable.
|
||||
|
||||
## Configuration
|
||||
|
||||
Deployment config lives in `/etc/skd/env` (see `env.example` in repo root):
|
||||
- `SKD_AUTH_SECRET`: HMAC secret for JWT signing (must be strong in production)
|
||||
- `SKD_AUTH_ALLOWED_USERS`: Comma-separated user allowlist (for login and OIDC claims)
|
||||
- `SKD_AUTH_ALLOWED_GROUPS`: Groups whose members may log in (PAM only, default `sudo`)
|
||||
- `SKD_AUTH_PAM_SERVICE`: PAM service name (Ubuntu/Debian use `skd`, others may use `login`)
|
||||
- `SKD_OIDC_*`: OIDC provider config (ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, SCOPES)
|
||||
- `SKD_ALLOWED_USERS`: Comma-separated list of manageable system accounts (optional)
|
||||
- `SKD_DRY_RUN`: If `true`, logs all privileged commands without executing
|
||||
- `SKD_UPDATE_*`: Update service URL, token, interval, status/log file paths
|
||||
|
||||
## Important Workflows
|
||||
|
||||
### Disable User Flow
|
||||
1. API call to `/users/{username}/disable` with optional `{countdown, sound, message}`
|
||||
2. `actions.disable_user()` locks account via `usermod -L`
|
||||
3. If user logged in: sends desktop notifications, plays sound (if enabled), countdown loop with periodic reminders
|
||||
4. Terminates sessions via `pkill -KILL -u`
|
||||
5. Triggers `shutdown now` only if user was logged in
|
||||
|
||||
### OIDC Login Flow
|
||||
1. User accesses `/login/oidc/start` → redirected to provider with state cookie
|
||||
2. Provider redirects to `/login/oidc/callback` with code + state
|
||||
3. Validates state, exchanges code for tokens, extracts username from claims
|
||||
4. Issues JWT session cookie if user in allowlist
|
||||
|
||||
### Update Flow
|
||||
1. `check_update()` polls remote update service for latest manifest (version, artifact_url, sha256)
|
||||
2. `start_update()` writes "in_progress" status, launches `update_client.sh` in background
|
||||
3. Script creates backup, downloads artifact, verifies checksum, installs, restarts service
|
||||
4. On failure: `rollback_client.sh` restores from backup
|
||||
5. Status/logs written to JSON files at `SKD_UPDATE_STATUS_FILE` and `SKD_UPDATE_LOG_FILE`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Service runs as root by default (required for PAM, usermod, pkill, shutdown). Limit exposure via firewall.
|
||||
- Set strong `SKD_AUTH_SECRET` and rotate by changing value + restarting service.
|
||||
- Restrict API/Web UI to LAN/VPN; consider mTLS or IP allowlisting.
|
||||
- `skd` user/group created by install script; consider sudoers rules to limit privileges to specific commands.
|
||||
- OIDC redirect URI must match exactly (no wildcards); re-register client if host/port changes.
|
||||
- Validate TLS certificates in production; self-signed certs require CA trust or fallback to PAM.
|
||||
|
||||
## Notes
|
||||
|
||||
- Legacy `sk.sh` remains for emergency CLI fallback; API is preferred for all operations.
|
||||
- `src/` hexagonal architecture skeleton is currently unused; logic lives in `backend/`.
|
||||
- `tests/` directory exists but is empty; use manual curl-based API testing.
|
||||
- Both German and English comments exist in code; favor English going forward.
|
||||
- Deployment via `deploy.sh` supports both YAML (`deploy_hosts.yml`) and JSON host configs.
|
||||
- PAM service file (`/etc/pam.d/skd`) created by `scripts/install.sh` on Ubuntu/Debian; other distros may need manual setup.
|
||||
9
Makefile
@ -8,12 +8,12 @@ ENV_FILE ?= $(ENV_DIR)/env
|
||||
SYSTEMD_PATH ?= /etc/systemd/system/$(SERVICE).service
|
||||
BRANCH ?= main
|
||||
HOST ?= 127.0.0.1
|
||||
PORT ?= 8000
|
||||
PORT ?= 80
|
||||
HEALTH_URL ?= http://$(HOST):$(PORT)/health
|
||||
TOKEN ?= $(shell awk -F= '/^SKD_AUTH_TOKEN=/{print $$2}' $(ENV_FILE) 2>/dev/null)
|
||||
KEEP_INSTALL_DIR ?= 1
|
||||
|
||||
.PHONY: install up down uninstall healthcheck update token
|
||||
.PHONY: install up down restart uninstall healthcheck update token
|
||||
|
||||
install:
|
||||
$(SUDO) env SERVICE_NAME=$(SERVICE) SERVICE_USER=$(SERVICE_USER) SERVICE_GROUP=$(SERVICE_GROUP) INSTALL_DIR=$(INSTALL_DIR) ./scripts/install.sh
|
||||
@ -24,6 +24,9 @@ up:
|
||||
down:
|
||||
$(SUDO) systemctl stop $(SERVICE).service
|
||||
|
||||
restart:
|
||||
$(SUDO) systemctl restart $(SERVICE).service
|
||||
|
||||
uninstall:
|
||||
-$(SUDO) systemctl stop $(SERVICE).service
|
||||
-$(SUDO) systemctl disable $(SERVICE).service
|
||||
@ -41,7 +44,7 @@ healthcheck:
|
||||
echo "No token set; set TOKEN=... or populate $(ENV_FILE) with SKD_AUTH_TOKEN."; \
|
||||
exit 1; \
|
||||
fi
|
||||
curl -fsS -H "X-API-Token: $(TOKEN)" "$(HEALTH_URL)" || (echo "Health check failed" && exit 1)
|
||||
curl -fsS -H "Authorization: Bearer $(TOKEN)" "$(HEALTH_URL)" || (echo "Health check failed" && exit 1)
|
||||
|
||||
update:
|
||||
$(SUDO) env PROJECT_ROOT=$(INSTALL_DIR) SERVICE_NAME=$(SERVICE) SERVICE_USER=$(SERVICE_USER) BRANCH=$(BRANCH) bash -c 'cd $(INSTALL_DIR) && ./scripts/update.sh'
|
||||
|
||||
122
README.md
@ -1,80 +1,70 @@
|
||||
ID: README_000001 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Safe Kiddo Daemon
|
||||
|
||||
Service to lock/unlock local user accounts on kids' laptops with countdown, notifications, optional sound, and shutdown. Provides a REST API plus a small web UI for remote control; retains the original `sk.sh` script as legacy/CLI fallback.
|
||||
## Kurzbeschreibung
|
||||
Safe Kiddo Daemon ist ein lokaler Systemdienst, der Benutzerkonten auf Linux-Systemen sperrt/entsperrt, Sitzungen beendet und optional einen Shutdown ausloest. Der Dienst bietet eine REST-API und eine Web-UI fuer die Fernsteuerung. Ein Legacy-CLI-Script (`sk.sh`) bleibt als Fallback erhalten.
|
||||
|
||||
## Features
|
||||
- Disable/enable accounts, terminate sessions, optionally trigger shutdown.
|
||||
- Desktop notifications and optional sound during countdown.
|
||||
- Login-protected API with minimal web UI (PAM auth for root users, bearer token for calls).
|
||||
- Systemd-managed service, virtualenv-based deploy, remote update script.
|
||||
- Dry-run mode to validate flows without touching accounts.
|
||||
## Fuer wen ist das Projekt?
|
||||
1. Endnutzer (Consumers): Nutzen die Web-Oberflaeche ohne technische Details.
|
||||
2. Technische Einsteiger: Installieren und starten den Dienst.
|
||||
3. Power-User: Konfigurieren, automatisieren und betreiben den Dienst.
|
||||
4. Entwickler/Professionals: Verstehen Architektur, Interna und Erweiterungspunkte.
|
||||
|
||||
## Quick Start (Local/Target Device)
|
||||
## Was es NICHT ist
|
||||
- Kein Cloud-Service und keine zentrale Benutzerverwaltung.
|
||||
- Kein Ersatz fuer Mobile-Device-Management (MDM).
|
||||
- Kein plattformuebergreifendes Kontrollen-System; Fokus ist Linux und lokale Accounts.
|
||||
|
||||
## Hauptfunktionen
|
||||
- Sperren/Entsperren lokaler Nutzerkonten inkl. Session-Management und optionalem Shutdown.
|
||||
- Benachrichtigungen und optionaler Sound waehrend Countdown.
|
||||
- PAM-Login (immer aktiv) und optionaler OIDC-Login.
|
||||
- Systemd-Service mit Installations- und Update-Skripten.
|
||||
- Update-Client-Integration (Enrollment, Manifest, Apply, Rollback, Logs).
|
||||
- Dry-Run-Modus zum sicheren Testen.
|
||||
|
||||
## Quickstart (5 Minuten)
|
||||
```bash
|
||||
git clone <repo> /opt/sk
|
||||
# 1) Repo holen
|
||||
sudo mkdir -p /opt/sk
|
||||
sudo git clone ssh://git@git.wlkns.org:2222/stephan/kiddo /opt/sk
|
||||
# Hinweis: verwende hier die Repo-URL deiner Instanz
|
||||
cd /opt/sk
|
||||
./scripts/install.sh
|
||||
|
||||
# 2) Installieren (legt User, env und Systemd-Unit an)
|
||||
make install
|
||||
|
||||
# 3) Status pruefen
|
||||
sudo systemctl status skd.service
|
||||
|
||||
# 4) Login testen (PAM)
|
||||
curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d '{"username":"root","password":"example-password"}' \
|
||||
http://localhost/login
|
||||
```
|
||||
Then open `http://localhost:8000/` and set the API token in the UI.
|
||||
Danach die Web-UI unter `http://localhost/` oeffnen und anmelden.
|
||||
|
||||
## Configuration
|
||||
Set in `/etc/skd/env` (see `env.example`):
|
||||
- `SKD_AUTH_MODE`: `pam` (default) or `oidc`.
|
||||
- `SKD_AUTH_SECRET`: HMAC secret for bearer tokens/cookies (set a strong value).
|
||||
- `SKD_TOKEN_TTL_SECONDS`: token lifetime (default 900s).
|
||||
- `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in (used for PAM and as an allowlist for OIDC claims).
|
||||
- `SKD_AUTH_ALLOWED_GROUPS`: groups whose members may log in (PAM only, default `sudo`).
|
||||
- `SKD_OIDC_*`: `ISSUER`, `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI`, `SCOPES` to point at your OIDC provider; set `SKD_SESSION_COOKIE_SECURE=true` for HTTPS.
|
||||
- OIDC dynamic registration helper: `scripts/register_oidc_client.sh` (requires `OIDC_INITIAL_ACCESS_TOKEN` and `SKD_OIDC_ISSUER`; uses `SKD_OIDC_REDIRECT_URI` for the redirect). Run once during setup if your provider issues initial access tokens for client creation.
|
||||
- `SKD_ALLOWED_USERS`: optional comma list to limit manageable accounts (must exist on the system).
|
||||
- `SKD_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`: behavior defaults.
|
||||
- `SKD_DRY_RUN=true` to test without real account changes or shutdown.
|
||||
- `SKD_SOUND_PLAYER`/`SKD_SOUND_FILE`, `SKD_NOTIFY_SEND_PATH` if defaults differ.
|
||||
Notes:
|
||||
- `./scripts/install.sh` will create `/etc/skd/env` from `env.example` if missing (edit afterwards) and ensure the `skd` service user/group exist.
|
||||
## Dokumentation nach Zielgruppe
|
||||
- Endnutzer (Consumers): `docs/FOR_USERS.md`
|
||||
- Technische Einsteiger: `docs/GETTING_STARTED.md`
|
||||
- Power-User: `docs/USAGE.md` und `docs/CONFIGURATION.md`
|
||||
- Entwickler/Professionals: `docs/ARCHITECTURE.md`, `docs/DEVELOPMENT.md`, `docs/DEPLOYMENT.md`
|
||||
- Gemeinsame Referenz: `docs/FAQ.md`, `docs/TROUBLESHOOTING.md`
|
||||
|
||||
## Running
|
||||
- Service: managed by systemd; `./scripts/install.sh` writes the unit dynamically to `/etc/systemd/system/skd.service` with the current repo path and restarts it.
|
||||
- Manual run: `./scripts/run.sh` (uses `.venv`, defaults to `0.0.0.0:8000`).
|
||||
- Login (PAM): `curl -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login`
|
||||
- Login (OIDC): open `http://localhost:8000/login/oidc/start` → provider → redirected back with session cookie set.
|
||||
- Health: `curl -H "Authorization: Bearer <token>" http://localhost:8000/health`
|
||||
## Externe Services
|
||||
- Update-Service (intern): https://git.wlkns.org/stephan/update-webservice
|
||||
- OIDC-Service (intern): https://git.wlkns.org/stephan/oicd
|
||||
Einbindung und Konfiguration: `docs/DEPLOYMENT.md` und `docs/CONFIGURATION.md`.
|
||||
|
||||
## API (Bearer token via `/login`)
|
||||
- `GET /users` → `[{user, logged_in}]` (manageable system users; excludes root)
|
||||
- `POST /users/{name}/disable` with JSON `{countdown?, sound?, message?}`
|
||||
- `POST /users/{name}/enable`
|
||||
- `GET /health`
|
||||
- `GET /me` (returns current user + auth mode when a session/bearer token is present)
|
||||
## Badges
|
||||
Derzeit keine offiziellen Badges, da im Repository keine CI, Coverage, Release oder Docker-Pipeline definiert ist.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
token=$(curl -s -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login | jq -r .token)
|
||||
curl -X POST -H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"countdown":90,"sound":true}' \
|
||||
http://localhost:8000/users/child1/disable
|
||||
```
|
||||
|
||||
## Web UI
|
||||
Served at `/`. Nutze den Button „Login via OIDC“ (setzt Session-Cookie) oder das PAM-Formular, falls OIDC deaktiviert; danach werden verfügbare System-User angezeigt und Aktionen senden Token/Cookies automatisch.
|
||||
|
||||
## Updates
|
||||
- Remote update via SSH: `ssh user@kid-laptop 'cd /opt/sk && ./scripts/update.sh'` (fetch/reset to `origin/main`, reinstalls deps, restarts service).
|
||||
- Manual: `git pull && source .venv/bin/activate && pip install -r backend/requirements.txt && sudo systemctl restart skd`
|
||||
|
||||
## Deployment (zip/SSH)
|
||||
- Quick copy: create `sk_deploy.zip` (already in repo root) and unzip on target under `/opt/sk`, then refresh venv deps and restart service.
|
||||
- Scripted deploy: edit `deploy_hosts.yml` (host/user/port/install_dir/service user/group), then run `./scripts/deploy.sh <host-name>`; accepts JSON configs too. Requires SSH access and `sudo` on target.
|
||||
- After deploy on target: `sudo -u skd /opt/sk/.venv/bin/pip install -r /opt/sk/backend/requirements.txt && sudo systemctl restart skd.service`
|
||||
|
||||
## Security Hardening
|
||||
- Restrict access to API/Web UI to LAN/VPN; firewall the port.
|
||||
- Set a strong `SKD_AUTH_SECRET`; rotate tokens by changing the secret.
|
||||
- Create dedicated `skd` user/group; no login shell.
|
||||
- Configure sudoers minimally: allow `skd` to run `usermod -L/-U`, `pkill -KILL -u`, `shutdown now`, and sound/notify binaries if needed (no full passwordless sudo).
|
||||
- Consider mTLS or IP allowlisting for added protection.
|
||||
## Hinweise
|
||||
- Abweichung von der Zielstruktur: Die OpenAPI-Spezifikation des Update-Services bleibt unter `docs/architecture/openapi.yaml` und `docs/architecture/openapi/` erhalten.
|
||||
- Altdokumente liegen unter `docs/_archive/` und sind nicht geloescht.
|
||||
- Konfigurationsdateien: `/etc/skd/env` (Core) und `/etc/skd/update.env` (Update-Service).
|
||||
|
||||
## Legacy Script
|
||||
`sk.sh` remains for direct SSH use. Plan to replace its logic with API-backed helpers; keep it as emergency fallback.
|
||||
Das Script `sk.sh` bleibt als CLI-Fallback. Details und Beispiele: `docs/USAGE.md`.
|
||||
|
||||
7
assets/branding/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#1F2A37"/>
|
||||
<rect x="12" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||
<rect x="40" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||
<!-- Teal Keystone -->
|
||||
<rect x="26" y="29" width="12" height="19" rx="2" fill="#0EA5A4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 428 B |
15
assets/branding/logo.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<svg width="250" height="60" viewBox="0 0 250 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Monolithic Symbol (V2 Optimized - Teal) -->
|
||||
<g transform="translate(10, 10)">
|
||||
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||
<!-- Middle Block: Teal, 19px Height -->
|
||||
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
|
||||
</g>
|
||||
|
||||
<!-- Wordmark -->
|
||||
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
|
||||
|
||||
<!-- The Underline: Teal, aligned like V8 -->
|
||||
<rect x="56" y="48" width="121" height="4" rx="1" fill="#0EA5A4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 764 B |
@ -133,8 +133,14 @@ def disable_user(
|
||||
if play_sound:
|
||||
_play_sound_if_available()
|
||||
|
||||
try:
|
||||
_run(["sudo", "pkill", "-KILL", "-u", user])
|
||||
steps.append("sessions terminated")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if exc.returncode == 1:
|
||||
steps.append("no sessions to terminate")
|
||||
else:
|
||||
raise
|
||||
|
||||
if logged_in:
|
||||
_run(["sudo", "shutdown", "now"])
|
||||
|
||||
186
backend/app.py
@ -1,22 +1,43 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from backend import actions
|
||||
from backend import actions, enforcer, rules
|
||||
from backend.actions import ActionError
|
||||
from backend.auth import (
|
||||
authenticate_admin_user,
|
||||
get_current_admin,
|
||||
is_authorized_admin,
|
||||
is_account_locked,
|
||||
issue_token,
|
||||
list_manageable_users,
|
||||
)
|
||||
from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
|
||||
from backend.models import (
|
||||
AccessRule,
|
||||
ActionRequest,
|
||||
ActionResponse,
|
||||
EnrollRequest,
|
||||
EnrollResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RuleSet,
|
||||
UpdateActionResponse,
|
||||
UpdateCheckResponse,
|
||||
UpdateLogEntry,
|
||||
UpdateServiceStatus,
|
||||
UpdateStatus,
|
||||
SystemMetrics,
|
||||
UserStatus,
|
||||
)
|
||||
from backend.oidc import OIDCClient, OIDCError
|
||||
from backend.settings import Settings, get_settings
|
||||
from backend import update
|
||||
from backend import system_metrics
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@ -25,14 +46,21 @@ logging.basicConfig(
|
||||
logger = logging.getLogger("skd")
|
||||
|
||||
app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
|
||||
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
||||
app.mount("/assets", StaticFiles(directory="assets"), name="assets")
|
||||
templates = Jinja2Templates(directory="backend/templates")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
asyncio.create_task(enforcer.enforcement_loop())
|
||||
|
||||
|
||||
def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient:
|
||||
if settings.auth_mode != "oidc":
|
||||
if not settings.oidc_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="OIDC auth not enabled",
|
||||
detail="OIDC not configured",
|
||||
)
|
||||
try:
|
||||
return OIDCClient(settings)
|
||||
@ -59,7 +87,8 @@ def whoami(
|
||||
current_user: str = Depends(get_current_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
return {"user": current_user, "auth_mode": settings.auth_mode}
|
||||
auth_mode = "pam+oidc" if settings.oidc_enabled else "pam"
|
||||
return {"user": current_user, "auth_mode": auth_mode}
|
||||
|
||||
|
||||
@app.post("/login", response_model=LoginResponse)
|
||||
@ -115,7 +144,7 @@ def oidc_callback(
|
||||
username = oidc.extract_username(claims)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing username claim")
|
||||
if not is_authorized_admin(username, settings):
|
||||
if not is_authorized_admin(username, settings, mode="oidc"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not authorized to log in")
|
||||
|
||||
token = issue_token(username, settings)
|
||||
@ -132,11 +161,23 @@ def oidc_callback(
|
||||
return redirect
|
||||
|
||||
|
||||
@app.get("/login/oidc/status")
|
||||
def oidc_status(settings: Settings = Depends(get_settings)) -> dict:
|
||||
return {"enabled": settings.oidc_enabled}
|
||||
|
||||
|
||||
@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]
|
||||
return [
|
||||
UserStatus(
|
||||
user=user,
|
||||
logged_in=user in logged_in,
|
||||
account_locked=is_account_locked(user),
|
||||
)
|
||||
for user in targets
|
||||
]
|
||||
|
||||
|
||||
@app.post(
|
||||
@ -148,7 +189,13 @@ def disable_user(
|
||||
username: str = Depends(validate_user),
|
||||
payload: ActionRequest | None = Body(default=None),
|
||||
settings: Settings = Depends(get_settings),
|
||||
current_user: str = Depends(get_current_admin),
|
||||
) -> ActionResponse:
|
||||
if username == current_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot disable current user",
|
||||
)
|
||||
try:
|
||||
steps = actions.disable_user(
|
||||
username,
|
||||
@ -199,6 +246,131 @@ def enable_user(
|
||||
)
|
||||
|
||||
|
||||
@app.get("/rules", response_model=RuleSet, dependencies=[Depends(get_current_admin)])
|
||||
def get_rules(settings: Settings = Depends(get_settings)) -> RuleSet:
|
||||
manager = rules.RuleManager(settings)
|
||||
return manager.load_rules()
|
||||
|
||||
|
||||
@app.post("/rules", dependencies=[Depends(get_current_admin)])
|
||||
def set_rule(
|
||||
rule: AccessRule,
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
manager = rules.RuleManager(settings)
|
||||
manager.set_rule(rule)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/rules/{username}", dependencies=[Depends(get_current_admin)])
|
||||
def delete_rule(
|
||||
username: str,
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
manager = rules.RuleManager(settings)
|
||||
manager.delete_rule(username)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/update/status", response_model=UpdateStatus, dependencies=[Depends(get_current_admin)])
|
||||
def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus:
|
||||
status_data = update.get_status(settings)
|
||||
return UpdateStatus(**status_data)
|
||||
|
||||
|
||||
@app.post("/update/enroll", response_model=EnrollResponse, dependencies=[Depends(get_current_admin)])
|
||||
def update_enroll(
|
||||
payload: EnrollRequest | None = Body(default=None),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> EnrollResponse:
|
||||
if payload and payload.enroll_token:
|
||||
settings.update_enroll_token = payload.enroll_token
|
||||
try:
|
||||
update.enroll(settings)
|
||||
return EnrollResponse(enrolled=True, message="Enrollment successful")
|
||||
except Exception as exc:
|
||||
logger.exception("Enrollment failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Enrollment failed: {str(exc)}",
|
||||
) from exc
|
||||
|
||||
|
||||
@app.post("/update/check", response_model=UpdateCheckResponse, dependencies=[Depends(get_current_admin)])
|
||||
def update_check(settings: Settings = Depends(get_settings)) -> UpdateCheckResponse:
|
||||
try:
|
||||
check_data = update.check_update(settings)
|
||||
except Exception as exc:
|
||||
logger.exception("Update check failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"Update check failed: {str(exc)}",
|
||||
) from exc
|
||||
return UpdateCheckResponse(**check_data)
|
||||
|
||||
|
||||
@app.post("/update/apply", response_model=UpdateActionResponse, dependencies=[Depends(get_current_admin)])
|
||||
def update_apply(
|
||||
settings: Settings = Depends(get_settings),
|
||||
payload: dict | None = Body(default=None),
|
||||
) -> UpdateActionResponse:
|
||||
version = payload.get("version") if payload else None
|
||||
try:
|
||||
update.start_update(settings, version)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to start update")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to start update: {str(exc)}",
|
||||
) from exc
|
||||
return UpdateActionResponse(started=True, message="Update started")
|
||||
|
||||
|
||||
@app.post("/update/rollback", response_model=UpdateActionResponse, dependencies=[Depends(get_current_admin)])
|
||||
def update_rollback(settings: Settings = Depends(get_settings)) -> UpdateActionResponse:
|
||||
try:
|
||||
update.start_rollback(settings)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to start rollback")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to start rollback: {str(exc)}",
|
||||
) from exc
|
||||
return UpdateActionResponse(started=True, message="Rollback started")
|
||||
|
||||
|
||||
@app.get("/update/logs", dependencies=[Depends(get_current_admin)])
|
||||
def update_logs(settings: Settings = Depends(get_settings), limit: int = 200) -> list[dict]:
|
||||
try:
|
||||
return update.get_logs(settings, limit)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to retrieve update logs")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve logs: {str(exc)}",
|
||||
) from exc
|
||||
|
||||
|
||||
@app.get("/update/service-status", response_model=UpdateServiceStatus, dependencies=[Depends(get_current_admin)])
|
||||
def update_service_status(settings: Settings = Depends(get_settings)) -> UpdateServiceStatus:
|
||||
data = update.get_service_status(settings)
|
||||
return UpdateServiceStatus(**data)
|
||||
|
||||
|
||||
@app.get("/system/metrics", response_model=SystemMetrics, dependencies=[Depends(get_current_admin)])
|
||||
def system_metrics_status() -> SystemMetrics:
|
||||
data = system_metrics.get_system_metrics()
|
||||
return SystemMetrics(**data)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/ui/rules", response_class=HTMLResponse)
|
||||
def rules_ui(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("rules.html", {"request": request})
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import datetime as dt
|
||||
import grp
|
||||
import pwd
|
||||
from typing import List, Set
|
||||
import spwd
|
||||
from typing import List, Optional, Set
|
||||
|
||||
import jwt
|
||||
import pam
|
||||
@ -23,8 +24,8 @@ def _is_member_of(username: str, groups: Set[str]) -> bool:
|
||||
return bool(user_groups & groups)
|
||||
|
||||
|
||||
def is_authorized_admin(username: str, settings: Settings) -> bool:
|
||||
if settings.auth_mode == "oidc":
|
||||
def is_authorized_admin(username: str, settings: Settings, mode: Optional[str] = None) -> bool:
|
||||
if mode == "oidc":
|
||||
allowed_users = set(settings.auth_allowed_users)
|
||||
if allowed_users and username not in allowed_users:
|
||||
return False
|
||||
@ -46,12 +47,7 @@ def is_authorized_admin(username: str, settings: Settings) -> bool:
|
||||
|
||||
|
||||
def authenticate_admin_user(username: str, password: str, settings: Settings) -> None:
|
||||
if settings.auth_mode != "pam":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Password login disabled; OIDC is configured",
|
||||
)
|
||||
if not is_authorized_admin(username, settings):
|
||||
if not is_authorized_admin(username, settings, mode="pam"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User not authorized to log in",
|
||||
@ -82,7 +78,12 @@ def decode_token(token: str, settings: Settings) -> str:
|
||||
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):
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
if not (
|
||||
is_authorized_admin(username, settings, mode="pam")
|
||||
or is_authorized_admin(username, settings, mode="oidc")
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Unauthorized user")
|
||||
return username
|
||||
|
||||
@ -119,3 +120,12 @@ def list_manageable_users(settings: Settings) -> List[str]:
|
||||
candidates.append(entry.pw_name)
|
||||
candidates.sort()
|
||||
return candidates
|
||||
|
||||
|
||||
def is_account_locked(username: str) -> bool:
|
||||
try:
|
||||
entry = spwd.getspnam(username)
|
||||
except (KeyError, PermissionError):
|
||||
return False
|
||||
password_hash = entry.sp_pwdp or ""
|
||||
return password_hash.startswith(("!", "*"))
|
||||
|
||||
71
backend/enforcer.py
Normal file
@ -0,0 +1,71 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from backend import actions
|
||||
from backend.auth import is_account_locked, list_manageable_users
|
||||
from backend.rules import RuleManager
|
||||
from backend.settings import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def check_and_enforce_rules():
|
||||
"""Iterate all manageable users and enforce time window rules."""
|
||||
settings = get_settings()
|
||||
manager = RuleManager(settings)
|
||||
|
||||
# We only care about users explicitly managed via settings or rules.
|
||||
target_users = list_manageable_users(settings)
|
||||
|
||||
for user in target_users:
|
||||
try:
|
||||
allowed = manager.is_login_allowed(user)
|
||||
rule = manager.get_rule(user)
|
||||
|
||||
if allowed:
|
||||
# If user has a rule with auto-reenable, ensure unlocked
|
||||
# We check rule existence because "allowed" is also true for users with NO rules.
|
||||
# But for users with no rules, we don't want to auto-unlock randomly (maybe manual lock?).
|
||||
# US says: "Given a rule allows automatic reactivation ... Then account is automatically activated"
|
||||
# So we only auto-unlock if a rule EXISTS and explicitly asks for it.
|
||||
if rule and rule.auto_reenable and is_account_locked(user):
|
||||
logger.info("Auto-enabling user %s (Time window started)", user)
|
||||
actions.enable_user(user)
|
||||
continue
|
||||
|
||||
# If we are here, access is DENIED.
|
||||
|
||||
# Check if logged in
|
||||
logged_in_users = actions.list_logged_in_users()
|
||||
is_logged_in = user in logged_in_users
|
||||
|
||||
if is_logged_in:
|
||||
logger.warning("User %s is logged in during forbidden time. Enforcing logout.", user)
|
||||
# Warn and shutdown
|
||||
actions.disable_user(
|
||||
user,
|
||||
countdown=60,
|
||||
sound=True,
|
||||
message="Time limit reached. Shutdown in 60s.",
|
||||
)
|
||||
else:
|
||||
# Not logged in. Ensure account is locked to prevent login.
|
||||
if not is_account_locked(user):
|
||||
logger.info("Locking user %s (Time window ended)", user)
|
||||
# disable_user locks the account. We pass countdown=0 but since not logged in, it won't matter much.
|
||||
actions.disable_user(user, countdown=0, sound=False)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error enforcing rules for user %s", user)
|
||||
|
||||
|
||||
async def enforcement_loop():
|
||||
logger.info("Starting enforcement loop")
|
||||
while True:
|
||||
try:
|
||||
await check_and_enforce_rules()
|
||||
except Exception:
|
||||
logger.exception("Error in enforcement loop")
|
||||
|
||||
# Run every minute
|
||||
await asyncio.sleep(60)
|
||||
@ -1,8 +1,36 @@
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DayOfWeek(str, Enum):
|
||||
MONDAY = "mon"
|
||||
TUESDAY = "tue"
|
||||
WEDNESDAY = "wed"
|
||||
THURSDAY = "thu"
|
||||
FRIDAY = "fri"
|
||||
SATURDAY = "sat"
|
||||
SUNDAY = "sun"
|
||||
|
||||
|
||||
class TimeWindow(BaseModel):
|
||||
days: List[DayOfWeek]
|
||||
start_time: str = Field(..., pattern=r"^\d{2}:\d{2}$", description="HH:MM format")
|
||||
end_time: str = Field(..., pattern=r"^\d{2}:\d{2}$", description="HH:MM format")
|
||||
|
||||
|
||||
class AccessRule(BaseModel):
|
||||
user: str
|
||||
enabled: bool = True
|
||||
auto_reenable: bool = False
|
||||
allowed_windows: List[TimeWindow] = []
|
||||
|
||||
|
||||
class RuleSet(BaseModel):
|
||||
rules: Dict[str, AccessRule] = {} # Keyed by username for O(1) lookup
|
||||
|
||||
|
||||
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")
|
||||
@ -23,6 +51,7 @@ class ActionResponse(BaseModel):
|
||||
class UserStatus(BaseModel):
|
||||
user: str
|
||||
logged_in: bool
|
||||
account_locked: bool
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@ -33,3 +62,62 @@ class LoginRequest(BaseModel):
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
class UpdateStatus(BaseModel):
|
||||
current_version: str
|
||||
last_status: str
|
||||
last_error: Optional[str] = None
|
||||
last_timestamp: Optional[str] = None
|
||||
enrolled: bool = False
|
||||
|
||||
|
||||
class EnrollRequest(BaseModel):
|
||||
enroll_token: Optional[str] = None
|
||||
|
||||
|
||||
class EnrollResponse(BaseModel):
|
||||
enrolled: bool
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateCheckResponse(BaseModel):
|
||||
available: bool
|
||||
latest_version: str
|
||||
artifact_url: str
|
||||
sha256: str
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateActionResponse(BaseModel):
|
||||
started: bool
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateLogEntry(BaseModel):
|
||||
timestamp: str
|
||||
status: str
|
||||
message: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
device_id: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateServiceStatus(BaseModel):
|
||||
url: str
|
||||
reachable: bool
|
||||
status_code: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
checked_url: str
|
||||
environment: str
|
||||
|
||||
|
||||
class SystemMetrics(BaseModel):
|
||||
cpu_percent: float
|
||||
ram_total_mb: float
|
||||
ram_used_percent: float
|
||||
gpu_vram_total_mb: Optional[float] = None
|
||||
gpu_vram_used_percent: Optional[float] = None
|
||||
gpu_present: bool = False
|
||||
net_rx_mbps: float
|
||||
net_tx_mbps: float
|
||||
|
||||
91
backend/rules.py
Normal file
@ -0,0 +1,91 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from backend.models import RuleSet, AccessRule, DayOfWeek
|
||||
from backend.settings import get_settings, Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuleManager:
|
||||
def __init__(self, settings: Optional[Settings] = None):
|
||||
self.settings = settings or get_settings()
|
||||
self.rules_file = self.settings.rules_file
|
||||
|
||||
def load_rules(self) -> RuleSet:
|
||||
if not os.path.exists(self.rules_file):
|
||||
return RuleSet()
|
||||
try:
|
||||
with open(self.rules_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return RuleSet(**data)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error("Failed to load rules from %s: %s", self.rules_file, e)
|
||||
return RuleSet()
|
||||
|
||||
def save_rules(self, rules: RuleSet) -> None:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.rules_file), exist_ok=True)
|
||||
with open(self.rules_file, "w", encoding="utf-8") as f:
|
||||
f.write(rules.model_dump_json(indent=2))
|
||||
except OSError as e:
|
||||
logger.error("Failed to save rules to %s: %s", self.rules_file, e)
|
||||
raise
|
||||
|
||||
def get_rule(self, user: str) -> Optional[AccessRule]:
|
||||
rules = self.load_rules()
|
||||
return rules.rules.get(user)
|
||||
|
||||
def set_rule(self, rule: AccessRule) -> None:
|
||||
rules = self.load_rules()
|
||||
rules.rules[rule.user] = rule
|
||||
self.save_rules(rules)
|
||||
|
||||
def delete_rule(self, user: str) -> None:
|
||||
rules = self.load_rules()
|
||||
if user in rules.rules:
|
||||
del rules.rules[user]
|
||||
self.save_rules(rules)
|
||||
|
||||
def is_login_allowed(self, user: str, now: Optional[datetime] = None) -> bool:
|
||||
"""
|
||||
Check if user is allowed to login at the given time (or now).
|
||||
Returns True if no rule exists (or disabled) or if time is within an allowed window.
|
||||
Returns False if a rule exists and current time is outside all allowed windows.
|
||||
"""
|
||||
rule = self.get_rule(user)
|
||||
if not rule or not rule.enabled:
|
||||
# No rule or rule disabled -> Default Allow
|
||||
return True
|
||||
|
||||
if not rule.allowed_windows:
|
||||
# Rule enabled but no windows -> Deny All
|
||||
return False
|
||||
|
||||
if now is None:
|
||||
now = datetime.now()
|
||||
|
||||
# Map weekday to Enum
|
||||
weekday_map = {
|
||||
0: DayOfWeek.MONDAY,
|
||||
1: DayOfWeek.TUESDAY,
|
||||
2: DayOfWeek.WEDNESDAY,
|
||||
3: DayOfWeek.THURSDAY,
|
||||
4: DayOfWeek.FRIDAY,
|
||||
5: DayOfWeek.SATURDAY,
|
||||
6: DayOfWeek.SUNDAY,
|
||||
}
|
||||
current_day = weekday_map[now.weekday()]
|
||||
current_time_str = now.strftime("%H:%M")
|
||||
|
||||
for window in rule.allowed_windows:
|
||||
if current_day in window.days:
|
||||
# Handle simple range start <= now <= end
|
||||
# (Overnight windows assumed to be split by user into two windows)
|
||||
if window.start_time <= current_time_str <= window.end_time:
|
||||
return True
|
||||
|
||||
return False
|
||||
@ -32,10 +32,34 @@ class Settings:
|
||||
self.oidc_state_cookie_name: str = os.getenv(
|
||||
"SKD_OIDC_STATE_COOKIE_NAME", "skd_oidc_state"
|
||||
)
|
||||
self.oidc_enabled: bool = bool(
|
||||
self.oidc_issuer and self.oidc_client_id and self.oidc_client_secret
|
||||
)
|
||||
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"
|
||||
self.update_url: str = os.getenv("SKD_UPDATE_URL", "https://update.wlkns.org")
|
||||
self.update_service_url: str = os.getenv("SKD_UPDATE_SERVICE_URL", "https://update.wlkns.org")
|
||||
self.update_project_id: str = os.getenv("SKD_UPDATE_PROJECT_ID", "safe-kiddo-control")
|
||||
self.update_enroll_token: str = os.getenv("SKD_UPDATE_ENROLL_TOKEN", "")
|
||||
self.update_token_file: str = os.getenv(
|
||||
"SKD_UPDATE_TOKEN_FILE", "/var/lib/skd/update_token"
|
||||
)
|
||||
self.update_token: str = self._load_update_token()
|
||||
self.update_interval: int = int(os.getenv("SKD_UPDATE_INTERVAL", "3600"))
|
||||
self.update_status_url: str = os.getenv(
|
||||
"SKD_UPDATE_STATUS_URL", "https://update.wlkns.org/status"
|
||||
)
|
||||
self.update_status_file: str = os.getenv(
|
||||
"SKD_UPDATE_STATUS_FILE", "/var/lib/skd/update_status.json"
|
||||
)
|
||||
self.update_log_file: str = os.getenv(
|
||||
"SKD_UPDATE_LOG_FILE", "/var/lib/skd/update_logs.jsonl"
|
||||
)
|
||||
self.rules_file: str = os.getenv(
|
||||
"SKD_RULES_FILE", "/var/lib/skd/rules.json"
|
||||
)
|
||||
# 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")
|
||||
@ -48,6 +72,18 @@ class Settings:
|
||||
def _parse_list(value: str) -> List[str]:
|
||||
return [item for item in (part.strip() for part in value.split(",")) if item]
|
||||
|
||||
def _load_update_token(self) -> str:
|
||||
env_token = os.getenv("SKD_UPDATE_TOKEN", "")
|
||||
if env_token:
|
||||
return env_token
|
||||
if os.path.exists(self.update_token_file):
|
||||
try:
|
||||
with open(self.update_token_file, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
928
backend/static/styles.css
Executable file
@ -0,0 +1,928 @@
|
||||
/* Professional IT Asset Management - Best of Both Worlds with Dark Mode */
|
||||
:root {
|
||||
--bg-main: #f5f7fa;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--text-main: #2d3748;
|
||||
--text-secondary: #718096;
|
||||
--text-light: #a0aec0;
|
||||
--border-main: #e2e8f0;
|
||||
--primary: #667eea;
|
||||
--primary-hover: #5568d3;
|
||||
--success: #48bb78;
|
||||
--success-hover: #38a169;
|
||||
--warning: #ed8936;
|
||||
--danger: #f56565;
|
||||
--danger-hover: #e53e3e;
|
||||
--info: #4299e1;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
|
||||
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Dark Mode Theme */
|
||||
body.dark-mode {
|
||||
--bg-main: #1a202c;
|
||||
--bg-panel: #2d3748;
|
||||
--bg-header: linear-gradient(135deg, #4c51bf 0%, #6b46c1 100%);
|
||||
--text-main: #f7fafc;
|
||||
--text-secondary: #cbd5e0;
|
||||
--text-light: #a0aec0;
|
||||
--border-main: #4a5568;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
|
||||
--shadow-md: 0 4px 6px rgba(0,0,0,0.3);
|
||||
--shadow-lg: 0 10px 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
line-height: 1.6;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Theme Toggle Button */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.theme-toggle svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke: white;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.theme-toggle .sun-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.dark-mode .theme-toggle .moon-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.dark-mode .theme-toggle .sun-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.login-landing {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: radial-gradient(circle at 20% 10%, rgba(102, 126, 234, 0.15), transparent 45%),
|
||||
radial-gradient(circle at 80% 20%, rgba(72, 187, 120, 0.12), transparent 40%),
|
||||
var(--bg-main);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(520px, 100%);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-brand i {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.login-brand h1 {
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header - Keep the gradient but more professional */
|
||||
header {
|
||||
background: var(--bg-header);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 1rem;
|
||||
opacity: 0.95;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-main i {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
height: 34px;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-version {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Statistics Dashboard - More visual interest */
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-panel);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-md);
|
||||
border-left: 4px solid var(--primary);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: var(--primary);
|
||||
opacity: 0.05;
|
||||
border-radius: 50%;
|
||||
transform: translate(30%, -30%);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-left-width: 6px;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Controls - Clean but distinctive */
|
||||
.controls {
|
||||
background: var(--bg-panel);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-main);
|
||||
}
|
||||
|
||||
/* Inputs - More refined */
|
||||
input, select {
|
||||
padding: 11px 14px;
|
||||
border: 2px solid var(--border-main);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-main);
|
||||
transition: all 0.2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
/* Buttons - Keep gradient feel but professional */
|
||||
button {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 11px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: var(--success-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
/* Table Container - More polished */
|
||||
.table-container {
|
||||
background: var(--bg-panel);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-main);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-main);
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid var(--border-main);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid var(--border-main);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
td strong {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Badges - More colorful but professional */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-available {
|
||||
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.status-in_use {
|
||||
background: linear-gradient(135deg, #bee3f8 0%, #90cdf4 100%);
|
||||
color: #2c5282;
|
||||
}
|
||||
|
||||
.status-maintenance {
|
||||
background: linear-gradient(135deg, #feebc8 0%, #fbd38d 100%);
|
||||
color: #7c2d12;
|
||||
}
|
||||
|
||||
.status-retired {
|
||||
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%);
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.status-lost {
|
||||
background: linear-gradient(135deg, #fed7d7 0%, #fc8181 100%);
|
||||
color: #742a2a;
|
||||
}
|
||||
|
||||
/* Modal - Elegant overlay */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-panel);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid var(--border-main);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
color: var(--text-main);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: var(--border-main);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
line-height: 1;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Form - Clean and accessible */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: inherit;
|
||||
padding: 11px 14px;
|
||||
border: 2px solid var(--border-main);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-main);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid var(--border-main);
|
||||
}
|
||||
|
||||
/* Loading & Empty States */
|
||||
.loading, .empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.4;
|
||||
stroke: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: var(--text-main);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
padding: 7px 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Import Zone - Visual and inviting */
|
||||
.import-zone {
|
||||
border: 3px dashed var(--border-main);
|
||||
border-radius: 12px;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
.import-zone:hover, .import-zone.drag-over {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-panel);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.import-zone.processing {
|
||||
border-color: var(--success);
|
||||
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
|
||||
}
|
||||
|
||||
.import-zone svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
.import-zone h3 {
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.import-zone p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Import Results */
|
||||
.import-results {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.import-results.success {
|
||||
background: #c6f6d5;
|
||||
border-color: var(--success);
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.import-results.error {
|
||||
background: #fed7d7;
|
||||
border-color: var(--danger);
|
||||
color: #742a2a;
|
||||
}
|
||||
|
||||
/* Progress Bar - More visual */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: var(--border-main);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary) 0%, var(--info) 100%);
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* NEW STYLES FOR ENHANCED UI */
|
||||
|
||||
.error-card {
|
||||
background-color: var(--danger);
|
||||
color: white;
|
||||
border-left-color: var(--danger-hover);
|
||||
}
|
||||
|
||||
.error-card h3 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-left-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card details {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stat-card summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card summary:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.flags-list {
|
||||
list-style-type: none;
|
||||
padding-left: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flags-list li {
|
||||
margin-bottom: 6px;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flags-list li::before {
|
||||
content: '›';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.value.level-high {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.value.level-medium {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.value.level-low {
|
||||
color: var(--success);
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--bg-panel);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-md);
|
||||
border-left: 4px solid var(--primary);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: var(--primary);
|
||||
opacity: 0.05;
|
||||
border-radius: 50%;
|
||||
transform: translate(30%, -30%);
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-left-width: 6px;
|
||||
}
|
||||
|
||||
.metric-card .label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.metric-card .value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.metric-bar {
|
||||
height: 6px;
|
||||
background: var(--border-main);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.metric-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--primary);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.metric-bar-fill.info {
|
||||
background: var(--info);
|
||||
}
|
||||
|
||||
.metric-bar-fill.success {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.metric-bar-fill.warning {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
background: var(--bg-panel);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--border-main);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.panel-section h3 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
139
backend/system_metrics.py
Normal file
@ -0,0 +1,139 @@
|
||||
import time
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
_last_cpu: Optional[Tuple[float, float]] = None
|
||||
_last_net: Optional[Tuple[float, float, float]] = None
|
||||
|
||||
|
||||
def _read_cpu_times() -> Tuple[float, float]:
|
||||
with open("/proc/stat", "r", encoding="utf-8") as handle:
|
||||
line = handle.readline()
|
||||
parts = line.strip().split()
|
||||
if not parts or parts[0] != "cpu":
|
||||
return 0.0, 0.0
|
||||
values = [float(p) for p in parts[1:]]
|
||||
total = sum(values)
|
||||
idle = values[3] if len(values) > 3 else 0.0
|
||||
return total, idle
|
||||
|
||||
|
||||
def _cpu_percent() -> float:
|
||||
global _last_cpu
|
||||
total, idle = _read_cpu_times()
|
||||
if _last_cpu is None:
|
||||
_last_cpu = (total, idle)
|
||||
return 0.0
|
||||
last_total, last_idle = _last_cpu
|
||||
_last_cpu = (total, idle)
|
||||
delta_total = total - last_total
|
||||
delta_idle = idle - last_idle
|
||||
if delta_total <= 0:
|
||||
return 0.0
|
||||
return max(0.0, min(100.0, (delta_total - delta_idle) / delta_total * 100.0))
|
||||
|
||||
|
||||
def _read_meminfo() -> Dict[str, float]:
|
||||
data: Dict[str, float] = {}
|
||||
with open("/proc/meminfo", "r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
key, value = line.split(":", 1)
|
||||
parts = value.strip().split()
|
||||
if not parts:
|
||||
continue
|
||||
data[key] = float(parts[0])
|
||||
return data
|
||||
|
||||
|
||||
def _read_net_bytes() -> Tuple[float, float]:
|
||||
rx_total = 0.0
|
||||
tx_total = 0.0
|
||||
with open("/proc/net/dev", "r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if ":" not in line:
|
||||
continue
|
||||
iface, stats = line.split(":", 1)
|
||||
iface = iface.strip()
|
||||
if iface == "lo":
|
||||
continue
|
||||
fields = stats.split()
|
||||
if len(fields) < 16:
|
||||
continue
|
||||
rx_total += float(fields[0])
|
||||
tx_total += float(fields[8])
|
||||
return rx_total, tx_total
|
||||
|
||||
|
||||
def _net_mbps() -> Tuple[float, float]:
|
||||
global _last_net
|
||||
now = time.time()
|
||||
rx, tx = _read_net_bytes()
|
||||
if _last_net is None:
|
||||
_last_net = (now, rx, tx)
|
||||
return 0.0, 0.0
|
||||
last_time, last_rx, last_tx = _last_net
|
||||
_last_net = (now, rx, tx)
|
||||
delta_t = now - last_time
|
||||
if delta_t <= 0:
|
||||
return 0.0, 0.0
|
||||
rx_mbps = (rx - last_rx) * 8.0 / (delta_t * 1_000_000.0)
|
||||
tx_mbps = (tx - last_tx) * 8.0 / (delta_t * 1_000_000.0)
|
||||
return max(0.0, rx_mbps), max(0.0, tx_mbps)
|
||||
|
||||
|
||||
def _gpu_metrics() -> Dict[str, Any]:
|
||||
if not shutil.which("nvidia-smi"):
|
||||
return {
|
||||
"gpu_vram_total_mb": None,
|
||||
"gpu_vram_used_percent": None,
|
||||
"gpu_present": False,
|
||||
}
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=memory.total,memory.used",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
text=True,
|
||||
).strip()
|
||||
if not output:
|
||||
raise ValueError("empty nvidia-smi output")
|
||||
total_str, used_str = output.split(",", 1)
|
||||
total_mb = float(total_str.strip())
|
||||
used_mb = float(used_str.strip())
|
||||
used_percent = 0.0 if total_mb == 0 else used_mb / total_mb * 100.0
|
||||
return {
|
||||
"gpu_vram_total_mb": total_mb,
|
||||
"gpu_vram_used_percent": used_percent,
|
||||
"gpu_present": True,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"gpu_vram_total_mb": None,
|
||||
"gpu_vram_used_percent": None,
|
||||
"gpu_present": False,
|
||||
}
|
||||
|
||||
|
||||
def get_system_metrics() -> Dict[str, Any]:
|
||||
meminfo = _read_meminfo()
|
||||
total_kb = meminfo.get("MemTotal", 0.0)
|
||||
available_kb = meminfo.get("MemAvailable", 0.0)
|
||||
used_kb = max(0.0, total_kb - available_kb)
|
||||
ram_total_mb = total_kb / 1024.0
|
||||
ram_used_percent = 0.0 if total_kb == 0 else used_kb / total_kb * 100.0
|
||||
|
||||
cpu_percent = _cpu_percent()
|
||||
rx_mbps, tx_mbps = _net_mbps()
|
||||
gpu = _gpu_metrics()
|
||||
|
||||
return {
|
||||
"cpu_percent": cpu_percent,
|
||||
"ram_total_mb": ram_total_mb,
|
||||
"ram_used_percent": ram_used_percent,
|
||||
"net_rx_mbps": rx_mbps,
|
||||
"net_tx_mbps": tx_mbps,
|
||||
**gpu,
|
||||
}
|
||||
@ -4,105 +4,269 @@
|
||||
<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>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg" />
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
</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>
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div id="loginLanding" class="login-landing">
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<i data-lucide="shield-check"></i>
|
||||
<div>
|
||||
<h1>Safe Kiddo Control</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="loginSection">
|
||||
<h3><i data-lucide="log-in"></i> Anmeldung</h3>
|
||||
|
||||
<section>
|
||||
<h3>Login (nur Root-User)</h3>
|
||||
<p>Bevorzugt OIDC nutzen, falls konfiguriert. Die Anmeldung öffnet den Identity Provider und setzt eine Session-Cookie.</p>
|
||||
<button id="oidcLogin" type="button">Login via OIDC</button>
|
||||
<hr />
|
||||
<p>Lokale Anmeldung (PAM) nur falls OIDC nicht verfügbar:</p>
|
||||
<form id="loginForm">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label for="loginUser">Benutzer</label>
|
||||
<input id="loginUser" name="loginUser" autocomplete="username" required />
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<label for="loginPass">Passwort</label>
|
||||
<input id="loginPass" name="loginPass" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Anmelden</button>
|
||||
<div class="login-actions">
|
||||
<button type="submit">
|
||||
<i data-lucide="log-in"></i>
|
||||
Anmelden
|
||||
</button>
|
||||
<button id="oidcLogin" type="button" class="secondary" disabled>
|
||||
<i data-lucide="key-round"></i>
|
||||
OIDC Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="loginStatus" class="log"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="appContainer" class="container hidden">
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<div class="header-main">
|
||||
<h1>
|
||||
<i data-lucide="shield-check"></i>
|
||||
Safe Kiddo Control
|
||||
</h1>
|
||||
<img class="header-logo" src="/assets/branding/logo.svg" alt="Safe Kiddo Logo" />
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<div class="header-version" id="headerVersion">v-</div>
|
||||
<div class="user-info" id="headerStatus">
|
||||
<span id="currentUser">Nicht angemeldet</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Metrics Dashboard -->
|
||||
<div class="metrics">
|
||||
<div class="metric-card" id="metricUsers">
|
||||
<div class="label"><i data-lucide="users"></i> Verwaltbare Nutzer</div>
|
||||
<div class="value">-</div>
|
||||
</div>
|
||||
<div class="metric-card" id="metricActive">
|
||||
<div class="label"><i data-lucide="user-check"></i> Aktive Konten</div>
|
||||
<div class="value">-</div>
|
||||
</div>
|
||||
<div class="metric-card warning" id="metricLocked">
|
||||
<div class="label"><i data-lucide="lock"></i> Gesperrte Konten</div>
|
||||
<div class="value">-</div>
|
||||
</div>
|
||||
<div class="metric-card" id="metricVersion">
|
||||
<div class="label"><i data-lucide="package"></i> Version</div>
|
||||
<div class="value text-muted" style="font-size: 1.25rem;">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Information Section -->
|
||||
<section id="systemSection" class="panel-section hidden">
|
||||
<h3><i data-lucide="monitor"></i> System Information</h3>
|
||||
<div class="metrics" id="systemMetrics">
|
||||
<div class="metric-card" id="metricCpu">
|
||||
<div class="label"><i data-lucide="cpu"></i> CPU</div>
|
||||
<div class="value">-</div>
|
||||
<div class="metric-bar"><div class="metric-bar-fill primary" id="metricCpuBar"></div></div>
|
||||
</div>
|
||||
<div class="metric-card" id="metricRam">
|
||||
<div class="label"><i data-lucide="memory-stick"></i> RAM</div>
|
||||
<div class="value">-</div>
|
||||
<div class="metric-bar"><div class="metric-bar-fill info" id="metricRamBar"></div></div>
|
||||
</div>
|
||||
<div class="metric-card" id="metricGpu">
|
||||
<div class="label"><i data-lucide="monitor"></i> GPU</div>
|
||||
<div class="value">-</div>
|
||||
<div class="metric-bar"><div class="metric-bar-fill warning" id="metricGpuBar"></div></div>
|
||||
</div>
|
||||
<div class="metric-card" id="metricNet">
|
||||
<div class="label"><i data-lucide="network"></i> Netzwerk</div>
|
||||
<div class="value">-</div>
|
||||
<div class="metric-bar"><div class="metric-bar-fill success" id="metricNetBar"></div></div>
|
||||
<div class="text-muted" style="font-size: 0.75rem; margin-top: 6px;">Skala: 100 Mbps</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size: 0.75rem; margin-top: 0.5rem;">Aktualisierung alle 5 Sekunden.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Status abrufen</h3>
|
||||
<button id="refreshBtn">Status laden</button>
|
||||
<div id="status" class="log"></div>
|
||||
<!-- User Management Section -->
|
||||
<section id="userSection" class="panel-section hidden">
|
||||
<h3><i data-lucide="users"></i> Nutzerverwaltung</h3>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<button id="refreshUsersBtn" class="secondary small">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Aktualisieren
|
||||
</button>
|
||||
<a href="/ui/rules" class="button secondary small">
|
||||
<i data-lucide="clock"></i>
|
||||
Regeln verwalten
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="user-table mt-1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzer</th>
|
||||
<th>Status</th>
|
||||
<th>Eingeloggt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userTableBody">
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">
|
||||
<div class="spinner" style="margin: 1rem auto;"></div>
|
||||
Lade Benutzer...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<!-- Update Management Section -->
|
||||
<section id="updateSection" class="panel-section hidden">
|
||||
<h3><i data-lucide="download"></i> Update-Verwaltung</h3>
|
||||
|
||||
<div id="updateStatus" style="background: var(--bg-panel); border: 1px solid var(--border-main); border-left: 3px solid var(--primary); padding: 1rem; border-radius: var(--radius-sm); margin-bottom: 1rem;">
|
||||
<div class="spinner" style="margin: 0 auto;"></div>
|
||||
<p class="text-center text-muted mt-1">Lade Update-Status...</p>
|
||||
</div>
|
||||
|
||||
<!-- Enrollment Section -->
|
||||
<div id="enrollmentSection" class="hidden" style="margin-bottom: 1rem; padding: 1rem; background: var(--bg-panel); border-radius: var(--radius-sm); border: 1px dashed var(--border-main);">
|
||||
<h4 style="margin-top: 0; margin-bottom: 0.5rem;"><i data-lucide="link"></i> Gerät registrieren (Enrollment)</h4>
|
||||
<p class="text-muted" style="font-size: 0.875rem; margin-bottom: 0.5rem;">
|
||||
Dieses Gerät ist noch nicht beim Update-Service registriert. Bitte geben Sie einen gültigen Enrollment-Token ein.
|
||||
</p>
|
||||
<form id="enrollmentForm" style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<input type="text" id="enrollToken" placeholder="Enrollment-Token eingeben..." required style="flex: 1;" />
|
||||
<button type="submit" class="small">
|
||||
<i data-lucide="check"></i> Registrieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<button id="checkUpdateBtn" type="button">
|
||||
<i data-lucide="search"></i>
|
||||
Nach Updates suchen
|
||||
</button>
|
||||
<button id="applyUpdateBtn" type="button" disabled>
|
||||
<i data-lucide="download-cloud"></i>
|
||||
Update installieren
|
||||
</button>
|
||||
<button id="rollbackBtn" type="button" class="secondary">
|
||||
<i data-lucide="undo-2"></i>
|
||||
Rollback durchführen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="updateResult" class="mt-1"></div>
|
||||
|
||||
<details class="mt-1">
|
||||
<summary><i data-lucide="file-text"></i> Update-Logs anzeigen</summary>
|
||||
<button id="refreshLogsBtn" type="button" class="secondary small mt-1">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Logs neu laden
|
||||
</button>
|
||||
<div id="updateLogs" class="mt-1" style="max-height: 400px; overflow-y: auto; background: rgba(10, 14, 20, 0.4); padding: 1rem; border-radius: var(--radius-sm); font-family: var(--font-mono); font-size: 0.875rem;">
|
||||
Keine Logs verfügbar.
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- Action Modal -->
|
||||
<div class="modal" id="actionModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Aktion ausführen</h3>
|
||||
<button class="secondary small" onclick="closeActionModal()">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="actionForm">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label for="username">Benutzer</label>
|
||||
<select id="username" name="username" required>
|
||||
<option value="">-- wählen --</option>
|
||||
</select>
|
||||
<input type="hidden" id="modalUsername" />
|
||||
<input type="hidden" id="modalAction" />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="countdown">Countdown (Sekunden)</label>
|
||||
<input id="countdown" name="countdown" type="number" min="0" value="60" />
|
||||
</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>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sound">Sound abspielen</label>
|
||||
<select id="sound" name="sound">
|
||||
<option value="">Default</option>
|
||||
<option value="true">An</option>
|
||||
<option value="false">Aus</option>
|
||||
<option value="">Standard</option>
|
||||
<option value="true">Ja</option>
|
||||
<option value="false">Nein</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="message">Nachricht (optional)</label>
|
||||
<input id="message" name="message" />
|
||||
<button type="submit">Senden</button>
|
||||
<input id="message" name="message" placeholder="z.B. Speichere deine Arbeit!" />
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" onclick="closeActionModal()">Abbrechen</button>
|
||||
<button type="submit" id="actionSubmitBtn">
|
||||
<i data-lucide="play"></i>
|
||||
Ausführen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="result" class="log"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const statusDiv = document.getElementById('status');
|
||||
const resultDiv = document.getElementById('result');
|
||||
const loginStatus = document.getElementById('loginStatus');
|
||||
const tokenKey = 'skdToken';
|
||||
let currentToken = sessionStorage.getItem(tokenKey) || '';
|
||||
// Initialize Lucide icons
|
||||
lucide.createIcons();
|
||||
|
||||
function setToken(token) {
|
||||
currentToken = token;
|
||||
if (token) {
|
||||
sessionStorage.setItem(tokenKey, token);
|
||||
loginStatus.textContent = 'Angemeldet (Token gespeichert)';
|
||||
} else {
|
||||
sessionStorage.removeItem(tokenKey);
|
||||
loginStatus.textContent = 'Nicht angemeldet';
|
||||
}
|
||||
}
|
||||
setToken(currentToken);
|
||||
// Global state
|
||||
let currentToken = sessionStorage.getItem('skdToken') || '';
|
||||
let latestUpdateCheck = null;
|
||||
|
||||
// Helper functions
|
||||
function authHeaders() {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (currentToken) headers['Authorization'] = `Bearer ${currentToken}`;
|
||||
@ -114,62 +278,346 @@
|
||||
const res = await fetch(path, { ...options, headers, credentials: 'same-origin' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
const error = new Error(text || `${res.status} ${res.statusText}`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Toast notifications
|
||||
function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<i data-lucide="${type === 'success' ? 'check-circle' : type === 'error' ? 'alert-circle' : 'alert-triangle'}"></i>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
lucide.createIcons();
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideIn 0.3s ease reverse';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// Modal functions
|
||||
function openActionModal(username, action) {
|
||||
document.getElementById('modalUsername').value = username;
|
||||
document.getElementById('modalAction').value = action;
|
||||
document.getElementById('actionModal').classList.add('active');
|
||||
|
||||
const title = document.querySelector('#actionModal h3');
|
||||
title.textContent = action === 'disable' ? `${username} deaktivieren` : `${username} aktivieren`;
|
||||
|
||||
// Hide countdown/sound options for enable action
|
||||
const countdownGroup = document.getElementById('countdown').closest('.form-group');
|
||||
const soundGroup = document.getElementById('sound').closest('.form-group');
|
||||
const messageGroup = document.getElementById('message').closest('.form-group');
|
||||
|
||||
if (action === 'enable') {
|
||||
countdownGroup.style.display = 'none';
|
||||
soundGroup.style.display = 'none';
|
||||
messageGroup.style.display = 'none';
|
||||
} else {
|
||||
countdownGroup.style.display = 'block';
|
||||
soundGroup.style.display = 'block';
|
||||
messageGroup.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function closeActionModal() {
|
||||
document.getElementById('actionModal').classList.remove('active');
|
||||
document.getElementById('actionForm').reset();
|
||||
}
|
||||
|
||||
// Authentication
|
||||
function setPath(path) {
|
||||
if (window.location.pathname !== path) {
|
||||
history.replaceState(null, '', path);
|
||||
}
|
||||
}
|
||||
|
||||
function showLanding() {
|
||||
document.getElementById('loginLanding').classList.remove('hidden');
|
||||
document.getElementById('appContainer').classList.add('hidden');
|
||||
document.getElementById('headerVersion').textContent = 'v-';
|
||||
if (window.location.pathname !== '/login') {
|
||||
setPath('/login');
|
||||
}
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
document.getElementById('loginLanding').classList.add('hidden');
|
||||
document.getElementById('appContainer').classList.remove('hidden');
|
||||
setPath('/dashboard');
|
||||
}
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
const data = await api('/me');
|
||||
loginStatus.textContent = `Angemeldet als ${data.user} (${data.auth_mode})`;
|
||||
currentToken = sessionStorage.getItem('skdToken') || currentToken;
|
||||
document.getElementById('currentUser').textContent = `Angemeldet als ${data.user} (${data.auth_mode})`;
|
||||
showApp();
|
||||
document.getElementById('userSection').classList.remove('hidden');
|
||||
document.getElementById('updateSection').classList.remove('hidden');
|
||||
document.getElementById('systemSection').classList.remove('hidden');
|
||||
await refreshUsers();
|
||||
await refreshUpdateStatus();
|
||||
await refreshSystemMetrics();
|
||||
if (!window.systemMetricsTimer) {
|
||||
window.systemMetricsTimer = setInterval(refreshSystemMetrics, 5000);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.status === 401) {
|
||||
loginStatus.textContent = 'Nicht angemeldet';
|
||||
} else {
|
||||
loginStatus.textContent = `Session-Check fehlgeschlagen: ${err.message}`;
|
||||
document.getElementById('currentUser').textContent = 'Nicht angemeldet';
|
||||
showLanding();
|
||||
document.getElementById('userSection').classList.add('hidden');
|
||||
document.getElementById('updateSection').classList.add('hidden');
|
||||
document.getElementById('systemSection').classList.add('hidden');
|
||||
if (window.systemMetricsTimer) {
|
||||
clearInterval(window.systemMetricsTimer);
|
||||
window.systemMetricsTimer = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
statusDiv.textContent = 'Lade...';
|
||||
async function checkOidcStatus() {
|
||||
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);
|
||||
});
|
||||
const data = await api('/login/oidc/status');
|
||||
const button = document.getElementById('oidcLogin');
|
||||
if (data.enabled) {
|
||||
button.disabled = false;
|
||||
button.title = 'Login via OIDC';
|
||||
} else {
|
||||
button.disabled = true;
|
||||
button.title = 'OIDC nicht konfiguriert oder nicht erreichbar';
|
||||
}
|
||||
} catch (err) {
|
||||
statusDiv.textContent = `Fehler: ${err.message}`;
|
||||
const button = document.getElementById('oidcLogin');
|
||||
button.disabled = true;
|
||||
button.title = 'OIDC nicht erreichbar';
|
||||
}
|
||||
}
|
||||
|
||||
// User management
|
||||
async function refreshUsers() {
|
||||
const tbody = document.getElementById('userTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center"><div class="spinner" style="margin: 1rem auto;"></div></td></tr>';
|
||||
|
||||
try {
|
||||
const data = await api('/users');
|
||||
|
||||
// Update metrics
|
||||
document.querySelector('#metricUsers .value').textContent = data.length;
|
||||
document.querySelector('#metricActive .value').textContent = data.filter(u => !u.account_locked).length;
|
||||
document.querySelector('#metricLocked .value').textContent = data.filter(u => u.account_locked).length;
|
||||
|
||||
if (data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Keine verwaltbaren Benutzer gefunden.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.map(u => `
|
||||
<tr>
|
||||
<td><strong>${u.user}</strong></td>
|
||||
<td>
|
||||
<span class="badge ${u.account_locked ? 'error' : 'success'}">
|
||||
<i data-lucide="${u.account_locked ? 'lock' : 'unlock'}"></i>
|
||||
${u.account_locked ? 'Gesperrt' : 'Aktiv'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${u.logged_in ? 'success' : 'neutral'}">
|
||||
<i data-lucide="${u.logged_in ? 'monitor' : 'monitor-off'}"></i>
|
||||
${u.logged_in ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
${u.account_locked
|
||||
? `<button class="small secondary" onclick="openActionModal('${u.user}', 'enable')"><i data-lucide="unlock"></i> Entsperren</button>`
|
||||
: `<button class="small danger" onclick="openActionModal('${u.user}', 'disable')"><i data-lucide="lock"></i> Sperren</button>`
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
lucide.createIcons();
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="text-center"><span class="badge error"><i data-lucide="alert-circle"></i> Fehler: ${err.message}</span></td></tr>`;
|
||||
lucide.createIcons();
|
||||
showToast('Fehler beim Laden der Benutzer: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Update management
|
||||
async function refreshUpdateStatus() {
|
||||
const statusDiv = document.getElementById('updateStatus');
|
||||
// Do not clear the div if we are just refreshing to avoid flickering, maybe?
|
||||
// Actually, loading spinner is fine.
|
||||
statusDiv.innerHTML = '<div class="spinner" style="margin: 0 auto;"></div><p class="text-center text-muted mt-1">Lade Update-Status...</p>';
|
||||
|
||||
try {
|
||||
const [data, serviceStatus] = await Promise.all([
|
||||
api('/update/status'),
|
||||
api('/update/service-status')
|
||||
]);
|
||||
document.querySelector('#metricVersion .value').textContent = data.current_version;
|
||||
document.getElementById('headerVersion').textContent = `v${data.current_version}`;
|
||||
|
||||
const statusBadge = data.last_status === 'success'
|
||||
? '<span class="badge success"><i data-lucide="check-circle"></i> Erfolgreich</span>'
|
||||
: data.last_status === 'failed'
|
||||
? '<span class="badge error"><i data-lucide="x-circle"></i> Fehlgeschlagen</span>'
|
||||
: '<span class="badge neutral"><i data-lucide="minus-circle"></i> Unbekannt</span>';
|
||||
|
||||
const enrolledBadge = data.enrolled
|
||||
? '<span class="badge success"><i data-lucide="link"></i> Verbunden</span>'
|
||||
: '<span class="badge warning"><i data-lucide="link-2-off"></i> Nicht registriert</span>';
|
||||
|
||||
// Toggle Enrollment Section
|
||||
const enrollSection = document.getElementById('enrollmentSection');
|
||||
if (data.enrolled) {
|
||||
enrollSection.classList.add('hidden');
|
||||
} else {
|
||||
enrollSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
const serviceBadge = serviceStatus.reachable
|
||||
? '<span class="badge success"><i data-lucide="server"></i> Online</span>'
|
||||
: '<span class="badge error"><i data-lucide="server-off"></i> Offline</span>';
|
||||
const envLabel = serviceStatus.environment
|
||||
? serviceStatus.environment.toUpperCase()
|
||||
: 'UNKNOWN';
|
||||
const serviceHint = serviceStatus.status_code
|
||||
? `HTTP ${serviceStatus.status_code}`
|
||||
: (serviceStatus.error || 'keine Antwort');
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||
<div>
|
||||
<div class="text-muted" style="font-size: 0.75rem; text-transform: uppercase; margin-bottom: 0.25rem;">Version</div>
|
||||
<div style="color: var(--color-accent); font-weight: 600;">${data.current_version}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted" style="font-size: 0.75rem; text-transform: uppercase; margin-bottom: 0.25rem;">Update-Service</div>
|
||||
<div>${serviceBadge}</div>
|
||||
<div class="badge neutral" style="display: inline-flex; margin-top: 0.25rem;">${envLabel}</div>
|
||||
<div class="text-muted" style="font-size: 0.75rem;">${serviceStatus.url} (${serviceHint})</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted" style="font-size: 0.75rem; text-transform: uppercase; margin-bottom: 0.25rem;">Registrierung</div>
|
||||
<div>${enrolledBadge}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted" style="font-size: 0.75rem; text-transform: uppercase; margin-bottom: 0.25rem;">Letzter Status</div>
|
||||
<div>${statusBadge}</div>
|
||||
</div>
|
||||
${data.last_timestamp ? `
|
||||
<div>
|
||||
<div class="text-muted" style="font-size: 0.75rem; text-transform: uppercase; margin-bottom: 0.25rem;">Zeitstempel</div>
|
||||
<div class="text-muted" style="font-size: 0.875rem;">${new Date(data.last_timestamp).toLocaleString('de-DE')}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${data.last_error ? `
|
||||
<div>
|
||||
<div class="text-muted" style="font-size: 0.75rem; text-transform: uppercase; margin-bottom: 0.25rem;">Fehler</div>
|
||||
<div style="color: var(--color-error); font-size: 0.875rem;">${data.last_error}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
} catch (err) {
|
||||
statusDiv.innerHTML = `<span class="badge error"><i data-lucide="alert-circle"></i> Fehler: ${err.message}</span>`;
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUpdateLogs() {
|
||||
const logsDiv = document.getElementById('updateLogs');
|
||||
logsDiv.innerHTML = '<div class="spinner" style="margin: 1rem auto;"></div>';
|
||||
|
||||
try {
|
||||
const logs = await api('/update/logs');
|
||||
if (!logs || logs.length === 0) {
|
||||
logsDiv.textContent = 'Keine Logs verfügbar.';
|
||||
return;
|
||||
}
|
||||
|
||||
logsDiv.innerHTML = logs.reverse().map(entry => {
|
||||
const timestamp = entry.timestamp ? new Date(entry.timestamp).toLocaleString('de-DE') : 'unbekannt';
|
||||
const status = entry.status || 'unknown';
|
||||
const version = entry.version || '-';
|
||||
const error = entry.error ? `\n Fehler: ${entry.error}` : '';
|
||||
return `[${timestamp}] ${status} - Version: ${version}${error}`;
|
||||
}).join('\n\n');
|
||||
} catch (err) {
|
||||
logsDiv.textContent = `Fehler: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSystemMetrics() {
|
||||
try {
|
||||
const data = await api('/system/metrics');
|
||||
document.querySelector('#metricCpu .value').textContent = `${data.cpu_percent.toFixed(1)}%`;
|
||||
document.getElementById('metricCpuBar').style.width = `${Math.min(100, data.cpu_percent).toFixed(1)}%`;
|
||||
const ramGb = data.ram_total_mb / 1024.0;
|
||||
document.querySelector('#metricRam .value').textContent = `${data.ram_used_percent.toFixed(1)}% (${ramGb.toFixed(1)} GB)`;
|
||||
document.getElementById('metricRamBar').style.width = `${Math.min(100, data.ram_used_percent).toFixed(1)}%`;
|
||||
if (data.gpu_present && data.gpu_vram_total_mb) {
|
||||
const gpuGb = data.gpu_vram_total_mb / 1024.0;
|
||||
const gpuPct = data.gpu_vram_used_percent ?? 0;
|
||||
document.querySelector('#metricGpu .value').textContent = `${gpuPct.toFixed(1)}% (${gpuGb.toFixed(1)} GB)`;
|
||||
document.getElementById('metricGpuBar').style.width = `${Math.min(100, gpuPct).toFixed(1)}%`;
|
||||
} else {
|
||||
document.querySelector('#metricGpu .value').textContent = 'Nicht verfuegbar';
|
||||
document.getElementById('metricGpuBar').style.width = '0%';
|
||||
}
|
||||
document.querySelector('#metricNet .value').textContent = `${data.net_rx_mbps.toFixed(1)} / ${data.net_tx_mbps.toFixed(1)} Mbps`;
|
||||
const netPeak = Math.max(data.net_rx_mbps, data.net_tx_mbps);
|
||||
const netPercent = Math.min(100, (netPeak / 100.0) * 100.0);
|
||||
document.getElementById('metricNetBar').style.width = `${netPercent.toFixed(1)}%`;
|
||||
lucide.createIcons();
|
||||
} catch (err) {
|
||||
document.querySelector('#metricCpu .value').textContent = 'Fehler';
|
||||
document.querySelector('#metricRam .value').textContent = 'Fehler';
|
||||
document.querySelector('#metricGpu .value').textContent = 'Fehler';
|
||||
document.querySelector('#metricNet .value').textContent = 'Fehler';
|
||||
document.getElementById('metricCpuBar').style.width = '0%';
|
||||
document.getElementById('metricRamBar').style.width = '0%';
|
||||
document.getElementById('metricGpuBar').style.width = '0%';
|
||||
document.getElementById('metricNetBar').style.width = '0%';
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
loginStatus.textContent = 'Anmeldung...';
|
||||
const btn = e.target.querySelector('button[type="submit"]');
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<div class="spinner"></div> Anmeldung...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
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();
|
||||
|
||||
currentToken = data.token;
|
||||
sessionStorage.setItem('skdToken', data.token);
|
||||
showToast('Anmeldung erfolgreich', 'success');
|
||||
await checkSession();
|
||||
} catch (err) {
|
||||
setToken('');
|
||||
loginStatus.textContent = `Login fehlgeschlagen: ${err.message}`;
|
||||
showToast('Login fehlgeschlagen: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.disabled = false;
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
|
||||
@ -177,35 +625,192 @@
|
||||
window.location.href = '/login/oidc/start';
|
||||
});
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', refreshUsers);
|
||||
document.getElementById('refreshUsersBtn').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 username = document.getElementById('modalUsername').value;
|
||||
const action = document.getElementById('modalAction').value;
|
||||
const countdown = document.getElementById('countdown').value;
|
||||
const sound = document.getElementById('sound').value;
|
||||
const message = document.getElementById('message').value.trim();
|
||||
|
||||
const btn = document.getElementById('actionSubmitBtn');
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<div class="spinner"></div> Sende...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
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('; ')}`;
|
||||
|
||||
showToast(`${action === 'disable' ? 'Deaktivierung' : 'Aktivierung'} von ${username} erfolgreich`, 'success');
|
||||
closeActionModal();
|
||||
await refreshUsers();
|
||||
} catch (err) {
|
||||
resultDiv.textContent = `Fehler: ${err.message}`;
|
||||
showToast('Fehler: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.disabled = false;
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('checkUpdateBtn').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('checkUpdateBtn');
|
||||
const resultDiv = document.getElementById('updateResult');
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<div class="spinner"></div> Prüfe...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await api('/update/check', { method: 'POST' });
|
||||
latestUpdateCheck = data;
|
||||
|
||||
if (data.available) {
|
||||
resultDiv.innerHTML = `
|
||||
<div style="background: rgba(0, 224, 143, 0.1); border: 1px solid var(--color-success); border-radius: var(--radius-sm); padding: 1rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<i data-lucide="info"></i>
|
||||
<strong style="color: var(--color-success);">Update verfügbar!</strong>
|
||||
</div>
|
||||
<p class="text-muted">Version: ${data.latest_version}</p>
|
||||
${data.message ? `<p class="text-muted">${data.message}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('applyUpdateBtn').disabled = false;
|
||||
} else {
|
||||
resultDiv.innerHTML = `
|
||||
<div style="background: rgba(255, 255, 255, 0.05); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 1rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<i data-lucide="check"></i>
|
||||
<span class="text-muted">Keine Updates verfügbar. Aktuelle Version ist aktuell.</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('applyUpdateBtn').disabled = true;
|
||||
}
|
||||
lucide.createIcons();
|
||||
} catch (err) {
|
||||
resultDiv.innerHTML = `<span class="badge error"><i data-lucide="alert-circle"></i> Fehler: ${err.message}</span>`;
|
||||
lucide.createIcons();
|
||||
showToast('Update-Check fehlgeschlagen', 'error');
|
||||
} finally {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.disabled = false;
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('applyUpdateBtn').addEventListener('click', async () => {
|
||||
if (!latestUpdateCheck || !latestUpdateCheck.available) {
|
||||
showToast('Bitte zuerst nach Updates suchen', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Update auf Version ${latestUpdateCheck.latest_version} installieren?\n\n⚠️ WICHTIG:\n- Ein Backup wird automatisch erstellt\n- Der Service wird neu gestartet\n- Bei Fehlern erfolgt automatischer Rollback\n\nFortfahren?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('applyUpdateBtn');
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<div class="spinner"></div> Starte...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
await api('/update/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ version: latestUpdateCheck.latest_version })
|
||||
});
|
||||
|
||||
showToast('Update gestartet (läuft im Hintergrund)', 'success');
|
||||
setTimeout(() => refreshUpdateStatus(), 5000);
|
||||
} catch (err) {
|
||||
showToast('Fehler beim Starten des Updates: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.disabled = false;
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('rollbackBtn').addEventListener('click', async () => {
|
||||
if (!confirm(`Rollback zum letzten Backup durchführen?\n\n⚠️ WICHTIG:\n- Dies stellt die vorherige Version wieder her\n- Der Service wird neu gestartet\n- Ein Backup muss vorhanden sein\n\nFortfahren?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('rollbackBtn');
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<div class="spinner"></div> Starte...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
await api('/update/rollback', { method: 'POST' });
|
||||
showToast('Rollback gestartet (läuft im Hintergrund)', 'success');
|
||||
setTimeout(() => refreshUpdateStatus(), 5000);
|
||||
} catch (err) {
|
||||
showToast('Fehler beim Rollback: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.disabled = false;
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('refreshLogsBtn').addEventListener('click', refreshUpdateLogs);
|
||||
|
||||
document.getElementById('enrollmentForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const tokenInput = document.getElementById('enrollToken');
|
||||
const token = tokenInput.value.trim();
|
||||
const btn = e.target.querySelector('button');
|
||||
|
||||
if (!token) return;
|
||||
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<div class="spinner"></div> Registriere...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
await api('/update/enroll', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enroll_token: token })
|
||||
});
|
||||
showToast('Gerät erfolgreich registriert!', 'success');
|
||||
tokenInput.value = '';
|
||||
await refreshUpdateStatus();
|
||||
} catch (err) {
|
||||
showToast('Registrierung fehlgeschlagen: ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.disabled = false;
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
checkSession();
|
||||
checkOidcStatus();
|
||||
|
||||
// Dark Mode Toggle
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
// Load dark mode preference
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
442
backend/templates/rules.html
Normal file
@ -0,0 +1,442 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Regelverwaltung - Safe Kiddo</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg" />
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
.rule-editor {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.user-list {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.user-item {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border-main);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.user-item:last-child { border-bottom: none; }
|
||||
.user-item:hover { background: rgba(255,255,255,0.05); }
|
||||
.user-item.active { background: rgba(var(--primary-rgb), 0.1); border-left: 3px solid var(--primary); }
|
||||
|
||||
.editor-panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.window-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.window-item {
|
||||
background: var(--bg-main);
|
||||
border: 1px solid var(--border-main);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.day-badges {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.day-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border-main);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.day-badge.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.time-range {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
.remove-btn {
|
||||
margin-left: auto;
|
||||
color: var(--color-error);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.day-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.day-checkbox {
|
||||
display: none;
|
||||
}
|
||||
.day-label {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--border-main);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
user-select: none;
|
||||
}
|
||||
.day-checkbox:checked + .day-label {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-main">
|
||||
<h1>
|
||||
<i data-lucide="clock"></i>
|
||||
Regelverwaltung
|
||||
</h1>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<a href="/dashboard" class="button secondary small">
|
||||
<i data-lucide="arrow-left"></i> Zurück zum Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="rule-editor" id="ruleEditor">
|
||||
<!-- User List -->
|
||||
<div class="user-list" id="userList">
|
||||
<div style="padding: 1rem; text-align: center; color: var(--text-muted);">
|
||||
Lade Nutzer...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Panel -->
|
||||
<div class="editor-panel hidden" id="editorPanel">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h2 id="selectedUserTitle" style="margin: 0;">Benutzer</h2>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="danger small" id="deleteRulesBtn">Regeln löschen</button>
|
||||
<button class="primary small" id="saveRulesBtn">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="ruleEnabled" checked>
|
||||
<label for="ruleEnabled">Regelwerk aktiv</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="autoReenable">
|
||||
<label for="autoReenable">Automatisch entsperren (wenn Zeitfenster beginnt)</label>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Erlaubte Zeitfenster</h3>
|
||||
<div class="window-list" id="windowList">
|
||||
<!-- Windows go here -->
|
||||
</div>
|
||||
|
||||
<button class="secondary small mt-1" onclick="openAddWindowModal()">
|
||||
<i data-lucide="plus"></i> Zeitfenster hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-panel" id="emptyState" style="text-align: center; padding: 3rem;">
|
||||
<i data-lucide="user" style="width: 48px; height: 48px; color: var(--text-muted); margin-bottom: 1rem;"></i>
|
||||
<p class="text-muted">Wähle einen Benutzer aus, um Regeln zu bearbeiten.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Window Modal -->
|
||||
<div class="modal" id="addWindowModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Zeitfenster hinzufügen</h3>
|
||||
<button class="secondary small" onclick="closeAddWindowModal()">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form id="addWindowForm">
|
||||
<div class="form-group">
|
||||
<label>Wochentage</label>
|
||||
<div class="day-selector">
|
||||
<label><input type="checkbox" class="day-checkbox" value="mon"><span class="day-label">Mo</span></label>
|
||||
<label><input type="checkbox" class="day-checkbox" value="tue"><span class="day-label">Di</span></label>
|
||||
<label><input type="checkbox" class="day-checkbox" value="wed"><span class="day-label">Mi</span></label>
|
||||
<label><input type="checkbox" class="day-checkbox" value="thu"><span class="day-label">Do</span></label>
|
||||
<label><input type="checkbox" class="day-checkbox" value="fri"><span class="day-label">Fr</span></label>
|
||||
<label><input type="checkbox" class="day-checkbox" value="sat"><span class="day-label">Sa</span></label>
|
||||
<label><input type="checkbox" class="day-checkbox" value="sun"><span class="day-label">So</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="form-group">
|
||||
<label>Start</label>
|
||||
<input type="time" id="startTime" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Ende</label>
|
||||
<input type="time" id="endTime" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" onclick="closeAddWindowModal()">Abbrechen</button>
|
||||
<button type="submit">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<script>
|
||||
lucide.createIcons();
|
||||
|
||||
// State
|
||||
let users = [];
|
||||
let currentRules = {}; // Keyed by username
|
||||
let selectedUser = null;
|
||||
let currentWindows = []; // Temp storage for editor
|
||||
|
||||
// API
|
||||
async function api(path, options = {}) {
|
||||
const token = sessionStorage.getItem('skdToken');
|
||||
if (!token) window.location.href = '/login';
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...(options.headers || {})
|
||||
};
|
||||
const res = await fetch(path, { ...options, headers });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}"></i>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
lucide.createIcons();
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideIn 0.3s ease reverse';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// Init
|
||||
async function init() {
|
||||
try {
|
||||
// Load users and rules in parallel
|
||||
const [usersData, rulesData] = await Promise.all([
|
||||
api('/users'),
|
||||
api('/rules')
|
||||
]);
|
||||
|
||||
users = usersData.map(u => u.user);
|
||||
currentRules = rulesData.rules || {};
|
||||
|
||||
renderUserList();
|
||||
} catch (err) {
|
||||
showToast('Fehler beim Laden: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderUserList() {
|
||||
const list = document.getElementById('userList');
|
||||
if (users.length === 0) {
|
||||
list.innerHTML = '<div style="padding:1rem;">Keine Nutzer gefunden.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = users.map(u => {
|
||||
const hasRule = !!currentRules[u];
|
||||
return `
|
||||
<div class="user-item ${selectedUser === u ? 'active' : ''}" onclick="selectUser('${u}')">
|
||||
<div>
|
||||
<strong>${u}</strong>
|
||||
${hasRule ? '<i data-lucide="check" style="width:14px; height:14px; margin-left:4px; color:var(--color-success)"></i>' : ''}
|
||||
</div>
|
||||
<i data-lucide="chevron-right" style="width:16px; height:16px; color:var(--text-muted)"></i>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function selectUser(user) {
|
||||
selectedUser = user;
|
||||
renderUserList(); // Update active state
|
||||
|
||||
document.getElementById('emptyState').classList.add('hidden');
|
||||
document.getElementById('editorPanel').classList.remove('hidden');
|
||||
document.getElementById('selectedUserTitle').textContent = user;
|
||||
|
||||
const rule = currentRules[user] || { enabled: true, auto_reenable: false, allowed_windows: [] };
|
||||
|
||||
document.getElementById('ruleEnabled').checked = rule.enabled;
|
||||
document.getElementById('autoReenable').checked = rule.auto_reenable;
|
||||
currentWindows = [...(rule.allowed_windows || [])];
|
||||
|
||||
renderWindows();
|
||||
}
|
||||
|
||||
function renderWindows() {
|
||||
const container = document.getElementById('windowList');
|
||||
if (currentWindows.length === 0) {
|
||||
container.innerHTML = '<div class="text-muted" style="font-size:0.9rem;">Keine Zeitfenster definiert. Login ist standardmäßig <strong>nicht erlaubt</strong> (wenn Regelwerk aktiv).</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const dayMap = { mon:'Mo', tue:'Di', wed:'Mi', thu:'Do', fri:'Fr', sat:'Sa', sun:'So' };
|
||||
const allDays = ['mon','tue','wed','thu','fri','sat','sun'];
|
||||
|
||||
container.innerHTML = currentWindows.map((win, idx) => {
|
||||
const badges = allDays.map(d => `
|
||||
<span class="day-badge ${win.days.includes(d) ? 'active' : ''}">${dayMap[d]}</span>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<div class="window-item">
|
||||
<div class="day-badges">${badges}</div>
|
||||
<div class="time-range">
|
||||
${win.start_time} - ${win.end_time}
|
||||
</div>
|
||||
<button class="remove-btn" onclick="removeWindow(${idx})">
|
||||
<i data-lucide="trash-2" style="width:16px; height:16px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function removeWindow(index) {
|
||||
currentWindows.splice(index, 1);
|
||||
renderWindows();
|
||||
}
|
||||
|
||||
function openAddWindowModal() {
|
||||
document.getElementById('addWindowForm').reset();
|
||||
// Default checkboxes
|
||||
document.querySelectorAll('.day-checkbox').forEach(cb => {
|
||||
if (['mon','tue','wed','thu','fri'].includes(cb.value)) cb.checked = true;
|
||||
else cb.checked = false;
|
||||
});
|
||||
document.getElementById('addWindowModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeAddWindowModal() {
|
||||
document.getElementById('addWindowModal').classList.remove('active');
|
||||
}
|
||||
|
||||
document.getElementById('addWindowForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const days = Array.from(document.querySelectorAll('.day-checkbox:checked')).map(cb => cb.value);
|
||||
if (days.length === 0) {
|
||||
showToast('Bitte mindestens einen Wochentag wählen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const start = document.getElementById('startTime').value;
|
||||
const end = document.getElementById('endTime').value;
|
||||
|
||||
if (start >= end) {
|
||||
showToast('Endzeit muss nach Startzeit liegen.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentWindows.push({ days, start_time: start, end_time: end });
|
||||
currentWindows.sort((a, b) => a.start_time.localeCompare(b.start_time));
|
||||
|
||||
closeAddWindowModal();
|
||||
renderWindows();
|
||||
});
|
||||
|
||||
document.getElementById('saveRulesBtn').addEventListener('click', async () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
const rule = {
|
||||
user: selectedUser,
|
||||
enabled: document.getElementById('ruleEnabled').checked,
|
||||
auto_reenable: document.getElementById('autoReenable').checked,
|
||||
allowed_windows: currentWindows
|
||||
};
|
||||
|
||||
try {
|
||||
await api('/rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(rule)
|
||||
});
|
||||
currentRules[selectedUser] = rule;
|
||||
showToast('Regeln gespeichert', 'success');
|
||||
renderUserList();
|
||||
} catch (err) {
|
||||
showToast('Speichern fehlgeschlagen: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('deleteRulesBtn').addEventListener('click', async () => {
|
||||
if (!selectedUser || !confirm(`Regeln für ${selectedUser} wirklich löschen?\nDer Nutzer hat dann uneingeschränkten Zugriff.`)) return;
|
||||
|
||||
try {
|
||||
await api(`/rules/${selectedUser}`, { method: 'DELETE' });
|
||||
delete currentRules[selectedUser];
|
||||
showToast('Regeln gelöscht', 'success');
|
||||
selectUser(selectedUser); // Refresh view (defaults)
|
||||
} catch (err) {
|
||||
showToast('Löschen fehlgeschlagen: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Dark Mode (Shared)
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
304
backend/update.py
Normal file
@ -0,0 +1,304 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.settings import Settings
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
try:
|
||||
return (_project_root() / "VERSION").read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _status_path(settings: Settings) -> Path:
|
||||
return Path(settings.update_status_file)
|
||||
|
||||
|
||||
def _log_path(settings: Settings) -> Path:
|
||||
return Path(settings.update_log_file)
|
||||
|
||||
|
||||
def _ensure_parent(path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _write_status(settings: Settings, status: str, version: str, error: str | None = None) -> None:
|
||||
status_path = _status_path(settings)
|
||||
_ensure_parent(status_path)
|
||||
payload = {
|
||||
"device_id": os.uname().nodename,
|
||||
"version": version,
|
||||
"status": status,
|
||||
"error": error or "",
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
status_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
_append_log(settings, payload)
|
||||
|
||||
|
||||
def _append_log(settings: Settings, payload: Dict[str, Any]) -> None:
|
||||
log_path = _log_path(settings)
|
||||
_ensure_parent(log_path)
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload) + "\n")
|
||||
|
||||
|
||||
def get_status(settings: Settings) -> Dict[str, Any]:
|
||||
current_version = _read_version()
|
||||
status_path = _status_path(settings)
|
||||
if status_path.exists():
|
||||
try:
|
||||
data = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
else:
|
||||
data = {}
|
||||
|
||||
return {
|
||||
"current_version": current_version,
|
||||
"last_status": data.get("status", "unknown"),
|
||||
"last_error": data.get("error") or None,
|
||||
"last_timestamp": data.get("timestamp"),
|
||||
"enrolled": bool(settings.update_token),
|
||||
}
|
||||
|
||||
|
||||
def enroll(settings: Settings) -> str:
|
||||
if not settings.update_enroll_token:
|
||||
raise ValueError("No enrollment token provided in settings")
|
||||
|
||||
enroll_url = f"{settings.update_service_url}/v1/enroll"
|
||||
payload = {
|
||||
"project_id": settings.update_project_id,
|
||||
"client_id": os.uname().nodename,
|
||||
"software_id": "safe-kiddo",
|
||||
"enroll_token": settings.update_enroll_token,
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.post(enroll_url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
token = data.get("token")
|
||||
if not token:
|
||||
raise ValueError("Enrollment response did not contain a token")
|
||||
|
||||
# Save token
|
||||
token_path = Path(settings.update_token_file)
|
||||
_ensure_parent(token_path)
|
||||
token_path.write_text(token, encoding="utf-8")
|
||||
# Update settings object for immediate use
|
||||
settings.update_token = token
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def _parse_version(value: str) -> List[int]:
|
||||
return [int(part) for part in value.split(".")]
|
||||
|
||||
|
||||
def _get_fresh_token(settings: Settings) -> str:
|
||||
"""Always reload token from file/env to avoid stale cache."""
|
||||
# Env var takes precedence
|
||||
env_token = os.getenv("SKD_UPDATE_TOKEN", "")
|
||||
if env_token:
|
||||
return env_token
|
||||
# Fallback to file
|
||||
token_file = Path(settings.update_token_file)
|
||||
if token_file.exists():
|
||||
try:
|
||||
return token_file.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
pass
|
||||
return settings.update_token # Fallback to cached value
|
||||
|
||||
|
||||
def check_update(settings: Settings) -> Dict[str, Any]:
|
||||
token = _get_fresh_token(settings)
|
||||
if not token:
|
||||
raise ValueError("Client is not enrolled (missing update token)")
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
manifest_url = (
|
||||
f"{settings.update_service_url}/v1/projects/{settings.update_project_id}/manifest"
|
||||
)
|
||||
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
response = client.get(manifest_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
manifest = response.json()
|
||||
|
||||
latest_version = manifest.get("version", "")
|
||||
artifact_url = manifest.get("artifact_url", "")
|
||||
sha256 = manifest.get("sha256", "")
|
||||
message = manifest.get("message")
|
||||
|
||||
available = False
|
||||
current_version = _read_version()
|
||||
try:
|
||||
available = _parse_version(latest_version) > _parse_version(current_version)
|
||||
except ValueError:
|
||||
if latest_version and latest_version != current_version:
|
||||
available = True
|
||||
|
||||
return {
|
||||
"available": available,
|
||||
"latest_version": latest_version,
|
||||
"artifact_url": artifact_url,
|
||||
"sha256": sha256,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
def report_status(
|
||||
settings: Settings,
|
||||
status: str,
|
||||
version: str,
|
||||
error: str | None = None,
|
||||
duration_ms: int | None = None,
|
||||
) -> None:
|
||||
token = _get_fresh_token(settings)
|
||||
if not token:
|
||||
return
|
||||
|
||||
report_url = (
|
||||
f"{settings.update_service_url}/v1/projects/{settings.update_project_id}/status"
|
||||
)
|
||||
payload = {
|
||||
"project_id": settings.update_project_id,
|
||||
"version": version,
|
||||
"status": status,
|
||||
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"client_id": os.uname().nodename,
|
||||
}
|
||||
if error:
|
||||
payload["error_code"] = error
|
||||
payload["reason"] = error
|
||||
if duration_ms is not None:
|
||||
payload["duration_ms"] = duration_ms
|
||||
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
client.post(report_url, json=payload, headers=headers).raise_for_status()
|
||||
except Exception:
|
||||
# We don't want to crash if status reporting fails
|
||||
pass
|
||||
|
||||
|
||||
def _run_async(script_path: Path, settings: Settings) -> None:
|
||||
# Explicitly gather the env vars we need to pass
|
||||
env_vars = {
|
||||
"SKD_UPDATE_SERVICE_URL": settings.update_service_url,
|
||||
"SKD_UPDATE_PROJECT_ID": settings.update_project_id,
|
||||
"SKD_UPDATE_TOKEN": _get_fresh_token(settings),
|
||||
"SKD_UPDATE_STATUS_FILE": settings.update_status_file,
|
||||
"SKD_UPDATE_LOG_FILE": settings.update_log_file,
|
||||
}
|
||||
|
||||
# Construct systemd-run command with --setenv for each variable
|
||||
cmd = [
|
||||
"systemd-run",
|
||||
"--unit=skd-update",
|
||||
"--collect",
|
||||
"--description=Safe Kiddo Update Process",
|
||||
]
|
||||
|
||||
for key, val in env_vars.items():
|
||||
if val: # Only pass if not empty
|
||||
cmd.append(f"--setenv={key}={val}")
|
||||
|
||||
cmd.append(str(script_path))
|
||||
|
||||
# We don't pass 'env' parameter to Popen because systemd-run ignores it
|
||||
# for the target process (it only uses it for itself, but we use --setenv).
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
cwd="/",
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def start_update(settings: Settings, version: str | None = None) -> None:
|
||||
current_version = version or _read_version()
|
||||
_write_status(settings, "in_progress", current_version)
|
||||
report_status(settings, "in_progress", current_version)
|
||||
script = _project_root() / "scripts" / "update_client.sh"
|
||||
_run_async(script, settings)
|
||||
|
||||
|
||||
def start_rollback(settings: Settings) -> None:
|
||||
current_version = _read_version()
|
||||
_write_status(settings, "in_progress", current_version)
|
||||
report_status(settings, "in_progress", current_version)
|
||||
script = _project_root() / "scripts" / "rollback_client.sh"
|
||||
_run_async(script, settings)
|
||||
|
||||
|
||||
def get_logs(settings: Settings, limit: int = 200) -> List[Dict[str, Any]]:
|
||||
log_path = _log_path(settings)
|
||||
if not log_path.exists():
|
||||
return []
|
||||
lines = log_path.read_text(encoding="utf-8").splitlines()
|
||||
entries: List[Dict[str, Any]] = []
|
||||
for line in lines[-limit:]:
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def get_service_status(settings: Settings) -> Dict[str, Any]:
|
||||
base_url = settings.update_service_url.rstrip("/")
|
||||
if not base_url:
|
||||
return {
|
||||
"url": "",
|
||||
"reachable": False,
|
||||
"status_code": None,
|
||||
"error": "update service url not configured",
|
||||
"checked_url": "",
|
||||
"environment": "unknown",
|
||||
}
|
||||
|
||||
env = "prod"
|
||||
lowered = base_url.lower()
|
||||
if "://dev." in lowered or lowered.startswith("dev."):
|
||||
env = "dev"
|
||||
elif "://staging." in lowered or lowered.startswith("staging."):
|
||||
env = "staging"
|
||||
|
||||
check_url = base_url
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as client:
|
||||
response = client.get(check_url)
|
||||
return {
|
||||
"url": base_url,
|
||||
"reachable": True,
|
||||
"status_code": response.status_code,
|
||||
"error": None,
|
||||
"checked_url": check_url,
|
||||
"environment": env,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"url": base_url,
|
||||
"reachable": False,
|
||||
"status_code": None,
|
||||
"error": str(exc),
|
||||
"checked_url": check_url,
|
||||
"environment": env,
|
||||
}
|
||||
79
docs/ARCHITECTURE.md
Normal file
@ -0,0 +1,79 @@
|
||||
ID: DOC_000012 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Architektur
|
||||
|
||||
## Systemuebersicht
|
||||
Safe Kiddo Daemon ist ein FastAPI-basierter Service, der lokale Systemkonten verwaltet. Er kombiniert:
|
||||
- API und Web-UI (FastAPI + Jinja2 Templates)
|
||||
- Authentifizierung (PAM und optional OIDC)
|
||||
- Systemaktionen via sudo/usermod/pkill/shutdown
|
||||
- Update-Client-Integration (externes Update-Service-Backend)
|
||||
|
||||
## Externe Abhaengigkeiten
|
||||
- Update-Service (intern): https://git.wlkns.org/stephan/update-webservice
|
||||
- OIDC-Service (intern): https://git.wlkns.org/stephan/oicd
|
||||
|
||||
## Integrationspunkte
|
||||
### Update-Service
|
||||
Die Update-Integration nutzt v1-Endpunkte des Update-Services:
|
||||
- `POST /v1/enroll` (Enrollment fuer Langzeit-Token)
|
||||
- `GET /v1/projects/{project_id}/manifest`
|
||||
- `POST /v1/projects/{project_id}/status`
|
||||
Der Langzeit-Token wird lokal in `SKD_UPDATE_TOKEN_FILE` gespeichert und fuer Manifest/Status als Bearer-Token verwendet.
|
||||
|
||||
### OIDC-Service
|
||||
OIDC nutzt Discovery unter `/.well-known/openid-configuration` basierend auf `SKD_OIDC_ISSUER`.
|
||||
Der Login-Flow tauscht einen Code gegen ein ID-Token (RS256) und validiert es gegen JWKS.
|
||||
|
||||
## Module und Verantwortlichkeiten
|
||||
- `backend/app.py`: API-Routing, Web-UI-Endpunkte, Update-Endpunkte.
|
||||
- `backend/auth.py`: PAM-Login, JWT-Handling, Auth-Guards, Allowlists.
|
||||
- `backend/oidc.py`: OIDC Discovery, Token-Exchange, JWT-Validierung.
|
||||
- `backend/actions.py`: Systemaktionen (lock/unlock, notify, sound, shutdown).
|
||||
- `backend/update.py`: Update-Enrollment, Manifest-Check, Update/Rollback-Start, Status/Logs.
|
||||
- `backend/settings.py`: Zentrale ENV-Konfiguration.
|
||||
- `backend/templates/` + `backend/static/`: Web-UI.
|
||||
- `scripts/*.sh`: Installation, Deployment, Update-Client, Rollback, OIDC-Registration.
|
||||
|
||||
## Daten- und Kontrollfluss
|
||||
### Login und Auth
|
||||
1. `POST /login` authentifiziert via PAM.
|
||||
2. JWT wird erstellt und als Cookie oder Bearer-Token genutzt.
|
||||
3. Schutz aller Admin-Endpunkte via `get_current_admin`.
|
||||
|
||||
### OIDC-Flow (optional)
|
||||
1. `GET /login/oidc/start` generiert State und leitet zum IdP.
|
||||
2. Callback `GET /login/oidc/callback` validiert State, tauscht Code gegen ID-Token.
|
||||
3. ID-Token wird gegen JWKS geprueft, Username extrahiert, Session gesetzt.
|
||||
|
||||
### Benutzeraktionen
|
||||
1. `POST /users/{username}/disable` ruft `actions.disable_user`.
|
||||
2. Systemaktionen: `usermod -L`, optional notify/sound, `pkill`, optional `shutdown`.
|
||||
3. `POST /users/{username}/enable` fuehrt `usermod -U` aus.
|
||||
|
||||
### Update-Flow
|
||||
1. `POST /update/enroll` schreibt Langzeit-Token in `SKD_UPDATE_TOKEN_FILE`.
|
||||
2. `POST /update/check` ruft Manifest beim Update-Service ab.
|
||||
3. `POST /update/apply` startet `scripts/update_client.sh` asynchron.
|
||||
4. `POST /update/rollback` startet `scripts/rollback_client.sh` asynchron.
|
||||
5. Status/Logs werden lokal in Dateien geschrieben und optional an den Update-Service gemeldet.
|
||||
|
||||
## Designentscheidungen und Tradeoffs
|
||||
- **Root-Run**: Service laeuft als root, da PAM und Systemkommandos Root erfordern.
|
||||
- **JWT + Cookie**: Einfache lokale Auth; keine externe Session-Datenbank.
|
||||
- **OIDC optional**: OIDC ist optional, PAM bleibt als Fallback aktiv.
|
||||
- **Update als Script**: Update/Backup/Swap via Bash-Skripte fuer einfache Ops, Tradeoff: weniger granularer Fehler-Handling.
|
||||
|
||||
## Erweiterungspunkte
|
||||
- **Auth**: Weitere Auth-Mechanismen koennen in `backend/auth.py` integriert werden.
|
||||
- **UI**: Templates unter `backend/templates/` und CSS in `backend/static/`.
|
||||
- **Update-Client**: Anpassung der Update-Strategie in `scripts/update_client.sh`.
|
||||
- **Notifications/Sound**: Konfigurierbar per `SKD_NOTIFY_SEND_PATH`, `SKD_SOUND_PLAYER`, `SKD_SOUND_FILE`.
|
||||
|
||||
## Spezifikationen
|
||||
- Update-Service OpenAPI: `docs/architecture/openapi.yaml` und `docs/architecture/openapi/`.
|
||||
|
||||
## Weitere Dokumente
|
||||
- Entwicklung: `docs/DEVELOPMENT.md`
|
||||
- Deployment: `docs/DEPLOYMENT.md`
|
||||
91
docs/CONFIGURATION.md
Normal file
@ -0,0 +1,91 @@
|
||||
ID: DOC_000011 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Konfiguration
|
||||
|
||||
## Speicherort
|
||||
Die Konfiguration erfolgt per ENV-Dateien:
|
||||
- `/etc/skd/env` (Core-App, Vorlage: `env.example`)
|
||||
- `/etc/skd/update.env` (Update-Service fuer Laufzeit, Vorlage: `env.update.example`)
|
||||
- `update-addon.env` (lokal fuer Upload/Enrollment, Vorlage: `update-addon.env.example`)
|
||||
|
||||
## Authentifizierung
|
||||
- `SKD_AUTH_MODE` (default `pam`): `pam` oder `oidc`. Ungueltige Werte fallen auf `pam` zurueck. Hinweis: Der Wert wird aktuell nicht zur Erzwingung genutzt; OIDC ist aktiv, sobald die OIDC-Variablen gesetzt sind.
|
||||
- `SKD_AUTH_SECRET` (default `change-me-secret`): HMAC-Secret fuer JWTs.
|
||||
- `SKD_TOKEN_TTL_SECONDS` (default `900`): Token-Laufzeit in Sekunden.
|
||||
- `SKD_AUTH_ALLOWED_USERS` (default leer): Kommagetrennte Liste erlaubter Admin-User (gilt fuer PAM und OIDC).
|
||||
- `SKD_AUTH_ALLOWED_GROUPS` (default `sudo`): Erlaubte Gruppen fuer PAM-Login.
|
||||
- `SKD_AUTH_PAM_SERVICE` (default `login`, auf Debian/Ubuntu via `install.sh` auf `skd` gesetzt).
|
||||
|
||||
## OIDC
|
||||
OIDC ist optional und zusaetzlich zu PAM.
|
||||
- `SKD_OIDC_ISSUER`
|
||||
- `SKD_OIDC_CLIENT_ID`
|
||||
- `SKD_OIDC_CLIENT_SECRET`
|
||||
- `SKD_OIDC_REDIRECT_URI` (default `http://localhost:8000/login/oidc/callback`)
|
||||
- `SKD_OIDC_SCOPES` (default `openid profile email`)
|
||||
- `SKD_SESSION_COOKIE_SECURE` (default `false`): Setze `true` fuer HTTPS.
|
||||
- `SKD_OIDC_STATE_COOKIE_NAME` (default `skd_oidc_state`)
|
||||
Referenz: OIDC-Service (intern) https://git.wlkns.org/stephan/oicd
|
||||
|
||||
### OIDC-Einbindung (Kurz)
|
||||
1. Issuer setzen (muss der externen URL des IdP entsprechen):
|
||||
- `SKD_OIDC_ISSUER=https://auth.example.org`
|
||||
2. Client registrieren (DCR), falls der IdP es erlaubt:
|
||||
```bash
|
||||
export SKD_OIDC_ISSUER="https://auth.example.org"
|
||||
export SKD_OIDC_REDIRECT_URI="https://kiddo.example.org/login/oidc/callback"
|
||||
export OIDC_INITIAL_ACCESS_TOKEN="example-token"
|
||||
./scripts/register_oidc_client.sh
|
||||
```
|
||||
3. Client-Credentials in `/etc/skd/env` setzen:
|
||||
```
|
||||
SKD_OIDC_CLIENT_ID=example-client-id
|
||||
SKD_OIDC_CLIENT_SECRET=example-client-secret
|
||||
SKD_OIDC_REDIRECT_URI=https://kiddo.example.org/login/oidc/callback
|
||||
SKD_SESSION_COOKIE_SECURE=true
|
||||
```
|
||||
Hinweis: OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind.
|
||||
|
||||
## Session/Benutzerverwaltung
|
||||
- `SKD_SESSION_COOKIE_NAME` (default `skd_session`)
|
||||
- `SKD_ALLOWED_USERS` (default leer): Optionales Allowlist fuer verwaltbare System-User.
|
||||
|
||||
## Aktionen (Countdown/Notify/Sound)
|
||||
- `SKD_DEFAULT_COUNTDOWN` (default `60` Sekunden)
|
||||
- `SKD_DEFAULT_SOUND` (default `false`)
|
||||
- `SKD_NOTIFY_TIMEOUT` (default `5` Sekunden)
|
||||
- `SKD_NOTIFY_SEND_PATH` (default `notify-send`)
|
||||
- `SKD_SOUND_PLAYER` (default `paplay`)
|
||||
- `SKD_SOUND_FILE` (default `/usr/share/sounds/freedesktop/stereo/dialog-warning.oga`)
|
||||
|
||||
## Update-Client
|
||||
- `SKD_UPDATE_SERVICE_URL` (default `https://update.wlkns.org`)
|
||||
- `SKD_UPDATE_PROJECT_ID` (default `safe-kiddo-control`)
|
||||
- `SKD_UPDATE_ENROLL_TOKEN` (optional; fuer `/update/enroll`)
|
||||
- `SKD_UPDATE_TOKEN` (optional; alternativ per Datei)
|
||||
- `SKD_UPDATE_TOKEN_FILE` (default `/var/lib/skd/update_token`)
|
||||
- `SKD_UPDATE_STATUS_FILE` (default `/var/lib/skd/update_status.json`)
|
||||
- `SKD_UPDATE_LOG_FILE` (default `/var/lib/skd/update_logs.jsonl`)
|
||||
- `SKD_UPDATE_INTERVAL` (default `3600`): Hinweis: wird aktuell nur eingelesen, aber nicht automatisch genutzt.
|
||||
- `SKD_UPDATE_UPLOAD_TOKEN` (optional; fuer Release-Upload)
|
||||
- `SKD_UPDATE_UPLOAD_TOKEN_FILE` (optional; z.B. `/etc/skd/update.upload.token`)
|
||||
|
||||
## Dry-Run
|
||||
- `SKD_DRY_RUN` (default `false`): Keine echten System-Aktionen, nur Logging.
|
||||
|
||||
## Tuning und Betrieb (Power-User)
|
||||
- `SKD_TOKEN_TTL_SECONDS`: kuerzere Tokens reduzieren Risiko, laengere Tokens reduzieren Login-Haeufigkeit.
|
||||
- `SKD_DEFAULT_COUNTDOWN`: steuert Nutzerwarnung vor Sperre/Shutdown.
|
||||
- `SKD_NOTIFY_TIMEOUT`: Dauer der Desktop-Benachrichtigung.
|
||||
- `SKD_SESSION_COOKIE_SECURE=true`: zwingend bei HTTPS, sonst Login-Cookies unsicher.
|
||||
|
||||
## Hinweise
|
||||
- `scripts/install.sh` erstellt `/etc/skd/env` und setzt Default-Werte fuer PAM/Allowed-User.
|
||||
- `scripts/install.sh` erstellt `/etc/skd/update.env` fuer Update-Service Variablen.
|
||||
- Aenderungen in `/etc/skd/env` erfordern einen Service-Restart (`sudo systemctl restart skd.service`).
|
||||
|
||||
## Weitere Dokumente
|
||||
- Nutzung/Automation: `docs/USAGE.md`
|
||||
- Deployment: `docs/DEPLOYMENT.md`
|
||||
- Architektur: `docs/ARCHITECTURE.md`
|
||||
133
docs/DEPLOYMENT.md
Normal file
@ -0,0 +1,133 @@
|
||||
ID: DOC_000014 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Deployment
|
||||
|
||||
## Lokale Installation (systemd)
|
||||
`./scripts/install.sh` fuehrt folgende Schritte aus:
|
||||
- legt Service-User/Group an
|
||||
- kopiert das Projekt nach `/opt/sk`
|
||||
- erstellt/aktualisiert `.venv`
|
||||
- erstellt `/etc/skd/env` aus `env.example`
|
||||
- erstellt `/etc/skd/update.env` aus `env.update.example`
|
||||
- schreibt eine systemd-Unit nach `/etc/systemd/system/skd.service`
|
||||
|
||||
Beispiel:
|
||||
```bash
|
||||
make install
|
||||
sudo systemctl status skd.service
|
||||
```
|
||||
|
||||
## Manuelles Starten
|
||||
```bash
|
||||
./scripts/run.sh
|
||||
```
|
||||
|
||||
## Makefile Targets (Ops)
|
||||
```bash
|
||||
make install
|
||||
make up
|
||||
make down
|
||||
make restart
|
||||
make update
|
||||
make uninstall
|
||||
```
|
||||
|
||||
## Remote-Deploy (SSH)
|
||||
`./scripts/deploy.sh` packt das Repo und deployt es auf einen Zielhost.
|
||||
Konfiguration in `deploy_hosts.yml`.
|
||||
|
||||
Beispiel:
|
||||
```bash
|
||||
./scripts/deploy.sh kid-laptop
|
||||
```
|
||||
|
||||
## Deployment via ZIP
|
||||
Im Repo liegt `sk_deploy.zip`. Dieses Archiv kann auf den Zielhost kopiert und nach `/opt/sk` entpackt werden.
|
||||
Anschliessend Abhaengigkeiten installieren und Service neu starten:
|
||||
```bash
|
||||
sudo -u skd /opt/sk/.venv/bin/pip install -r /opt/sk/backend/requirements.txt
|
||||
sudo systemctl restart skd.service
|
||||
```
|
||||
## Update des Services
|
||||
`./scripts/update.sh` zieht den Branch neu und fuehrt einen harten Reset aus.
|
||||
|
||||
Wichtig: Das Script nutzt `git reset --hard origin/main`.
|
||||
```bash
|
||||
sudo ./scripts/update.sh
|
||||
```
|
||||
|
||||
## Update-Client (Remote Update Service)
|
||||
Die Update-API startet `scripts/update_client.sh` bzw. `scripts/rollback_client.sh`.
|
||||
Wichtige ENV-Variablen:
|
||||
- `SKD_UPDATE_SERVICE_URL`
|
||||
- `SKD_UPDATE_PROJECT_ID`
|
||||
- `SKD_UPDATE_TOKEN` oder `SKD_UPDATE_TOKEN_FILE`
|
||||
Voraussetzungen auf dem Host:
|
||||
- `curl`, `tar`, `sha256sum`, `python3`, `systemctl`
|
||||
Referenz: Update-Service (intern) https://git.wlkns.org/stephan/update-webservice
|
||||
|
||||
### Update-Service einbinden
|
||||
1. Service-URL und Projekt setzen:
|
||||
```
|
||||
SKD_UPDATE_SERVICE_URL=https://update.wlkns.org
|
||||
SKD_UPDATE_PROJECT_ID=safe-kiddo-control
|
||||
```
|
||||
2. Enrollment-Token besorgen (vom Update-Service-Admin) und einen Langzeit-Token erzeugen:
|
||||
- Option A: Ueber lokale API
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"enroll_token\":\"example-enroll-token\"}" \
|
||||
http://localhost/update/enroll
|
||||
```
|
||||
- Option B: Direkter Enrollment-Client
|
||||
```bash
|
||||
./scripts/manual_enroll.py --url "https://update.wlkns.org" --project "safe-kiddo-control" --token "example-enroll-token"
|
||||
```
|
||||
3. Token-Datei pruefen:
|
||||
```
|
||||
sudo cat /var/lib/skd/update_token
|
||||
```
|
||||
Hinweis: Der Update-Check ist erst moeglich, wenn der Langzeit-Token gespeichert wurde.
|
||||
|
||||
Enrollment-Tools:
|
||||
- `scripts/manual_enroll.py`: Enrollment direkt gegen den Update-Service, schreibt Token in `SKD_UPDATE_TOKEN_FILE`.
|
||||
- `scripts/enroll_local.py`: Enrollment ueber die lokale API (`/update/enroll`), benoetigt Admin-Session; `--token` setzen (Default-Token ist nur Prototyp-Altlast).
|
||||
- `scripts/enroll_update_service.sh`: Holt den Langzeit-Token per curl vom Update-Service (liest `update-addon.env`).
|
||||
|
||||
Beispiel (curl-Script):
|
||||
```bash
|
||||
sudo ./scripts/enroll_update_service.sh --enroll-token "enroll_example"
|
||||
```
|
||||
|
||||
## Release-Upload (Dev/Prod)
|
||||
Fuer Dev/Prod Uploads kann ein Release-Archiv (tar.gz) automatisiert gebaut und hochgeladen werden.
|
||||
|
||||
Beispiel:
|
||||
```bash
|
||||
./scripts/upload_release.sh --profile dev
|
||||
```
|
||||
|
||||
Token-Quelle:
|
||||
- `update-addon.env` (z.B. `DEV_UPDATE_UPLOAD_TOKEN_FILE=./upload.token`).
|
||||
|
||||
## Lokale update-addon.env
|
||||
Fuer lokale Tests kann eine `update-addon.env` im Repo genutzt werden (gitignored).
|
||||
Beispiel:
|
||||
```bash
|
||||
cp update-addon.env.example update-addon.env
|
||||
```
|
||||
|
||||
Lokale Status/Logs:
|
||||
- `SKD_UPDATE_STATUS_FILE` (default `/var/lib/skd/update_status.json`)
|
||||
- `SKD_UPDATE_LOG_FILE` (default `/var/lib/skd/update_logs.jsonl`)
|
||||
|
||||
## Backup/Restore
|
||||
- Bei Apply wird `/opt/sk` nach `/opt/sk_backup_1.2.3_1700000000` verschoben (Beispiel).
|
||||
- Rollback nutzt das letzte Backup (`/opt/sk_backup_*`).
|
||||
|
||||
## Weitere Dokumente
|
||||
- Konfiguration: `docs/CONFIGURATION.md`
|
||||
- Nutzung: `docs/USAGE.md`
|
||||
- Troubleshooting: `docs/TROUBLESHOOTING.md`
|
||||
87
docs/DEVELOPMENT.md
Normal file
@ -0,0 +1,87 @@
|
||||
ID: DOC_000013 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Development
|
||||
|
||||
## Repository-Struktur (Kurz)
|
||||
- `backend/`: FastAPI-App, Auth, Update-Logik, Templates, Static Assets.
|
||||
- `scripts/`: Install/Deploy/Update/Helper-Skripte.
|
||||
- `systemd/`: Beispiel-Unit.
|
||||
- `docs/architecture/`: OpenAPI-Spezifikation fuer Update-Service.
|
||||
- `docs/`: Dokumentation.
|
||||
- `assets/`: Branding und Design.
|
||||
- `sk.sh`: Legacy-CLI.
|
||||
|
||||
## Lokales Setup
|
||||
```bash
|
||||
./scripts/create_venv.sh
|
||||
source .venv/bin/activate
|
||||
./scripts/run.sh
|
||||
```
|
||||
Standard: `0.0.0.0:80`. Fuer andere Ports:
|
||||
```bash
|
||||
HOST=127.0.0.1 PORT=8000 ./scripts/run.sh
|
||||
```
|
||||
|
||||
## Release-Upload (Dev/Prod)
|
||||
Das Update-Artefakt wird als tar.gz gebaut und ueber den Update-Service hochgeladen.
|
||||
Das Script nutzt `VERSION` und laedt ein vollstaendiges Release (kein Delta).
|
||||
|
||||
Vorbereitung:
|
||||
```bash
|
||||
cp update-addon.env.example update-addon.env
|
||||
```
|
||||
|
||||
Beispiel (Dev):
|
||||
```bash
|
||||
./scripts/upload_release.sh --profile dev
|
||||
```
|
||||
|
||||
## Tests und Lint
|
||||
Im Repo sind keine automatisierten Tests enthalten. Verfuegbare Checks:
|
||||
- Bash-Syntax: `bash -n sk.sh`
|
||||
- ShellCheck: `shellcheck sk.sh`
|
||||
|
||||
## Coding Conventions
|
||||
- Bash 4+, `set -euo pipefail` in neuen Skripten.
|
||||
- Python: FastAPI-Patterns, klare Modultrennung (Auth, Actions, Update, OIDC).
|
||||
|
||||
## Beitrag und Workflow
|
||||
- Arbeite mit Feature-Branches.
|
||||
- Aktualisiere `VERSION`, Doku-Header und `CHANGELOG.md` gemaess SOP.
|
||||
- PRs sollten Verhalten, Risiken und manuelle Tests beschreiben.
|
||||
|
||||
## CI/CD
|
||||
Aktuell keine CI/CD-Pipeline im Repository definiert.
|
||||
|
||||
## Legacy/Interna
|
||||
- `SKD_UPDATE_URL` und `SKD_UPDATE_STATUS_URL` sind in `backend/settings.py` noch vorhanden, werden aber im aktuellen Code nicht genutzt.
|
||||
|
||||
## Externe Abhaengigkeiten (mit Quelle und Zweck)
|
||||
### Python-Libraries (requirements.txt)
|
||||
- FastAPI: https://fastapi.tiangolo.com/ (Web-API und Routing)
|
||||
- Uvicorn: https://www.uvicorn.org/ (ASGI-Server)
|
||||
- Pydantic: https://docs.pydantic.dev/ (Datenmodelle und Validierung)
|
||||
- Jinja2: https://jinja.palletsprojects.com/ (HTML-Templates)
|
||||
- PyJWT: https://pyjwt.readthedocs.io/ (JWT-Erstellung und -Validierung)
|
||||
- python-pam: https://pypi.org/project/python-pam/ (PAM-Authentifizierung)
|
||||
- httpx: https://www.python-httpx.org/ (HTTP-Client fuer Update/OIDC)
|
||||
- cryptography: https://cryptography.io/ (Krypto-Abhaengigkeit fuer JWT)
|
||||
- PyYAML (optional): https://pyyaml.org/ (YAML-Parsing in `scripts/deploy.sh`)
|
||||
|
||||
### System-Tools
|
||||
- systemd: https://www.freedesktop.org/software/systemd/man/systemd.html (Service-Management)
|
||||
- Linux-PAM: https://www.linux-pam.org/ (System-Authentifizierung)
|
||||
- usermod/pkill/shutdown: https://man7.org/linux/man-pages/ (Account- und Session-Management)
|
||||
- curl/tar/sha256sum/rsync/git/ssh: https://man7.org/linux/man-pages/ (Install/Update/Deploy)
|
||||
- jq (optional): https://stedolan.github.io/jq/ (JSON-Parsing in Beispielen)
|
||||
- notify-send: https://developer.gnome.org/libnotify/ (Desktop-Benachrichtigungen)
|
||||
- paplay/aplay: https://www.freedesktop.org/wiki/Software/PulseAudio/ und https://alsa-project.org/ (Sound)
|
||||
|
||||
### Externe Services
|
||||
- Update-Service (intern): https://git.wlkns.org/stephan/update-webservice (Manifest/Status/Enrollment)
|
||||
- OIDC-Service (intern): https://git.wlkns.org/stephan/oicd (Login via OIDC)
|
||||
|
||||
## Weitere Dokumente
|
||||
- Architektur: `docs/ARCHITECTURE.md`
|
||||
- Deployment: `docs/DEPLOYMENT.md`
|
||||
26
docs/FAQ.md
Normal file
@ -0,0 +1,26 @@
|
||||
ID: DOC_000015 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# FAQ
|
||||
|
||||
## Braucht der Dienst Root-Rechte?
|
||||
Ja. PAM-Authentifizierung und Systemkommandos (usermod/pkill/shutdown) erfordern Root.
|
||||
|
||||
## Kann ich OIDC ohne PAM nutzen?
|
||||
OIDC ist optional und zusaetzlich zu PAM. PAM bleibt als Fallback aktiv.
|
||||
|
||||
## Wie aendere ich den Port?
|
||||
- Lokaler Run: `PORT=8000 ./scripts/run.sh`
|
||||
- Systemd: Unit-Datei in `/etc/systemd/system/skd.service` anpassen und Service neu starten.
|
||||
|
||||
## Gibt es einen Docker-Container?
|
||||
Nein, im Repository ist kein Docker-Setup enthalten.
|
||||
|
||||
## Wo liegen Logs?
|
||||
- systemd: `journalctl -u skd.service`
|
||||
- Update-Status/Logs: siehe `SKD_UPDATE_STATUS_FILE` und `SKD_UPDATE_LOG_FILE`.
|
||||
|
||||
## Weitere Dokumente
|
||||
- Einstieg: `docs/GETTING_STARTED.md`
|
||||
- Nutzung: `docs/USAGE.md`
|
||||
- Troubleshooting: `docs/TROUBLESHOOTING.md`
|
||||
37
docs/FOR_USERS.md
Normal file
@ -0,0 +1,37 @@
|
||||
ID: DOC_000017 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Fuer Nutzerinnen und Nutzer
|
||||
|
||||
## Worum geht es?
|
||||
Safe Kiddo Daemon hilft dabei, lokale Benutzerkonten auf einem Familien- oder Schulgeraet zu sperren und wieder freizugeben. Ziel ist, klare Nutzungszeiten durchzusetzen und sicherzustellen, dass nach einer Sperrung keine Sitzung offen bleibt.
|
||||
|
||||
## Welche Probleme loest es?
|
||||
- Ein Konto soll zu bestimmten Zeiten nicht nutzbar sein.
|
||||
- Offene Sitzungen sollen beendet werden, wenn ein Konto gesperrt wird.
|
||||
- Eltern/Betreuende wollen den Zustand zentral sehen und verwalten.
|
||||
|
||||
## Typische Anwendungsfaelle
|
||||
- Abendliche Nutzungszeit endet, der Account wird gesperrt.
|
||||
- Bei Verstoessen gegen Regeln wird ein Konto kurzzeitig deaktiviert.
|
||||
- Eine Sitzung bleibt offen und muss beendet werden.
|
||||
|
||||
## Wie wird es bedient?
|
||||
Die Bedienung erfolgt ueber eine einfache Web-Oberflaeche im lokalen Netzwerk.
|
||||
Dort kann eine berechtigte Person:
|
||||
- Konten sperren oder freigeben.
|
||||
- Den aktuellen Status sehen.
|
||||
|
||||
## Grenzen und Sicherheit
|
||||
- Die Sperrung betrifft nur lokale Konten auf dem Geraet.
|
||||
- Wenn kein berechtigter Zugang vorhanden ist, kann die Web-Oberflaeche nicht genutzt werden.
|
||||
- Das System kann den Rechner im Bedarfsfall herunterfahren, um offene Sitzungen zu beenden.
|
||||
|
||||
## Was tun, wenn etwas schiefgeht?
|
||||
- Wenn die Web-Oberflaeche nicht erreichbar ist, die betreuende Person informieren.
|
||||
- Wenn das Konto unerwartet gesperrt wurde, nicht weiter experimentieren, sondern nachfragen.
|
||||
- Bei wiederholten Problemen soll der Betreiber die technische Fehlerbehebung pruefen.
|
||||
|
||||
## Weitere Informationen (fuer Betreiber)
|
||||
- Einstieg: `docs/GETTING_STARTED.md`
|
||||
- Hilfe bei Problemen: `docs/TROUBLESHOOTING.md`
|
||||
69
docs/GETTING_STARTED.md
Normal file
@ -0,0 +1,69 @@
|
||||
ID: DOC_000009 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Getting Started
|
||||
|
||||
## Ziel
|
||||
Schneller Einstieg fuer neue Nutzer: Installation, erster Login und erste Aktion.
|
||||
|
||||
## Voraussetzungen
|
||||
- Linux-System mit systemd.
|
||||
- Root-Zugriff (PAM, usermod, shutdown).
|
||||
- Python 3, curl, tar, sha256sum (fuer Update-Client-Skripte).
|
||||
- Optional: notify-send (Benachrichtigungen), paplay/aplay (Sound).
|
||||
|
||||
## Schnellstart
|
||||
```bash
|
||||
# 1) Repo installieren
|
||||
sudo mkdir -p /opt/sk
|
||||
sudo git clone ssh://git@git.wlkns.org:2222/stephan/kiddo /opt/sk
|
||||
# Hinweis: verwende hier die Repo-URL deiner Instanz
|
||||
cd /opt/sk
|
||||
|
||||
# 2) Installation (legt Service-User, env, systemd-Unit an)
|
||||
make install
|
||||
|
||||
# 3) Service pruefen
|
||||
sudo systemctl status skd.service
|
||||
|
||||
# 4) Login (PAM)
|
||||
curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d '{"username":"root","password":"..."}' \
|
||||
http://localhost/login
|
||||
```
|
||||
|
||||
## Erster API-Test
|
||||
```bash
|
||||
token=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d '{"username":"root","password":"example-password"}' \
|
||||
http://localhost/login | jq -r .token)
|
||||
|
||||
curl -s -H "Authorization: Bearer $token" http://localhost/me
|
||||
```
|
||||
Hinweis: `jq` ist optional; ohne jq das Token manuell aus der JSON-Antwort lesen.
|
||||
|
||||
## Makefile-Kurzbefehle
|
||||
```bash
|
||||
make install # Installation (systemd, env, venv)
|
||||
make up # Service starten
|
||||
make down # Service stoppen
|
||||
make restart # Service neu starten
|
||||
make update # Code-Update (git reset --hard origin/main)
|
||||
make healthcheck TOKEN=... # Healthcheck mit Bearer-Token
|
||||
```
|
||||
|
||||
## Naechste Schritte
|
||||
- Konfiguration anpassen: `docs/CONFIGURATION.md`
|
||||
- API und Web-UI nutzen: `docs/USAGE.md`
|
||||
- Deployment und Updates: `docs/DEPLOYMENT.md`
|
||||
|
||||
## Typische Einsteigerfehler
|
||||
- Service startet, aber Port 80 ist bereits belegt (loese den Konflikt oder nutze einen anderen Port).
|
||||
- Login scheitert, weil `SKD_AUTH_ALLOWED_USERS`/`SKD_AUTH_ALLOWED_GROUPS` den Nutzer nicht erlauben.
|
||||
- OIDC wird erwartet, ist aber nicht aktiv (Issuer/Client-ID/Secret fehlen).
|
||||
- Token wird nicht gesendet (fehlender `Authorization: Bearer` Header).
|
||||
|
||||
## Weitere Dokumente
|
||||
- Endnutzer-Sicht: `docs/FOR_USERS.md`
|
||||
- FAQ: `docs/FAQ.md`
|
||||
- Troubleshooting: `docs/TROUBLESHOOTING.md`
|
||||
42
docs/TROUBLESHOOTING.md
Normal file
@ -0,0 +1,42 @@
|
||||
ID: DOC_000016 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
## Service startet nicht
|
||||
- Status pruefen: `sudo systemctl status skd.service`
|
||||
- Logs: `sudo journalctl -u skd.service -n 200 --no-pager`
|
||||
- Port 80 belegt? Test: `sudo ss -ltnp | grep ':80'`
|
||||
|
||||
## 401/403 bei API-Aufrufen
|
||||
- Bearer-Token fehlt oder abgelaufen.
|
||||
- Nutzer nicht in `SKD_AUTH_ALLOWED_USERS` oder `SKD_AUTH_ALLOWED_GROUPS`.
|
||||
- `SKD_AUTH_SECRET` geaendert? Tokens muessen neu erzeugt werden.
|
||||
|
||||
## OIDC-Login fehlschlaegt
|
||||
- `SKD_OIDC_ISSUER`, `SKD_OIDC_CLIENT_ID`, `SKD_OIDC_CLIENT_SECRET` gesetzt?
|
||||
- Redirect-URI exakt registriert?
|
||||
- Netzwerkzugriff auf Discovery/JWKS moeglich?
|
||||
|
||||
## Benachrichtigung/Sound fehlt
|
||||
- `notify-send` fehlt: `sudo apt install libnotify-bin`
|
||||
- Sound-Player fehlt: `paplay` oder `aplay` installieren.
|
||||
- `SKD_NOTIFY_SEND_PATH` oder `SKD_SOUND_PLAYER` falsch gesetzt.
|
||||
|
||||
## Update-Check meldet "Client is not enrolled"
|
||||
- `SKD_UPDATE_TOKEN` oder `SKD_UPDATE_TOKEN_FILE` fehlt.
|
||||
- Enrollment ueber `/update/enroll` oder `scripts/manual_enroll.py` durchfuehren.
|
||||
|
||||
## Update-Apply fehlschlaegt
|
||||
- Update-Service nicht erreichbar oder Token ungueltig.
|
||||
- Prüfe `SKD_UPDATE_STATUS_FILE` und `SKD_UPDATE_LOG_FILE`.
|
||||
- Hinweis: `update_client.sh` wird asynchron gestartet und stdout/stderr werden verworfen.
|
||||
|
||||
## Rollback meldet "no backup found"
|
||||
- Es existiert kein `/opt/sk_backup_*` vom vorherigen Update.
|
||||
- Rollback erst nach mindestens einem erfolgreichen Update moeglich.
|
||||
|
||||
## Weitere Dokumente
|
||||
- Einstieg: `docs/GETTING_STARTED.md`
|
||||
- Konfiguration: `docs/CONFIGURATION.md`
|
||||
- Deployment: `docs/DEPLOYMENT.md`
|
||||
103
docs/USAGE.md
Normal file
@ -0,0 +1,103 @@
|
||||
ID: DOC_000010 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Usage
|
||||
|
||||
## Authentifizierung
|
||||
- PAM-Login: `POST /login` mit Benutzername/Passwort. Liefert JWT und setzt Session-Cookie.
|
||||
- OIDC-Login: `GET /login/oidc/start` startet Flow, Callback setzt Session-Cookie.
|
||||
- Alle geschuetzten Endpunkte akzeptieren `Authorization: Bearer $TOKEN` oder Session-Cookie.
|
||||
|
||||
### Beispiel: Login und Token nutzen
|
||||
```bash
|
||||
token=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||
-d '{"username":"root","password":"example-password"}' \
|
||||
http://localhost/login | jq -r .token)
|
||||
|
||||
curl -s -H "Authorization: Bearer $token" http://localhost/me
|
||||
```
|
||||
Hinweis: `jq` ist optional; ohne jq das Token manuell aus der JSON-Antwort lesen.
|
||||
Login ist nur fuer erlaubte Nutzer moeglich (siehe `SKD_AUTH_ALLOWED_USERS` und `SKD_AUTH_ALLOWED_GROUPS`).
|
||||
|
||||
## Web-UI
|
||||
- Aufruf: `http://localhost/`
|
||||
- Login per PAM oder OIDC (wenn konfiguriert).
|
||||
- Aktionen: Benutzer sperren/entsperren, Update-Status, Update-Check, Apply/Rollback.
|
||||
|
||||
## Automatisierung (Power-User)
|
||||
Die API kann in Skripten oder Zeitplaenen genutzt werden, z.B. fuer regelmaessige Sperrungen.
|
||||
|
||||
Beispiel (cron, taeglich 21:00 sperren):
|
||||
```bash
|
||||
0 21 * * * curl -s -X POST -H "Authorization: Bearer $TOKEN" http://localhost/users/child1/disable
|
||||
```
|
||||
Hinweis: Token sicher speichern (z.B. Root-Only Datei) und regelmaessig rotieren.
|
||||
|
||||
## API-Endpunkte (Auszug)
|
||||
### Health und Identitaet
|
||||
- `GET /health` (ohne Auth)
|
||||
- `GET /me`
|
||||
|
||||
### Benutzerverwaltung
|
||||
- `GET /users`
|
||||
- `POST /users/{username}/disable` mit JSON `{countdown?, sound?, message?}`
|
||||
- `POST /users/{username}/enable`
|
||||
|
||||
Beispiel (disable):
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"countdown":90,"sound":true,"message":"Bitte speichern"}' \
|
||||
http://localhost/users/child1/disable
|
||||
```
|
||||
|
||||
### Update-API (lokal)
|
||||
Alle Update-Endpunkte erfordern Admin-Auth.
|
||||
- `GET /update/status`
|
||||
- `GET /update/service-status` (zeigt URL, Erreichbarkeit und Dev/Prod-Umgebung)
|
||||
- `POST /update/enroll` (optional Body: `{ "enroll_token": "..." }`)
|
||||
- `POST /update/check`
|
||||
- `POST /update/apply` (optional Body: `{ "version": "x.y.z" }`)
|
||||
- `POST /update/rollback`
|
||||
- `GET /update/logs?limit=200`
|
||||
|
||||
### System-API (lokal)
|
||||
- `GET /system/metrics` (CPU %, RAM, GPU VRAM, Netzwerk Mbps)
|
||||
|
||||
Beispiel (Enrollment):
|
||||
```bash
|
||||
ENROLL_TOKEN="example-enroll-token"
|
||||
curl -X POST -H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"enroll_token\":\"${ENROLL_TOKEN}\"}" \
|
||||
http://localhost/update/enroll
|
||||
```
|
||||
|
||||
Beispiel (Update-Check):
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $token" http://localhost/update/check
|
||||
```
|
||||
Hinweis: Ohne gespeichertes Update-Token liefert der Check einen Fehler.
|
||||
|
||||
## Healthcheck per Makefile
|
||||
```bash
|
||||
make healthcheck TOKEN="$token"
|
||||
```
|
||||
|
||||
## Weitere Dokumente
|
||||
- Konfiguration: `docs/CONFIGURATION.md`
|
||||
- Deployment: `docs/DEPLOYMENT.md`
|
||||
- Troubleshooting: `docs/TROUBLESHOOTING.md`
|
||||
|
||||
## CLI-Fallback (sk.sh)
|
||||
Das Legacy-Script arbeitet direkt auf dem Host und benoetigt Root-Rechte.
|
||||
|
||||
Aufruf:
|
||||
```bash
|
||||
sudo ./sk.sh USERNAME disable|enable [countdown] [sound] [countdown_time_in_seconds]
|
||||
```
|
||||
|
||||
Beispiel:
|
||||
```bash
|
||||
sudo ./sk.sh demo_user disable countdown sound 90
|
||||
```
|
||||
93
docs/_archive/admin-token-operations.md
Normal file
@ -0,0 +1,93 @@
|
||||
ID: DOC_000006 | Version: 0.2.3 | Status: Draft
|
||||
Archived – superseded by new documentation.
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Admin Token Operations
|
||||
|
||||
## Purpose
|
||||
This document describes how operators create and manage pre-shared enrollment tokens for clients.
|
||||
|
||||
## Pre-Shared Token Creation
|
||||
Operators generate a single-use enrollment token and share it out-of-band with the client.
|
||||
|
||||
Recommended properties:
|
||||
- Single-use only
|
||||
- Short TTL (e.g., 24h)
|
||||
- Scoped to `project_id` and optional `client_id`/`software_id`
|
||||
|
||||
## Admin Interfaces
|
||||
We provide both an Admin API and a CLI tool for token operations. A frontend will be added later.
|
||||
|
||||
### Admin User and Access
|
||||
- An admin user must exist to operate token workflows.
|
||||
- Initial access uses a local admin token.
|
||||
- Later, admin auth will be integrated with the OIDC service.
|
||||
|
||||
### CLI and Admin API Capabilities
|
||||
- Create enrollment tokens
|
||||
- List token metadata (no plaintext output)
|
||||
- Revoke tokens
|
||||
- Export a token as a file for client installation
|
||||
|
||||
### Local Admin Token (Initial Phase)
|
||||
- Admin requests must include `Authorization: Bearer <ADMIN_TOKEN>`.
|
||||
- The admin token is stored locally (e.g., `.env`) and never committed.
|
||||
|
||||
Example `.env` (local only):
|
||||
```
|
||||
ADMIN_TOKEN=change-me-please
|
||||
```
|
||||
|
||||
Minimal flow (first token):
|
||||
1) Set `ADMIN_TOKEN` in `.env`.
|
||||
2) Call `POST /v1/admin/enrollment-tokens` with the bearer token.
|
||||
3) Export the returned one-time token to a file and hand it to the client.
|
||||
|
||||
## Admin API (Draft)
|
||||
All admin endpoints are authenticated. Initial auth is local; later OIDC.
|
||||
|
||||
Base path:
|
||||
- `/v1/admin`
|
||||
|
||||
Endpoints:
|
||||
- `POST /v1/admin/enrollment-tokens`
|
||||
- Create a pre-shared enrollment token.
|
||||
- Request: `project_id`, optional `client_id`, optional `software_id`, optional `expires_at`.
|
||||
- Response: token metadata + one-time plaintext token.
|
||||
- `GET /v1/admin/enrollment-tokens`
|
||||
- List token metadata (never return plaintext tokens).
|
||||
- Supports filtering by `project_id`, `client_id`, `status` (active/used/expired).
|
||||
- `POST /v1/admin/enrollment-tokens/{token_id}/revoke`
|
||||
- Revoke a token (marks as revoked or sets `used_at`/`revoked_at`).
|
||||
- `GET /v1/admin/enrollment-tokens/{token_id}/export`
|
||||
- Export the one-time token to a file download (single use).
|
||||
|
||||
## CLI (Draft)
|
||||
Example commands (names can be adjusted):
|
||||
- `update-service admin token create --project <id> [--client <id>] [--software <id>] [--expires <iso8601>]`
|
||||
- `update-service admin token list --project <id> [--status active|used|expired|revoked]`
|
||||
- `update-service admin token revoke --id <token_id>`
|
||||
- `update-service admin token export --id <token_id> --out ./enroll-token.txt`
|
||||
|
||||
Example format:
|
||||
```
|
||||
enroll_<random_32_bytes>
|
||||
```
|
||||
|
||||
## Storage and Safety
|
||||
- Store only a hash of the enrollment token (never plaintext).
|
||||
- Track `created_at`, `expires_at`, and `used_at`.
|
||||
- Deny enrollment if `expires_at` is exceeded or `used_at` is set.
|
||||
|
||||
## Rotation and Revocation
|
||||
- Revoke enrollment tokens by invalidating their stored hash.
|
||||
- Issue a new enrollment token if the previous one expires or is leaked.
|
||||
|
||||
## Distribution
|
||||
Preferred channels:
|
||||
- One-time install code (copy/paste)
|
||||
- QR code
|
||||
- Encrypted file included in an install bundle
|
||||
|
||||
## Audit Expectations
|
||||
- Log token creation and enrollment usage for traceability.
|
||||
45
docs/_archive/client-quickstart.md
Normal file
@ -0,0 +1,45 @@
|
||||
ID: DOC_000008 | Version: 0.2.3 | Status: Draft
|
||||
Archived – superseded by new documentation.
|
||||
|
||||
# Client Quickstart
|
||||
|
||||
## Goal
|
||||
Enroll a client, store the long-term token, fetch the manifest, and report status.
|
||||
|
||||
## 1) Get a Pre-Shared Token
|
||||
Request a one-time enrollment token from an admin/operator.
|
||||
|
||||
## 2) Enroll and Receive Long-Term Token
|
||||
```
|
||||
curl -X POST https://update.wlkns.org/v1/enroll \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"project_id": "safe-kiddo-control",
|
||||
"client_id": "kiddo-001",
|
||||
"software_id": "kiddo-agent",
|
||||
"enroll_token": "<pre_shared_token>"
|
||||
}'
|
||||
```
|
||||
|
||||
Store the returned token locally (file or secret store). Example:
|
||||
```
|
||||
echo "<long_term_token>" > ./update-token.txt
|
||||
```
|
||||
|
||||
## 3) Fetch Manifest
|
||||
```
|
||||
curl -H "Authorization: Bearer $(cat ./update-token.txt)" \
|
||||
https://update.wlkns.org/v1/projects/safe-kiddo-control/manifest
|
||||
```
|
||||
|
||||
## 4) Report Status
|
||||
```
|
||||
curl -H "Authorization: Bearer $(cat ./update-token.txt)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project_id":"safe-kiddo-control","version":"0.1.2","status":"success","timestamp":"2025-12-30T10:00:00Z"}' \
|
||||
https://update.wlkns.org/v1/projects/safe-kiddo-control/status
|
||||
```
|
||||
|
||||
## Notes
|
||||
- All endpoints require `Authorization: Bearer <token>` except `/v1/enroll`.
|
||||
- Status values: `success`, `failed`, `in_progress`.
|
||||
39
docs/_archive/oidc-validation.md
Normal file
@ -0,0 +1,39 @@
|
||||
ID: DOC_000003 | Version: 0.2.3 | Status: Draft
|
||||
Archived – superseded by new documentation.
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# OIDC End-to-End Validation (Kiddo)
|
||||
|
||||
## Purpose
|
||||
Validiere den OIDC-Login-Flow gegen einen realen oder Stub-Provider und dokumentiere Ergebnisse.
|
||||
|
||||
## Preconditions
|
||||
- Kiddo laeuft und ist erreichbar (z.B. `http://localhost`).
|
||||
- OIDC Provider oder Stub erreichbar.
|
||||
- `SKD_AUTH_MODE=oidc` und `SKD_OIDC_*` gesetzt.
|
||||
- Redirect-URI: `https://<device-host>[:port]/login/oidc/callback` ist registriert.
|
||||
|
||||
## Validation Steps
|
||||
1. OIDC Start
|
||||
- Aufruf: `GET /login/oidc/start`
|
||||
- Erwartet: Redirect zum Provider, State-Cookie gesetzt.
|
||||
2. Callback
|
||||
- Provider ruft `GET /login/oidc/callback?code=...&state=...` auf.
|
||||
- Erwartet: State valid, Token-Exchange erfolgreich, Session-Cookie gesetzt, Redirect `/`.
|
||||
3. Session Check
|
||||
- Aufruf: `GET /me` mit Cookie oder Bearer.
|
||||
- Erwartet: `user` und `auth_mode=oidc`.
|
||||
4. Allowlist
|
||||
- Falls `SKD_AUTH_ALLOWED_USERS` gesetzt: nicht erlaubte User werden mit 403 abgewiesen.
|
||||
|
||||
## Results
|
||||
- Datum: 28.12.2025
|
||||
- Provider: nicht konfiguriert (IdP noch nicht bereit)
|
||||
- Host/Redirect: n/a
|
||||
- Ergebnis: Blocked (Service nicht erreichbar unter http://localhost/health)
|
||||
- Fehlerbilder: curl (7) Couldn't connect to server
|
||||
|
||||
## Fallbacks bei unvollstaendigem IdP
|
||||
- Discovery/JWKS fehlt: OIDC deaktivieren und PAM nutzen.
|
||||
- DCR fehlt: Client manuell im IdP anlegen und `SKD_OIDC_*` setzen.
|
||||
- TLS-Probleme: CA trusten oder PAM fuer Dev nutzen.
|
||||
137
docs/_archive/third-party-api.md
Normal file
@ -0,0 +1,137 @@
|
||||
ID: DOC_000005 | Version: 0.2.3 | Status: Draft
|
||||
Archived – superseded by new documentation.
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Third-Party API Guide
|
||||
|
||||
## Purpose
|
||||
This document explains how third-party services integrate with the Update Webservice: obtaining tokens, fetching manifests, downloading artifacts, and reporting status.
|
||||
|
||||
## Quick Start (First Client)
|
||||
1) Request a pre-shared enrollment token from an admin/operator.
|
||||
2) Enroll once to obtain a long-term token.
|
||||
3) Store the long-term token locally and use it for all API calls.
|
||||
|
||||
## Base URLs
|
||||
- Production: `https://update.wlkns.org`
|
||||
- Staging: `https://staging.update.wlkns.org`
|
||||
|
||||
All endpoints are versioned under `/v1`.
|
||||
|
||||
## Authentication
|
||||
All endpoints require `Authorization: Bearer <token>`.
|
||||
|
||||
### Enrollment (Pre-Shared Token -> Long-Term Token)
|
||||
Clients obtain a long-term token by exchanging a pre-shared token provided by an admin/operator.
|
||||
|
||||
Request (example):
|
||||
```
|
||||
POST /v1/enroll
|
||||
{
|
||||
"project_id": "<project>",
|
||||
"client_id": "<client>",
|
||||
"software_id": "<software>",
|
||||
"enroll_token": "<pre_shared_token>"
|
||||
}
|
||||
```
|
||||
|
||||
Response (example):
|
||||
```
|
||||
200 OK
|
||||
{
|
||||
"token": "<long_term_token>",
|
||||
"scope": "read_manifest report_status",
|
||||
"expires_at": "<iso8601 or null>"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Enrollment tokens are single-use and must be invalidated after a successful exchange.
|
||||
- If the token is invalid or reused, the server responds with `unauthorized` or `invalid_payload`.
|
||||
- Enrollment does not require an existing bearer token.
|
||||
- If the client is already enrolled, the server responds with `already_enrolled` (HTTP 409).
|
||||
|
||||
## Client API (Read + Report)
|
||||
|
||||
### Get Manifest
|
||||
```
|
||||
GET /v1/projects/{project_id}/manifest
|
||||
```
|
||||
|
||||
Response:
|
||||
```
|
||||
{
|
||||
"version": "0.1.2",
|
||||
"artifact_url": "https://update.wlkns.org/v1/projects/<project_id>/releases/0.1.2/artifact",
|
||||
"sha256": "<hex>",
|
||||
"sig_url": "<optional>"
|
||||
}
|
||||
```
|
||||
|
||||
Required scope: `read_manifest`
|
||||
|
||||
### Download Artifact
|
||||
```
|
||||
GET /v1/projects/{project_id}/releases/{version}/artifact
|
||||
```
|
||||
|
||||
Required scope: `read_manifest`
|
||||
|
||||
### Report Status
|
||||
```
|
||||
POST /v1/projects/{project_id}/status
|
||||
{
|
||||
"project_id": "<project_id>",
|
||||
"version": "<semver>",
|
||||
"status": "success|failed|in_progress",
|
||||
"timestamp": "<iso8601>",
|
||||
"client_id": "<optional>",
|
||||
"duration_ms": "<optional>",
|
||||
"error_code": "<optional>"
|
||||
}
|
||||
```
|
||||
|
||||
Required scope: `report_status`
|
||||
|
||||
## Release API (Upload)
|
||||
|
||||
### Upload Release
|
||||
```
|
||||
POST /v1/projects/{project_id}/releases
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Required scope: `upload_release`
|
||||
|
||||
Required fields:
|
||||
- `version` (SemVer)
|
||||
- `artifact` (file)
|
||||
- `sha256` (hex)
|
||||
|
||||
Optional fields:
|
||||
- `sig_url` or inline signature
|
||||
- `key_id`
|
||||
|
||||
## Error Codes
|
||||
Common error codes:
|
||||
`unauthorized`, `rate_limited`, `not_found`, `invalid_payload`, `version_invalid`,
|
||||
`version_exists`, `checksum_mismatch`, `signature_missing`, `signature_invalid`,
|
||||
`payload_too_large`, `status_invalid`
|
||||
|
||||
## Rate Limits
|
||||
Limits are tiered by scope. See `docs/architecture/openapi/paths/limits.yaml` for current values.
|
||||
|
||||
## Examples
|
||||
Fetch manifest:
|
||||
```
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://update.wlkns.org/v1/projects/$PROJECT_ID/manifest
|
||||
```
|
||||
|
||||
Report status:
|
||||
```
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project_id":"'"$PROJECT_ID"'","version":"0.1.2","status":"success","timestamp":"2025-12-30T10:00:00Z"}' \
|
||||
https://update.wlkns.org/v1/projects/$PROJECT_ID/status
|
||||
```
|
||||
80
docs/_archive/update-api.md
Normal file
@ -0,0 +1,80 @@
|
||||
ID: DOC_000006 | Version: 0.2.3 | Status: Draft
|
||||
Archived – superseded by new documentation.
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Update API (Kiddo Backend)
|
||||
|
||||
## Purpose
|
||||
Definiert interne API-Endpunkte fuer Update-Status, Check, Apply, Rollback und Logs.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### POST /update/enroll
|
||||
Body (optional):
|
||||
```json
|
||||
{ "enroll_token": "<one-time-token>" }
|
||||
```
|
||||
Antwort:
|
||||
```json
|
||||
{ "enrolled": true, "message": "Enrollment successful" }
|
||||
```
|
||||
|
||||
### GET /update/status
|
||||
Antwort:
|
||||
```json
|
||||
{
|
||||
"current_version": "0.1.2",
|
||||
"last_status": "success|failed|unknown",
|
||||
"last_error": "<optional>",
|
||||
"last_timestamp": "2025-12-28T12:34:56Z",
|
||||
"enrolled": true
|
||||
}
|
||||
```
|
||||
|
||||
### POST /update/check
|
||||
Antwort:
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
"latest_version": "0.1.3",
|
||||
"artifact_url": "https://update.wlkns.org/kiddo/kiddo-0.1.3.tar.gz",
|
||||
"sha256": "<hex>",
|
||||
"message": "<optional>"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /update/apply
|
||||
Body (optional):
|
||||
```json
|
||||
{ "version": "0.1.3" }
|
||||
```
|
||||
Antwort:
|
||||
```json
|
||||
{ "started": true, "message": "update started" }
|
||||
```
|
||||
|
||||
### POST /update/rollback
|
||||
Antwort:
|
||||
```json
|
||||
{ "started": true, "message": "rollback started" }
|
||||
```
|
||||
|
||||
### GET /update/logs
|
||||
Antwort:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"timestamp": "2025-12-28T12:34:56Z",
|
||||
"status": "success",
|
||||
"message": "updated to 0.1.2",
|
||||
"version": "0.1.2",
|
||||
"device_id": "kiddo-001",
|
||||
"error": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Notes
|
||||
- Alle Endpunkte erfordern Auth (Session-Cookie oder `Authorization: Bearer <token>`).
|
||||
- Apply/Rollback starten async; UI pollt /update/status.
|
||||
- `/update/enroll` speichert das Update-Token lokal (siehe `SKD_UPDATE_TOKEN_FILE`).
|
||||
52
docs/_archive/update-client.md
Normal file
@ -0,0 +1,52 @@
|
||||
ID: DOC_000004 | Version: 0.2.3 | Status: Draft
|
||||
Archived – superseded by new documentation.
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Client Update Flow (Kiddo)
|
||||
|
||||
## Purpose
|
||||
Definiert den Client-seitigen Ablauf fuer das Pull-Update vom Update-Service.
|
||||
|
||||
## Manifest Format (JSON)
|
||||
Beispiel:
|
||||
```json
|
||||
{
|
||||
"version": "0.1.2",
|
||||
"artifact_url": "https://update.wlkns.org/kiddo/kiddo-0.1.2.tar.gz",
|
||||
"sha256": "<hex>",
|
||||
"sig_url": "https://update.wlkns.org/kiddo/kiddo-0.1.2.sig"
|
||||
}
|
||||
```
|
||||
|
||||
## Flow (High Level)
|
||||
0. Falls kein Token vorhanden ist: Enrollment durchfuehren (Pre-Shared Token -> Langzeit-Token).
|
||||
1. Manifest abrufen (auth via Bearer Token).
|
||||
2. `artifact_url` herunterladen.
|
||||
3. SHA256 pruefen (Signatur optional).
|
||||
4. In Staging-Verzeichnis entpacken.
|
||||
5. Service stoppen.
|
||||
6. Atomic swap: aktuelles Verzeichnis sichern, Staging nach `/opt/sk` verschieben.
|
||||
7. Service starten.
|
||||
8. Bei Fehlern Rollback auf Backup.
|
||||
|
||||
## Prototype Script
|
||||
- `scripts/update_client.sh` implementiert den Flow als CLI-Prototyp.
|
||||
- Erfordert `curl`, `tar`, `sha256sum`, `python3` und `systemctl`.
|
||||
|
||||
## Rollback
|
||||
- Wenn Start fehlschlaegt: Backup nach `/opt/sk` zurueck, Service neu starten.
|
||||
- Backup-Verzeichnis benoetigt genuegend Speicher.
|
||||
|
||||
## Security Notes
|
||||
- Artefakte muessen checksum-verifiziert sein.
|
||||
- Token-Handling ueber `SKD_UPDATE_TOKEN` oder `SKD_UPDATE_TOKEN_FILE`.
|
||||
- Enrollment nutzt einen Pre-Shared Token und speichert das Langzeit-Token lokal.
|
||||
|
||||
## Constraints
|
||||
- Update-Service ist extern (update.wlkns.org).
|
||||
- Service muss als root stoppen/starten koennen.
|
||||
|
||||
## Status Reporting
|
||||
- Status wird per HTTP POST an `${SKD_UPDATE_SERVICE_URL}/v1/projects/${SKD_UPDATE_PROJECT_ID}/status` gemeldet.
|
||||
- Schema siehe `docs/update-status.md`.
|
||||
- Lokaler Status/Logs liegen unter `/var/lib/skd` (konfigurierbar via ENV).
|
||||
30
docs/_archive/update-status.md
Normal file
@ -0,0 +1,30 @@
|
||||
ID: DOC_000005 | Version: 0.2.3 | Status: Draft
|
||||
Archived – superseded by new documentation.
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Update Status Reporting
|
||||
|
||||
## Purpose
|
||||
Definiert das Status-Schema fuer Update-Resultate und den Uebertragungsweg.
|
||||
|
||||
## Status Schema (JSON)
|
||||
```json
|
||||
{
|
||||
"project_id": "safe-kiddo-control",
|
||||
"client_id": "<hostname>",
|
||||
"version": "0.2.1",
|
||||
"status": "success|failed|in_progress",
|
||||
"timestamp": "2025-12-28T12:34:56Z",
|
||||
"duration_ms": 1234,
|
||||
"error_code": "<optional>",
|
||||
"reason": "<optional>"
|
||||
}
|
||||
```
|
||||
|
||||
## Transport
|
||||
- HTTP POST an `${SKD_UPDATE_SERVICE_URL}/v1/projects/${SKD_UPDATE_PROJECT_ID}/status`
|
||||
- Auth: Bearer Token (`SKD_UPDATE_TOKEN` oder `SKD_UPDATE_TOKEN_FILE`)
|
||||
|
||||
## Notes
|
||||
- Statusmeldungen sind best-effort; Fehler beim Senden blockieren kein Update.
|
||||
- Reporting wird nur gesendet, wenn ein Update-Token vorhanden ist.
|
||||
57
docs/architecture/openapi.yaml
Normal file
@ -0,0 +1,57 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Update Webservice API
|
||||
version: 0.2.1
|
||||
servers:
|
||||
- url: https://update.wlkns.org
|
||||
- url: https://staging.update.wlkns.org
|
||||
security:
|
||||
- bearerAuth: []
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
schemas:
|
||||
Manifest:
|
||||
$ref: './openapi/schemas/manifest.yaml'
|
||||
StatusReport:
|
||||
$ref: './openapi/schemas/status-report.yaml'
|
||||
UploadResponse:
|
||||
$ref: './openapi/schemas/upload-response.yaml'
|
||||
EnrollRequest:
|
||||
$ref: './openapi/schemas/enroll-request.yaml'
|
||||
EnrollResponse:
|
||||
$ref: './openapi/schemas/enroll-response.yaml'
|
||||
EnrollmentToken:
|
||||
$ref: './openapi/schemas/enrollment-token.yaml'
|
||||
EnrollmentTokenCreateRequest:
|
||||
$ref: './openapi/schemas/enrollment-token-create-request.yaml'
|
||||
EnrollmentTokenCreateResponse:
|
||||
$ref: './openapi/schemas/enrollment-token-create-response.yaml'
|
||||
Error:
|
||||
$ref: './openapi/schemas/error.yaml'
|
||||
Limits:
|
||||
$ref: './openapi/schemas/limits.yaml'
|
||||
LimitsPolicy:
|
||||
$ref: './openapi/schemas/limits-policy.yaml'
|
||||
paths:
|
||||
/v1/enroll:
|
||||
$ref: './openapi/paths/enroll.yaml'
|
||||
/v1/admin/enrollment-tokens:
|
||||
$ref: './openapi/paths/admin-enrollment-tokens.yaml'
|
||||
/v1/admin/enrollment-tokens/{token_id}/revoke:
|
||||
$ref: './openapi/paths/admin-enrollment-tokens-revoke.yaml'
|
||||
/v1/admin/enrollment-tokens/{token_id}/export:
|
||||
$ref: './openapi/paths/admin-enrollment-tokens-export.yaml'
|
||||
/v1/projects/{project_id}/manifest:
|
||||
$ref: './openapi/paths/manifest.yaml'
|
||||
/v1/projects/{project_id}/releases/{version}/artifact:
|
||||
$ref: './openapi/paths/artifact.yaml'
|
||||
/v1/projects/{project_id}/status:
|
||||
$ref: './openapi/paths/status.yaml'
|
||||
/v1/projects/{project_id}/releases:
|
||||
$ref: './openapi/paths/releases.yaml'
|
||||
/v1/limits:
|
||||
$ref: './openapi/paths/limits.yaml'
|
||||
@ -0,0 +1,31 @@
|
||||
get:
|
||||
summary: Export enrollment token
|
||||
x-auth-scopes: [admin]
|
||||
parameters:
|
||||
- name: token_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Token file
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
example: enroll_6f3d2c...
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-error-codes: [unauthorized]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
'404':
|
||||
description: Not Found
|
||||
x-error-codes: [not_found]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
@ -0,0 +1,30 @@
|
||||
post:
|
||||
summary: Revoke enrollment token
|
||||
x-auth-scopes: [admin]
|
||||
parameters:
|
||||
- name: token_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Revoked
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/enrollment-token.yaml'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-error-codes: [unauthorized]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
'404':
|
||||
description: Not Found
|
||||
x-error-codes: [not_found]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
69
docs/architecture/openapi/paths/admin-enrollment-tokens.yaml
Normal file
@ -0,0 +1,69 @@
|
||||
get:
|
||||
summary: List enrollment tokens
|
||||
x-auth-scopes: [admin]
|
||||
parameters:
|
||||
- name: project_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: client_id
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: status
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [active, used, expired, revoked]
|
||||
responses:
|
||||
'200':
|
||||
description: Token list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../schemas/enrollment-token.yaml'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-error-codes: [unauthorized]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
post:
|
||||
summary: Create enrollment token
|
||||
x-auth-scopes: [admin]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/enrollment-token-create-request.yaml'
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/enrollment-token-create-response.yaml'
|
||||
'400':
|
||||
description: Bad Request
|
||||
x-error-codes: [invalid_payload]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-error-codes: [unauthorized]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
58
docs/architecture/openapi/paths/artifact.yaml
Normal file
@ -0,0 +1,58 @@
|
||||
get:
|
||||
summary: Download artifact
|
||||
x-auth-scopes: [read_manifest]
|
||||
parameters:
|
||||
- name: project_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: version
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Artifact tar.gz
|
||||
content:
|
||||
application/gzip:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-error-codes: [unauthorized]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
unauthorized:
|
||||
value:
|
||||
code: unauthorized
|
||||
message: Missing or invalid token
|
||||
'404':
|
||||
description: Not Found
|
||||
x-error-codes: [not_found]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
not_found:
|
||||
value:
|
||||
code: not_found
|
||||
message: Artifact not found
|
||||
'429':
|
||||
description: Too Many Requests
|
||||
x-error-codes: [rate_limited]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
rate_limited:
|
||||
value:
|
||||
code: rate_limited
|
||||
message: Too many requests
|
||||
65
docs/architecture/openapi/paths/enroll.yaml
Normal file
@ -0,0 +1,65 @@
|
||||
post:
|
||||
summary: Enroll client and issue long-term token
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/enroll-request.yaml'
|
||||
examples:
|
||||
enroll:
|
||||
value:
|
||||
project_id: demo
|
||||
client_id: device-42
|
||||
software_id: kiosk
|
||||
enroll_token: enroll_6f3d2c...
|
||||
responses:
|
||||
'200':
|
||||
description: Enrollment successful
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/enroll-response.yaml'
|
||||
examples:
|
||||
issued:
|
||||
value:
|
||||
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
scope: read_manifest report_status
|
||||
expires_at: 2026-12-30T10:00:00Z
|
||||
'400':
|
||||
description: Bad Request
|
||||
x-error-codes: [invalid_payload]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
invalid_payload:
|
||||
value:
|
||||
code: invalid_payload
|
||||
message: Missing required fields
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-error-codes: [unauthorized]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
unauthorized:
|
||||
value:
|
||||
code: unauthorized
|
||||
message: Invalid or expired enrollment token
|
||||
'409':
|
||||
description: Conflict
|
||||
x-error-codes: [already_enrolled]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
already_enrolled:
|
||||
value:
|
||||
code: already_enrolled
|
||||
message: Client already enrolled
|
||||
48
docs/architecture/openapi/paths/limits.yaml
Normal file
@ -0,0 +1,48 @@
|
||||
get:
|
||||
summary: Get service limits
|
||||
x-auth-scopes: [read_manifest]
|
||||
responses:
|
||||
'200':
|
||||
description: Limits
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/limits-policy.yaml'
|
||||
examples:
|
||||
medium:
|
||||
value:
|
||||
tier: medium
|
||||
limits:
|
||||
upload_max_artifact_size_bytes_soft: 1073741824
|
||||
upload_max_artifact_size_bytes_hard: 2147483648
|
||||
read_max_requests_per_minute_soft: 300
|
||||
read_max_requests_per_minute_hard: 600
|
||||
upload_max_requests_per_minute_soft: 6
|
||||
upload_max_requests_per_minute_hard: 12
|
||||
report_max_requests_per_minute_soft: 120
|
||||
report_max_requests_per_minute_hard: 240
|
||||
burst_requests_per_minute: 1200
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-error-codes: [unauthorized]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
unauthorized:
|
||||
value:
|
||||
code: unauthorized
|
||||
message: Missing or invalid token
|
||||
'429':
|
||||
description: Too Many Requests
|
||||
x-error-codes: [rate_limited]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
rate_limited:
|
||||
value:
|
||||
code: rate_limited
|
||||
message: Too many requests
|
||||
47
docs/architecture/openapi/paths/manifest.yaml
Normal file
@ -0,0 +1,47 @@
|
||||
get:
|
||||
summary: Get active manifest
|
||||
x-auth-scopes: [read_manifest]
|
||||
parameters:
|
||||
- name: project_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Manifest
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/manifest.yaml'
|
||||
examples:
|
||||
default:
|
||||
value:
|
||||
version: 1.2.3
|
||||
artifact_url: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/artifact
|
||||
sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
||||
sig_url: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/signature
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-error-codes: [unauthorized]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
unauthorized:
|
||||
value:
|
||||
code: unauthorized
|
||||
message: Missing or invalid token
|
||||
'429':
|
||||
description: Too Many Requests
|
||||
x-error-codes: [rate_limited]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
rate_limited:
|
||||
value:
|
||||
code: rate_limited
|
||||
message: Too many requests
|
||||
126
docs/architecture/openapi/paths/releases.yaml
Normal file
@ -0,0 +1,126 @@
|
||||
post:
|
||||
summary: Upload release
|
||||
x-auth-scopes: [upload_release]
|
||||
parameters:
|
||||
- name: project_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- version
|
||||
- sha256
|
||||
- artifact
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
|
||||
example: 1.2.3
|
||||
sha256:
|
||||
type: string
|
||||
example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
||||
sig_url:
|
||||
type: string
|
||||
format: uri
|
||||
description: Optional reference to a detached signature
|
||||
signature:
|
||||
type: string
|
||||
format: binary
|
||||
description: Detached signature file (optional alternative to sig_url)
|
||||
key_id:
|
||||
type: string
|
||||
description: Public key identifier for signature verification
|
||||
artifact:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/upload-response.yaml'
|
||||
examples:
|
||||
created:
|
||||
value:
|
||||
version: 1.2.3
|
||||
manifest_url: https://update.wlkns.org/v1/projects/demo/manifest
|
||||
active: true
|
||||
'400':
|
||||
description: Bad Request
|
||||
x-error-codes: [invalid_payload]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
invalid_payload:
|
||||
value:
|
||||
code: invalid_payload
|
||||
message: Missing required fields
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-error-codes: [unauthorized]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
unauthorized:
|
||||
value:
|
||||
code: unauthorized
|
||||
message: Missing or invalid token
|
||||
'409':
|
||||
description: Conflict
|
||||
x-error-codes: [version_exists]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
version_exists:
|
||||
value:
|
||||
code: version_exists
|
||||
message: Version already exists
|
||||
'413':
|
||||
description: Payload Too Large
|
||||
x-error-codes: [payload_too_large]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
payload_too_large:
|
||||
value:
|
||||
code: payload_too_large
|
||||
message: Artifact exceeds size limit
|
||||
'422':
|
||||
description: Unprocessable Entity (invalid checksum/signature/version)
|
||||
x-error-codes: [checksum_mismatch, signature_invalid, signature_missing, version_invalid]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
checksum_mismatch:
|
||||
value:
|
||||
code: checksum_mismatch
|
||||
message: SHA256 does not match artifact
|
||||
'429':
|
||||
description: Too Many Requests
|
||||
x-error-codes: [rate_limited]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
rate_limited:
|
||||
value:
|
||||
code: rate_limited
|
||||
message: Too many requests
|
||||
84
docs/architecture/openapi/paths/status.yaml
Normal file
@ -0,0 +1,84 @@
|
||||
post:
|
||||
summary: Report update status
|
||||
x-auth-scopes: [report_status]
|
||||
parameters:
|
||||
- name: project_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/status-report.yaml'
|
||||
examples:
|
||||
success:
|
||||
value:
|
||||
project_id: demo
|
||||
version: 1.2.3
|
||||
status: success
|
||||
timestamp: 2025-12-28T10:15:30Z
|
||||
client_id: device-42
|
||||
duration_ms: 2450
|
||||
failure:
|
||||
value:
|
||||
project_id: demo
|
||||
version: 1.2.3
|
||||
status: failed
|
||||
timestamp: 2025-12-28T10:15:30Z
|
||||
client_id: device-42
|
||||
reason: checksum_mismatch
|
||||
error_code: checksum_mismatch
|
||||
responses:
|
||||
'202':
|
||||
description: Accepted
|
||||
'400':
|
||||
description: Bad Request
|
||||
x-error-codes: [invalid_payload]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
invalid_payload:
|
||||
value:
|
||||
code: invalid_payload
|
||||
message: Missing required fields
|
||||
'401':
|
||||
description: Unauthorized
|
||||
x-error-codes: [unauthorized]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
unauthorized:
|
||||
value:
|
||||
code: unauthorized
|
||||
message: Missing or invalid token
|
||||
'422':
|
||||
description: Unprocessable Entity (invalid version or status)
|
||||
x-error-codes: [version_invalid, status_invalid]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
version_invalid:
|
||||
value:
|
||||
code: version_invalid
|
||||
message: Version does not match SemVer
|
||||
'429':
|
||||
description: Too Many Requests
|
||||
x-error-codes: [rate_limited]
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '../schemas/error.yaml'
|
||||
examples:
|
||||
rate_limited:
|
||||
value:
|
||||
code: rate_limited
|
||||
message: Too many requests
|
||||
20
docs/architecture/openapi/schemas/enroll-request.yaml
Normal file
@ -0,0 +1,20 @@
|
||||
type: object
|
||||
required:
|
||||
- project_id
|
||||
- client_id
|
||||
- software_id
|
||||
- enroll_token
|
||||
properties:
|
||||
project_id:
|
||||
type: string
|
||||
example: demo
|
||||
client_id:
|
||||
type: string
|
||||
example: device-42
|
||||
software_id:
|
||||
type: string
|
||||
example: kiosk
|
||||
enroll_token:
|
||||
type: string
|
||||
description: Pre-shared, single-use enrollment token
|
||||
example: enroll_6f3d2c...
|
||||
19
docs/architecture/openapi/schemas/enroll-response.yaml
Normal file
@ -0,0 +1,19 @@
|
||||
type: object
|
||||
required:
|
||||
- token
|
||||
- scope
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: Long-term bearer token for client requests
|
||||
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
scope:
|
||||
type: string
|
||||
description: Space-delimited scopes
|
||||
example: read_manifest report_status
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: Null for non-expiring tokens
|
||||
example: 2026-12-30T10:00:00Z
|
||||
@ -0,0 +1,20 @@
|
||||
type: object
|
||||
required:
|
||||
- project_id
|
||||
properties:
|
||||
project_id:
|
||||
type: string
|
||||
example: demo
|
||||
client_id:
|
||||
type: string
|
||||
nullable: true
|
||||
example: device-42
|
||||
software_id:
|
||||
type: string
|
||||
nullable: true
|
||||
example: kiosk
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
example: 2026-12-30T10:00:00Z
|
||||
@ -0,0 +1,11 @@
|
||||
type: object
|
||||
required:
|
||||
- token
|
||||
- token_meta
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: One-time plaintext enrollment token
|
||||
example: enroll_6f3d2c...
|
||||
token_meta:
|
||||
$ref: './enrollment-token.yaml'
|
||||
39
docs/architecture/openapi/schemas/enrollment-token.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- project_id
|
||||
- status
|
||||
- created_at
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: tok_123
|
||||
project_id:
|
||||
type: string
|
||||
example: demo
|
||||
client_id:
|
||||
type: string
|
||||
nullable: true
|
||||
example: device-42
|
||||
software_id:
|
||||
type: string
|
||||
nullable: true
|
||||
example: kiosk
|
||||
status:
|
||||
type: string
|
||||
enum: [active, used, expired, revoked]
|
||||
example: active
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
example: 2026-12-30T10:00:00Z
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: 2025-12-30T10:00:00Z
|
||||
used_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
example: 2025-12-30T10:15:00Z
|
||||
15
docs/architecture/openapi/schemas/error.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: Error code (e.g., unauthorized, invalid_payload, already_enrolled)
|
||||
example: unauthorized
|
||||
message:
|
||||
type: string
|
||||
example: Missing or invalid token
|
||||
details:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
10
docs/architecture/openapi/schemas/limits-policy.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
type: object
|
||||
required:
|
||||
- tier
|
||||
- limits
|
||||
properties:
|
||||
tier:
|
||||
type: string
|
||||
enum: [small, medium, large]
|
||||
limits:
|
||||
$ref: './limits.yaml'
|
||||
39
docs/architecture/openapi/schemas/limits.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
type: object
|
||||
required:
|
||||
- upload_max_artifact_size_bytes_soft
|
||||
- upload_max_artifact_size_bytes_hard
|
||||
- read_max_requests_per_minute_soft
|
||||
- read_max_requests_per_minute_hard
|
||||
- upload_max_requests_per_minute_soft
|
||||
- upload_max_requests_per_minute_hard
|
||||
- report_max_requests_per_minute_soft
|
||||
- report_max_requests_per_minute_hard
|
||||
- burst_requests_per_minute
|
||||
properties:
|
||||
upload_max_artifact_size_bytes_soft:
|
||||
type: integer
|
||||
default: 1073741824
|
||||
upload_max_artifact_size_bytes_hard:
|
||||
type: integer
|
||||
default: 2147483648
|
||||
read_max_requests_per_minute_soft:
|
||||
type: integer
|
||||
default: 300
|
||||
read_max_requests_per_minute_hard:
|
||||
type: integer
|
||||
default: 600
|
||||
upload_max_requests_per_minute_soft:
|
||||
type: integer
|
||||
default: 6
|
||||
upload_max_requests_per_minute_hard:
|
||||
type: integer
|
||||
default: 12
|
||||
report_max_requests_per_minute_soft:
|
||||
type: integer
|
||||
default: 120
|
||||
report_max_requests_per_minute_hard:
|
||||
type: integer
|
||||
default: 240
|
||||
burst_requests_per_minute:
|
||||
type: integer
|
||||
default: 1200
|
||||
24
docs/architecture/openapi/schemas/manifest.yaml
Normal file
@ -0,0 +1,24 @@
|
||||
type: object
|
||||
required:
|
||||
- version
|
||||
- artifact_url
|
||||
- sha256
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
description: SemVer string (e.g., 1.2.3)
|
||||
pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
|
||||
example: 1.2.3
|
||||
artifact_url:
|
||||
type: string
|
||||
format: uri
|
||||
example: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/artifact
|
||||
sha256:
|
||||
type: string
|
||||
description: Hex-encoded SHA256
|
||||
example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
||||
sig_url:
|
||||
type: string
|
||||
format: uri
|
||||
nullable: true
|
||||
example: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/signature
|
||||
42
docs/architecture/openapi/schemas/status-report.yaml
Normal file
@ -0,0 +1,42 @@
|
||||
type: object
|
||||
required:
|
||||
- project_id
|
||||
- version
|
||||
- status
|
||||
- timestamp
|
||||
properties:
|
||||
project_id:
|
||||
type: string
|
||||
example: demo
|
||||
version:
|
||||
type: string
|
||||
example: 1.2.3
|
||||
status:
|
||||
type: string
|
||||
enum: [success, failed, in_progress]
|
||||
example: success
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
example: 2025-12-28T10:15:30Z
|
||||
reason:
|
||||
type: string
|
||||
example: checksum_mismatch
|
||||
client_id:
|
||||
type: string
|
||||
example: device-42
|
||||
client_version:
|
||||
type: string
|
||||
example: 1.2.2
|
||||
device_type:
|
||||
type: string
|
||||
example: kiosk
|
||||
update_channel:
|
||||
type: string
|
||||
example: stable
|
||||
duration_ms:
|
||||
type: integer
|
||||
example: 2450
|
||||
error_code:
|
||||
type: string
|
||||
example: checksum_mismatch
|
||||
16
docs/architecture/openapi/schemas/upload-response.yaml
Normal file
@ -0,0 +1,16 @@
|
||||
type: object
|
||||
required:
|
||||
- version
|
||||
- manifest_url
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
example: 1.2.3
|
||||
manifest_url:
|
||||
type: string
|
||||
format: uri
|
||||
example: https://update.wlkns.org/v1/projects/demo/manifest
|
||||
active:
|
||||
type: boolean
|
||||
description: True if release is active
|
||||
example: true
|
||||
10
env.example
@ -1,17 +1,19 @@
|
||||
# Copy to /etc/skd/env or .env for local runs
|
||||
# Optional allowlist of manageable users (otherwise all real users with uid>=1000)
|
||||
SKD_ALLOWED_USERS=child1,child2
|
||||
SKD_ALLOWED_USERS=
|
||||
SKD_AUTH_SECRET=change-me-secret
|
||||
SKD_TOKEN_TTL_SECONDS=900
|
||||
# Auth mode: pam (default) or oidc
|
||||
# PAM ist immer aktiv; OIDC wird zusaetzlich angeboten, wenn konfiguriert.
|
||||
# SKD_AUTH_MODE bleibt optional und wird derzeit nicht erzwungen.
|
||||
SKD_AUTH_MODE=pam
|
||||
SKD_AUTH_ALLOWED_USERS=
|
||||
SKD_AUTH_ALLOWED_GROUPS=sudo
|
||||
SKD_AUTH_PAM_SERVICE=login
|
||||
# Ubuntu/Debian: install.sh creates /etc/pam.d/skd; adjust for other distros.
|
||||
SKD_AUTH_PAM_SERVICE=skd
|
||||
SKD_OIDC_ISSUER=
|
||||
SKD_OIDC_CLIENT_ID=
|
||||
SKD_OIDC_CLIENT_SECRET=
|
||||
SKD_OIDC_REDIRECT_URI=http://localhost:8000/login/oidc/callback
|
||||
SKD_OIDC_REDIRECT_URI=http://localhost/login/oidc/callback
|
||||
SKD_OIDC_SCOPES=openid profile email
|
||||
SKD_SESSION_COOKIE_NAME=skd_session
|
||||
SKD_SESSION_COOKIE_SECURE=false
|
||||
|
||||
11
env.update.example
Normal file
@ -0,0 +1,11 @@
|
||||
# Copy to /etc/skd/update.env for update-service configuration
|
||||
SKD_UPDATE_SERVICE_URL=https://update.wlkns.org
|
||||
SKD_UPDATE_PROJECT_ID=safe-kiddo-control
|
||||
SKD_UPDATE_ENROLL_TOKEN=
|
||||
SKD_UPDATE_TOKEN=
|
||||
SKD_UPDATE_TOKEN_FILE=/var/lib/skd/update_token
|
||||
SKD_UPDATE_INTERVAL=3600
|
||||
SKD_UPDATE_STATUS_FILE=/var/lib/skd/update_status.json
|
||||
SKD_UPDATE_LOG_FILE=/var/lib/skd/update_logs.jsonl
|
||||
SKD_UPDATE_UPLOAD_TOKEN=
|
||||
SKD_UPDATE_UPLOAD_TOKEN_FILE=/etc/skd/update.upload.token
|
||||
@ -1,4 +1,4 @@
|
||||
ID: AGENTS_000001 | Version: 0.1.0 | Status: Draft
|
||||
ID: AGENTS_000001 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Repository Guidelines
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ID: STATUS_000001 | Version: 0.1.0 | Status: Final
|
||||
ID: STATUS_000001 | Version: 0.3.6 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Projekt-Status
|
||||
@ -10,14 +10,21 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
|
||||
✅ Stabilization
|
||||
|
||||
## Aktueller Fokus
|
||||
1. Dokumentierter Ist-Zustand der Module.
|
||||
2. Pflege der Anforderungen bei neuen Features.
|
||||
3. Doku und Ops-Automation aktuell halten.
|
||||
1. OIDC-Validierung abschliessen (EPIC_000003 / US_000025).
|
||||
2. Client-Update-Mechanik pflegen (EPIC_000008).
|
||||
3. Anforderungen bei neuen Features sauber dokumentieren.
|
||||
|
||||
## Projekt-Tagebuch (Kurz, optional)
|
||||
| Datum | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| 28.12.2025 | 🏗️ Planning | Anforderungen als Epics und Stories dokumentiert. |
|
||||
| 15.01.2026 | 📝 Req | Doku und ENV-Beispiele an Code-Stand angepasst. |
|
||||
| 15.01.2026 | 📝 Req | Doku-Overhaul mit neuer Struktur und Archivierung. |
|
||||
| 15.01.2026 | 📝 Req | Externe Service-Links in der Doku ergaenzt. |
|
||||
| 15.01.2026 | 📝 Req | Makefile als Einstieg in Doku aufgenommen. |
|
||||
| 15.01.2026 | ⚙️ Code | Makefile restart-Target hinzugefuegt. |
|
||||
| 16.01.2026 | ✨ Feat | Login-Zeitfenster (Rules & Scheduler) implementiert (US_000045). |
|
||||
| 16.01.2026 | 🐞 Fix | Update-Prozess entkoppelt (systemd-run), damit er beim Service-Stop weiterlaeuft. |
|
||||
|
||||
## Epic-Backlog (Uebersicht)
|
||||
### EPIC_000001: Legacy CLI Account Control (sk.sh)
|
||||
@ -45,14 +52,17 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
|
||||
- [x] TASK_000008: OIDC auth callback
|
||||
- [x] US_000009: Autorisierung und /me-Identitaet
|
||||
- [x] TASK_000009: Authorization /me gate
|
||||
- [ ] US_000025: OIDC End-to-End Validierung und Runbook
|
||||
- [ ] TASK_000025: OIDC E2E validation
|
||||
- [ ] TASK_000026: OIDC runbook update
|
||||
|
||||
### EPIC_000004: Web UI
|
||||
- [x] US_000010: Index-Seite ausliefern
|
||||
- [x] TASK_000010: Serve UI template
|
||||
- [x] US_000022: Web-UI Aktionen ausfuehren
|
||||
- [x] TASK_000022: UI login and actions
|
||||
- [ ] US_000024: Watchtower Theme fuer Web-UI (zurueckgestellt)
|
||||
- [ ] TASK_000024: Apply Watchtower theme (zurueckgestellt)
|
||||
- [x] US_000024: Watchtower Theme fuer Web-UI
|
||||
- [x] TASK_000024: Apply Watchtower theme
|
||||
|
||||
### EPIC_000005: Automation Scripts
|
||||
- [x] US_000011: Virtualenv und Abhaengigkeiten erstellen
|
||||
@ -82,9 +92,85 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
|
||||
- [x] US_000023: Runbook und Security-Hinweise dokumentieren
|
||||
- [x] TASK_000023: README runbook notes
|
||||
|
||||
### EPIC_000008: Client-Side Update Mechanism
|
||||
- [x] US_000026: Client bezieht Updates (Pull)
|
||||
- [x] TASK_000027: Update endpoint config
|
||||
- [x] US_000027: Client verifiziert und wendet Updates an
|
||||
- [x] TASK_000028: Verify and apply update
|
||||
- [x] US_000028: Client meldet Update-Status
|
||||
- [x] TASK_000029: Report update status
|
||||
- [x] US_000029: Update-Status im Web-UI anzeigen
|
||||
- [x] TASK_000030: UI update status view
|
||||
- [x] US_000030: Update-Check im Web-UI ausloesen
|
||||
- [x] TASK_000031: UI update check trigger
|
||||
- [x] US_000031: Update im Web-UI anstossen
|
||||
- [x] TASK_000032: UI update apply action
|
||||
- [x] US_000032: Update-Logs im Web-UI anzeigen
|
||||
- [x] TASK_000033: UI update logs view
|
||||
- [x] US_000033: Rollback im Web-UI anstossen
|
||||
- [x] TASK_000034: UI rollback action
|
||||
- [x] US_000043: Enrollment-Token per Script abrufen
|
||||
- [x] TASK_000049: Script fuer Enrollment-Flow erstellen
|
||||
- [x] US_000046: Update-Service Erreichbarkeit anzeigen
|
||||
- [x] TASK_000055: Endpoint und UI fuer Update-Service Status
|
||||
- [x] US_000047: Update-Config auslagern
|
||||
- [x] TASK_000056: Update-ENV separieren
|
||||
- [x] US_000048: Release-Upload automatisieren
|
||||
- [x] TASK_000057: Script fuer Release-Upload erstellen
|
||||
- [x] US_000049: Lokale deployment.env fuer Update-Uploads
|
||||
- [x] TASK_000058: deployment.env Beispiel und Script-Anpassungen
|
||||
|
||||
### EPIC_000009: Update Webservice (External Team)
|
||||
- [x] US_000026: Client bezieht Updates (Pull)
|
||||
- [x] US_000027: Client verifiziert und wendet Updates an
|
||||
- [x] US_000028: Client meldet Update-Status
|
||||
|
||||
### EPIC_000010: Update-Service v1 Migration (Major Release)
|
||||
- [x] US_000034: Enrollment fuer Langzeit-Token
|
||||
- [x] TASK_000040: Enrollment-Flow implementieren
|
||||
- [x] US_000035: v1 Update-Endpoints und Status-Schema
|
||||
- [x] TASK_000041: v1 Endpunkte im Update-Client umstellen
|
||||
|
||||
### EPIC_000011: Documentation and Configuration Alignment
|
||||
- [x] US_000036: Doku-Versionen auf VERSION synchronisieren
|
||||
- [x] US_000037: Update-API-Doku mit /update/enroll abgleichen
|
||||
- [x] US_000038: ENV-Beispiele und Healthcheck-Auth angleichen
|
||||
- [x] US_000039: Doku-Audit fuer verbleibende Abweichungen
|
||||
- [x] TASK_000042: Doku-Audit verbleibender Dateien
|
||||
|
||||
### EPIC_000012: Dokumentations-Overhaul
|
||||
- [x] US_000040: Doku-Struktur und Inhalte erstellen
|
||||
- [x] TASK_000043: Neue Doku-Dateien erstellen und verlinken
|
||||
- [x] TASK_000044: Altdoku archivieren
|
||||
- [x] US_000041: Einbindung externer Services dokumentieren
|
||||
- [x] TASK_000045: Einbindung externer Services dokumentieren
|
||||
- [x] US_000042: Consumer-Perspektive und Audience-Split ergaenzen
|
||||
- [x] TASK_000046: Consumer-Doku erstellen (FOR_USERS.md)
|
||||
- [x] TASK_000047: README und Audience-Split anpassen
|
||||
- [x] TASK_000048: External Dependencies und Audience-Ergaenzungen
|
||||
|
||||
### EPIC_000013: System Telemetry im Dashboard
|
||||
- [x] US_000044: Systemmetriken im Dashboard anzeigen
|
||||
- [x] TASK_000050: Backend-Endpoint fuer Systemmetriken
|
||||
- [x] TASK_000051: UI-Kacheln im Dashboard (System Information)
|
||||
|
||||
### EPIC_000014: Login-Zeitfenster und Parent-Control Regeln
|
||||
- [x] US_000045: Regeln fuer Login-Zeitfenster definieren und durchsetzen
|
||||
- [x] TASK_000052: Regelmodell und Speicherung definieren
|
||||
- [x] TASK_000053: Login-Pruefung und Enforcement
|
||||
- [x] TASK_000054: Admin-UI fuer Regeln und Scheduler
|
||||
|
||||
## Fehler / Bugs (History)
|
||||
- [x] BUG_000001: Update-Apply scheitert an WorkingDirectory
|
||||
- [x] BUG_000002: Update-Prozess wird beim Service-Stop gekillt
|
||||
- [x] BUG_000003: Veralteter Update-Token durch Caching
|
||||
|
||||
## Offene Risiken / Abhaengigkeiten
|
||||
- Betrieb erfordert Root/sudo und lokale System-Tools (notify-send, sound player, uvicorn).
|
||||
- OIDC-Validierung blockiert bis IdP bereit und Service laeuft.
|
||||
- Update-Service (update.wlkns.org) liegt ausserhalb des Kiddo-Scopes.
|
||||
|
||||
## Naechste Schritte
|
||||
- Anforderungen beim naechsten Feature-Start erweitern.
|
||||
- Tests fuer kritische Pfade evaluieren.
|
||||
- OIDC-Validierung abschliessen und US_000025 auf Done setzen.
|
||||
- Doku-Audit fuer weitere Abweichungen priorisieren (wenn Zeitfenster frei).
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ID: PROJECT_STATUS_TEMPLATE | Version: 0.1.0 | Status: Draft
|
||||
ID: PROJECT_STATUS_TEMPLATE | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# 📊 Projekt-Status (Template)
|
||||
|
||||
729
project-management/feedback/static/styles.css
Executable file
@ -0,0 +1,729 @@
|
||||
/* Professional IT Asset Management - Best of Both Worlds with Dark Mode */
|
||||
:root {
|
||||
--bg-main: #f5f7fa;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--text-main: #2d3748;
|
||||
--text-secondary: #718096;
|
||||
--text-light: #a0aec0;
|
||||
--border-main: #e2e8f0;
|
||||
--primary: #667eea;
|
||||
--primary-hover: #5568d3;
|
||||
--success: #48bb78;
|
||||
--success-hover: #38a169;
|
||||
--warning: #ed8936;
|
||||
--danger: #f56565;
|
||||
--danger-hover: #e53e3e;
|
||||
--info: #4299e1;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
|
||||
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Dark Mode Theme */
|
||||
body.dark-mode {
|
||||
--bg-main: #1a202c;
|
||||
--bg-panel: #2d3748;
|
||||
--bg-header: linear-gradient(135deg, #4c51bf 0%, #6b46c1 100%);
|
||||
--text-main: #f7fafc;
|
||||
--text-secondary: #cbd5e0;
|
||||
--text-light: #a0aec0;
|
||||
--border-main: #4a5568;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
|
||||
--shadow-md: 0 4px 6px rgba(0,0,0,0.3);
|
||||
--shadow-lg: 0 10px 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
line-height: 1.6;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Theme Toggle Button */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.theme-toggle svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke: white;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.theme-toggle .sun-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.dark-mode .theme-toggle .moon-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.dark-mode .theme-toggle .sun-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Header - Keep the gradient but more professional */
|
||||
header {
|
||||
background: var(--bg-header);
|
||||
color: white;
|
||||
padding: 32px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
header p {
|
||||
font-size: 1rem;
|
||||
opacity: 0.95;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Statistics Dashboard - More visual interest */
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-panel);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-md);
|
||||
border-left: 4px solid var(--primary);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: var(--primary);
|
||||
opacity: 0.05;
|
||||
border-radius: 50%;
|
||||
transform: translate(30%, -30%);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-left-width: 6px;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Controls - Clean but distinctive */
|
||||
.controls {
|
||||
background: var(--bg-panel);
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-main);
|
||||
}
|
||||
|
||||
/* Inputs - More refined */
|
||||
input, select {
|
||||
padding: 11px 14px;
|
||||
border: 2px solid var(--border-main);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-main);
|
||||
transition: all 0.2s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
/* Buttons - Keep gradient feel but professional */
|
||||
button {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 11px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: var(--success-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
/* Table Container - More polished */
|
||||
.table-container {
|
||||
background: var(--bg-panel);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-main);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--bg-main);
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 2px solid var(--border-main);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid var(--border-main);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
td strong {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Badges - More colorful but professional */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-available {
|
||||
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.status-in_use {
|
||||
background: linear-gradient(135deg, #bee3f8 0%, #90cdf4 100%);
|
||||
color: #2c5282;
|
||||
}
|
||||
|
||||
.status-maintenance {
|
||||
background: linear-gradient(135deg, #feebc8 0%, #fbd38d 100%);
|
||||
color: #7c2d12;
|
||||
}
|
||||
|
||||
.status-retired {
|
||||
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%);
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.status-lost {
|
||||
background: linear-gradient(135deg, #fed7d7 0%, #fc8181 100%);
|
||||
color: #742a2a;
|
||||
}
|
||||
|
||||
/* Modal - Elegant overlay */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-panel);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid var(--border-main);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
color: var(--text-main);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: var(--border-main);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
line-height: 1;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Form - Clean and accessible */
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: inherit;
|
||||
padding: 11px 14px;
|
||||
border: 2px solid var(--border-main);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-main);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid var(--border-main);
|
||||
}
|
||||
|
||||
/* Loading & Empty States */
|
||||
.loading, .empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
opacity: 0.4;
|
||||
stroke: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: var(--text-main);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
padding: 7px 14px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Import Zone - Visual and inviting */
|
||||
.import-zone {
|
||||
border: 3px dashed var(--border-main);
|
||||
border-radius: 12px;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
background: var(--bg-main);
|
||||
}
|
||||
|
||||
.import-zone:hover, .import-zone.drag-over {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-panel);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.import-zone.processing {
|
||||
border-color: var(--success);
|
||||
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
|
||||
}
|
||||
|
||||
.import-zone svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 16px;
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
.import-zone h3 {
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.import-zone p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Import Results */
|
||||
.import-results {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.import-results.success {
|
||||
background: #c6f6d5;
|
||||
border-color: var(--success);
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.import-results.error {
|
||||
background: #fed7d7;
|
||||
border-color: var(--danger);
|
||||
color: #742a2a;
|
||||
}
|
||||
|
||||
/* Progress Bar - More visual */
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: var(--border-main);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary) 0%, var(--info) 100%);
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* NEW STYLES FOR ENHANCED UI */
|
||||
|
||||
.error-card {
|
||||
background-color: var(--danger);
|
||||
color: white;
|
||||
border-left-color: var(--danger-hover);
|
||||
}
|
||||
|
||||
.error-card h3 {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
border-left-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-card details {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stat-card summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card summary:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.flags-list {
|
||||
list-style-type: none;
|
||||
padding-left: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.flags-list li {
|
||||
margin-bottom: 6px;
|
||||
padding-left: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flags-list li::before {
|
||||
content: '›';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.value.level-high {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.value.level-medium {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.value.level-low {
|
||||
color: var(--success);
|
||||
}
|
||||
10
project-management/feedback/teal/app_icon.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- V2 App Icon (Teal Optimized) -->
|
||||
<rect width="512" height="512" rx="115" fill="#1F2A37"/>
|
||||
<g transform="translate(120, 96) scale(8)">
|
||||
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||
<!-- Teal Keystone -->
|
||||
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 539 B |
9
project-management/feedback/teal/app_icon_ai.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- V12 App Icon (Teal) -->
|
||||
<rect width="512" height="512" rx="115" fill="#1F2A37"/>
|
||||
<g transform="translate(120, 96) scale(8)">
|
||||
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 503 B |
7
project-management/feedback/teal/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#1F2A37"/>
|
||||
<rect x="12" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||
<rect x="40" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||
<!-- Teal Keystone -->
|
||||
<rect x="26" y="29" width="12" height="19" rx="2" fill="#0EA5A4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 428 B |
15
project-management/feedback/teal/logo.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<svg width="250" height="60" viewBox="0 0 250 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Monolithic Symbol (V2 Optimized - Teal) -->
|
||||
<g transform="translate(10, 10)">
|
||||
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||
<!-- Middle Block: Teal, 19px Height -->
|
||||
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
|
||||
</g>
|
||||
|
||||
<!-- Wordmark -->
|
||||
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
|
||||
|
||||
<!-- The Underline: Teal, aligned like V8 -->
|
||||
<rect x="56" y="48" width="121" height="4" rx="1" fill="#0EA5A4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 764 B |
16
project-management/feedback/teal/logo_ai.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<svg width="340" height="60" viewBox="0 0 340 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- V12: ALL TEAL -->
|
||||
<g transform="translate(10, 10)">
|
||||
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
|
||||
</g>
|
||||
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
|
||||
<rect x="188" y="27" width="6" height="6" rx="1" fill="#4B5563"/>
|
||||
|
||||
<!-- AI: Teal -->
|
||||
<text x="206" y="42" fill="#0EA5A4" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">AI</text>
|
||||
|
||||
<!-- Underline: Teal -->
|
||||
<rect x="56" y="48" width="186" height="4" rx="1" fill="#0EA5A4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 900 B |
91
project-management/feedback/templates/admin/clients.html
Normal file
@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Client Administration</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>OIDC Clients</h1>
|
||||
<p>Manage OIDC clients - Logged in as: <strong>{{ admin_user.username }}</strong></p>
|
||||
</header>
|
||||
{% if message %}
|
||||
<div class="import-results success" style="max-width: 100%; margin-bottom: 20px;">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="controls">
|
||||
<a href="/admin/client/create" style="text-decoration: none;">
|
||||
<button class="secondary">Create New Client</button>
|
||||
</a>
|
||||
<a href="/admin/users" style="text-decoration: none;">
|
||||
<button>Manage Users</button>
|
||||
</a>
|
||||
<a href="/admin/logout" style="text-decoration: none;">
|
||||
<button class="danger">Logout</button>
|
||||
</a>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Client ID</th>
|
||||
<th>Client Name</th>
|
||||
<th>Redirect URIs</th>
|
||||
<th>Allowed Scopes</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for client in clients %}
|
||||
<tr>
|
||||
<td><strong>{{ client.id }}</strong></td>
|
||||
<td><code>{{ client.client_id }}</code></td>
|
||||
<td>{{ client.client_name }}</td>
|
||||
<td>
|
||||
<ul>
|
||||
{% for uri in client.get_redirect_uris() %}
|
||||
<li>{{ uri }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
<td>{{ client.get_allowed_scopes()|join(', ') }}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<a href="/admin/client/{{ client.id }}/edit" style="text-decoration: none;">
|
||||
<button type="button" style="padding: 7px 14px; font-size: 0.8rem;">Edit</button>
|
||||
</a>
|
||||
<form method="POST" action="/admin/client/{{ client.id }}/delete" style="display: inline;" onsubmit="return confirm('Delete client {{ client.client_name }}?');">
|
||||
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Create New Client</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Create New OIDC Client</h1>
|
||||
<p>Add a new client application to the system</p>
|
||||
</header>
|
||||
|
||||
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
|
||||
{% if error %}
|
||||
<div class="import-results error">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" style="margin-top: 24px;">
|
||||
<div class="form-group">
|
||||
<label for="client_name">Client Name</label>
|
||||
<input type="text" id="client_name" name="client_name" placeholder="My Awesome App" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="client_id">Client ID</label>
|
||||
<input type="text" id="client_id" name="client_id" placeholder="leave blank to auto-generate" >
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="client_secret">Client Secret</label>
|
||||
<input type="text" id="client_secret" name="client_secret" placeholder="leave blank to auto-generate">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="redirect_uris">Redirect URIs (one per line)</label>
|
||||
<textarea id="redirect_uris" name="redirect_uris" rows="3" placeholder="https://app.example.com/callback" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="allowed_scopes">Allowed Scopes (comma-separated)</label>
|
||||
<input type="text" id="allowed_scopes" name="allowed_scopes" value="openid, profile, email" placeholder="e.g. openid, profile, email">
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="/admin/clients">
|
||||
<button type="button" class="danger">Cancel</button>
|
||||
</a>
|
||||
<button type="submit" class="secondary">Create Client</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
106
project-management/feedback/templates/admin/create_user.html
Normal file
@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Create New User</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Create New User</h1>
|
||||
<p>Add a new user to the system</p>
|
||||
</header>
|
||||
|
||||
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
|
||||
{% if error %}
|
||||
<div class="import-results error">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" style="margin-top: 24px;">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Enter username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" placeholder="user@example.com" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Full Name</label>
|
||||
<input type="text" id="name" name="name" placeholder="John Doe" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Enter password" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">Role</label>
|
||||
<select id="role" name="role" required>
|
||||
<option value="user" selected>User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="moderator">Moderator</option>
|
||||
<option value="readonly">Read-Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="permissions">Permissions (comma-separated)</label>
|
||||
<input type="text" id="permissions" name="permissions" placeholder="e.g. read:data, write:data">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 8px;">
|
||||
Common permissions: read:data, write:data, manage:users, manage:settings
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin">
|
||||
Admin User
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_active" checked>
|
||||
Account Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="/admin/users">
|
||||
<button type="button" class="danger">Cancel</button>
|
||||
</a>
|
||||
<button type="submit" class="secondary">Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
158
project-management/feedback/templates/admin/dashboard.html
Normal file
@ -0,0 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Administration</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>User Administration</h1>
|
||||
<p>Manage OIDC users - Logged in as: <strong>{{ admin_user.username }}</strong></p>
|
||||
</header>
|
||||
|
||||
{% if message %}
|
||||
<div class="import-results success" style="max-width: 100%; margin-bottom: 20px;">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<h3>Total Users</h3>
|
||||
<div class="value">{{ total_users }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Active Users</h3>
|
||||
<div class="value value.level-low">{{ active_users }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Admin Users</h3>
|
||||
<div class="value">{{ admin_users }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3>Inactive Users</h3>
|
||||
<div class="value value.level-medium">{{ inactive_users }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<a href="/admin/analytics" style="text-decoration: none;">
|
||||
<button>📊 Analytics</button>
|
||||
</a>
|
||||
<a href="/admin/clients" style="text-decoration: none;">
|
||||
<button>Manage Clients</button>
|
||||
</a>
|
||||
<a href="/admin/user/create" style="text-decoration: none;">
|
||||
<button class="secondary">Create New User</button>
|
||||
</a>
|
||||
<a href="/admin/logout" style="text-decoration: none;">
|
||||
<button class="danger">Logout</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Role</th>
|
||||
<th>Permissions</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td><strong>{{ user.id }}</strong></td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.name }}</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="status-badge status-available">Active</span>
|
||||
{% else %}
|
||||
<span class="status-badge status-retired">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.role == 'admin' %}
|
||||
<span class="status-badge status-in_use">{{ user.role|capitalize }}</span>
|
||||
{% elif user.role == 'moderator' %}
|
||||
<span class="status-badge status-available">{{ user.role|capitalize }}</span>
|
||||
{% elif user.role == 'readonly' %}
|
||||
<span class="status-badge status-retired">{{ user.role|capitalize }}</span>
|
||||
{% else %}
|
||||
<span class="status-badge">{{ user.role|capitalize }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-size: 0.85rem;">
|
||||
{% if user.get_permissions()|length > 0 %}
|
||||
{{ user.get_permissions()|join(', ') }}
|
||||
{% else %}
|
||||
<em style="color: var(--text-secondary);">None</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<a href="/admin/user/{{ user.id }}/edit" style="text-decoration: none;">
|
||||
<button type="button" style="padding: 7px 14px; font-size: 0.8rem;">Edit</button>
|
||||
</a>
|
||||
|
||||
{% if user.is_active %}
|
||||
<form method="POST" action="/admin/user/{{ user.id }}/deactivate" style="display: inline;">
|
||||
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Deactivate</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="/admin/user/{{ user.id }}/activate" style="display: inline;">
|
||||
<button type="submit" class="secondary" style="padding: 7px 14px; font-size: 0.8rem;">Activate</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if not user.is_admin or admin_count > 1 %}
|
||||
<form method="POST" action="/admin/user/{{ user.id }}/delete" style="display: inline;" onsubmit="return confirm('Delete user {{ user.username }}?');">
|
||||
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<a href="/admin/user/{{ user.id }}/tokens" style="text-decoration: none;">
|
||||
<button type="button" class="secondary" style="padding: 7px 14px; font-size: 0.8rem;">Tokens</button>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
79
project-management/feedback/templates/admin/edit_client.html
Normal file
@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edit Client - {{ client.client_name }}</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Edit OIDC Client</h1>
|
||||
<p>Modify details for client: <strong>{{ client.client_name }}</strong></p>
|
||||
</header>
|
||||
|
||||
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
|
||||
{% if error %}
|
||||
<div class="import-results error">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" style="margin-top: 24px;">
|
||||
<div class="form-group">
|
||||
<label for="client_name">Client Name</label>
|
||||
<input type="text" id="client_name" name="client_name" value="{{ client.client_name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="client_id">Client ID</label>
|
||||
<input type="text" id="client_id" name="client_id" value="{{ client.client_id }}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new_client_secret">New Client Secret (leave empty to keep current)</label>
|
||||
<input type="text" id="new_client_secret" name="new_client_secret" placeholder="Optional: Set new secret">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="redirect_uris">Redirect URIs (one per line)</label>
|
||||
<textarea id="redirect_uris" name="redirect_uris" rows="3" required>{{ client.get_redirect_uris()|join('\n') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="allowed_scopes">Allowed Scopes (comma-separated)</label>
|
||||
<input type="text" id="allowed_scopes" name="allowed_scopes" value="{{ client.get_allowed_scopes()|join(', ') }}" placeholder="e.g. openid, profile, email">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="/admin/clients">
|
||||
<button type="button" class="danger">Cancel</button>
|
||||
</a>
|
||||
<button type="submit" class="secondary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
106
project-management/feedback/templates/admin/edit_user.html
Normal file
@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edit User - {{ user.username }}</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Edit User</h1>
|
||||
<p>Modify user details for: <strong>{{ user.username }}</strong></p>
|
||||
</header>
|
||||
|
||||
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
|
||||
{% if error %}
|
||||
<div class="import-results error">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" style="margin-top: 24px;">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" value="{{ user.username }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" value="{{ user.email }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Full Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ user.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin" {% if user.is_admin %}checked{% endif %}>
|
||||
Admin User
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_active" {% if user.is_active %}checked{% endif %}>
|
||||
Account Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">Role</label>
|
||||
<select id="role" name="role" required>
|
||||
<option value="user" {% if user.role == 'user' %}selected{% endif %}>User</option>
|
||||
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>Admin</option>
|
||||
<option value="moderator" {% if user.role == 'moderator' %}selected{% endif %}>Moderator</option>
|
||||
<option value="readonly" {% if user.role == 'readonly' %}selected{% endif %}>Read-Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="permissions">Permissions (comma-separated)</label>
|
||||
<input type="text" id="permissions" name="permissions" value="{{ user.get_permissions()|join(', ') }}" placeholder="e.g. read:data, write:data, manage:users">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 8px;">
|
||||
Common permissions: read:data, write:data, manage:users, manage:settings
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password (leave empty to keep current)</label>
|
||||
<input type="password" id="new_password" name="new_password" placeholder="Optional: Set new password">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="/admin/users">
|
||||
<button type="button" class="danger">Cancel</button>
|
||||
</a>
|
||||
<button type="submit" class="secondary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
63
project-management/feedback/templates/admin/login.html
Normal file
@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Login</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Admin Login</h1>
|
||||
<p>User Administration Access</p>
|
||||
</header>
|
||||
|
||||
<div class="modal-content" style="max-width: 450px; margin: 0 auto;">
|
||||
{% if error %}
|
||||
<div class="import-results error">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" style="margin-top: 24px;">
|
||||
<div class="form-group">
|
||||
<label for="username">Admin Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Enter admin username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Enter password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" style="width: 100%; margin-top: 8px;">Admin Login</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
|
||||
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
99
project-management/feedback/templates/admin/user_tokens.html
Normal file
@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Tokens</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Access Tokens for {{ target_user.username }}</h1>
|
||||
<p>Logged in as admin: <strong>{{ admin_user.username }}</strong></p>
|
||||
</header>
|
||||
|
||||
{% if message %}
|
||||
<div class="import-results success" style="max-width: 100%; margin-bottom: 16px;">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if new_token %}
|
||||
<div class="import-results warning" style="max-width: 100%; margin-bottom: 16px; word-break: break-all;">
|
||||
New Token (copy now): <code>{{ new_token }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="import-results" style="background: #3b1a1a; color: #f0b6b6; max-width: 100%; margin-bottom: 16px;">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="modal-content" style="max-width: 640px; margin: 0 auto;">
|
||||
<h2>Create Initial Access Token</h2>
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label>Scope</label>
|
||||
<input type="text" name="scope" value="dcr:register">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>TTL (days)</label>
|
||||
<input type="number" name="ttl_days" value="365" min="1" max="3650">
|
||||
</div>
|
||||
<button type="submit">Create Token</button>
|
||||
<a href="/admin/users" style="margin-left: 8px;">Back</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-container" style="margin-top: 24px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token</th>
|
||||
<th>Scope</th>
|
||||
<th>Expires</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for token in tokens %}
|
||||
<tr>
|
||||
<td style="max-width: 320px; word-break: break-all;"><code>{{ token.token }}</code></td>
|
||||
<td>{{ token.scope }}</td>
|
||||
<td>{{ token.expires_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if token.revoked %}
|
||||
<span class="status-badge status-retired">Revoked</span>
|
||||
{% elif token.is_valid %}
|
||||
<span class="status-badge status-available">Valid</span>
|
||||
{% else %}
|
||||
<span class="status-badge status-retired">Expired</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
|
||||
<input type="hidden" name="action" value="revoke">
|
||||
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;">Revoke</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
|
||||
<input type="hidden" name="action" value="update">
|
||||
<input type="text" name="scope" value="{{ token.scope }}" style="width: 140px; font-size: 0.8rem;" aria-label="Scope">
|
||||
<input type="number" name="ttl_days" value="365" min="1" max="3650" style="width: 70px; font-size: 0.8rem;" aria-label="TTL days">
|
||||
<button type="submit" class="secondary" style="padding: 6px 10px; font-size: 0.8rem;">Update</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block;">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;" onclick="return confirm('Delete this token?');">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
79
project-management/feedback/templates/change_password.html
Normal file
@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OIDC IdP - Change Password</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Change Password</h1>
|
||||
<p>Update your account security</p>
|
||||
</header>
|
||||
|
||||
<div class="modal-content" style="max-width: 500px; margin: 0 auto;">
|
||||
{% if error %}
|
||||
<div class="import-results error">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if success %}
|
||||
<div class="import-results success">
|
||||
<strong>Success:</strong> {{ success }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" style="margin-top: 24px;">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Your username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="current_password">Current Password</label>
|
||||
<input type="password" id="current_password" name="current_password" placeholder="Enter current password" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password</label>
|
||||
<input type="password" id="new_password" name="new_password" placeholder="Min. 8 characters" required minlength="8">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="new_password_confirm">Confirm New Password</label>
|
||||
<input type="password" id="new_password_confirm" name="new_password_confirm" placeholder="Repeat new password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" style="width: 100%; margin-top: 8px;">Update Password</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
|
||||
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
86
project-management/feedback/templates/dashboard.html
Normal file
@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>👤 User Dashboard</h1>
|
||||
<p>Logged in as: <strong>{{ user.username }}</strong></p>
|
||||
</header>
|
||||
|
||||
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: var(--text-main); margin-bottom: 20px;">Your Information</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input type="text" value="{{ user.username }}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Email</label>
|
||||
<input type="text" value="{{ user.email }}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Full Name</label>
|
||||
<input type="text" value="{{ user.name }}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Role</label>
|
||||
<input type="text" value="{{ user.role }}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Permissions</label>
|
||||
<input type="text" value="{{ user.get_permissions()|join(', ') }}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Account Status</label>
|
||||
<input type="text" value="{% if user.is_active %}Active{% else %}Inactive{% endif %}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls" style="margin-top: 24px;">
|
||||
<a href="/my-sessions" style="text-decoration: none;">
|
||||
<button>📊 My Sessions</button>
|
||||
</a>
|
||||
<a href="/change-password" style="text-decoration: none;">
|
||||
<button>🔑 Change Password</button>
|
||||
</a>
|
||||
<a href="/my-tokens" style="text-decoration: none;">
|
||||
<button>🎟️ My Tokens</button>
|
||||
</a>
|
||||
<a href="/logout" style="text-decoration: none;">
|
||||
<button class="danger">Logout</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
60
project-management/feedback/templates/index.html
Normal file
@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OIDC Identity Provider</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔐 OIDC Identity Provider</h1>
|
||||
<p>Secure authentication server for your services</p>
|
||||
</header>
|
||||
|
||||
<div class="modal-content" style="max-width: 700px; margin: 0 auto;">
|
||||
<h2 style="color: var(--text-main); margin-bottom: 20px;">Welcome</h2>
|
||||
<p style="color: var(--text-secondary); line-height: 1.6;">
|
||||
This is an OpenID Connect (OIDC) Identity Provider that enables secure authentication
|
||||
for your applications using industry-standard protocols.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="controls" style="margin-top: 32px; justify-content: center;">
|
||||
<a href="/login" style="text-decoration: none;">
|
||||
<button>🔑 Login</button>
|
||||
</a>
|
||||
<a href="/register" style="text-decoration: none;">
|
||||
<button class="secondary">📝 Register</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
|
||||
<p style="color: var(--text-secondary); font-size: 0.9rem;">
|
||||
Administrators: <a href="/admin/login" style="color: var(--primary); text-decoration: none;">Access admin panel</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
71
project-management/feedback/templates/login.html
Normal file
@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OIDC IdP - Login</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🔐 Homelab OIDC Login</h1>
|
||||
<p>Secure authentication for your homelab services</p>
|
||||
</header>
|
||||
|
||||
<div class="modal-content" style="max-width: 450px; margin: 0 auto;">
|
||||
{% if error %}
|
||||
<div class="import-results error">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if success %}
|
||||
<div class="import-results success">
|
||||
<strong>Success:</strong> {{ success }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" style="margin-top: 24px;">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Enter your username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Enter your password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" style="width: 100%; margin-top: 8px;">Sign In</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">Don't have an account?</p>
|
||||
<a href="/register" style="color: var(--primary); text-decoration: none; font-weight: 600;">Create new account →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
// Load saved theme
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
79
project-management/feedback/templates/register.html
Normal file
@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OIDC IdP - Registration</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Create New Account</h1>
|
||||
<p>Join your homelab authentication system</p>
|
||||
</header>
|
||||
|
||||
<div class="modal-content" style="max-width: 500px; margin: 0 auto;">
|
||||
{% if error %}
|
||||
<div class="import-results error">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" style="margin-top: 24px;">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" placeholder="Choose a username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address</label>
|
||||
<input type="email" id="email" name="email" placeholder="your.email@homelab.local" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Full Name</label>
|
||||
<input type="text" id="name" name="name" placeholder="John Doe" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" placeholder="Min. 8 characters" required minlength="8">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Confirm Password</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" placeholder="Repeat your password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="secondary" style="width: 100%; margin-top: 8px;">Create Account</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">Already have an account?</p>
|
||||
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
103
project-management/feedback/templates/user_analytics.html
Normal file
@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Active Sessions</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
</svg>
|
||||
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>My Active Sessions</h1>
|
||||
<p>{{ user.name }} ({{ user.email }})</p>
|
||||
</header>
|
||||
|
||||
<div style="margin-bottom: 24px;">
|
||||
<a href="/dashboard" style="text-decoration: none;">
|
||||
<button>Back to Dashboard</button>
|
||||
</a>
|
||||
<a href="/logout" style="text-decoration: none;">
|
||||
<button class="danger">Logout</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="analytics-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 32px;">
|
||||
<div class="analytics-card" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px;">
|
||||
<h3 style="font-size: 14px; color: var(--text-secondary); margin-bottom: 8px;">Active Sessions</h3>
|
||||
<div class="metric" style="font-size: 32px; font-weight: 600; color: var(--primary-color);">{{ summary.total_active_sessions }}</div>
|
||||
<div class="label" style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">Currently active</div>
|
||||
</div>
|
||||
<div class="analytics-card" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px;">
|
||||
<h3 style="font-size: 14px; color: var(--text-secondary); margin-bottom: 8px;">Applications</h3>
|
||||
<div class="metric" style="font-size: 32px; font-weight: 600; color: var(--primary-color);">{{ summary.total_clients }}</div>
|
||||
<div class="label" style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">You're using</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<h2 style="margin-bottom: 20px;">Active Sessions</h2>
|
||||
|
||||
{% if active_sessions %}
|
||||
{% set current_client = namespace(value='') %}
|
||||
{% for session in active_sessions %}
|
||||
{% if session.client_name != current_client.value %}
|
||||
{% set current_client.value = session.client_name %}
|
||||
{% if not loop.first %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="client-section" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<h3 style="margin-bottom: 16px;">{{ session.client_name }}</h3>
|
||||
{% endif %}
|
||||
|
||||
<div class="session-item" style="padding: 16px; background: var(--bg-primary); border-radius: 6px; margin-bottom: 12px;">
|
||||
<div class="session-info" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<div>
|
||||
<strong>Session</strong>
|
||||
</div>
|
||||
<span class="status-badge status-available" style="padding: 4px 12px; background: #10b981; color: white; border-radius: 4px; font-size: 12px;">Active</span>
|
||||
</div>
|
||||
<div class="session-meta" style="font-size: 14px; color: var(--text-secondary);">
|
||||
Created: {{ session.created_at.strftime('%Y-%m-%d %H:%M:%S') }} |
|
||||
Expires: {{ session.expires_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if loop.last %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="import-results" style="background: var(--bg-secondary); padding: 20px; border-radius: 8px;">
|
||||
No active sessions. Log in to an application to see sessions here.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||
}
|
||||
|
||||
if (localStorage.getItem('darkMode') === 'true') {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
99
project-management/feedback/templates/user_tokens.html
Normal file
@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Tokens</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Initial Access Tokens</h1>
|
||||
<p>Logged in as: <strong>{{ user.username }}</strong></p>
|
||||
</header>
|
||||
|
||||
{% if message %}
|
||||
<div class="import-results success" style="max-width: 100%; margin-bottom: 16px;">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if new_token %}
|
||||
<div class="import-results warning" style="max-width: 100%; margin-bottom: 16px; word-break: break-all;">
|
||||
New Token (copy now): <code>{{ new_token }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="import-results" style="background: #3b1a1a; color: #f0b6b6; max-width: 100%; margin-bottom: 16px;">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="modal-content" style="max-width: 640px; margin: 0 auto;">
|
||||
<h2>Create Initial Access Token</h2>
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label>Scope</label>
|
||||
<input type="text" name="scope" value="dcr:register">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>TTL (days)</label>
|
||||
<input type="number" name="ttl_days" value="365" min="1" max="3650">
|
||||
</div>
|
||||
<button type="submit">Create Token</button>
|
||||
<a href="/dashboard" style="margin-left: 8px;">Back</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-container" style="margin-top: 24px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token</th>
|
||||
<th>Scope</th>
|
||||
<th>Expires</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for token in tokens %}
|
||||
<tr>
|
||||
<td style="max-width: 320px; word-break: break-all;"><code>{{ token.token }}</code></td>
|
||||
<td>{{ token.scope }}</td>
|
||||
<td>{{ token.expires_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if token.revoked %}
|
||||
<span class="status-badge status-retired">Revoked</span>
|
||||
{% elif token.is_valid %}
|
||||
<span class="status-badge status-available">Valid</span>
|
||||
{% else %}
|
||||
<span class="status-badge status-retired">Expired</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
|
||||
<input type="hidden" name="action" value="revoke">
|
||||
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;">Revoke</button>
|
||||
</form>
|
||||
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
|
||||
<input type="hidden" name="action" value="update">
|
||||
<input type="text" name="scope" value="{{ token.scope }}" style="width: 140px; font-size: 0.8rem;" aria-label="Scope">
|
||||
<input type="number" name="ttl_days" value="365" min="1" max="3650" style="width: 70px; font-size: 0.8rem;" aria-label="TTL days">
|
||||
<button type="submit" class="secondary" style="padding: 6px 10px; font-size: 0.8rem;">Update</button>
|
||||
</form>
|
||||
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block;">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;" onclick="return confirm('Delete this token?');">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,4 +1,4 @@
|
||||
ID: SOP_000001 | Version: 0.1.0 | Status: Draft
|
||||
ID: SOP_000001 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Onboarding: Arbeitsweise im Sound Architect Projekt
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ID: SETUP_000005 | Version: 0.1.0 | Status: Draft
|
||||
ID: SETUP_000005 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# 🧬 SETUP_GUIDE: Phase 0 - Project Genesis
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ID: PROMPT_000008 | Version: 0.1.0 | Status: Draft
|
||||
ID: PROMPT_000008 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Requirements Engineer Prompt
|
||||
|
||||
26
project-management/requirements/bugs/BUG_000001.md
Normal file
@ -0,0 +1,26 @@
|
||||
ID: BUG_000001 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# BUG_000001: Update scheitert wegen WorkingDirectory /opt/sk
|
||||
|
||||
## Beschreibung
|
||||
Beim Update-Apply bricht `scripts/update_client.sh` ab, wenn `/opt/sk` verschoben wird,
|
||||
weil der Prozess selbst im Verzeichnis arbeitet (WorkingDirectory=/opt/sk).
|
||||
Das fuehrt zu einem Abbruch ohne Abschlussstatus (status bleibt `in_progress`).
|
||||
|
||||
## Schritte zur Reproduktion
|
||||
1. `skd.service` laeuft mit `WorkingDirectory=/opt/sk`.
|
||||
2. Update ueber UI oder `POST /update/apply` starten.
|
||||
3. Script stoppt Service und versucht `mv /opt/sk ...`.
|
||||
4. Fehler wegen busy CWD, Script bricht ab, Service bleibt gestoppt.
|
||||
|
||||
## Erwartetes Verhalten
|
||||
Update-Prozess laeuft aus einem neutralen CWD (z.B. `/`), und Fehler werden als `failed`
|
||||
in Status/Logs vermerkt.
|
||||
|
||||
## Ist-Verhalten
|
||||
Update bleibt im Status `in_progress`, Service bleibt gestoppt.
|
||||
|
||||
## Fix-Idee
|
||||
- `subprocess.Popen(..., cwd="/")` fuer Update-Client.
|
||||
- Fehlertrap in `scripts/update_client.sh` fuer `failed` Status.
|
||||
16
project-management/requirements/bugs/BUG_000002.md
Normal file
@ -0,0 +1,16 @@
|
||||
ID: BUG_000002 | Status: Fixed | Severity: High
|
||||
By: Gemini CLI
|
||||
|
||||
# BUG_000002: Update-Prozess wird beim Stoppen des Services beendet
|
||||
|
||||
## Beschreibung
|
||||
Der Update-Prozess (`scripts/update_client.sh`) wird vom Backend-Service gestartet. Da er in derselben Systemd-CGroup wie der `skd.service` läuft, beendet Systemd den Updater sofort, wenn der Service für das Update gestoppt wird.
|
||||
|
||||
## Ursache
|
||||
Prozesse, die direkt via `subprocess.Popen` aus einer Systemd-Unit gestartet werden, gehören zur selben Unit und werden beim Stoppen mit beendet (SIGTERM/SIGKILL).
|
||||
|
||||
## Fix
|
||||
Umstellung des Start-Mechanismus in `backend/update.py` auf `systemd-run --unit=skd-update --collect`. Dies entkoppelt den Prozess in eine eigene transiente Unit.
|
||||
|
||||
## Verifizierung
|
||||
- Update von v0.3.1 auf v0.3.2 verlief erfolgreich, ohne dass der Prozess abgewürgt wurde.
|
||||
16
project-management/requirements/bugs/BUG_000003.md
Normal file
@ -0,0 +1,16 @@
|
||||
ID: BUG_000003 | Status: Fixed | Severity: Medium
|
||||
By: Gemini CLI
|
||||
|
||||
# BUG_000003: Veralteter Update-Token durch Caching in Settings
|
||||
|
||||
## Beschreibung
|
||||
Wenn ein Gerät registriert (Enrollment) wird, wird das Token in einer Datei gespeichert. Die `Settings`-Klasse lädt das Token jedoch nur einmal beim Start des Services. Nachfolgende Update-Versuche nutzen ein leeres oder veraltetes Token aus dem Cache, was zu 401/403 Fehlern beim Manifest-Download führt.
|
||||
|
||||
## Ursache
|
||||
Die `Settings`-Instanz wird via `@lru_cache` in `backend/settings.py` gehalten und nicht aktualisiert, wenn sich Dateien auf der Festplatte ändern.
|
||||
|
||||
## Fix
|
||||
Einführung der Hilfsfunktion `_get_fresh_token(settings)` in `backend/update.py`, die das Token bei jedem kritischen Vorgang (Check, Report, Update) frisch von der Festplatte oder aus der Environment lädt.
|
||||
|
||||
## Verifizierung
|
||||
- Update auf v0.3.3 nutzt nun erfolgreich das Token, auch wenn der Service seit dem Enrollment nicht neu gestartet wurde.
|
||||
@ -1,4 +1,4 @@
|
||||
ID: EPIC_000001 | Version: 0.1.0 | Status: Final
|
||||
ID: EPIC_000001 | Version: 0.2.3 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# EPIC_000001: Legacy CLI Account Control (sk.sh)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ID: EPIC_000002 | Version: 0.1.0 | Status: Final
|
||||
ID: EPIC_000002 | Version: 0.2.3 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# EPIC_000002: Backend API Service
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ID: EPIC_000003 | Version: 0.1.0 | Status: Final
|
||||
ID: EPIC_000003 | Version: 0.2.3 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# EPIC_000003: Authentication & Sessions
|
||||
@ -34,7 +34,8 @@ Stelle einen sicheren Admin-Login bereit, der Token oder Session-Cookies ausstel
|
||||
- Abhaengigkeit von PAM und OIDC-Provider-Verfuegbarkeit.
|
||||
- Cookie-Sicherheit muss korrekt konfiguriert sein.
|
||||
|
||||
## Zugeordnete User Stories (Done)
|
||||
## Zugeordnete User Stories
|
||||
- US_000007: PAM-Login mit Token
|
||||
- US_000008: OIDC-Login Flow
|
||||
- US_000009: Autorisierung und /me-Identitaet
|
||||
- US_000025: OIDC End-to-End Validierung und Runbook
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ID: EPIC_000004 | Version: 0.1.0 | Status: Final
|
||||
ID: EPIC_000004 | Version: 0.2.3 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# EPIC_000004: Web UI
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ID: EPIC_000005 | Version: 0.1.0 | Status: Final
|
||||
ID: EPIC_000005 | Version: 0.2.3 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# EPIC_000005: Automation Scripts
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ID: EPIC_000006 | Version: 0.1.0 | Status: Final
|
||||
ID: EPIC_000006 | Version: 0.2.3 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# EPIC_000006: Systemd & Deployment Artifacts
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
ID: EPIC_000007 | Version: 0.1.0 | Status: Final
|
||||
ID: EPIC_000007 | Version: 0.2.3 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# EPIC_000007: Documentation & Runbook
|
||||
|
||||
50
project-management/requirements/epics/EPIC_000008.md
Normal file
@ -0,0 +1,50 @@
|
||||
ID: EPIC_000008 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# EPIC_000008: Client-Side Update Mechanism
|
||||
|
||||
## Beschreibung
|
||||
Der Kiddo-Client kann Updates aus einer zentralen Quelle beziehen, verifizieren und anwenden.
|
||||
|
||||
## Ziel / Business Value
|
||||
Schnelle, sichere Aktualisierung ohne SSH-Zugriff auf Zielgeraete.
|
||||
|
||||
## Mission Statement
|
||||
Ermoegliche einen robusten Client-Update-Flow mit Verifikation und Rollback.
|
||||
|
||||
## Business Value & Metriken
|
||||
- Reduzierter manueller Update-Aufwand.
|
||||
- Erfolgsmetrik: Update kann automatisiert und nachvollziehbar erfolgen.
|
||||
|
||||
## In-Scope
|
||||
- Client zieht Update-Artefakte (Pull-Modell).
|
||||
- Verifikation (Checksum/Signatur).
|
||||
- Atomare Anwendung und Rollback bei Fehlern.
|
||||
- Statusreporting vom Client.
|
||||
|
||||
## Out-of-Scope
|
||||
- Aufbau/Betrieb des zentralen Update-Webservice.
|
||||
- Release-Publishing/CI/CD und Artefakt-Hosting.
|
||||
- Service-Monitoring und Verfuegbarkeit des Update-Backends.
|
||||
|
||||
## High-Level Akzeptanzkriterien
|
||||
- Client kann Updates erkennen, verifizieren und anwenden.
|
||||
- Fehler fuehren zu Rollback und klarer Statusmeldung.
|
||||
|
||||
## Technische Constraints & Risiken
|
||||
- Abhaengigkeit von Update-Backend und Netzverfuegbarkeit.
|
||||
- Update-Service wird unter `update.wlkns.org` erwartet.
|
||||
|
||||
## Zugeordnete User Stories
|
||||
- US_000026: Client bezieht Updates (Pull)
|
||||
- US_000027: Client verifiziert und wendet Updates an
|
||||
- US_000028: Client meldet Update-Status
|
||||
- US_000029: Update-Status im Web-UI anzeigen
|
||||
- US_000030: Update-Check im Web-UI ausloesen
|
||||
- US_000031: Update im Web-UI anstossen
|
||||
- US_000032: Update-Logs im Web-UI anzeigen
|
||||
- US_000033: Rollback im Web-UI anstossen
|
||||
- US_000043: Enrollment-Token per Script abrufen
|
||||
- US_000046: Update-Service Erreichbarkeit anzeigen
|
||||
- US_000048: Release-Upload automatisieren
|
||||
- US_000049: Lokale deployment.env fuer Update-Uploads
|
||||
43
project-management/requirements/epics/EPIC_000009.md
Normal file
@ -0,0 +1,43 @@
|
||||
ID: EPIC_000009 | Version: 0.2.3 | Status: Done
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# EPIC_000009: Update Webservice (External Team)
|
||||
|
||||
## Beschreibung
|
||||
Zentraler Update-Service stellt Releases und Artefakte fuer Kiddo-Clients bereit.
|
||||
|
||||
## Ziel / Business Value
|
||||
Zuverlaessige, sichere Bereitstellung von Updates ohne SSH-Zugriff auf Zielgeraete.
|
||||
|
||||
## Mission Statement
|
||||
Liefer einen stabilen Release-Endpunkt, der Versionen und signierte Artefakte ausliefert.
|
||||
|
||||
## Business Value & Metriken
|
||||
- Reduzierte manuelle Deployments.
|
||||
- Erfolgsmetrik: Clients koennen Updates sicher und reproduzierbar beziehen.
|
||||
|
||||
## In-Scope (Update-Service)
|
||||
- Endpoint unter `https://update.wlkns.org`.
|
||||
- JSON-Manifest mit `version`, `artifact_url`, `sha256`, optional `sig_url`.
|
||||
- Artefakte als `tar.gz`.
|
||||
- Authentifizierung (Token/API-Key).
|
||||
- Signatur oder Checksum-Validierung ermoeglichen.
|
||||
|
||||
## Out-of-Scope (Kiddo Team)
|
||||
- Betrieb/Hosting des Services.
|
||||
- CI/CD fuer Release-Publishing.
|
||||
- Artefakt-Storage und Verfuegbarkeitsmonitoring.
|
||||
|
||||
## High-Level Akzeptanzkriterien
|
||||
- Manifest liefert aktuelle Version + Artefakt-Metadaten.
|
||||
- Artefakt ist verifizierbar (Checksum/Signatur).
|
||||
- Zugriff ist auth-geschuetzt.
|
||||
|
||||
## Technische Constraints & Risiken
|
||||
- Netzwerk- und Service-Verfuegbarkeit.
|
||||
- Kompatible Versionierung und semantische Releases.
|
||||
|
||||
## Zugeordnete User Stories
|
||||
- US_000026: Client bezieht Updates (Pull)
|
||||
- US_000027: Client verifiziert und wendet Updates an
|
||||
- US_000028: Client meldet Update-Status
|
||||