Compare commits

...

43 Commits

Author SHA1 Message Date
b2edb400aa chore: bump version to 0.2.0 for update-service v1 release 2025-12-31 00:10:33 +01:00
47290d2d8f feat: implement update-service v1 migration and enrollment flow
- added /update/enroll endpoint and enrollment logic
- migrated update client to v1 api endpoints and bearer auth
- implemented remote status reporting in backend and scripts
- updated requirements and project status
2025-12-31 00:05:46 +01:00
fde2825112 planning: add update v1 tasks 2025-12-30 17:25:32 +01:00
f639e3c56a planning: update-service v1 epic and stories 2025-12-30 14:26:21 +01:00
e75a989c54 ui: refine login and panels 2025-12-30 13:51:10 +01:00
044203e332 ui: add logo and favicon 2025-12-30 13:40:11 +01:00
725b67d734 ui: place oidc button near login 2025-12-30 13:36:01 +01:00
f27e423ef3 ui: add login routing and docs 2025-12-30 13:25:15 +01:00
b12e0bf4d5 docs: log login landing change 2025-12-30 13:16:57 +01:00
5e7eb6a6e7 ui: login landing and header version 2025-12-30 13:15:25 +01:00
49adf77808 feat: replace Watchtower theme with OIDC professional design
- Remove Watchtower theme CSS files (tokens_watchtower.css, theme_watchtower.css)
- Add OIDC styles.css (professional IT asset management design)
- Add dark mode toggle button (moon/sun icon)
- Remove custom CSS in favor of OIDC styles.css
- Add dark mode persistence with localStorage
- Remove bg-noise div (Watchtower-specific)
- Wrap content in .container div for OIDC layout

Benefits:
- Professional gradient header
- Light/Dark mode toggle
- Better color palette for readability
- Consistent with homelab-service-oidc design

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 12:32:57 +01:00
73ade70f16 feat: modernize web UI with dashboard and enhanced UX
- Add Lucide Icons library integration
- Implement metrics dashboard with 4 cards (users count, active, locked, version)
- Replace text-based user list with proper table including status badges
- Add toast notification system with slide-in animation
- Implement loading states with spinners for async operations
- Add action modal for user disable/enable operations
- Add status badges (success/warning/error/neutral) throughout
- Enhance CSS with hover effects, transitions, and modern styling
- Improve visual hierarchy with card-based layout
- Add icon-based actions for better UX

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 12:25:38 +01:00
efd006276d feat: implement Watchtower theme for web UI
Replace Pico CSS with custom Watchtower theme (Sci-Fi Dark Mode):
- Add backend/static/ directory with Watchtower CSS files
- tokens_watchtower.css: Design tokens (colors, typography, spacing)
- theme_watchtower.css: Theme overrides for components

Features:
- High-contrast dark mode (#0b0f14 background)
- Neon accent color (#00e08f teal/cyan)
- Glow effects on buttons and inputs
- Radial gradient background
- SVG noise texture overlay (bg-noise class)
- Pill-shaped buttons (border-radius: 999px)
- Uppercase labels with letter-spacing
- Monospace font for logs

UI Enhancements:
- FastAPI static files mount added
- Custom styling for all components (sections, buttons, inputs, forms)
- Accent border-left on header and update status
- Improved visual hierarchy with color coding
- OIDC button gets secondary style (outline)

Complete US_000024 and TASK_000024.
Based on minecraft-watchtower UI design.
All Pico CSS dependencies removed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 11:59:24 +01:00
f7b1a7987d Merge feature/update-client: Complete EPIC_000008 Client-Side Update Mechanism 2025-12-30 11:38:54 +01:00
d799c0f042 feat: implement update management web UI
Add complete update management interface to web UI:
- Update status display (current version, last status, timestamp, errors)
- Check for updates button with availability indicator
- Apply update button with confirmation dialog and backup warnings
- Rollback button with confirmation dialog
- Update logs viewer (collapsible, reverse chronological)
- Auto-refresh after update/rollback actions (5s delay)

Features:
- Uses existing Pico CSS framework for consistent styling
- Integrates with existing auth system (Bearer token/session cookies)
- Real-time feedback with loading states and error handling
- German UI language matching existing interface
- All API calls use existing api() helper function

Complete US_000029-033 and TASK_000030-034:
- US_000029: Display update status in web UI
- US_000030: Trigger update check from UI
- US_000031: Apply updates from UI
- US_000032: Display update logs in UI
- US_000033: Trigger rollback from UI

EPIC_000008 (Client-Side Update Mechanism) now fully complete.
Documentation updated per SOP (CHANGELOG, PROJECT_STATUS, stories/tasks).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 11:32:39 +01:00
107cdabe8d feat: implement update backend API and client logic
Add complete update mechanism for client-side updates:
- backend/update.py: Core update logic (check, apply, rollback, status/logs)
- backend/app.py: REST API endpoints (GET /update/status, POST /update/check, POST /update/apply, POST /update/rollback, GET /update/logs)
- backend/models.py: Pydantic models for update API responses
- backend/settings.py: Update config (status/log file paths)
- scripts/rollback_client.sh: Rollback script for failed updates
- scripts/update_client.sh: Enhanced update client script
- CLAUDE.md: Documentation for future Claude Code instances

Complete US_000026-028 and TASK_000027-029:
- US_000026: Client pulls updates from remote service
- US_000027: Client verifies and applies updates atomically
- US_000028: Client reports update status to backend

All endpoints require authentication. Updates run asynchronously.
Documentation updated per SOP (CHANGELOG, PROJECT_STATUS, stories/tasks).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 11:18:34 +01:00
b64cc5981c docs: define update status/log files 2025-12-29 00:22:16 +01:00
fde207a3e6 docs: define update backend api 2025-12-29 00:08:16 +01:00
2fc189a467 docs: start update ui stories 2025-12-29 00:01:22 +01:00
312365a5f6 docs: require backup before update/rollback 2025-12-28 23:59:26 +01:00
132604de2d code: report update status 2025-12-28 23:51:20 +01:00
37087e7925 docs: define update status reporting 2025-12-28 23:51:01 +01:00
c41a4560c1 code: add update client prototype script 2025-12-28 23:44:19 +01:00
2d829b74dd docs: add update client flow 2025-12-28 23:44:00 +01:00
b508f5c58c code: load update client config 2025-12-28 23:39:20 +01:00
8a16a8633b docs: add update client config keys 2025-12-28 23:39:10 +01:00
2aa04795b3 docs: start update client stories 2025-12-28 23:32:09 +01:00
c004aeffa7 docs: add update service requirements 2025-12-28 23:20:31 +01:00
2c36744ea8 docs: add update client stories 2025-12-28 23:15:51 +01:00
3caff1f6e2 docs: define update service url 2025-12-28 22:57:37 +01:00
2d1ca05bf0 feature: root service and account status 2025-12-28 22:35:34 +01:00
b566055d0a auth: improve pam flow and user status 2025-12-28 16:47:43 +01:00
5c9ef1ddcb auth: add Debian/Ubuntu PAM service skd 2025-12-28 14:49:36 +01:00
098ba7b187 docs: update changelog for installer defaults 2025-12-28 14:04:32 +01:00
a4f043ea96 ops: seed env defaults in installer 2025-12-28 14:03:09 +01:00
dffc0d79e4 Merge branch 'feature/oidc-validation' 2025-12-28 13:58:59 +01:00
e380288755 code: keep pam enabled and gate oidc 2025-12-28 13:49:30 +01:00
97ea7070cc docs: enforce pam always available 2025-12-28 13:49:22 +01:00
4809027227 docs: note oidc pr preparation 2025-12-28 13:37:43 +01:00
98d51655ba docs: record oidc validation blocked 2025-12-28 13:32:08 +01:00
a9c57caaf1 docs: add oidc validation runbook 2025-12-28 13:20:59 +01:00
4eb20e2449 docs: add project management structure 2025-12-28 09:16:22 +01:00
3991362e67 feature: add oidc login flow 2025-12-28 09:16:16 +01:00
154 changed files with 9001 additions and 150 deletions

16
.gitignore vendored Normal file
View 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

View File

@ -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
View 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
View 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
View 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.

View File

@ -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

View File

@ -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).

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.2.0

View 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
View 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

View 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)
)
```

View 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);
}

View 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;
}

View File

@ -133,8 +133,14 @@ def disable_user(
if play_sound: if play_sound:
_play_sound_if_available() _play_sound_if_available()
try:
_run(["sudo", "pkill", "-KILL", "-u", user]) _run(["sudo", "pkill", "-KILL", "-u", user])
steps.append("sessions terminated") 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"])

View File

@ -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})

View File

@ -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
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token") if auth_header and auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip() token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.cookies.get(settings.session_cookie_name)
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
return decode_token(token, settings) 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(("!", "*"))

View File

@ -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
View 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")

View File

@ -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

View File

@ -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
View 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;
}

View File

@ -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>
<header> <!-- Dark Mode Toggle -->
<h1>Safe Kiddo Control</h1> <button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<p>Steuere Nutzerkonten über die lokale API. Stelle sicher, dass der API-Token gesetzt ist.</p> <svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
</header> <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div id="loginLanding" class="login-landing">
<div class="login-card">
<div class="login-brand">
<i data-lucide="shield-check"></i>
<div>
<h1>Safe Kiddo Control</h1>
</div>
</div>
<section id="loginSection">
<h3><i data-lucide="log-in"></i> Anmeldung</h3>
<section>
<h3>Login (nur Root-User)</h3>
<form id="loginForm"> <form id="loginForm">
<div class="grid"> <div class="grid">
<div> <div class="form-group">
<label for="loginUser">Benutzer</label> <label for="loginUser">Benutzer</label>
<input id="loginUser" name="loginUser" autocomplete="username" required /> <input id="loginUser" name="loginUser" autocomplete="username" required />
</div> </div>
<div> <div class="form-group">
<label for="loginPass">Passwort</label> <label for="loginPass">Passwort</label>
<input id="loginPass" name="loginPass" type="password" autocomplete="current-password" required /> <input id="loginPass" name="loginPass" type="password" autocomplete="current-password" required />
</div> </div>
</div> </div>
<button type="submit">Anmelden</button> <div class="login-actions">
<button type="submit">
<i data-lucide="log-in"></i>
Anmelden
</button>
<button id="oidcLogin" type="button" class="secondary" disabled>
<i data-lucide="key-round"></i>
OIDC Login
</button>
</div>
</form> </form>
<div id="loginStatus" class="log"></div> </section>
</div>
</div>
<div id="appContainer" class="container hidden">
<!-- Header -->
<header>
<div class="header-main">
<h1>
<i data-lucide="shield-check"></i>
Safe Kiddo Control
</h1>
<img class="header-logo" src="/assets/branding/logo.svg" alt="Safe Kiddo Logo" />
</div>
<div class="header-meta">
<div class="header-version" id="headerVersion">v-</div>
<div class="user-info" id="headerStatus">
<span id="currentUser">Nicht angemeldet</span>
</div>
</div>
</header>
<!-- Metrics Dashboard -->
<div class="metrics">
<div class="metric-card" id="metricUsers">
<div class="label"><i data-lucide="users"></i> Verwaltbare Nutzer</div>
<div class="value">-</div>
</div>
<div class="metric-card" id="metricActive">
<div class="label"><i data-lucide="user-check"></i> Aktive Konten</div>
<div class="value">-</div>
</div>
<div class="metric-card warning" id="metricLocked">
<div class="label"><i data-lucide="lock"></i> Gesperrte Konten</div>
<div class="value">-</div>
</div>
<div class="metric-card" id="metricVersion">
<div class="label"><i data-lucide="package"></i> Version</div>
<div class="value text-muted" style="font-size: 1.25rem;">-</div>
</div>
</div>
<!-- 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 -->
<div class="toast-container" id="toastContainer"></div>
<!-- Action Modal -->
<div class="modal" id="actionModal">
<div class="modal-content">
<div class="modal-header">
<h3>Aktion ausführen</h3> <h3>Aktion ausführen</h3>
<button class="secondary small" onclick="closeActionModal()">
<i data-lucide="x"></i>
</button>
</div>
<form id="actionForm"> <form id="actionForm">
<div class="grid"> <input type="hidden" id="modalUsername" />
<div> <input type="hidden" id="modalAction" />
<label for="username">Benutzer</label>
<select id="username" name="username" required> <div class="form-group">
<option value="">-- wählen --</option> <label for="countdown">Countdown (Sekunden)</label>
</select> <input id="countdown" name="countdown" type="number" min="0" value="60" />
</div> </div>
<div>
<label for="action">Aktion</label> <div class="form-group">
<select id="action" name="action"> <label for="sound">Sound abspielen</label>
<option value="disable">Disable</option>
<option value="enable">Enable</option>
</select>
</div>
</div>
<div class="grid">
<div>
<label for="countdown">Countdown (Sekunden, optional)</label>
<input id="countdown" name="countdown" type="number" min="0" />
</div>
<div>
<label for="sound">Sound</label>
<select id="sound" name="sound"> <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>
<div class="form-group">
<label for="message">Nachricht (optional)</label> <label for="message">Nachricht (optional)</label>
<input id="message" name="message" /> <input id="message" name="message" placeholder="z.B. Speichere deine Arbeit!" />
<button type="submit">Senden</button> </div>
<div class="modal-actions">
<button type="button" class="secondary" onclick="closeActionModal()">Abbrechen</button>
<button type="submit" id="actionSubmitBtn">
<i data-lucide="play"></i>
Ausführen
</button>
</div>
</form> </form>
<div id="result" class="log"></div> </div>
</section> </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 originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="spinner"></div> Anmeldung...';
btn.disabled = true;
try {
const username = document.getElementById('loginUser').value.trim(); const username = document.getElementById('loginUser').value.trim();
const password = document.getElementById('loginPass').value; const password = document.getElementById('loginPass').value;
try {
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 btn = document.getElementById('actionSubmitBtn');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="spinner"></div> Sende...';
btn.disabled = true;
try {
const body = {}; const body = {};
if (countdown) body.countdown = Number(countdown); if (countdown) body.countdown = Number(countdown);
if (sound === 'true') body.sound = true; if (sound === 'true') body.sound = true;
if (sound === 'false') body.sound = false; if (sound === 'false') body.sound = false;
if (message) body.message = message; if (message) body.message = message;
try {
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
View 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

View 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.

View 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'

View File

@ -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'

View File

@ -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'

View 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'

View 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

View 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

View 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

View 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

View 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

View 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

View 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...

View 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

View File

@ -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

View File

@ -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'

View 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

View 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

View File

@ -0,0 +1,10 @@
type: object
required:
- tier
- limits
properties:
tier:
type: string
enum: [small, medium, large]
limits:
$ref: './limits.yaml'

View 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

View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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.

View File

@ -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

View 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.

View 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).

View 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).

View 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)
)
```

View 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.

View 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);
}

View 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

View 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

View 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

View 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

View 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

View 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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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);
}

View 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;
}

View 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! 👻☕

View 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! 👻☕"
```

View 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.

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

Some files were not shown because too many files have changed in this diff Show More