Compare commits
43 Commits
6f49528bbd
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
16
.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
.env
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.log
|
||||||
|
output/
|
||||||
|
temp/
|
||||||
|
.project_analysis/
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
.DS_Store
|
||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- `bash -n sk.sh` — Syntax check to catch parsing errors early.
|
- `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.
|
- `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.
|
- `./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.
|
- `sudo ./sk.sh demo_user disable countdown sound 90` — Example invocation combining optional modes.
|
||||||
|
|||||||
57
CHANGELOG.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
ID: DOC_000001 | Version: 0.1.5 | 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) |
|
||||||
|
|
||||||
|
---
|
||||||
|
## 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.
|
||||||
2
Makefile
@ -8,7 +8,7 @@ ENV_FILE ?= $(ENV_DIR)/env
|
|||||||
SYSTEMD_PATH ?= /etc/systemd/system/$(SERVICE).service
|
SYSTEMD_PATH ?= /etc/systemd/system/$(SERVICE).service
|
||||||
BRANCH ?= main
|
BRANCH ?= main
|
||||||
HOST ?= 127.0.0.1
|
HOST ?= 127.0.0.1
|
||||||
PORT ?= 8000
|
PORT ?= 80
|
||||||
HEALTH_URL ?= http://$(HOST):$(PORT)/health
|
HEALTH_URL ?= http://$(HOST):$(PORT)/health
|
||||||
TOKEN ?= $(shell awk -F= '/^SKD_AUTH_TOKEN=/{print $$2}' $(ENV_FILE) 2>/dev/null)
|
TOKEN ?= $(shell awk -F= '/^SKD_AUTH_TOKEN=/{print $$2}' $(ENV_FILE) 2>/dev/null)
|
||||||
KEEP_INSTALL_DIR ?= 1
|
KEEP_INSTALL_DIR ?= 1
|
||||||
|
|||||||
68
README.md
@ -16,44 +16,88 @@ cd /opt/sk
|
|||||||
./scripts/install.sh
|
./scripts/install.sh
|
||||||
sudo systemctl status skd.service
|
sudo systemctl status skd.service
|
||||||
```
|
```
|
||||||
Then open `http://localhost:8000/` and set the API token in the UI.
|
Then open `http://localhost/` and log in via PAM (default) to start quickly.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
Set in `/etc/skd/env` (see `env.example`):
|
Set in `/etc/skd/env` (see `env.example`):
|
||||||
- `SKD_AUTH_SECRET`: HMAC secret for bearer tokens (set a strong value).
|
- PAM-Login ist immer aktiv. OIDC wird zusaetzlich angeboten, wenn konfiguriert.
|
||||||
|
- `SKD_AUTH_SECRET`: HMAC secret for bearer tokens/cookies (set a strong value).
|
||||||
- `SKD_TOKEN_TTL_SECONDS`: token lifetime (default 900s).
|
- `SKD_TOKEN_TTL_SECONDS`: token lifetime (default 900s).
|
||||||
- `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in.
|
- `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in (used for PAM and as an allowlist for OIDC claims).
|
||||||
- `SKD_AUTH_ALLOWED_GROUPS`: groups whose members may log in (default `sudo`).
|
- `SKD_AUTH_ALLOWED_GROUPS`: groups whose members may log in (PAM only, default `sudo`).
|
||||||
|
- `SKD_AUTH_PAM_SERVICE`: PAM service name; Ubuntu/Debian uses `/etc/pam.d/skd` (created by `scripts/install.sh`), other distros may prefer `login` or `sshd`.
|
||||||
|
- `SKD_OIDC_*`: `ISSUER`, `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI`, `SCOPES` to point at your OIDC provider; set `SKD_SESSION_COOKIE_SECURE=true` for HTTPS.
|
||||||
|
- OIDC dynamic registration helper: `scripts/register_oidc_client.sh` (requires `OIDC_INITIAL_ACCESS_TOKEN` and `SKD_OIDC_ISSUER`; uses `SKD_OIDC_REDIRECT_URI` for the redirect). Run once during setup if your provider issues initial access tokens for client creation.
|
||||||
- `SKD_ALLOWED_USERS`: optional comma list to limit manageable accounts (must exist on the system).
|
- `SKD_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_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`: behavior defaults.
|
||||||
- `SKD_DRY_RUN=true` to test without real account changes or shutdown.
|
- `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.
|
- `SKD_SOUND_PLAYER`/`SKD_SOUND_FILE`, `SKD_NOTIFY_SEND_PATH` if defaults differ.
|
||||||
|
- Update client:
|
||||||
|
- `SKD_UPDATE_URL` (default `https://update.wlkns.org`)
|
||||||
|
- `SKD_UPDATE_TOKEN` (API token for update service)
|
||||||
|
- `SKD_UPDATE_INTERVAL` (seconds; default 3600)
|
||||||
|
- `SKD_UPDATE_STATUS_URL` (default `https://update.wlkns.org/status`)
|
||||||
|
- `SKD_UPDATE_STATUS_FILE` (default `/var/lib/skd/update_status.json`)
|
||||||
|
- `SKD_UPDATE_LOG_FILE` (default `/var/lib/skd/update_logs.jsonl`)
|
||||||
Notes:
|
Notes:
|
||||||
- `./scripts/install.sh` will create `/etc/skd/env` from `env.example` if missing (edit afterwards) and ensure the `skd` service user/group exist.
|
- `./scripts/install.sh` will create `/etc/skd/env` from `env.example` if missing (edit afterwards) and ensure the `skd` service user/group exist.
|
||||||
|
|
||||||
|
## OIDC Setup
|
||||||
|
OIDC ist optional. PAM bleibt immer verfuegbar; `SKD_AUTH_MODE` ist optional.
|
||||||
|
1. Issuer muss der externen URL des Providers entsprechen (TLS trust erforderlich).
|
||||||
|
2. OIDC Client registrieren (DCR), z.B.:
|
||||||
|
```bash
|
||||||
|
export SKD_OIDC_ISSUER="https://auth.example.org"
|
||||||
|
export SKD_OIDC_REDIRECT_URI="https://<device-host>/login/oidc/callback"
|
||||||
|
export OIDC_INITIAL_ACCESS_TOKEN="<initial-access-token>"
|
||||||
|
./scripts/register_oidc_client.sh
|
||||||
|
```
|
||||||
|
3. Danach in `/etc/skd/env` setzen:
|
||||||
|
```
|
||||||
|
SKD_AUTH_MODE=oidc
|
||||||
|
SKD_OIDC_ISSUER=...
|
||||||
|
SKD_OIDC_CLIENT_ID=...
|
||||||
|
SKD_OIDC_CLIENT_SECRET=...
|
||||||
|
SKD_OIDC_REDIRECT_URI=...
|
||||||
|
SKD_OIDC_SCOPES=openid profile email
|
||||||
|
SKD_SESSION_COOKIE_SECURE=true
|
||||||
|
```
|
||||||
|
Hinweise:
|
||||||
|
- Redirect-URI muss exakt sein (keine Wildcards).
|
||||||
|
- Bei Host/Port-Aenderung neu registrieren und neue Credentials setzen.
|
||||||
|
- Allowlist fuer OIDC: `SKD_AUTH_ALLOWED_USERS` prueft `preferred_username`, `email` oder `sub`.
|
||||||
|
|
||||||
## Running
|
## 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.
|
- 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 (runs as root for PAM).
|
||||||
- Manual run: `./scripts/run.sh` (uses `.venv`, defaults to `0.0.0.0:8000`).
|
- Manual run: `./scripts/run.sh` (uses `.venv`, defaults to `0.0.0.0:80`).
|
||||||
- Login: `curl -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login`
|
- Login (PAM): `curl -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost/login`
|
||||||
- Health: `curl -H "Authorization: Bearer <token>" http://localhost:8000/health`
|
- Login (OIDC): open `http://localhost/login/oidc/start` → provider → redirected back with session cookie set.
|
||||||
|
- Health: `curl -H "Authorization: Bearer <token>" http://localhost/health`
|
||||||
|
|
||||||
|
## OIDC Validation & Fallbacks
|
||||||
|
- Validierungsschritte: `docs/oidc-validation.md` (State, Token-Exchange, Claims, Cookie).
|
||||||
|
- Falls Discovery/JWKS nicht verfuegbar: OIDC deaktivieren und PAM nutzen.
|
||||||
|
- Falls DCR nicht verfuegbar: Client manuell im IdP anlegen und `SKD_OIDC_*` setzen.
|
||||||
|
- Bei Self-Signed TLS: CA im System trusten oder in Dev PAM nutzen.
|
||||||
|
|
||||||
## API (Bearer token via `/login`)
|
## API (Bearer token via `/login`)
|
||||||
- `GET /users` → `[{user, logged_in}]` (manageable system users; excludes root)
|
- `GET /users` → `[{user, logged_in, account_locked}]` (manageable system users; excludes root)
|
||||||
- `POST /users/{name}/disable` with JSON `{countdown?, sound?, message?}`
|
- `POST /users/{name}/disable` with JSON `{countdown?, sound?, message?}`
|
||||||
- `POST /users/{name}/enable`
|
- `POST /users/{name}/enable`
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
|
- `GET /me` (returns current user + auth mode when a session/bearer token is present)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```bash
|
```bash
|
||||||
token=$(curl -s -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login | jq -r .token)
|
token=$(curl -s -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost/login | jq -r .token)
|
||||||
curl -X POST -H "Authorization: Bearer $token" \
|
curl -X POST -H "Authorization: Bearer $token" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"countdown":90,"sound":true}' \
|
-d '{"countdown":90,"sound":true}' \
|
||||||
http://localhost:8000/users/child1/disable
|
http://localhost/users/child1/disable
|
||||||
```
|
```
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
Served at `/`. Login mit Root-Account, danach werden verfügbare System-User angezeigt; Aktionen senden Bearer Token automatisch.
|
Served at `/`. Nutze den Button „Login via OIDC“ (setzt Session-Cookie) oder das PAM-Formular, falls OIDC deaktiviert; danach werden verfügbare System-User angezeigt und Aktionen senden Token/Cookies automatisch.
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
- Remote update via SSH: `ssh user@kid-laptop 'cd /opt/sk && ./scripts/update.sh'` (fetch/reset to `origin/main`, reinstalls deps, restarts service).
|
- Remote update via SSH: `ssh user@kid-laptop 'cd /opt/sk && ./scripts/update.sh'` (fetch/reset to `origin/main`, reinstalls deps, restarts service).
|
||||||
|
|||||||
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:
|
if play_sound:
|
||||||
_play_sound_if_available()
|
_play_sound_if_available()
|
||||||
|
|
||||||
_run(["sudo", "pkill", "-KILL", "-u", user])
|
try:
|
||||||
steps.append("sessions terminated")
|
_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:
|
if logged_in:
|
||||||
_run(["sudo", "shutdown", "now"])
|
_run(["sudo", "shutdown", "now"])
|
||||||
|
|||||||
221
backend/app.py
@ -1,15 +1,37 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, status
|
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from backend import actions
|
from backend import actions
|
||||||
from backend.actions import ActionError
|
from backend.actions import ActionError
|
||||||
from backend.auth import authenticate_admin_user, get_current_admin, issue_token, list_manageable_users
|
from backend.auth import (
|
||||||
from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
|
authenticate_admin_user,
|
||||||
|
get_current_admin,
|
||||||
|
is_authorized_admin,
|
||||||
|
is_account_locked,
|
||||||
|
issue_token,
|
||||||
|
list_manageable_users,
|
||||||
|
)
|
||||||
|
from backend.models import (
|
||||||
|
ActionRequest,
|
||||||
|
ActionResponse,
|
||||||
|
EnrollRequest,
|
||||||
|
EnrollResponse,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
UpdateActionResponse,
|
||||||
|
UpdateCheckResponse,
|
||||||
|
UpdateLogEntry,
|
||||||
|
UpdateStatus,
|
||||||
|
UserStatus,
|
||||||
|
)
|
||||||
|
from backend.oidc import OIDCClient, OIDCError
|
||||||
from backend.settings import Settings, get_settings
|
from backend.settings import Settings, get_settings
|
||||||
|
from backend import update
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@ -18,9 +40,25 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger("skd")
|
logger = logging.getLogger("skd")
|
||||||
|
|
||||||
app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
|
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")
|
templates = Jinja2Templates(directory="backend/templates")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def validate_user(username: str, settings: Settings = Depends(get_settings)) -> str:
|
||||||
allowed = set(list_manageable_users(settings))
|
allowed = set(list_manageable_users(settings))
|
||||||
if username not in allowed:
|
if username not in allowed:
|
||||||
@ -33,18 +71,102 @@ def health(settings: Settings = Depends(get_settings)) -> dict:
|
|||||||
return {"status": "ok", "dry_run": settings.dry_run}
|
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)
|
@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)
|
authenticate_admin_user(payload.username, payload.password, settings)
|
||||||
token = issue_token(payload.username, 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)
|
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)])
|
@app.get("/users", response_model=List[UserStatus], dependencies=[Depends(get_current_admin)])
|
||||||
def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]:
|
def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]:
|
||||||
logged_in = set(actions.list_logged_in_users())
|
logged_in = set(actions.list_logged_in_users())
|
||||||
targets = list_manageable_users(settings)
|
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(
|
@app.post(
|
||||||
@ -56,7 +178,13 @@ def disable_user(
|
|||||||
username: str = Depends(validate_user),
|
username: str = Depends(validate_user),
|
||||||
payload: ActionRequest | None = Body(default=None),
|
payload: ActionRequest | None = Body(default=None),
|
||||||
settings: Settings = Depends(get_settings),
|
settings: Settings = Depends(get_settings),
|
||||||
|
current_user: str = Depends(get_current_admin),
|
||||||
) -> ActionResponse:
|
) -> ActionResponse:
|
||||||
|
if username == current_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot disable current user",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
steps = actions.disable_user(
|
steps = actions.disable_user(
|
||||||
username,
|
username,
|
||||||
@ -107,6 +235,87 @@ def enable_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
|
@app.get("/dashboard", response_class=HTMLResponse)
|
||||||
def index(request: Request) -> HTMLResponse:
|
def index(request: Request) -> HTMLResponse:
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import grp
|
import grp
|
||||||
import pwd
|
import pwd
|
||||||
from typing import List, Set
|
import spwd
|
||||||
|
from typing import List, Optional, Set
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pam
|
import pam
|
||||||
@ -23,7 +24,12 @@ def _is_member_of(username: str, groups: Set[str]) -> bool:
|
|||||||
return bool(user_groups & groups)
|
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
|
# UID 0 always allowed
|
||||||
try:
|
try:
|
||||||
entry = pwd.getpwnam(username)
|
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:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="User not authorized to log in",
|
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
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
|
||||||
|
|
||||||
username = payload.get("sub")
|
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")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Unauthorized user")
|
||||||
return username
|
return username
|
||||||
|
|
||||||
@ -82,9 +93,13 @@ def get_current_admin(
|
|||||||
settings: Settings = Depends(get_settings),
|
settings: Settings = Depends(get_settings),
|
||||||
) -> str:
|
) -> str:
|
||||||
auth_header = request.headers.get("authorization")
|
auth_header = request.headers.get("authorization")
|
||||||
if not auth_header or not auth_header.lower().startswith("bearer "):
|
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")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||||
token = auth_header.split(" ", 1)[1].strip()
|
|
||||||
return decode_token(token, settings)
|
return decode_token(token, settings)
|
||||||
|
|
||||||
|
|
||||||
@ -105,3 +120,12 @@ def list_manageable_users(settings: Settings) -> List[str]:
|
|||||||
candidates.append(entry.pw_name)
|
candidates.append(entry.pw_name)
|
||||||
candidates.sort()
|
candidates.sort()
|
||||||
return candidates
|
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(("!", "*"))
|
||||||
|
|||||||
@ -23,6 +23,7 @@ class ActionResponse(BaseModel):
|
|||||||
class UserStatus(BaseModel):
|
class UserStatus(BaseModel):
|
||||||
user: str
|
user: str
|
||||||
logged_in: bool
|
logged_in: bool
|
||||||
|
account_locked: bool
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
@ -33,3 +34,42 @@ class LoginRequest(BaseModel):
|
|||||||
class LoginResponse(BaseModel):
|
class LoginResponse(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
expires_in: int
|
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
|
||||||
|
|||||||
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
|
PyJWT==2.9.0
|
||||||
python-pam==2.0.2
|
python-pam==2.0.2
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
|
httpx==0.27.2
|
||||||
|
cryptography==43.0.1
|
||||||
|
|||||||
@ -7,6 +7,9 @@ class Settings:
|
|||||||
"""Application settings loaded from environment."""
|
"""Application settings loaded from environment."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
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.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.auth_secret: str = os.getenv("SKD_AUTH_SECRET", "change-me-secret")
|
||||||
self.token_ttl_seconds: int = int(os.getenv("SKD_TOKEN_TTL_SECONDS", "900"))
|
self.token_ttl_seconds: int = int(os.getenv("SKD_TOKEN_TTL_SECONDS", "900"))
|
||||||
@ -15,10 +18,45 @@ class Settings:
|
|||||||
os.getenv("SKD_AUTH_ALLOWED_GROUPS", "sudo")
|
os.getenv("SKD_AUTH_ALLOWED_GROUPS", "sudo")
|
||||||
)
|
)
|
||||||
self.auth_pam_service: str = os.getenv("SKD_AUTH_PAM_SERVICE", "login")
|
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_countdown: int = int(os.getenv("SKD_DEFAULT_COUNTDOWN", "60"))
|
||||||
self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true"
|
self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true"
|
||||||
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
|
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
|
||||||
self.dry_run: bool = os.getenv("SKD_DRY_RUN", "false").lower() == "true"
|
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"
|
||||||
|
)
|
||||||
# Paths/tools
|
# Paths/tools
|
||||||
self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send")
|
self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send")
|
||||||
self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay")
|
self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay")
|
||||||
@ -31,6 +69,18 @@ class Settings:
|
|||||||
def _parse_list(value: str) -> List[str]:
|
def _parse_list(value: str) -> List[str]:
|
||||||
return [item for item in (part.strip() for part in value.split(",")) if item]
|
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)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
901
backend/static/styles.css
Executable file
@ -0,0 +1,901 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@ -4,101 +4,220 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Safe Kiddo Control</title>
|
<title>Safe Kiddo Control</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<style>
|
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg" />
|
||||||
body { max-width: 960px; margin: auto; padding: 1.5rem; }
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
.log { white-space: pre-line; }
|
|
||||||
form { margin-bottom: 1rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 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>
|
||||||
|
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="loginUser">Benutzer</label>
|
||||||
|
<input id="loginUser" name="loginUser" autocomplete="username" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="loginPass">Passwort</label>
|
||||||
|
<input id="loginPass" name="loginPass" type="password" autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="appContainer" class="container hidden">
|
||||||
|
<!-- Header -->
|
||||||
<header>
|
<header>
|
||||||
<h1>Safe Kiddo Control</h1>
|
<div class="header-main">
|
||||||
<p>Steuere Nutzerkonten über die lokale API. Stelle sicher, dass der API-Token gesetzt ist.</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
<section>
|
<!-- Metrics Dashboard -->
|
||||||
<h3>Login (nur Root-User)</h3>
|
<div class="metrics">
|
||||||
<form id="loginForm">
|
<div class="metric-card" id="metricUsers">
|
||||||
<div class="grid">
|
<div class="label"><i data-lucide="users"></i> Verwaltbare Nutzer</div>
|
||||||
<div>
|
<div class="value">-</div>
|
||||||
<label for="loginUser">Benutzer</label>
|
</div>
|
||||||
<input id="loginUser" name="loginUser" autocomplete="username" required />
|
<div class="metric-card" id="metricActive">
|
||||||
</div>
|
<div class="label"><i data-lucide="user-check"></i> Aktive Konten</div>
|
||||||
<div>
|
<div class="value">-</div>
|
||||||
<label for="loginPass">Passwort</label>
|
</div>
|
||||||
<input id="loginPass" name="loginPass" type="password" autocomplete="current-password" required />
|
<div class="metric-card warning" id="metricLocked">
|
||||||
</div>
|
<div class="label"><i data-lucide="lock"></i> Gesperrte Konten</div>
|
||||||
</div>
|
<div class="value">-</div>
|
||||||
<button type="submit">Anmelden</button>
|
</div>
|
||||||
</form>
|
<div class="metric-card" id="metricVersion">
|
||||||
<div id="loginStatus" class="log"></div>
|
<div class="label"><i data-lucide="package"></i> Version</div>
|
||||||
|
<div class="value text-muted" style="font-size: 1.25rem;">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Management Section -->
|
||||||
|
<section id="userSection" class="panel-section hidden">
|
||||||
|
<h3><i data-lucide="users"></i> Nutzerverwaltung</h3>
|
||||||
|
|
||||||
|
<button id="refreshUsersBtn" class="secondary small">
|
||||||
|
<i data-lucide="refresh-cw"></i>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<section>
|
<!-- Update Management Section -->
|
||||||
<h3>Status abrufen</h3>
|
<section id="updateSection" class="panel-section hidden">
|
||||||
<button id="refreshBtn">Status laden</button>
|
<h3><i data-lucide="download"></i> Update-Verwaltung</h3>
|
||||||
<div id="status" class="log"></div>
|
|
||||||
|
<div id="updateStatus" style="background: rgba(10, 14, 20, 0.6); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); 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>
|
||||||
|
|
||||||
|
<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>
|
</section>
|
||||||
|
|
||||||
<section>
|
<!-- Toast Container -->
|
||||||
<h3>Aktion ausführen</h3>
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
<form id="actionForm">
|
|
||||||
<div class="grid">
|
<!-- Action Modal -->
|
||||||
<div>
|
<div class="modal" id="actionModal">
|
||||||
<label for="username">Benutzer</label>
|
<div class="modal-content">
|
||||||
<select id="username" name="username" required>
|
<div class="modal-header">
|
||||||
<option value="">-- wählen --</option>
|
<h3>Aktion ausführen</h3>
|
||||||
</select>
|
<button class="secondary small" onclick="closeActionModal()">
|
||||||
</div>
|
<i data-lucide="x"></i>
|
||||||
<div>
|
</button>
|
||||||
<label for="action">Aktion</label>
|
|
||||||
<select id="action" name="action">
|
|
||||||
<option value="disable">Disable</option>
|
|
||||||
<option value="enable">Enable</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
|
||||||
<div>
|
<form id="actionForm">
|
||||||
<label for="countdown">Countdown (Sekunden, optional)</label>
|
<input type="hidden" id="modalUsername" />
|
||||||
<input id="countdown" name="countdown" type="number" min="0" />
|
<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>
|
||||||
<div>
|
|
||||||
<label for="sound">Sound</label>
|
<div class="form-group">
|
||||||
|
<label for="sound">Sound abspielen</label>
|
||||||
<select id="sound" name="sound">
|
<select id="sound" name="sound">
|
||||||
<option value="">Default</option>
|
<option value="">Standard</option>
|
||||||
<option value="true">An</option>
|
<option value="true">Ja</option>
|
||||||
<option value="false">Aus</option>
|
<option value="false">Nein</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<label for="message">Nachricht (optional)</label>
|
<div class="form-group">
|
||||||
<input id="message" name="message" />
|
<label for="message">Nachricht (optional)</label>
|
||||||
<button type="submit">Senden</button>
|
<input id="message" name="message" placeholder="z.B. Speichere deine Arbeit!" />
|
||||||
</form>
|
</div>
|
||||||
<div id="result" class="log"></div>
|
|
||||||
</section>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const statusDiv = document.getElementById('status');
|
// Initialize Lucide icons
|
||||||
const resultDiv = document.getElementById('result');
|
lucide.createIcons();
|
||||||
const loginStatus = document.getElementById('loginStatus');
|
|
||||||
const tokenKey = 'skdToken';
|
|
||||||
let currentToken = sessionStorage.getItem(tokenKey) || '';
|
|
||||||
|
|
||||||
function setToken(token) {
|
// Global state
|
||||||
currentToken = token;
|
let currentToken = sessionStorage.getItem('skdToken') || '';
|
||||||
if (token) {
|
let latestUpdateCheck = null;
|
||||||
sessionStorage.setItem(tokenKey, token);
|
|
||||||
loginStatus.textContent = 'Angemeldet';
|
|
||||||
} else {
|
|
||||||
sessionStorage.removeItem(tokenKey);
|
|
||||||
loginStatus.textContent = 'Nicht angemeldet';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setToken(currentToken);
|
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
function authHeaders() {
|
function authHeaders() {
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
if (currentToken) headers['Authorization'] = `Bearer ${currentToken}`;
|
if (currentToken) headers['Authorization'] = `Bearer ${currentToken}`;
|
||||||
@ -107,75 +226,432 @@
|
|||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const headers = { ...authHeaders(), ...(options.headers || {}) };
|
const headers = { ...authHeaders(), ...(options.headers || {}) };
|
||||||
const res = await fetch(path, { ...options, headers });
|
const res = await fetch(path, { ...options, headers, credentials: 'same-origin' });
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshUsers() {
|
// Toast notifications
|
||||||
statusDiv.textContent = 'Lade...';
|
function showToast(message, type = 'success') {
|
||||||
try {
|
const container = document.getElementById('toastContainer');
|
||||||
const data = await api('/users');
|
const toast = document.createElement('div');
|
||||||
statusDiv.textContent = data.map(u => `${u.user}: ${u.logged_in ? 'eingeloggt' : 'aus'}`).join('\n') || 'Keine Daten';
|
toast.className = `toast ${type}`;
|
||||||
const select = document.getElementById('username');
|
toast.innerHTML = `
|
||||||
select.innerHTML = '<option value="">-- wählen --</option>';
|
<i data-lucide="${type === 'success' ? 'check-circle' : type === 'error' ? 'alert-circle' : 'alert-triangle'}"></i>
|
||||||
data.forEach(u => {
|
<span>${message}</span>
|
||||||
const opt = document.createElement('option');
|
`;
|
||||||
opt.value = u.user;
|
container.appendChild(toast);
|
||||||
opt.textContent = u.user;
|
lucide.createIcons();
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
setTimeout(() => {
|
||||||
} catch (err) {
|
toast.style.animation = 'slideIn 0.3s ease reverse';
|
||||||
statusDiv.textContent = `Fehler: ${err.message}`;
|
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');
|
||||||
|
await refreshUsers();
|
||||||
|
await refreshUpdateStatus();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('currentUser').textContent = 'Nicht angemeldet';
|
||||||
|
showLanding();
|
||||||
|
document.getElementById('userSection').classList.add('hidden');
|
||||||
|
document.getElementById('updateSection').classList.add('hidden');
|
||||||
|
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');
|
||||||
|
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 = await api('/update/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>';
|
||||||
|
|
||||||
|
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;">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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
loginStatus.textContent = 'Anmeldung...';
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
const username = document.getElementById('loginUser').value.trim();
|
const originalHTML = btn.innerHTML;
|
||||||
const password = document.getElementById('loginPass').value;
|
btn.innerHTML = '<div class="spinner"></div> Anmeldung...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const username = document.getElementById('loginUser').value.trim();
|
||||||
|
const password = document.getElementById('loginPass').value;
|
||||||
const data = await api('/login', {
|
const data = await api('/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password })
|
||||||
});
|
});
|
||||||
setToken(data.token);
|
|
||||||
loginStatus.textContent = 'Anmeldung erfolgreich';
|
currentToken = data.token;
|
||||||
await refreshUsers();
|
sessionStorage.setItem('skdToken', data.token);
|
||||||
|
showToast('Anmeldung erfolgreich', 'success');
|
||||||
|
await checkSession();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setToken('');
|
showToast('Login fehlgeschlagen: ' + err.message, 'error');
|
||||||
loginStatus.textContent = `Login fehlgeschlagen: ${err.message}`;
|
} 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) => {
|
document.getElementById('actionForm').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
resultDiv.textContent = 'Sende...';
|
const username = document.getElementById('modalUsername').value;
|
||||||
const username = document.getElementById('username').value;
|
const action = document.getElementById('modalAction').value;
|
||||||
const action = document.getElementById('action').value;
|
|
||||||
const countdown = document.getElementById('countdown').value;
|
const countdown = document.getElementById('countdown').value;
|
||||||
const sound = document.getElementById('sound').value;
|
const sound = document.getElementById('sound').value;
|
||||||
const message = document.getElementById('message').value.trim();
|
const message = document.getElementById('message').value.trim();
|
||||||
|
|
||||||
const body = {};
|
const btn = document.getElementById('actionSubmitBtn');
|
||||||
if (countdown) body.countdown = Number(countdown);
|
const originalHTML = btn.innerHTML;
|
||||||
if (sound === 'true') body.sound = true;
|
btn.innerHTML = '<div class="spinner"></div> Sende...';
|
||||||
if (sound === 'false') body.sound = false;
|
btn.disabled = true;
|
||||||
if (message) body.message = message;
|
|
||||||
|
|
||||||
try {
|
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;
|
||||||
|
|
||||||
const data = await api(`/users/${encodeURIComponent(username)}/${action}`, {
|
const data = await api(`/users/${encodeURIComponent(username)}/${action}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: Object.keys(body).length ? JSON.stringify(body) : '{}'
|
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) {
|
} 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);
|
||||||
|
|
||||||
|
// 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>
|
</script>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
222
backend/update.py
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
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 check_update(settings: Settings) -> Dict[str, Any]:
|
||||||
|
if not settings.update_token:
|
||||||
|
raise ValueError("Client is not enrolled (missing update token)")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {settings.update_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:
|
||||||
|
if not settings.update_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 {settings.update_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:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["SKD_UPDATE_SERVICE_URL"] = settings.update_service_url
|
||||||
|
env["SKD_UPDATE_PROJECT_ID"] = settings.update_project_id
|
||||||
|
env["SKD_UPDATE_TOKEN"] = settings.update_token
|
||||||
|
env["SKD_UPDATE_STATUS_FILE"] = settings.update_status_file
|
||||||
|
env["SKD_UPDATE_LOG_FILE"] = settings.update_log_file
|
||||||
|
subprocess.Popen(
|
||||||
|
[str(script_path)], env=env, 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
|
||||||
91
docs/admin-token-operations.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
ID: DOC_000006 | Version: 0.1.0 | Status: Draft
|
||||||
|
|
||||||
|
# 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.
|
||||||
57
docs/architecture/openapi.yaml
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Update Webservice API
|
||||||
|
version: 0.1.0
|
||||||
|
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
|
||||||
44
docs/client-quickstart.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
ID: DOC_000008 | Version: 0.1.0 | Status: Draft
|
||||||
|
|
||||||
|
# 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`.
|
||||||
38
docs/oidc-validation.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
ID: DOC_000003 | Version: 0.1.0 | Status: Draft
|
||||||
|
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.
|
||||||
135
docs/third-party-api.md
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
ID: DOC_000005 | Version: 0.1.0 | Status: Draft
|
||||||
|
|
||||||
|
# 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/ARCHITECTURE.md` 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
|
||||||
|
```
|
||||||
60
docs/update-api.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
ID: DOC_000006 | Version: 0.1.0 | Status: Draft
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# Update API (Kiddo Backend)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Definiert interne API-Endpunkte fuer Update-Status, Check, Apply, Rollback und Logs.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### 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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Alle Endpunkte erfordern Auth (Session/Bearer).
|
||||||
|
- Apply/Rollback starten async; UI pollt /update/status.
|
||||||
49
docs/update-client.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
ID: DOC_000004 | Version: 0.1.0 | Status: Draft
|
||||||
|
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)
|
||||||
|
1. Manifest abrufen (auth optional 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`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- Update-Service ist extern (update.wlkns.org).
|
||||||
|
- Service muss als root stoppen/starten koennen.
|
||||||
|
|
||||||
|
## Status Reporting
|
||||||
|
- Status wird per HTTP POST an `https://update.wlkns.org/status` gemeldet.
|
||||||
|
- Schema siehe `docs/update-status.md`.
|
||||||
|
- Lokaler Status/Logs liegen unter `/var/lib/skd` (konfigurierbar via ENV).
|
||||||
25
docs/update-status.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
ID: DOC_000005 | Version: 0.1.0 | Status: Draft
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# Update Status Reporting
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Definiert das Status-Schema fuer Update-Resultate und den Uebertragungsweg.
|
||||||
|
|
||||||
|
## Status Schema (JSON)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"device_id": "<hostname>",
|
||||||
|
"version": "0.1.2",
|
||||||
|
"status": "success|failed",
|
||||||
|
"error": "<optional message>",
|
||||||
|
"timestamp": "2025-12-28T12:34:56Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
- HTTP POST an `https://update.wlkns.org/status`
|
||||||
|
- Auth: Bearer Token (`SKD_UPDATE_TOKEN`)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Statusmeldungen sind best-effort; Fehler beim Senden blockieren kein Update.
|
||||||
23
env.example
@ -1,13 +1,32 @@
|
|||||||
# Copy to /etc/skd/env or .env for local runs
|
# Copy to /etc/skd/env or .env for local runs
|
||||||
# Optional allowlist of manageable users (otherwise all real users with uid>=1000)
|
# 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_AUTH_SECRET=change-me-secret
|
||||||
SKD_TOKEN_TTL_SECONDS=900
|
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_USERS=
|
||||||
SKD_AUTH_ALLOWED_GROUPS=sudo
|
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_COUNTDOWN=60
|
||||||
SKD_DEFAULT_SOUND=false
|
SKD_DEFAULT_SOUND=false
|
||||||
SKD_NOTIFY_TIMEOUT=5
|
SKD_NOTIFY_TIMEOUT=5
|
||||||
|
# Update client configuration
|
||||||
|
SKD_UPDATE_URL=https://update.wlkns.org
|
||||||
|
SKD_UPDATE_TOKEN=
|
||||||
|
SKD_UPDATE_INTERVAL=3600
|
||||||
|
SKD_UPDATE_STATUS_URL=https://update.wlkns.org/status
|
||||||
|
SKD_UPDATE_STATUS_FILE=/var/lib/skd/update_status.json
|
||||||
|
SKD_UPDATE_LOG_FILE=/var/lib/skd/update_logs.jsonl
|
||||||
# Set to true to test without performing real system changes
|
# Set to true to test without performing real system changes
|
||||||
SKD_DRY_RUN=false
|
SKD_DRY_RUN=false
|
||||||
|
|||||||
43
project-management/AGENTS.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
ID: AGENTS_000001 | Version: 0.1.0 | 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.
|
||||||
125
project-management/PROJECT_STATUS.md
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
ID: STATUS_000001 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# Projekt-Status
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
|
||||||
|
|
||||||
|
## Aktuelle Phase
|
||||||
|
✅ Stabilization
|
||||||
|
|
||||||
|
## Aktueller Fokus
|
||||||
|
1. Client-Update-Mechanik planen (EPIC_000008).
|
||||||
|
2. Dokumentierter Ist-Zustand der Module.
|
||||||
|
3. Pflege der Anforderungen bei neuen Features.
|
||||||
|
|
||||||
|
## Projekt-Tagebuch (Kurz, optional)
|
||||||
|
| Datum | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| 28.12.2025 | 🏗️ Planning | Anforderungen als Epics und Stories dokumentiert. |
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### EPIC_000009: Update Webservice (External Team)
|
||||||
|
- [ ] US_000026: Client bezieht Updates (Pull)
|
||||||
|
- [ ] US_000027: Client verifiziert und wendet Updates an
|
||||||
|
- [ ] 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
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- PR vorbereiten: feature/oidc-validation (Summary, Risiko, Testschritte).
|
||||||
36
project-management/PROJECT_STATUS_TEMPLATE.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
ID: PROJECT_STATUS_TEMPLATE | Version: 0.1.0 | 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.1.0 | 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.1.0 | 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.1.0 | 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.
|
||||||
38
project-management/requirements/epics/EPIC_000001.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
ID: EPIC_000001 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000001: Legacy CLI Account Control (sk.sh)
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Der Legacy-CLI-Workflow ermoeglicht das Sperren und Entsperren lokaler Nutzerkonten per Bash-Skript.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Schnelle, direkte Steuerung ohne Web-Service als Notfall- oder SSH-Fallback.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Stelle eine robuste, direkte Konto-Steuerung fuer Admins bereit, wenn der Service nicht verfuegbar ist.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Weniger Support-Aufwand durch schnelle lokale Eingriffe.
|
||||||
|
- Erfolgsmetrik: Konto-Disable/Enable laesst sich per CLI ohne Zusatztools ausfuehren.
|
||||||
|
|
||||||
|
## In-Scope
|
||||||
|
- Sperren/Entsperren von Nutzerkonten.
|
||||||
|
- Optionaler Countdown, Benachrichtigungen und Sound.
|
||||||
|
- Shutdown nur bei aktivem Login.
|
||||||
|
|
||||||
|
## Out-of-Scope
|
||||||
|
- Web-UI oder API-Integration.
|
||||||
|
- Persistente Protokollierung im Backend.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- Admin kann einen Nutzer per CLI deaktivieren oder aktivieren.
|
||||||
|
- Countdown/Benachrichtigung/Shutdown verhalten sich wie dokumentiert.
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Root-Rechte erforderlich.
|
||||||
|
- Abhaengigkeit von notify-send und Sound-Tools.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories (Done)
|
||||||
|
- US_000001: Nutzerkonto deaktivieren mit Countdown
|
||||||
|
- US_000002: Nutzerkonto wieder aktivieren
|
||||||
41
project-management/requirements/epics/EPIC_000002.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
ID: EPIC_000002 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000002: Backend API Service
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Der Backend-Service bietet REST-Endpunkte fuer Health-Checks und Nutzeraktionen zum Sperren/Entsperren.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Remote-Steuerung von Nutzerkonten mit klaren API-Antworten fuer Automatisierung.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Biete eine sichere, zentrale API zur Verwaltung lokaler Nutzerkonten.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Remote-Verwaltung ohne direkten SSH-Zugriff.
|
||||||
|
- Erfolgsmetrik: API liefert konsistente Status- und Action-Responses.
|
||||||
|
|
||||||
|
## In-Scope
|
||||||
|
- Health-Endpoint.
|
||||||
|
- Auflistung verwaltbarer Nutzer.
|
||||||
|
- Disable/Enable-Endpoints mit Rueckgabe der Schritte.
|
||||||
|
|
||||||
|
## Out-of-Scope
|
||||||
|
- Frontend-Design-Iteration.
|
||||||
|
- Persistente Datenbank.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- Endpunkte sind erreichbar und liefern erwartete Payloads.
|
||||||
|
- Actions melden Aktion, Schritte und Login-Status.
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Abhaengigkeit von lokalen System-Befehlen fuer Aktionen.
|
||||||
|
- Fehler muessen als HTTP-Fehler sauber abgebildet werden.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories (Done)
|
||||||
|
- US_000003: Health-Status abfragen
|
||||||
|
- US_000004: Verfuegbare Nutzer auflisten
|
||||||
|
- US_000005: Nutzer per API deaktivieren
|
||||||
|
- US_000006: Nutzer per API aktivieren
|
||||||
|
- US_000021: Konfiguration per ENV steuern
|
||||||
41
project-management/requirements/epics/EPIC_000003.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
ID: EPIC_000003 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000003: Authentication & Sessions
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Authentifizierung und Autorisierung fuer Admins via PAM oder OIDC inkl. Session-Handling.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Sichere Zugriffskontrolle auf API und UI mit klarer Admin-Identitaet.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Stelle einen sicheren Admin-Login bereit, der Token oder Session-Cookies ausstellt.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Reduzierung unautorisierter Zugriffe.
|
||||||
|
- Erfolgsmetrik: Nur autorisierte Admins koennen Nutzeraktionen ausfuehren.
|
||||||
|
|
||||||
|
## In-Scope
|
||||||
|
- PAM-Login mit Token-Ausgabe.
|
||||||
|
- OIDC-Login Flow mit State-Validierung.
|
||||||
|
- Autorisierungs-Guards und /me-Endpoint.
|
||||||
|
|
||||||
|
## Out-of-Scope
|
||||||
|
- Multi-Faktor-Authentifizierung.
|
||||||
|
- Externe Session Stores.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- PAM-Login liefert Token und setzt Session-Cookie.
|
||||||
|
- OIDC-Flow validiert State und setzt Session-Cookie.
|
||||||
|
- Nicht autorisierte Nutzer werden blockiert.
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Abhaengigkeit von PAM und OIDC-Provider-Verfuegbarkeit.
|
||||||
|
- Cookie-Sicherheit muss korrekt konfiguriert sein.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories
|
||||||
|
- US_000007: PAM-Login mit Token
|
||||||
|
- US_000008: OIDC-Login Flow
|
||||||
|
- US_000009: Autorisierung und /me-Identitaet
|
||||||
|
- US_000025: OIDC End-to-End Validierung und Runbook
|
||||||
35
project-management/requirements/epics/EPIC_000004.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
ID: EPIC_000004 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000004: Web UI
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Eine schlanke Web-Oberflaeche wird als HTML-Template vom Backend ausgeliefert.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Remote-Bedienung ueber den Browser ohne separate Client-Installation.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Biete eine einfache UI fuer Admins zum Login und zur Nutzersteuerung.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Schnellere Bedienung fuer Nicht-CLI-Nutzer.
|
||||||
|
- Erfolgsmetrik: UI ist unter / erreichbar.
|
||||||
|
|
||||||
|
## In-Scope
|
||||||
|
- Auslieferung der Index-Seite.
|
||||||
|
- Einbindung der Login-Optionen im Template.
|
||||||
|
|
||||||
|
## Out-of-Scope
|
||||||
|
- Design-Overhaul oder umfassende Frontend-Architektur.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- GET / liefert eine HTML-Seite aus dem Template-Verzeichnis.
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Template-Abhaengigkeit von korrekter Backend-Konfiguration.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories (Done)
|
||||||
|
- US_000010: Index-Seite ausliefern
|
||||||
|
- US_000022: Web-UI Aktionen ausfuehren
|
||||||
|
- US_000024: Watchtower Theme fuer Web-UI
|
||||||
43
project-management/requirements/epics/EPIC_000005.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
ID: EPIC_000005 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000005: Automation Scripts
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Bash-Skripte automatisieren Installation, Start, Update und Deployment des Services.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Schnelle und reproduzierbare Betriebsablaeufe auf Zielsystemen.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Minimiere manuelle Admin-Schritte durch standardisierte Skripte.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Zeitersparnis bei Setup und Updates.
|
||||||
|
- Erfolgsmetrik: Install/Update/Deploy laufen ohne manuelle Nacharbeit.
|
||||||
|
|
||||||
|
## In-Scope
|
||||||
|
- Virtualenv-Erstellung und Abhaengigkeiten.
|
||||||
|
- Service-Start und Installation.
|
||||||
|
- Update- und Deployment-Workflows.
|
||||||
|
- OIDC-Client-Registrierungshilfe.
|
||||||
|
|
||||||
|
## Out-of-Scope
|
||||||
|
- CI/CD-Pipelines.
|
||||||
|
- Monitoring oder Alerting.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- Skripte decken lokale und remote Setups ab.
|
||||||
|
- Fehler brechen mit klarer Ausgabe ab.
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Abhaengigkeit von sudo, rsync, ssh, python3.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories (Done)
|
||||||
|
- US_000011: Virtualenv und Abhaengigkeiten erstellen
|
||||||
|
- US_000012: Service lokal starten
|
||||||
|
- US_000013: Service installieren
|
||||||
|
- US_000014: Service aktualisieren
|
||||||
|
- US_000015: Remote-Deployment durchfuehren
|
||||||
|
- US_000016: OIDC-Client registrieren
|
||||||
|
- US_000020: Makefile-Automation bereitstellen
|
||||||
37
project-management/requirements/epics/EPIC_000006.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
ID: EPIC_000006 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000006: Systemd & Deployment Artifacts
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Service-Unit und Konfigurationsvorlagen stellen den Betrieb und die manuelle Verteilung sicher.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Konsistente Service-Konfiguration und einfache Bereitstellungsvorlagen.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Stelle Service-Unit und Konfigurations-Templates fuer reproduzierbare Deployments bereit.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Reduzierte Fehlkonfigurationen durch Standardvorlagen.
|
||||||
|
- Erfolgsmetrik: Service startet mit Unit-Datei und Env-Template.
|
||||||
|
|
||||||
|
## In-Scope
|
||||||
|
- Systemd-Unit-Datei im Repo.
|
||||||
|
- Konfigurationsvorlagen fuer env und Deploy-Hosts.
|
||||||
|
- Manuelle Deployment-Archive.
|
||||||
|
|
||||||
|
## Out-of-Scope
|
||||||
|
- Automatisierte Release-Pipelines.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- Unit- und Template-Dateien sind im Repo vorhanden.
|
||||||
|
- Deployment-Archiv steht fuer manuelle Nutzung bereit.
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Pfadannahmen muessen zum Zielsystem passen.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories (Done)
|
||||||
|
- US_000017: Systemd-Unit im Repo
|
||||||
|
- US_000018: Konfigurations-Templates verfuegbar
|
||||||
|
- US_000019: Deployment-Archiv vorhanden
|
||||||
32
project-management/requirements/epics/EPIC_000007.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
ID: EPIC_000007 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000007: Documentation & Runbook
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Projekt-Dokumentation beschreibt Setup, Betrieb und Sicherheitsrichtlinien fuer Admins.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Schnelleres Onboarding und sichere Bedienung durch klare Runbooks.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Stelle eine verlaessliche Betriebs- und Sicherheitsdokumentation bereit.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Reduzierter Support durch klare Anleitungen.
|
||||||
|
- Erfolgsmetrik: Operatoren koennen Installation und Betrieb aus der Doku nachvollziehen.
|
||||||
|
|
||||||
|
## In-Scope
|
||||||
|
- README mit Setup, Running, Updates und Security-Hinweisen.
|
||||||
|
|
||||||
|
## Out-of-Scope
|
||||||
|
- Externe Wiki- oder Ticket-Systeme.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- README beschreibt Setup, Betrieb und Security Hardening.
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Dokumentation muss mit dem aktuellen Verhalten uebereinstimmen.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories (Done)
|
||||||
|
- US_000023: Runbook und Security-Hinweise dokumentieren
|
||||||
46
project-management/requirements/epics/EPIC_000008.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
ID: EPIC_000008 | Version: 0.1.0 | Status: Draft
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000008: Client-Side Update Mechanism
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Der Kiddo-Client kann Updates aus einer zentralen Quelle beziehen, verifizieren und anwenden.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Schnelle, sichere Aktualisierung ohne SSH-Zugriff auf Zielgeraete.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Ermoegliche einen robusten Client-Update-Flow mit Verifikation und Rollback.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Reduzierter manueller Update-Aufwand.
|
||||||
|
- Erfolgsmetrik: Update kann automatisiert und nachvollziehbar erfolgen.
|
||||||
|
|
||||||
|
## In-Scope
|
||||||
|
- Client zieht Update-Artefakte (Pull-Modell).
|
||||||
|
- Verifikation (Checksum/Signatur).
|
||||||
|
- Atomare Anwendung und Rollback bei Fehlern.
|
||||||
|
- Statusreporting vom Client.
|
||||||
|
|
||||||
|
## Out-of-Scope
|
||||||
|
- Aufbau/Betrieb des zentralen Update-Webservice.
|
||||||
|
- Release-Publishing/CI/CD und Artefakt-Hosting.
|
||||||
|
- Service-Monitoring und Verfuegbarkeit des Update-Backends.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- Client kann Updates erkennen, verifizieren und anwenden.
|
||||||
|
- Fehler fuehren zu Rollback und klarer Statusmeldung.
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Abhaengigkeit von Update-Backend und Netzverfuegbarkeit.
|
||||||
|
- Update-Service wird unter `update.wlkns.org` erwartet.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories
|
||||||
|
- US_000026: Client bezieht Updates (Pull)
|
||||||
|
- US_000027: Client verifiziert und wendet Updates an
|
||||||
|
- US_000028: Client meldet Update-Status
|
||||||
|
- US_000029: Update-Status im Web-UI anzeigen
|
||||||
|
- US_000030: Update-Check im Web-UI ausloesen
|
||||||
|
- US_000031: Update im Web-UI anstossen
|
||||||
|
- US_000032: Update-Logs im Web-UI anzeigen
|
||||||
|
- US_000033: Rollback im Web-UI anstossen
|
||||||
43
project-management/requirements/epics/EPIC_000009.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
ID: EPIC_000009 | Version: 0.1.0 | Status: Draft
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000009: Update Webservice (External Team)
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Zentraler Update-Service stellt Releases und Artefakte fuer Kiddo-Clients bereit.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Zuverlaessige, sichere Bereitstellung von Updates ohne SSH-Zugriff auf Zielgeraete.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Liefer einen stabilen Release-Endpunkt, der Versionen und signierte Artefakte ausliefert.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Reduzierte manuelle Deployments.
|
||||||
|
- Erfolgsmetrik: Clients koennen Updates sicher und reproduzierbar beziehen.
|
||||||
|
|
||||||
|
## In-Scope (Update-Service)
|
||||||
|
- Endpoint unter `https://update.wlkns.org`.
|
||||||
|
- JSON-Manifest mit `version`, `artifact_url`, `sha256`, optional `sig_url`.
|
||||||
|
- Artefakte als `tar.gz`.
|
||||||
|
- Authentifizierung (Token/API-Key).
|
||||||
|
- Signatur oder Checksum-Validierung ermoeglichen.
|
||||||
|
|
||||||
|
## Out-of-Scope (Kiddo Team)
|
||||||
|
- Betrieb/Hosting des Services.
|
||||||
|
- CI/CD fuer Release-Publishing.
|
||||||
|
- Artefakt-Storage und Verfuegbarkeitsmonitoring.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- Manifest liefert aktuelle Version + Artefakt-Metadaten.
|
||||||
|
- Artefakt ist verifizierbar (Checksum/Signatur).
|
||||||
|
- Zugriff ist auth-geschuetzt.
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Netzwerk- und Service-Verfuegbarkeit.
|
||||||
|
- Kompatible Versionierung und semantische Releases.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories
|
||||||
|
- US_000026: Client bezieht Updates (Pull)
|
||||||
|
- US_000027: Client verifiziert und wendet Updates an
|
||||||
|
- US_000028: Client meldet Update-Status
|
||||||
45
project-management/requirements/epics/EPIC_000010.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
ID: EPIC_000010 | Version: 0.1.5 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000010: Update-Service v1 Migration (Major Release)
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Migration des Update-Clients auf den neuen v1 Update-Service mit verpflichtender Authentifizierung
|
||||||
|
und Enrollment-Flow fuer Langzeit-Tokens. Diese Umstellung ist ein Major Release.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Sicheres, standardisiertes Update-Management mit verpflichtender Auth und nachvollziehbarem Status-Reporting.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Stelle sicher, dass der Client die v1 Endpunkte nutzen kann, inkl. Enrollment und
|
||||||
|
neuem Status-Schema.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Security: Auth ist obligatorisch fuer alle Requests.
|
||||||
|
- Erfolgsmetrik: 100% der Clients koennen per v1 manifest/artifact/status arbeiten.
|
||||||
|
|
||||||
|
## In-Scope (Kiddo Team)
|
||||||
|
- Enrollment-Flow fuer Langzeit-Token (mit Pre-Shared Token).
|
||||||
|
- Update-Client auf v1 Endpunkte umstellen.
|
||||||
|
- Status-Payload auf v1 Schema umstellen.
|
||||||
|
- Migration-Notiz/Docs fuer Client-Dev.
|
||||||
|
|
||||||
|
## Out-of-Scope
|
||||||
|
- Betrieb/Hosting des Update-Services.
|
||||||
|
- Ausgabe/Verwaltung von Pre-Shared Tokens auf Server-Seite.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- Auth ist Pflicht (Bearer Token) fuer Manifest, Artifact und Status.
|
||||||
|
- Enrollment liefert Langzeit-Token, der lokal gespeichert wird.
|
||||||
|
- v1 Endpunkte werden genutzt:
|
||||||
|
- GET /v1/projects/{project_id}/manifest
|
||||||
|
- GET /v1/projects/{project_id}/releases/{version}/artifact
|
||||||
|
- POST /v1/projects/{project_id}/status
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Major Release: Rollout-Strategie und Backward Compatibility klaeren.
|
||||||
|
- Token-Handling und sichere lokale Speicherung.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories
|
||||||
|
- US_000034: Enrollment fuer Langzeit-Token
|
||||||
|
- US_000035: v1 Update-Endpoints und Status-Schema
|
||||||
18
project-management/requirements/stories/US_000001.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
ID: US_000001 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000001: Nutzerkonto per CLI deaktivieren
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich ein Nutzerkonto per CLI deaktivieren, damit der Zugriff sofort unterbunden wird.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given ein existierender Nutzer und Root-Rechte
|
||||||
|
- When das Skript mit `disable` und optionalen Countdown/Sound-Parametern aufgerufen wird
|
||||||
|
- Then das Konto ist gesperrt und ein aktiver Login wird erkannt
|
||||||
|
- And bei aktivem Login wird eine Warnung/Countdown versendet und anschliessend abgemeldet
|
||||||
|
- And ein Shutdown erfolgt nur, wenn der Nutzer zuvor eingeloggt war
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000001: Disable user countdown (Details bei Story-Start)
|
||||||
17
project-management/requirements/stories/US_000002.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
ID: US_000002 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000002: Nutzerkonto per CLI aktivieren
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich ein Nutzerkonto per CLI aktivieren, damit sich der Nutzer wieder anmelden kann.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given ein existierender Nutzer und Root-Rechte
|
||||||
|
- When das Skript mit `enable` aufgerufen wird
|
||||||
|
- Then das Konto ist entsperrt und Login ist wieder moeglich
|
||||||
|
- And es wird kein Shutdown ausgeloest
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000002: Enable user account (Details bei Story-Start)
|
||||||
17
project-management/requirements/stories/US_000003.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
ID: US_000003 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000003: Health-Status abfragen
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich den Health-Status per API abfragen, damit ich den Service-Zustand sehe.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given der Service laeuft
|
||||||
|
- When ein GET auf `/health` erfolgt
|
||||||
|
- Then die Antwort enthaelt `status` mit dem Wert `ok`
|
||||||
|
- And die Antwort enthaelt `dry_run` als Boolean
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000003: Health response payload (Details bei Story-Start)
|
||||||
17
project-management/requirements/stories/US_000004.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
ID: US_000004 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000004: Verfuegbare Nutzer auflisten
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich verwaltbare Nutzer per API auflisten, damit ich ihren Login-Status sehe.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given ein autorisierter Admin-Login
|
||||||
|
- When ein GET auf `/users` erfolgt
|
||||||
|
- Then die Antwort ist eine Liste von Eintraegen mit `user`, `logged_in` und `account_locked`
|
||||||
|
- And die Liste enthaelt nur verwaltbare, nicht-root Nutzer
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000004: List users status (Details bei Story-Start)
|
||||||
18
project-management/requirements/stories/US_000005.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
ID: US_000005 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000005: Nutzer per API deaktivieren
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich einen Nutzer per API deaktivieren, damit ich den Zugriff remote steuern kann.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given ein autorisierter Admin-Login und ein erlaubter Nutzer
|
||||||
|
- When ein POST auf `/users/{username}/disable` mit optionalen Feldern `countdown`, `sound`, `message` erfolgt
|
||||||
|
- Then die Antwort enthaelt `user`, `action` = `disable`, `dry_run`, `steps` und `logged_in`
|
||||||
|
- And nicht erlaubte Nutzer werden mit 403 abgewiesen
|
||||||
|
- And der aktuell angemeldete Admin kann sich nicht selbst deaktivieren
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000005: API disable action (Details bei Story-Start)
|
||||||
17
project-management/requirements/stories/US_000006.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
ID: US_000006 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000006: Nutzer per API aktivieren
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich einen Nutzer per API aktivieren, damit ich den Zugriff remote wieder erlaube.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given ein autorisierter Admin-Login und ein erlaubter Nutzer
|
||||||
|
- When ein POST auf `/users/{username}/enable` erfolgt
|
||||||
|
- Then die Antwort enthaelt `user`, `action` = `enable`, `dry_run`, `steps` und `logged_in`
|
||||||
|
- And nicht erlaubte Nutzer werden mit 403 abgewiesen
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000006: API enable action (Details bei Story-Start)
|
||||||
19
project-management/requirements/stories/US_000007.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
ID: US_000007 | Version: 0.1.0 | Status: Final
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000007: PAM-Login mit Token
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich mich per PAM-Login anmelden, damit ich ein Session-Token erhalte.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given `SKD_AUTH_MODE=pam` und gueltige Admin-Credentials
|
||||||
|
- When ein POST auf `/login` mit Benutzername und Passwort erfolgt
|
||||||
|
- Then die Antwort enthaelt `token` und `expires_in`
|
||||||
|
- And ein Session-Cookie mit dem Token wird gesetzt
|
||||||
|
- And der Login ist ohne OIDC-Konfiguration als Schnellstart moeglich
|
||||||
|
- And PAM-Login bleibt auch bei aktivem OIDC verfuegbar
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000007: PAM login token (Details bei Story-Start)
|
||||||