Compare commits
68 Commits
6f49528bbd
...
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 | |||
| 4eb20e2449 | |||
| 3991362e67 |
18
.gitignore
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
upload.token
|
||||
update-addon.env
|
||||
venv/
|
||||
ENV/
|
||||
.env
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
output/
|
||||
temp/
|
||||
.project_analysis/
|
||||
*.sqlite3
|
||||
*.db
|
||||
.DS_Store
|
||||
@ -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.
|
||||
|
||||
108
CHANGELOG.md
Normal file
@ -0,0 +1,108 @@
|
||||
ID: DOC_000001 | Version: 0.2.3 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Projekt-Logbuch (Changelog)
|
||||
|
||||
| Datum | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| 28.12.2025 | 🚀 Init | ID: SETUP_000005 Struktur, VERSION, CHANGELOG, .gitignore initialisiert. By: Codex (GPT-5) |
|
||||
| 28.12.2025 | 🏗️ Planning | ID: EPIC_000001-EPIC_000006 und US_000001-US_000019 dokumentiert. 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
|
||||
* 🚀 Init = Setup | 📝 Req = Requirements | 🏛️ Arch = Architektur
|
||||
* 🎨 UI = Design | ✅ Done = Abgeschlossen | ⚙️ Code = Implementation
|
||||
* 🔊 Audio = Audio-Features | 🗣️ UX = User Experience | ✨ Feature = Neu | 🛠️ CRUD = Management
|
||||
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'
|
||||
|
||||
117
README.md
@ -1,75 +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_SECRET`: HMAC secret for bearer tokens (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.
|
||||
- `SKD_AUTH_ALLOWED_GROUPS`: groups whose members may log in (default `sudo`).
|
||||
- `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: `curl -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login`
|
||||
- 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`
|
||||
## 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 `/`. Login mit Root-Account, danach werden verfügbare System-User angezeigt; Aktionen senden Bearer Token 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 |
62
assets/design/THEME_WATCHTOWER.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Theme: Watchtower (Sci-Fi / Dark)
|
||||
|
||||
Dieses Theme basiert auf der `minecraft-watchtower` UI. Es ist ein **High-Contrast Dark Mode** mit Neon-Akzenten, ausgelegt auf technische Dashboards und "Immersive UIs".
|
||||
|
||||
## Verwendung
|
||||
|
||||
Binde statt `tokens.css` die Datei `tokens_watchtower.css` ein und ergänze `theme_watchtower.css`.
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="design/tokens_watchtower.css">
|
||||
<link rel="stylesheet" href="design/components.css"> <!-- Standard Components -->
|
||||
<link rel="stylesheet" href="design/theme_watchtower.css"> <!-- Theme Overrides -->
|
||||
```
|
||||
|
||||
Zusätzlich sollte die Klasse `.bg-noise` direkt nach dem `<body>` Tag eingefügt werden:
|
||||
```html
|
||||
<body>
|
||||
<div class="bg-noise"></div>
|
||||
...
|
||||
</body>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TUI (Terminal User Interface) Adaption
|
||||
|
||||
Da dieses Design oft in CLI-Tools oder TUIs verwendet wird, gelten folgende Mappings für Terminals (16/256 Farben).
|
||||
|
||||
### Farb-Palette
|
||||
|
||||
| Rolle | CSS Variable | ANSI Color (16) | ANSI Code | Hex Fallback |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Background** | `--bg-dark` | Black | `\e[40m` | `#0b0f14` |
|
||||
| **Text** | `--text` | White (Bright) | `\e[97m` | `#e6edf5` |
|
||||
| **Muted Text** | `--text-dim` | Cyan (Dim) | `\e[36m` | `#9aa7b8` |
|
||||
| **Accent** | `--accent` | Cyan (Bright) | `\e[96m` | `#00e08f` |
|
||||
| **Success** | `--success` | Green (Bright) | `\e[92m` | `#00e08f` (Teal) |
|
||||
| **Warning** | `--warning` | Yellow (Bright) | `\e[93m` | `#ffb454` |
|
||||
| **Error** | `--danger` | Red (Bright) | `\e[91m` | `#ff5e5e` |
|
||||
|
||||
### Block-Elemente & Rahmen
|
||||
|
||||
Für TUI-Rahmen nutzen wir "Heavy" oder "Double" Lines, um den technischen Look zu imitieren.
|
||||
|
||||
* **Box Border:** `═` (Double Horizontal), `║` (Double Vertical), `╔ ╗ ╚ ╝` (Corners)
|
||||
* **Progress Bar:** `█` (Full Block) für den Füllstand, `░` (Light Shade) für den Hintergrund.
|
||||
|
||||
### Beispiel (Charm / Bubble Tea - Go)
|
||||
|
||||
```go
|
||||
var (
|
||||
ColorAccent = lipgloss.Color("#00e08f")
|
||||
ColorBg = lipgloss.Color("#0b0f14")
|
||||
ColorText = lipgloss.Color("#e6edf5")
|
||||
|
||||
StyleCard = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorAccent).
|
||||
Padding(1, 2).
|
||||
Background(ColorBg)
|
||||
)
|
||||
```
|
||||
135
assets/design/theme_watchtower.css
Normal file
@ -0,0 +1,135 @@
|
||||
/*
|
||||
wlkns-dev-standards
|
||||
Component Theme: Watchtower
|
||||
Adapts standard components to the Sci-Fi/Dark aesthetic.
|
||||
*/
|
||||
|
||||
/* =========================
|
||||
Inputs & Forms
|
||||
========================= */
|
||||
input, select, textarea {
|
||||
background: rgba(10, 14, 20, 0.6) !important;
|
||||
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||
color: var(--color-text) !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--color-accent) !important;
|
||||
box-shadow: 0 0 15px var(--wt-glow);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Buttons
|
||||
========================= */
|
||||
.button {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
border-radius: 999px !important; /* Pill shape */
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
box-shadow: 0 0 15px var(--wt-glow);
|
||||
color: #081015; /* Dark text on bright accent */
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
border-color: var(--color-text);
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
box-shadow: 0 0 10px rgba(255, 94, 94, 0.4);
|
||||
color: #1b0d0d;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Tables
|
||||
========================= */
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 4px; /* Space between rows */
|
||||
}
|
||||
|
||||
thead th {
|
||||
border-bottom: 1px solid var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
border: none;
|
||||
}
|
||||
|
||||
tbody td:first-child {
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
}
|
||||
|
||||
tbody td:last-child {
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Alerts & Toasts
|
||||
========================= */
|
||||
.alert, .toast {
|
||||
background: rgba(10, 14, 20, 0.95);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.alert.success, .toast.success {
|
||||
border-color: var(--color-success);
|
||||
box-shadow: 0 0 10px rgba(0, 224, 143, 0.2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.alert.warning, .toast.warning {
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.alert.error, .toast.error {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 10px rgba(255, 94, 94, 0.2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Pagination
|
||||
========================= */
|
||||
.pagination .page {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pagination .page.active {
|
||||
background: var(--color-accent);
|
||||
color: #081015;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 10px var(--wt-glow);
|
||||
}
|
||||
69
assets/design/tokens_watchtower.css
Normal file
@ -0,0 +1,69 @@
|
||||
/*
|
||||
wlkns-dev-standards
|
||||
Theme: Watchtower (Dark Glow)
|
||||
Version: v1.0
|
||||
Based on: minecraft-watchtower UI
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* =========================
|
||||
Core Palette (Watchtower)
|
||||
========================= */
|
||||
--wt-bg-dark: #0b0f14;
|
||||
--wt-bg-panel: #10151d;
|
||||
--wt-accent: #00e08f;
|
||||
--wt-warning: #ffb454;
|
||||
--wt-danger: #ff5e5e;
|
||||
--wt-text: #e6edf5;
|
||||
--wt-text-dim: #9aa7b8;
|
||||
--wt-glow: rgba(0, 224, 143, 0.35);
|
||||
|
||||
/* =========================
|
||||
Mapping -> Standard Tokens
|
||||
========================= */
|
||||
|
||||
/* Farben – Identity & Status */
|
||||
--color-primary: #1a2230; /* Deep Blue/Grey from Gradient */
|
||||
--color-accent: var(--wt-accent);
|
||||
|
||||
--color-success: var(--wt-accent); /* Watchtower uses accent as success */
|
||||
--color-warning: var(--wt-warning);
|
||||
--color-error: var(--wt-danger);
|
||||
|
||||
/* Farben – Neutrals (Dark Mode Override) */
|
||||
--color-bg: var(--wt-bg-dark);
|
||||
--color-surface: var(--wt-bg-panel);
|
||||
--color-border: rgba(255, 255, 255, 0.08); /* Subtle white border */
|
||||
|
||||
--color-text: var(--wt-text);
|
||||
--color-text-muted: var(--wt-text-dim);
|
||||
|
||||
/* Typografie (Optional: Falls Space Grotesk geladen wird) */
|
||||
--font-ui: 'Space Grotesk', 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Radius & Spacing */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 12px; /* Watchtower uses larger radii */
|
||||
--radius-lg: 16px;
|
||||
|
||||
/* Shadows becomes Glows in this theme */
|
||||
--shadow-sm: 0 0 10px rgba(0,0,0,0.5);
|
||||
--shadow-glow: 0 0 15px var(--wt-glow);
|
||||
}
|
||||
|
||||
/* Global Theme Overrides */
|
||||
body {
|
||||
background: radial-gradient(circle at top, #1a2230 0%, #0b0f14 55%), linear-gradient(135deg, #0b0f14, #101623 60%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Noise Texture helper class */
|
||||
.bg-noise {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: soft-light;
|
||||
z-index: -1;
|
||||
}
|
||||
@ -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"])
|
||||
|
||||
278
backend/app.py
@ -1,15 +1,43 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
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, issue_token, list_manageable_users
|
||||
from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
|
||||
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 (
|
||||
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,
|
||||
@ -18,9 +46,30 @@ 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 not settings.oidc_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="OIDC not configured",
|
||||
)
|
||||
try:
|
||||
return OIDCClient(settings)
|
||||
except OIDCError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
def validate_user(username: str, settings: Settings = Depends(get_settings)) -> str:
|
||||
allowed = set(list_manageable_users(settings))
|
||||
if username not in allowed:
|
||||
@ -33,18 +82,102 @@ def health(settings: Settings = Depends(get_settings)) -> dict:
|
||||
return {"status": "ok", "dry_run": settings.dry_run}
|
||||
|
||||
|
||||
@app.get("/me")
|
||||
def whoami(
|
||||
current_user: str = Depends(get_current_admin),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
auth_mode = "pam+oidc" if settings.oidc_enabled else "pam"
|
||||
return {"user": current_user, "auth_mode": auth_mode}
|
||||
|
||||
|
||||
@app.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, settings: Settings = Depends(get_settings)) -> LoginResponse:
|
||||
def login(
|
||||
payload: LoginRequest,
|
||||
response: Response,
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> LoginResponse:
|
||||
authenticate_admin_user(payload.username, payload.password, settings)
|
||||
token = issue_token(payload.username, settings)
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
max_age=settings.token_ttl_seconds,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
return LoginResponse(token=token, expires_in=settings.token_ttl_seconds)
|
||||
|
||||
|
||||
@app.get("/login/oidc/start")
|
||||
def oidc_start(
|
||||
settings: Settings = Depends(get_settings),
|
||||
oidc: OIDCClient = Depends(get_oidc_client),
|
||||
):
|
||||
state = oidc.build_state_token()
|
||||
redirect = RedirectResponse(url=oidc.authorization_url(state))
|
||||
redirect.set_cookie(
|
||||
settings.oidc_state_cookie_name,
|
||||
state,
|
||||
max_age=300,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
return redirect
|
||||
|
||||
|
||||
@app.get("/login/oidc/callback")
|
||||
def oidc_callback(
|
||||
request: Request,
|
||||
code: str,
|
||||
state: str,
|
||||
settings: Settings = Depends(get_settings),
|
||||
oidc: OIDCClient = Depends(get_oidc_client),
|
||||
):
|
||||
stored_state = request.cookies.get(settings.oidc_state_cookie_name, "")
|
||||
if not oidc.is_state_valid(state, stored_state):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OIDC state")
|
||||
|
||||
claims = oidc.exchange_code_for_claims(code)
|
||||
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, mode="oidc"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not authorized to log in")
|
||||
|
||||
token = issue_token(username, settings)
|
||||
redirect = RedirectResponse(url="/")
|
||||
redirect.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
max_age=settings.token_ttl_seconds,
|
||||
httponly=True,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite="lax",
|
||||
)
|
||||
redirect.delete_cookie(settings.oidc_state_cookie_name)
|
||||
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(
|
||||
@ -56,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,
|
||||
@ -107,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,7 +24,12 @@ def _is_member_of(username: str, groups: Set[str]) -> bool:
|
||||
return bool(user_groups & groups)
|
||||
|
||||
|
||||
def is_authorized_admin(username: str, settings: Settings) -> bool:
|
||||
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
|
||||
return True
|
||||
# UID 0 always allowed
|
||||
try:
|
||||
entry = pwd.getpwnam(username)
|
||||
@ -41,7 +47,7 @@ def is_authorized_admin(username: str, settings: Settings) -> bool:
|
||||
|
||||
|
||||
def authenticate_admin_user(username: str, password: str, settings: Settings) -> None:
|
||||
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",
|
||||
@ -72,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
|
||||
|
||||
@ -82,9 +93,13 @@ def get_current_admin(
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> str:
|
||||
auth_header = request.headers.get("authorization")
|
||||
if not auth_header or not auth_header.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
token = None
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||
return decode_token(token, settings)
|
||||
|
||||
|
||||
@ -105,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
|
||||
|
||||
140
backend/oidc.py
Normal file
@ -0,0 +1,140 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import HTTPException, status
|
||||
from jwt import PyJWKClient
|
||||
|
||||
from backend.settings import Settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class OIDCConfig:
|
||||
issuer: str
|
||||
authorization_endpoint: str
|
||||
token_endpoint: str
|
||||
jwks_uri: str
|
||||
userinfo_endpoint: Optional[str] = None
|
||||
|
||||
|
||||
class OIDCError(Exception):
|
||||
"""Raised when the OIDC provider cannot be used."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_provider_config(issuer: str) -> OIDCConfig:
|
||||
discovery_url = urllib.parse.urljoin(issuer.rstrip("/") + "/", ".well-known/openid-configuration")
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
resp = client.get(discovery_url)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as exc: # pragma: no cover - network failure branch
|
||||
raise OIDCError(f"Failed to load discovery document: {exc}") from exc
|
||||
|
||||
data = resp.json()
|
||||
required = ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri")
|
||||
if not all(key in data for key in required):
|
||||
raise OIDCError("Discovery document missing required fields")
|
||||
|
||||
return OIDCConfig(
|
||||
issuer=data["issuer"],
|
||||
authorization_endpoint=data["authorization_endpoint"],
|
||||
token_endpoint=data["token_endpoint"],
|
||||
jwks_uri=data["jwks_uri"],
|
||||
userinfo_endpoint=data.get("userinfo_endpoint"),
|
||||
)
|
||||
|
||||
|
||||
class OIDCClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
if not settings.oidc_issuer:
|
||||
raise OIDCError("SKD_OIDC_ISSUER not configured")
|
||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||
raise OIDCError("SKD_OIDC_CLIENT_ID/SECRET must be set")
|
||||
self.config = _load_provider_config(settings.oidc_issuer)
|
||||
self.jwk_client = PyJWKClient(self.config.jwks_uri)
|
||||
|
||||
def build_state_token(self) -> str:
|
||||
raw = secrets.token_urlsafe(24)
|
||||
sig = hmac.new(self.settings.auth_secret.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{raw}.{sig}"
|
||||
|
||||
def is_state_valid(self, provided: str, stored: str) -> bool:
|
||||
if not provided or not stored or provided != stored:
|
||||
return False
|
||||
try:
|
||||
raw, sig = provided.split(".", 1)
|
||||
except ValueError:
|
||||
return False
|
||||
expected = hmac.new(self.settings.auth_secret.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
def authorization_url(self, state: str) -> str:
|
||||
params = {
|
||||
"client_id": self.settings.oidc_client_id,
|
||||
"redirect_uri": self.settings.oidc_redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": self.settings.oidc_scopes,
|
||||
"state": state,
|
||||
}
|
||||
return f"{self.config.authorization_endpoint}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def exchange_code_for_claims(self, code: str) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.settings.oidc_redirect_uri,
|
||||
"client_id": self.settings.oidc_client_id,
|
||||
"client_secret": self.settings.oidc_client_secret,
|
||||
}
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
resp = client.post(self.config.token_endpoint, data=payload, headers=headers)
|
||||
except httpx.HTTPError as exc: # pragma: no cover - network failure branch
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"OIDC token request failed: {exc}",
|
||||
) from exc
|
||||
|
||||
if resp.status_code != status.HTTP_200_OK:
|
||||
detail = resp.text or "token request failed"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"OIDC token exchange failed: {detail}",
|
||||
)
|
||||
|
||||
token_response = resp.json()
|
||||
id_token = token_response.get("id_token")
|
||||
if not id_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="OIDC response missing id_token",
|
||||
)
|
||||
|
||||
signing_key = self.jwk_client.get_signing_key_from_jwt(id_token).key
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
id_token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
audience=self.settings.oidc_client_id,
|
||||
issuer=self.config.issuer,
|
||||
)
|
||||
except jwt.PyJWTError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Invalid ID token: {exc}",
|
||||
) from exc
|
||||
return claims
|
||||
|
||||
@staticmethod
|
||||
def extract_username(claims: Dict[str, Any]) -> Optional[str]:
|
||||
return claims.get("preferred_username") or claims.get("email") or claims.get("sub")
|
||||
@ -5,3 +5,5 @@ jinja2==3.1.4
|
||||
PyJWT==2.9.0
|
||||
python-pam==2.0.2
|
||||
six==1.16.0
|
||||
httpx==0.27.2
|
||||
cryptography==43.0.1
|
||||
|
||||
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
|
||||
@ -7,6 +7,9 @@ class Settings:
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.auth_mode: str = os.getenv("SKD_AUTH_MODE", "pam").lower()
|
||||
if self.auth_mode not in ("pam", "oidc"):
|
||||
self.auth_mode = "pam"
|
||||
self.allowed_users: List[str] = self._parse_list(os.getenv("SKD_ALLOWED_USERS", ""))
|
||||
self.auth_secret: str = os.getenv("SKD_AUTH_SECRET", "change-me-secret")
|
||||
self.token_ttl_seconds: int = int(os.getenv("SKD_TOKEN_TTL_SECONDS", "900"))
|
||||
@ -15,10 +18,48 @@ class Settings:
|
||||
os.getenv("SKD_AUTH_ALLOWED_GROUPS", "sudo")
|
||||
)
|
||||
self.auth_pam_service: str = os.getenv("SKD_AUTH_PAM_SERVICE", "login")
|
||||
self.oidc_issuer: str = os.getenv("SKD_OIDC_ISSUER", "")
|
||||
self.oidc_client_id: str = os.getenv("SKD_OIDC_CLIENT_ID", "")
|
||||
self.oidc_client_secret: str = os.getenv("SKD_OIDC_CLIENT_SECRET", "")
|
||||
self.oidc_redirect_uri: str = os.getenv(
|
||||
"SKD_OIDC_REDIRECT_URI", "http://localhost:8000/login/oidc/callback"
|
||||
)
|
||||
self.oidc_scopes: str = os.getenv("SKD_OIDC_SCOPES", "openid profile email")
|
||||
self.session_cookie_name: str = os.getenv("SKD_SESSION_COOKIE_NAME", "skd_session")
|
||||
self.session_cookie_secure: bool = (
|
||||
os.getenv("SKD_SESSION_COOKIE_SECURE", "false").lower() == "true"
|
||||
)
|
||||
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")
|
||||
@ -31,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,101 +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>
|
||||
<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';
|
||||
} 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}`;
|
||||
@ -107,75 +275,542 @@
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = { ...authHeaders(), ...(options.headers || {}) };
|
||||
const res = await fetch(path, { ...options, headers });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
const res = await fetch(path, { ...options, headers, credentials: 'same-origin' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
statusDiv.textContent = 'Lade...';
|
||||
try {
|
||||
const data = await api('/users');
|
||||
statusDiv.textContent = data.map(u => `${u.user}: ${u.logged_in ? 'eingeloggt' : 'aus'}`).join('\n') || 'Keine Daten';
|
||||
const select = document.getElementById('username');
|
||||
select.innerHTML = '<option value="">-- wählen --</option>';
|
||||
data.forEach(u => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = u.user;
|
||||
opt.textContent = u.user;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
} catch (err) {
|
||||
statusDiv.textContent = `Fehler: ${err.message}`;
|
||||
// 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');
|
||||
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) {
|
||||
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 checkOidcStatus() {
|
||||
try {
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('refreshBtn').addEventListener('click', refreshUsers);
|
||||
document.getElementById('oidcLogin').addEventListener('click', () => {
|
||||
window.location.href = '/login/oidc/start';
|
||||
});
|
||||
|
||||
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
|
||||
16
env.example
@ -1,11 +1,23 @@
|
||||
# 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
|
||||
# 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/login/oidc/callback
|
||||
SKD_OIDC_SCOPES=openid profile email
|
||||
SKD_SESSION_COOKIE_NAME=skd_session
|
||||
SKD_SESSION_COOKIE_SECURE=false
|
||||
SKD_OIDC_STATE_COOKIE_NAME=skd_oidc_state
|
||||
SKD_DEFAULT_COUNTDOWN=60
|
||||
SKD_DEFAULT_SOUND=false
|
||||
SKD_NOTIFY_TIMEOUT=5
|
||||
|
||||
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
|
||||
43
project-management/AGENTS.md
Normal file
@ -0,0 +1,43 @@
|
||||
ID: AGENTS_000001 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Repository Guidelines
|
||||
|
||||
This repository is the management layer for the Sound Architect project. It is documentation-first and centers on requirements, status tracking, and process prompts. Use it to plan and document work before implementation.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- `onboarding.md` and `project-setup.md` define the SOP and initial setup.
|
||||
- `PROJECT_STATUS.md` and `PROJECT_STATUS_TEMPLATE.md` track vision, focus, and backlog.
|
||||
- `*.promt` files are process prompts (requirements, refactoring, architecture, etc.).
|
||||
- `feedback/session-feedback.md` holds feedback notes.
|
||||
- The setup guide describes a separate source layout (`src/`, `tests/`, `docs/architecture/`) that may live in a sibling repository. If those folders are added here, keep them aligned with the guide.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
There are no build or runtime commands in this repo today. Typical work is editing Markdown and prompt files. If you introduce automation later, document it here with concise examples (e.g., `make lint`, `npm test`).
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Keep files in Markdown with clear headings and short, direct paragraphs.
|
||||
- Follow the ID header rule from onboarding for new requirement documents: `ID: <ID> | Version: <VERSION> | Status: Draft/Review/Final`.
|
||||
- Use the naming patterns described in the SOP for requirements: `STD_EPIC_001`, `STD_STORY_001`, `STD_TASK_001`, `BUG_NNNNNN`.
|
||||
- Prefer ASCII; avoid emojis in new technical docs unless the file already uses them.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
No testing framework is defined in this repository. If you add code or automation, include a minimal test command and document it in this section.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
There is no Git history in this repository, so commit conventions are not established. If you add Git, align commit messages with the changelog legend in `project-setup.md` (e.g., `🏗️ Planning: ...`, `📝 Req: ...`). For pull requests:
|
||||
|
||||
- Describe the change and link related IDs (story/bug/task).
|
||||
- Update `PROJECT_STATUS.md` and the changelog if those files are part of your workflow.
|
||||
- Note any new files, templates, or schema changes.
|
||||
|
||||
## Documentation Workflow Notes
|
||||
|
||||
- Document-first is mandatory: capture requirements before implementation.
|
||||
- Keep `PROJECT_STATUS.md` aligned with its template and current focus.
|
||||
- If a `VERSION` file exists, treat it as the single source of truth for versioning.
|
||||
176
project-management/PROJECT_STATUS.md
Normal file
@ -0,0 +1,176 @@
|
||||
ID: STATUS_000001 | Version: 0.3.6 | Status: Final
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Projekt-Status
|
||||
|
||||
## Vision
|
||||
Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
|
||||
|
||||
## Aktuelle Phase
|
||||
✅ Stabilization
|
||||
|
||||
## Aktueller Fokus
|
||||
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)
|
||||
- [x] US_000001: Nutzerkonto per CLI deaktivieren
|
||||
- [x] TASK_000001: Disable user countdown
|
||||
- [x] US_000002: Nutzerkonto per CLI aktivieren
|
||||
- [x] TASK_000002: Enable user account
|
||||
|
||||
### EPIC_000002: Backend API Service
|
||||
- [x] US_000003: Health-Status abfragen
|
||||
- [x] TASK_000003: Health response payload
|
||||
- [x] US_000004: Verfuegbare Nutzer auflisten
|
||||
- [x] TASK_000004: List users status
|
||||
- [x] US_000005: Nutzer per API deaktivieren
|
||||
- [x] TASK_000005: API disable action
|
||||
- [x] US_000006: Nutzer per API aktivieren
|
||||
- [x] TASK_000006: API enable action
|
||||
- [x] US_000021: Konfiguration per ENV steuern
|
||||
- [x] TASK_000021: ENV settings defaults
|
||||
|
||||
### EPIC_000003: Authentication & Sessions
|
||||
- [x] US_000007: PAM-Login mit Token
|
||||
- [x] TASK_000007: PAM login token
|
||||
- [x] US_000008: OIDC-Login Flow
|
||||
- [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
|
||||
- [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
|
||||
- [x] TASK_000011: Provision venv deps
|
||||
- [x] US_000012: Service lokal starten
|
||||
- [x] TASK_000012: Run uvicorn service
|
||||
- [x] US_000013: Service installieren
|
||||
- [x] TASK_000013: Install service setup
|
||||
- [x] US_000014: Service aktualisieren
|
||||
- [x] TASK_000014: Update service refresh
|
||||
- [x] US_000015: Remote-Deployment durchfuehren
|
||||
- [x] TASK_000015: Remote deploy package
|
||||
- [x] US_000016: OIDC-Client registrieren
|
||||
- [x] TASK_000016: OIDC client register
|
||||
- [x] US_000020: Makefile-Automation bereitstellen
|
||||
- [x] TASK_000020: Makefile ops targets
|
||||
|
||||
### EPIC_000006: Systemd & Deployment Artifacts
|
||||
- [x] US_000017: Systemd-Unit im Repo
|
||||
- [x] TASK_000017: Systemd unit template
|
||||
- [x] US_000018: Konfigurations-Templates verfuegbar
|
||||
- [x] TASK_000018: Config templates ready
|
||||
- [x] US_000019: Deployment-Archiv vorhanden
|
||||
- [x] TASK_000019: Deployment zip artifact
|
||||
|
||||
### EPIC_000007: Documentation & Runbook
|
||||
- [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.
|
||||
- OIDC-Validierung abschliessen und US_000025 auf Done setzen.
|
||||
- Doku-Audit fuer weitere Abweichungen priorisieren (wenn Zeitfenster frei).
|
||||
36
project-management/PROJECT_STATUS_TEMPLATE.md
Normal file
@ -0,0 +1,36 @@
|
||||
ID: PROJECT_STATUS_TEMPLATE | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# 📊 Projekt-Status (Template)
|
||||
|
||||
## Vision
|
||||
Ein kurzer Satz, der das Ziel des Projekts beschreibt.
|
||||
|
||||
## Aktuelle Phase
|
||||
Ein einzelnes Label, z.B. 🏗️ Planning / ⚙️ Implementation / ✅ Stabilization.
|
||||
|
||||
## Aktueller Fokus
|
||||
1. Wichtigste Aufgabe oder Epic.
|
||||
2. Zweiter Fokuspunkt.
|
||||
3. Dritter Fokuspunkt (optional).
|
||||
|
||||
## Projekt-Tagebuch (Kurz, optional)
|
||||
Optional als schneller Ueberblick. Das vollstaendige Logbuch liegt in CHANGELOG.md.
|
||||
| Datum | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| DD.MM.YYYY | 📝 Req | Kurzer Eintrag. |
|
||||
| DD.MM.YYYY | ⚙️ Code | Kurzer Eintrag. |
|
||||
|
||||
## Epic-Backlog (Uebersicht)
|
||||
### EPIC_000001: <Titel>
|
||||
- [ ] US_000001: <Kurzbeschreibung>
|
||||
- [ ] US_000002: <Kurzbeschreibung>
|
||||
|
||||
### EPIC_000002: <Titel>
|
||||
- [x] US_000003: <Kurzbeschreibung>
|
||||
|
||||
## Offene Risiken / Abhaengigkeiten
|
||||
- Kurzer Punkt, z.B. externer Dienst, fehlende Zugriffsrechte, Tests fehlen.
|
||||
|
||||
## Naechste Schritte
|
||||
- Konkrete, kurzfristige Aktionen (2-5 Punkte).
|
||||
62
project-management/feedback/THEME_WATCHTOWER.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Theme: Watchtower (Sci-Fi / Dark)
|
||||
|
||||
Dieses Theme basiert auf der `minecraft-watchtower` UI. Es ist ein **High-Contrast Dark Mode** mit Neon-Akzenten, ausgelegt auf technische Dashboards und "Immersive UIs".
|
||||
|
||||
## Verwendung
|
||||
|
||||
Binde statt `tokens.css` die Datei `tokens_watchtower.css` ein und ergänze `theme_watchtower.css`.
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="design/tokens_watchtower.css">
|
||||
<link rel="stylesheet" href="design/components.css"> <!-- Standard Components -->
|
||||
<link rel="stylesheet" href="design/theme_watchtower.css"> <!-- Theme Overrides -->
|
||||
```
|
||||
|
||||
Zusätzlich sollte die Klasse `.bg-noise` direkt nach dem `<body>` Tag eingefügt werden:
|
||||
```html
|
||||
<body>
|
||||
<div class="bg-noise"></div>
|
||||
...
|
||||
</body>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TUI (Terminal User Interface) Adaption
|
||||
|
||||
Da dieses Design oft in CLI-Tools oder TUIs verwendet wird, gelten folgende Mappings für Terminals (16/256 Farben).
|
||||
|
||||
### Farb-Palette
|
||||
|
||||
| Rolle | CSS Variable | ANSI Color (16) | ANSI Code | Hex Fallback |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Background** | `--bg-dark` | Black | `\e[40m` | `#0b0f14` |
|
||||
| **Text** | `--text` | White (Bright) | `\e[97m` | `#e6edf5` |
|
||||
| **Muted Text** | `--text-dim` | Cyan (Dim) | `\e[36m` | `#9aa7b8` |
|
||||
| **Accent** | `--accent` | Cyan (Bright) | `\e[96m` | `#00e08f` |
|
||||
| **Success** | `--success` | Green (Bright) | `\e[92m` | `#00e08f` (Teal) |
|
||||
| **Warning** | `--warning` | Yellow (Bright) | `\e[93m` | `#ffb454` |
|
||||
| **Error** | `--danger` | Red (Bright) | `\e[91m` | `#ff5e5e` |
|
||||
|
||||
### Block-Elemente & Rahmen
|
||||
|
||||
Für TUI-Rahmen nutzen wir "Heavy" oder "Double" Lines, um den technischen Look zu imitieren.
|
||||
|
||||
* **Box Border:** `═` (Double Horizontal), `║` (Double Vertical), `╔ ╗ ╚ ╝` (Corners)
|
||||
* **Progress Bar:** `█` (Full Block) für den Füllstand, `░` (Light Shade) für den Hintergrund.
|
||||
|
||||
### Beispiel (Charm / Bubble Tea - Go)
|
||||
|
||||
```go
|
||||
var (
|
||||
ColorAccent = lipgloss.Color("#00e08f")
|
||||
ColorBg = lipgloss.Color("#0b0f14")
|
||||
ColorText = lipgloss.Color("#e6edf5")
|
||||
|
||||
StyleCard = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(ColorAccent).
|
||||
Padding(1, 2).
|
||||
Background(ColorBg)
|
||||
)
|
||||
```
|
||||
86
project-management/feedback/oidc-integration-userstories.md
Normal file
@ -0,0 +1,86 @@
|
||||
# OIDC Integration: OICD (IdP) + Kiddo (Clients)
|
||||
|
||||
## Herangehensweise und Gedankengang
|
||||
|
||||
Ziel war es, beide Projekte so zu verbinden, dass Kiddo OICD als OIDC-Provider nutzt, ohne Codeaenderungen vorzunehmen. Wir haben die vorhandenen Konfigurationspfade, DCR-Faehigkeiten und OIDC-Endpunkte geprueft und daraus die minimalen, operationalen Schritte abgeleitet.
|
||||
|
||||
Leitfragen, die wir dabei beantwortet haben:
|
||||
- Welche Konfiguration erwartet Kiddo fuer OIDC (Issuer, Client, Redirect, Cookies)?
|
||||
- Welche OIDC-Funktionen liefert OICD (Discovery, JWKS, DCR, Admin-UI)?
|
||||
- Wo liegen die harten OIDC-Anforderungen (Issuer-Match, exakte Redirect-URIs, TLS-Vertrauen)?
|
||||
- Wie kann die Registrierung skalieren, wenn Geraete und Redirects erst spaeter feststehen?
|
||||
|
||||
### OICD (IdP) - Herangehensweise
|
||||
Wir haben in OICD geprueft, welche OIDC-Endpunkte vorhanden sind (Discovery, JWKS, Token, DCR) und wie der Issuer erzeugt wird. Entscheidend ist, dass der Issuer exakt der externen URL entspricht, unter der OICD erreichbar ist. Zudem gibt es eine Admin-Funktion zur Erzeugung von Initial-Access-Tokens, die den DCR-Flow ermoeglichen. Daraus folgt: Stabiler Issuer (prod/dev), TLS trust, und ein standardisierter Weg zur Token-Erzeugung fuer DCR.
|
||||
|
||||
### Kiddo (Client) - Herangehensweise
|
||||
Wir haben in Kiddo geprueft, welche Umgebungsvariablen fuer OIDC benoetigt werden und wie die Claims interpretiert werden. Kiddo validiert ID-Tokens gegen den Issuer und JWKS und benoetigt exakte Redirect-URIs. Da die Device-Hosts dynamisch sind, ist DCR der beste Weg, pro Geraet eigene Clients zu registrieren, sobald die finale URL bekannt ist. Daraus folgen: pro Geraet DCR, danach Env-Setup, optional Allowlist fuer OIDC-User.
|
||||
|
||||
Aus diesen Punkten ergab sich der Weg: OICD stellt stabile Issuer-URLs bereit, Redirect-URIs muessen konkret registriert werden, und DCR (mit Initial-Access-Token) ist der beste Weg, um pro Geraet eigene Clients dynamisch zu erzeugen. Daraus wurden die Stories, Akzeptanzkriterien und das Provisioning-Kommando abgeleitet.
|
||||
|
||||
## Epic: OIDC-Login fuer Kiddo (Client-Seite)
|
||||
|
||||
### Story 1: Zentrales Provisioning registriert pro Geraet einen OIDC-Client
|
||||
Als Provisioner moechte ich pro Geraet einen OIDC-Client via DCR anlegen, damit jedes Geraet einen eigenen Client-ID/Secret hat.
|
||||
|
||||
Acceptance Criteria:
|
||||
- DCR-Call mit `SKD_OIDC_ISSUER`, `SKD_OIDC_REDIRECT_URI` und `OIDC_INITIAL_ACCESS_TOKEN` erzeugt `client_id` und `client_secret`.
|
||||
- Redirect-URI ist exakt `https://<device-host>[:port]/login/oidc/callback` (keine Wildcards).
|
||||
|
||||
Referenzen:
|
||||
- `kiddo/scripts/register_oidc_client.sh`
|
||||
|
||||
### Story 2: Kiddo-Geraet ist per OIDC konfiguriert
|
||||
Als Geraetebetreiber moechte ich ein Kiddo-Geraet so konfigurieren, dass es sich ueber OICD authentifiziert.
|
||||
|
||||
Acceptance Criteria:
|
||||
- `SKD_AUTH_MODE=oidc` ist gesetzt.
|
||||
- `SKD_OIDC_ISSUER`, `SKD_OIDC_CLIENT_ID`, `SKD_OIDC_CLIENT_SECRET`, `SKD_OIDC_REDIRECT_URI` sind gesetzt.
|
||||
- Bei HTTPS ist `SKD_SESSION_COOKIE_SECURE=true`.
|
||||
|
||||
Referenzen:
|
||||
- `kiddo/env.example`
|
||||
- `kiddo/README.md`
|
||||
- `kiddo/backend/settings.py`
|
||||
|
||||
### Story 3: Zugriffskontrolle auf OIDC-Login
|
||||
Als Betreiber moechte ich steuern, welche Benutzer sich via OIDC anmelden duerfen.
|
||||
|
||||
Acceptance Criteria:
|
||||
- `SKD_AUTH_ALLOWED_USERS` schraenkt Zugriff ein, basierend auf `preferred_username` oder `email` oder `sub` aus dem ID-Token.
|
||||
|
||||
Referenzen:
|
||||
- `kiddo/backend/oidc.py`
|
||||
- `kiddo/backend/auth.py`
|
||||
|
||||
### Story 4: Betrieb mit wechselnden Geraeten (DCR-Flow)
|
||||
Als Betreiber moechte ich neue Geraete spaeter onboarden koennen, ohne OICD manuell zu konfigurieren.
|
||||
|
||||
Acceptance Criteria:
|
||||
- DCR-Prozess ist dokumentiert und kann pro Geraet wiederholt werden.
|
||||
- Bei Host/Port-Aenderung erfolgt Neuregistrierung (neuer Client/Secret).
|
||||
|
||||
Referenzen:
|
||||
- `kiddo/scripts/register_oidc_client.sh`
|
||||
|
||||
## DCR Kommando (zentral, pro Geraet)
|
||||
|
||||
Prod:
|
||||
```bash
|
||||
export SKD_OIDC_ISSUER="https://auth.wlkns.org"
|
||||
export SKD_OIDC_REDIRECT_URI="https://<device-host>[:port]/login/oidc/callback"
|
||||
export OIDC_INITIAL_ACCESS_TOKEN="<initial-access-token>"
|
||||
/home/stephan/applications/wlkns/kiddo/scripts/register_oidc_client.sh
|
||||
```
|
||||
|
||||
Dev:
|
||||
```bash
|
||||
export SKD_OIDC_ISSUER="https://dev.wlkns.org"
|
||||
export SKD_OIDC_REDIRECT_URI="https://<device-host>[:port]/login/oidc/callback"
|
||||
export OIDC_INITIAL_ACCESS_TOKEN="<initial-access-token>"
|
||||
/home/stephan/applications/wlkns/kiddo/scripts/register_oidc_client.sh
|
||||
```
|
||||
|
||||
Hinweise:
|
||||
- Redirect-URIs muessen exakt registriert sein (keine Wildcards).
|
||||
- Bei Host/Port-Aenderung: neu registrieren und neue Client-Credentials setzen.
|
||||
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>
|
||||
135
project-management/feedback/theme_watchtower.css
Normal file
@ -0,0 +1,135 @@
|
||||
/*
|
||||
wlkns-dev-standards
|
||||
Component Theme: Watchtower
|
||||
Adapts standard components to the Sci-Fi/Dark aesthetic.
|
||||
*/
|
||||
|
||||
/* =========================
|
||||
Inputs & Forms
|
||||
========================= */
|
||||
input, select, textarea {
|
||||
background: rgba(10, 14, 20, 0.6) !important;
|
||||
border-color: rgba(255, 255, 255, 0.15) !important;
|
||||
color: var(--color-text) !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--color-accent) !important;
|
||||
box-shadow: 0 0 15px var(--wt-glow);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Buttons
|
||||
========================= */
|
||||
.button {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
border-radius: 999px !important; /* Pill shape */
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
box-shadow: 0 0 15px var(--wt-glow);
|
||||
color: #081015; /* Dark text on bright accent */
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
border-color: var(--color-text);
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
box-shadow: 0 0 10px rgba(255, 94, 94, 0.4);
|
||||
color: #1b0d0d;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Tables
|
||||
========================= */
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 4px; /* Space between rows */
|
||||
}
|
||||
|
||||
thead th {
|
||||
border-bottom: 1px solid var(--color-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
border: none;
|
||||
}
|
||||
|
||||
tbody td:first-child {
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
}
|
||||
|
||||
tbody td:last-child {
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Alerts & Toasts
|
||||
========================= */
|
||||
.alert, .toast {
|
||||
background: rgba(10, 14, 20, 0.95);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.alert.success, .toast.success {
|
||||
border-color: var(--color-success);
|
||||
box-shadow: 0 0 10px rgba(0, 224, 143, 0.2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.alert.warning, .toast.warning {
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.alert.error, .toast.error {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 10px rgba(255, 94, 94, 0.2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Pagination
|
||||
========================= */
|
||||
.pagination .page {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pagination .page.active {
|
||||
background: var(--color-accent);
|
||||
color: #081015;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 10px var(--wt-glow);
|
||||
}
|
||||
69
project-management/feedback/tokens_watchtower.css
Normal file
@ -0,0 +1,69 @@
|
||||
/*
|
||||
wlkns-dev-standards
|
||||
Theme: Watchtower (Dark Glow)
|
||||
Version: v1.0
|
||||
Based on: minecraft-watchtower UI
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* =========================
|
||||
Core Palette (Watchtower)
|
||||
========================= */
|
||||
--wt-bg-dark: #0b0f14;
|
||||
--wt-bg-panel: #10151d;
|
||||
--wt-accent: #00e08f;
|
||||
--wt-warning: #ffb454;
|
||||
--wt-danger: #ff5e5e;
|
||||
--wt-text: #e6edf5;
|
||||
--wt-text-dim: #9aa7b8;
|
||||
--wt-glow: rgba(0, 224, 143, 0.35);
|
||||
|
||||
/* =========================
|
||||
Mapping -> Standard Tokens
|
||||
========================= */
|
||||
|
||||
/* Farben – Identity & Status */
|
||||
--color-primary: #1a2230; /* Deep Blue/Grey from Gradient */
|
||||
--color-accent: var(--wt-accent);
|
||||
|
||||
--color-success: var(--wt-accent); /* Watchtower uses accent as success */
|
||||
--color-warning: var(--wt-warning);
|
||||
--color-error: var(--wt-danger);
|
||||
|
||||
/* Farben – Neutrals (Dark Mode Override) */
|
||||
--color-bg: var(--wt-bg-dark);
|
||||
--color-surface: var(--wt-bg-panel);
|
||||
--color-border: rgba(255, 255, 255, 0.08); /* Subtle white border */
|
||||
|
||||
--color-text: var(--wt-text);
|
||||
--color-text-muted: var(--wt-text-dim);
|
||||
|
||||
/* Typografie (Optional: Falls Space Grotesk geladen wird) */
|
||||
--font-ui: 'Space Grotesk', 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Radius & Spacing */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 12px; /* Watchtower uses larger radii */
|
||||
--radius-lg: 16px;
|
||||
|
||||
/* Shadows becomes Glows in this theme */
|
||||
--shadow-sm: 0 0 10px rgba(0,0,0,0.5);
|
||||
--shadow-glow: 0 0 15px var(--wt-glow);
|
||||
}
|
||||
|
||||
/* Global Theme Overrides */
|
||||
body {
|
||||
background: radial-gradient(circle at top, #1a2230 0%, #0b0f14 55%), linear-gradient(135deg, #0b0f14, #101623 60%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Noise Texture helper class */
|
||||
.bg-noise {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: soft-light;
|
||||
z-index: -1;
|
||||
}
|
||||
113
project-management/onboarding.md
Normal file
@ -0,0 +1,113 @@
|
||||
ID: SOP_000001 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Onboarding: Arbeitsweise im Sound Architect Projekt
|
||||
|
||||
Willkommen im Team! Dieses Dokument erklärt, wie wir im Sound Architect Projekt arbeiten, Features planen und die Dokumentation pflegen. Wir folgen einer strikten SOP (Standard Operating Procedure) [cite: 1.1].
|
||||
|
||||
## Grundprinzip: Documentation-First Development
|
||||
|
||||
Wichtig: Wir dokumentieren bevor wir coden. Jedes Feature durchläuft diesen Workflow:
|
||||
|
||||
1. Anforderung → Story/Bug anlegen → Epic zuordnen
|
||||
2. Epic erstellen → Stories definieren → Abhängigkeiten prüfen → CHANGELOG updaten
|
||||
3. Story starten → Tasks ausarbeiten → Feature Branch → Implementation → Tests → Commit → Merge → CHANGELOG updaten
|
||||
|
||||
## WICHTIG: Neue Anforderungen behandeln
|
||||
|
||||
Wenn der Stakeholder/User eine neue Anforderung stellt:
|
||||
|
||||
- NICHT sofort anfangen zu coden.
|
||||
- Entscheiden: Handelt es sich um ein Feature (Story US_NNNNNN) oder einen Fehler (Bug BUG_NNNNNN).
|
||||
- Dokumentieren in `project-management/requirements/`.
|
||||
- Loggen in der `CHANGELOG.md` im Root-Verzeichnis [cite: 1.2].
|
||||
|
||||
## Verzeichnisstruktur (Management-Layer)
|
||||
|
||||
Alle administrativen Dateien liegen im `project-management/` Ordner, außer dem Changelog und der README [cite: 1.1].
|
||||
|
||||
```
|
||||
project-management/
|
||||
├── PROJECT_STATUS.md # Vision, Aktueller Fokus & Backlog (Nutzt TMP_STATUS_001)
|
||||
├── ONBOARDING.md # Diese SOP
|
||||
└── requirements/ # Alle Anforderungen
|
||||
├── epics/ # High-Level Features (Standard: STD_EPIC_001)
|
||||
├── stories/ # User Stories (Standard: STD_STORY_001)
|
||||
├── tasks/ # Technische Umsetzung (Standard: STD_TASK_001)
|
||||
└── bugs/ # Bug-Reports (Fehlerdokumentation)
|
||||
|
||||
CHANGELOG.md # Root: Das tabellarische Logbuch (Die Wahrheit) [cite: 1.2]
|
||||
VERSION # Root: Die zentrale Versionsdatei (Single Source of Truth)
|
||||
```
|
||||
|
||||
## Unsere Standards & Phasen
|
||||
|
||||
### Dokument-Header Standard (PFLICHT)
|
||||
|
||||
Jedes Dokument (auch Anforderungen) muss mit folgendem Header beginnen. Der Header steht zusaetzlich zu bestehenden Template-Headern und bleibt immer die erste Zeile.
|
||||
|
||||
```
|
||||
ID: [ID] | Version: [Inhalt aus /VERSION] | Status: [Draft/Review/Final]
|
||||
By: [Name oder Agent]
|
||||
```
|
||||
|
||||
### Projekt-Status (PROJECT_STATUS_TEMPLATE.md)
|
||||
|
||||
Die `PROJECT_STATUS.md` folgt strikt dem bereitgestellten Template `PROJECT_STATUS_TEMPLATE.md` und weist die globale Projektversion aus. Die Header-Regel gilt auch hier (inkl. By-Zeile).
|
||||
|
||||
### Phase 0: Dokumentations-Standards (Anforderungen)
|
||||
|
||||
EPIC (ID: STD_EPIC_001):
|
||||
- Mission Statement: Das große Ganze (Fakten).
|
||||
- Business Value & Metriken: Welchen KPI verbessern wir?
|
||||
- In-Scope vs. Out-of-Scope: Wo ist die rote Linie?
|
||||
- High-Level Akzeptanzkriterien: Definition of Done für das Epic.
|
||||
- Technische Constraints & Risiken: Was könnte explodieren?
|
||||
|
||||
USER STORY (ID: STD_STORY_001):
|
||||
- Format: "Als [Rolle] möchte ich [Funktion], damit [Nutzen]."
|
||||
- Akzeptanzkriterien (Gherkin-Style): Given / When / Then.
|
||||
- Qualitätsregeln: Objektiv prüfbar, eindeutig, unabhängig von Details.
|
||||
- Rückfrage-Pflicht: Wenn Kriterien nicht ableitbar sind → Rückfrage an Stakeholder stellen.
|
||||
|
||||
### Phase 1: Planung & Log
|
||||
|
||||
Bei jeder Planung eines Epics oder einer Story muss die `CHANGELOG.md` im Root aktualisiert werden.
|
||||
|
||||
Format für Einträge:
|
||||
|
||||
```
|
||||
| DD.MM.YYYY | 🏗️ Planning | ID: Kurze Beschreibung geplant. |
|
||||
```
|
||||
|
||||
Hinweis: "ID: ..." ist ein Praefix im Beschreibungsfeld, kein eigenes Tabellenfeld.
|
||||
Ergaenze am Ende der Beschreibung immer den Hinweis `By: <Name/Agent>` (z.B. `ID: ... By: Jane Doe`), damit klar ist, wer die Aenderung gemacht hat.
|
||||
|
||||
### Phase 2: Just-in-Time Tasks (ID: STD_TASK_001)
|
||||
|
||||
Technische Tasks werden erst bei Story-Start detailliert ausgearbeitet.
|
||||
|
||||
- Feingranular: Max. 1–8 Arbeitsstunden pro Task.
|
||||
- Konkret: Technisch präzise und eindeutig abschließbar.
|
||||
- Inhalt: Titel, Outcome, Story-Bezug, Beschreibung & Definition of Done (DoD).
|
||||
|
||||
### Phase 3: Versionierung & Release-Audit (BINDEND)
|
||||
|
||||
Wir arbeiten strikt nach dem Format Major.Minor.Small (z.B. 0.0.0).
|
||||
|
||||
- Zentrale Datei: Die Datei `VERSION` im Root ist die einzige Quelle (SSOT).
|
||||
- Zwang: Die Version aus dieser Datei muss zwingend in alle Scripte, Dokumente (Header/Footer) und GUIs/TUIs eingebunden werden. Hardcoding ist verboten!
|
||||
- Doku-Audit-Pflicht: Bei jedem Major- und Minor-Release (X.Y.0) ist ein Audit zwingend:
|
||||
- Prüfung auf inhaltliche Übereinstimmung mit dem neuen Stand.
|
||||
- Aktualisierung veralteter Anweisungen/Beschreibungen.
|
||||
- Verifizierung der Versions-IDs in allen Headern.
|
||||
|
||||
## Die goldenen Regeln
|
||||
|
||||
- `CHANGELOG.md` ist die Wahrheit. [cite: 1.2]
|
||||
- `PROJECT_STATUS.md` ist der Kompass.
|
||||
- `VERSION` ist das Gesetz.
|
||||
- ID-Pflicht für alles: Ohne ID existiert keine Anforderung.
|
||||
- Code-First ist verboten!
|
||||
|
||||
Let's build some ghosts! 👻☕
|
||||
116
project-management/project-setup.md
Normal file
@ -0,0 +1,116 @@
|
||||
ID: SETUP_000005 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# 🧬 SETUP_GUIDE: Phase 0 - Project Genesis
|
||||
|
||||
Dieses Dokument ist die Master-Anweisung für das initiale Setup des Sound Architect Projekts. Wer hiervon abweicht, riskiert Daten-Chaos – und glaub mir, das willst du nicht.
|
||||
|
||||
## 1. Verzeichnis-Struktur (The Skeleton)
|
||||
|
||||
Erstelle die Verzeichnisse exakt so. Wir trennen Planung und Anforderungen (`project-management`) strikt von der Umsetzung (`src`).
|
||||
|
||||
```
|
||||
# ID: SCRIPT_000001
|
||||
# Management & Requirements
|
||||
mkdir -p project-management/requirements/{epics,stories,tasks,bugs}
|
||||
|
||||
# Source Code (Hexagonal Architecture)
|
||||
mkdir -p src/{core,ports,adapters,ui}
|
||||
|
||||
# Infrastructure & Docs
|
||||
mkdir -p tests docs/architecture
|
||||
```
|
||||
|
||||
## 2. Das Logbuch-Gesetz (CHANGELOG.md)
|
||||
|
||||
Das Logbuch im Hauptverzeichnis ist die einzige chronologische Wahrheit des Projekts. Es wird im Root-Verzeichnis abgelegt, um maximale Sichtbarkeit zu garantieren.
|
||||
|
||||
### Format
|
||||
|
||||
Das Logbuch folgt einem strikten Tabellenformat:
|
||||
|
||||
- Datum
|
||||
- Typ
|
||||
- Beschreibung
|
||||
|
||||
Beispiel: `DD.MM.YYYY | Icon + Kürzel | ID (optional): Präzise Beschreibung der Änderung.`
|
||||
|
||||
### Die Legende
|
||||
|
||||
Verwende ausschließlich diese Typen für das Logbuch und Commit-Messages:
|
||||
|
||||
| Icon | Typ | Bedeutung |
|
||||
| --- | --- | --- |
|
||||
| 🚀 | Init | Projekt-Setup & Initialisierung |
|
||||
| 📝 | Req | Requirements / Anforderungen |
|
||||
| 🏛️ | Arch | Architektur-Entscheidungen |
|
||||
| 🎨 | UI | UI-Implementation / Design |
|
||||
| ✅ | Done | Feature oder Story abgeschlossen |
|
||||
| ⚙️ | Code | Allgemeine Code-Implementation |
|
||||
| 🔊 | Audio | Spezifische Audio-Features |
|
||||
| 🗣️ | UX | User Experience Verbesserungen |
|
||||
| ✨ | Feature | Neues Feature / Funktionalität |
|
||||
| 🛠️ | CRUD | Asset-Management (Delete/Rename/etc.) |
|
||||
| 🏗️ | Planning | Grobplanung von Epics & Stories |
|
||||
|
||||
## 3. Die „No-Dirt“ Regel (.gitignore)
|
||||
|
||||
Erstelle eine `.gitignore` im Root, damit kein digitaler Müll unser sauberes Labor kontaminiert.
|
||||
|
||||
```
|
||||
# ID: DOC_000002
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
.env
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
output/
|
||||
temp/
|
||||
```
|
||||
|
||||
## 🛠️ Genesis-Automatisierungsscript
|
||||
|
||||
Kopiere diesen Block in dein Terminal, um das Labor mit einem Knall hochzufahren.
|
||||
|
||||
```
|
||||
# ID: SCRIPT_000002
|
||||
# 1. Verzeichnisse anlegen
|
||||
mkdir -p project-management/requirements/{epics,stories,tasks,bugs} src/{core,ports,adapters,ui} tests docs/architecture
|
||||
|
||||
# 2. Logbuch initialisieren (incl. Legende)
|
||||
cat <<EOF > CHANGELOG.md
|
||||
# 📜 Projekt-Logbuch (Changelog)
|
||||
# ID: DOC_000001
|
||||
|
||||
| Datum | Typ | Beschreibung |
|
||||
|---|---|---|
|
||||
| $(date +%d.%m.%Y) | 🚀 Init | **PROJECT_SETUP**: Struktur nach SETUP_000005 initialisiert. |
|
||||
|
||||
---
|
||||
## 🔑 Legende
|
||||
* 🚀 **Init** = Setup | 📝 **Req** = Requirements | 🏛️ **Arch** = Architektur
|
||||
* 🎨 **UI** = Design | ✅ **Done** = Abgeschlossen | ⚙️ **Code** = Implementation
|
||||
* 🔊 **Audio** = Audio-Engine | 🗣️ **UX** = User Experience | ✨ **Feature** = Neu | 🛠️ **CRUD** = Management
|
||||
EOF
|
||||
|
||||
# 3. .gitignore erstellen
|
||||
cat <<EOF > .gitignore
|
||||
__pycache__/
|
||||
.venv/
|
||||
.env
|
||||
*.log
|
||||
EOF
|
||||
|
||||
# 4. Status-File vorbereiten
|
||||
echo "# 📊 Projekt-Status" > project-management/PROJECT_STATUS.md
|
||||
|
||||
# 5. Version initialisieren
|
||||
echo "0.1.0" > VERSION
|
||||
|
||||
echo "Labor ist bereit. Let's create some ghosts! 👻☕"
|
||||
```
|
||||
38
project-management/requirements-engineer.promt
Normal file
@ -0,0 +1,38 @@
|
||||
ID: PROMPT_000008 | Version: 0.2.3 | Status: Draft
|
||||
By: Codex (GPT-5)
|
||||
|
||||
# Requirements Engineer Prompt
|
||||
|
||||
Uebernimm die Rolle eines Senior Agile Product Owners und Requirements Engineers. Ich gebe dir eine Projektvision und eine technische Projektspezifikation. Deine Aufgabe ist es, daraus Epics und User Stories mit klaren Akzeptanzkriterien abzuleiten.
|
||||
|
||||
## Vorgehensweise
|
||||
|
||||
- Arbeite Epic fuer Epic vor.
|
||||
- Stelle maximal eine Rueckfrage auf einmal, falls Informationen fehlen.
|
||||
- Nach jedem Epic: Kurze Zusammenfassung & aktualisierte Backlog-Uebersicht.
|
||||
|
||||
## Standards
|
||||
|
||||
### EPIC (ID: STD_EPIC_001)
|
||||
|
||||
- Mission Statement (Beschreibung): Fakten-basiertes "Grosse Ganze".
|
||||
- Business Value & Metriken: KPI-Verbesserung / Nutzen.
|
||||
- In-Scope vs. Out-of-Scope: Grenzen ziehen.
|
||||
- High-Level Akzeptanzkriterien: Epic-DoD.
|
||||
- Technische Constraints & Risiken: Abhaengigkeiten, Security, Legacy.
|
||||
|
||||
### USER STORY (ID: STD_STORY_001)
|
||||
|
||||
- Format: "Als [Rolle] moechte ich [Funktion], damit [Nutzen]."
|
||||
- Akzeptanzkriterien (Gherkin): Given / When / Then.
|
||||
|
||||
### TASK (ID: STD_TASK_001)
|
||||
|
||||
- Titel: Technisch, praezise.
|
||||
- Ziel / Outcome: Messbares Ergebnis.
|
||||
- Story-Bezug: Bezug zur US_NNNNNN.
|
||||
- Definition of Done (DoD): Pruefbare Abschlusskriterien.
|
||||
|
||||
## Start
|
||||
|
||||
Beginne mit einer vollstaendigen Epic-Uebersicht basierend auf STD_EPIC_001. Danach arbeite Epic fuer Epic weiter.
|
||||
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.
|
||||