Compare commits
20 Commits
132604de2d
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
| b2edb400aa | |||
| 47290d2d8f | |||
| fde2825112 | |||
| f639e3c56a | |||
| e75a989c54 | |||
| 044203e332 | |||
| 725b67d734 | |||
| f27e423ef3 | |||
| b12e0bf4d5 | |||
| 5e7eb6a6e7 | |||
| 49adf77808 | |||
| 73ade70f16 | |||
| efd006276d | |||
| f7b1a7987d | |||
| d799c0f042 | |||
| 107cdabe8d | |||
| b64cc5981c | |||
| fde207a3e6 | |||
| 2fc189a467 | |||
| 312365a5f6 |
23
CHANGELOG.md
@ -1,4 +1,4 @@
|
|||||||
ID: DOC_000001 | Version: 0.1.0 | Status: Final
|
ID: DOC_000001 | Version: 0.1.5 | Status: Final
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# Projekt-Logbuch (Changelog)
|
# Projekt-Logbuch (Changelog)
|
||||||
@ -27,7 +27,28 @@ By: Codex (GPT-5)
|
|||||||
| 28.12.2025 | 📝 Req | ID: Update-Flow Prototyp 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 | 📝 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 | ⚙️ 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) |
|
| 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
|
## Legende
|
||||||
|
|||||||
145
CLAUDE.md
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Safe Kiddo Daemon (SKD) is a FastAPI-based service for managing local user accounts on kids' laptops. It provides account locking/unlocking with countdown notifications, optional sound alerts, and shutdown capabilities. The service exposes a REST API with bearer token auth (PAM or OIDC) and serves a minimal web UI.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
# Run the service manually (uses .venv, binds to 0.0.0.0:80)
|
||||||
|
./scripts/run.sh
|
||||||
|
|
||||||
|
# Install service and dependencies
|
||||||
|
sudo make install
|
||||||
|
|
||||||
|
# Service management
|
||||||
|
sudo make up # Start service
|
||||||
|
sudo make down # Stop service
|
||||||
|
sudo make update # Pull latest from git, reinstall deps, restart
|
||||||
|
|
||||||
|
# Generate/set API token
|
||||||
|
make token
|
||||||
|
|
||||||
|
# Health check (requires token)
|
||||||
|
make healthcheck
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installation & Deployment
|
||||||
|
```bash
|
||||||
|
# Full install (creates service user, venv, systemd unit, PAM config)
|
||||||
|
sudo ./scripts/install.sh
|
||||||
|
|
||||||
|
# Deploy to remote host (requires deploy_hosts.yml)
|
||||||
|
./scripts/deploy.sh <host-name>
|
||||||
|
|
||||||
|
# Manual update on target
|
||||||
|
ssh user@target 'cd /opt/sk && ./scripts/update.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Python syntax validation
|
||||||
|
python -m py_compile backend/*.py
|
||||||
|
|
||||||
|
# Run specific tests (no formal test runner yet; tests/ is empty)
|
||||||
|
# Use curl for API testing:
|
||||||
|
token=$(curl -s -X POST -H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"root","password":"..."}' \
|
||||||
|
http://localhost/login | jq -r .token)
|
||||||
|
curl -H "Authorization: Bearer $token" http://localhost/users
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Structure
|
||||||
|
- `backend/`: FastAPI application
|
||||||
|
- `app.py`: Main FastAPI app with route handlers
|
||||||
|
- `actions.py`: User management actions (lock/unlock, notifications, shutdown logic)
|
||||||
|
- `auth.py`: PAM authentication, JWT tokens, user/group authorization checks
|
||||||
|
- `oidc.py`: OIDC client (dynamic discovery, token exchange, claims validation)
|
||||||
|
- `update.py`: Update client logic (check/status/logs, triggers async update/rollback scripts)
|
||||||
|
- `settings.py`: Environment-based configuration (Settings class, singleton via lru_cache)
|
||||||
|
- `models.py`: Pydantic models for API requests/responses
|
||||||
|
- `templates/`: Jinja2 templates for web UI
|
||||||
|
- `scripts/`: Deployment and lifecycle scripts
|
||||||
|
- `install.sh`: System setup (user, venv, systemd, PAM config)
|
||||||
|
- `run.sh`: Manual service start
|
||||||
|
- `update.sh`: Local git pull and service restart
|
||||||
|
- `update_client.sh`: Full update flow with backup/rollback
|
||||||
|
- `rollback_client.sh`: Restore from backup if update fails
|
||||||
|
- `deploy.sh`: SSH-based deployment to remote hosts
|
||||||
|
- `register_oidc_client.sh`: OIDC dynamic client registration helper
|
||||||
|
- `sk.sh`: Legacy bash script (CLI fallback for direct SSH use)
|
||||||
|
- `src/`: Hexagonal architecture skeleton (core/ports/adapters/ui) - currently empty placeholders
|
||||||
|
- `docs/`: Detailed specs for OIDC validation, update API, status/log formats
|
||||||
|
- `Makefile`: Convenience targets for install, service control, updates, token management
|
||||||
|
|
||||||
|
### Key Architectural Patterns
|
||||||
|
|
||||||
|
**Dual Authentication**: PAM-based local auth (root/sudo users) is always available; OIDC is optional if `SKD_OIDC_ISSUER`, `SKD_OIDC_CLIENT_ID`, and `SKD_OIDC_CLIENT_SECRET` are configured. Both modes issue JWT bearer tokens.
|
||||||
|
|
||||||
|
**Settings Management**: All config via environment variables (loaded from `/etc/skd/env` in production). `settings.py` provides a singleton `Settings` instance via `get_settings()` using `lru_cache`. FastAPI dependencies inject settings into route handlers.
|
||||||
|
|
||||||
|
**Action Execution**: `actions.py` wraps all privileged operations (usermod, pkill, shutdown) via `_run()` helper. Dry-run mode (`SKD_DRY_RUN=true`) logs commands without executing them.
|
||||||
|
|
||||||
|
**Update Flow**: `update.py` checks remote update service for new versions, writes status to JSON files, and triggers async scripts (`update_client.sh`, `rollback_client.sh`) that create backups, apply updates, and handle rollbacks on failure.
|
||||||
|
|
||||||
|
**Authorization**: `auth.py` checks both user allowlists (`SKD_AUTH_ALLOWED_USERS`) and group membership (`SKD_AUTH_ALLOWED_GROUPS`, defaults to `sudo`). UID 0 (root) always allowed for PAM. OIDC validates against `preferred_username`, `email`, or `sub` claims.
|
||||||
|
|
||||||
|
**Manageable Users**: Only system users with UID >= 1000, real shells (not nologin/false), and optional allowlist (`SKD_ALLOWED_USERS`) are exposed via API. Root accounts are never manageable.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Deployment config lives in `/etc/skd/env` (see `env.example` in repo root):
|
||||||
|
- `SKD_AUTH_SECRET`: HMAC secret for JWT signing (must be strong in production)
|
||||||
|
- `SKD_AUTH_ALLOWED_USERS`: Comma-separated user allowlist (for login and OIDC claims)
|
||||||
|
- `SKD_AUTH_ALLOWED_GROUPS`: Groups whose members may log in (PAM only, default `sudo`)
|
||||||
|
- `SKD_AUTH_PAM_SERVICE`: PAM service name (Ubuntu/Debian use `skd`, others may use `login`)
|
||||||
|
- `SKD_OIDC_*`: OIDC provider config (ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, SCOPES)
|
||||||
|
- `SKD_ALLOWED_USERS`: Comma-separated list of manageable system accounts (optional)
|
||||||
|
- `SKD_DRY_RUN`: If `true`, logs all privileged commands without executing
|
||||||
|
- `SKD_UPDATE_*`: Update service URL, token, interval, status/log file paths
|
||||||
|
|
||||||
|
## Important Workflows
|
||||||
|
|
||||||
|
### Disable User Flow
|
||||||
|
1. API call to `/users/{username}/disable` with optional `{countdown, sound, message}`
|
||||||
|
2. `actions.disable_user()` locks account via `usermod -L`
|
||||||
|
3. If user logged in: sends desktop notifications, plays sound (if enabled), countdown loop with periodic reminders
|
||||||
|
4. Terminates sessions via `pkill -KILL -u`
|
||||||
|
5. Triggers `shutdown now` only if user was logged in
|
||||||
|
|
||||||
|
### OIDC Login Flow
|
||||||
|
1. User accesses `/login/oidc/start` → redirected to provider with state cookie
|
||||||
|
2. Provider redirects to `/login/oidc/callback` with code + state
|
||||||
|
3. Validates state, exchanges code for tokens, extracts username from claims
|
||||||
|
4. Issues JWT session cookie if user in allowlist
|
||||||
|
|
||||||
|
### Update Flow
|
||||||
|
1. `check_update()` polls remote update service for latest manifest (version, artifact_url, sha256)
|
||||||
|
2. `start_update()` writes "in_progress" status, launches `update_client.sh` in background
|
||||||
|
3. Script creates backup, downloads artifact, verifies checksum, installs, restarts service
|
||||||
|
4. On failure: `rollback_client.sh` restores from backup
|
||||||
|
5. Status/logs written to JSON files at `SKD_UPDATE_STATUS_FILE` and `SKD_UPDATE_LOG_FILE`
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Service runs as root by default (required for PAM, usermod, pkill, shutdown). Limit exposure via firewall.
|
||||||
|
- Set strong `SKD_AUTH_SECRET` and rotate by changing value + restarting service.
|
||||||
|
- Restrict API/Web UI to LAN/VPN; consider mTLS or IP allowlisting.
|
||||||
|
- `skd` user/group created by install script; consider sudoers rules to limit privileges to specific commands.
|
||||||
|
- OIDC redirect URI must match exactly (no wildcards); re-register client if host/port changes.
|
||||||
|
- Validate TLS certificates in production; self-signed certs require CA trust or fallback to PAM.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Legacy `sk.sh` remains for emergency CLI fallback; API is preferred for all operations.
|
||||||
|
- `src/` hexagonal architecture skeleton is currently unused; logic lives in `backend/`.
|
||||||
|
- `tests/` directory exists but is empty; use manual curl-based API testing.
|
||||||
|
- Both German and English comments exist in code; favor English going forward.
|
||||||
|
- Deployment via `deploy.sh` supports both YAML (`deploy_hosts.yml`) and JSON host configs.
|
||||||
|
- PAM service file (`/etc/pam.d/skd`) created by `scripts/install.sh` on Ubuntu/Debian; other distros may need manual setup.
|
||||||
@ -37,6 +37,8 @@ Set in `/etc/skd/env` (see `env.example`):
|
|||||||
- `SKD_UPDATE_TOKEN` (API token for update service)
|
- `SKD_UPDATE_TOKEN` (API token for update service)
|
||||||
- `SKD_UPDATE_INTERVAL` (seconds; default 3600)
|
- `SKD_UPDATE_INTERVAL` (seconds; default 3600)
|
||||||
- `SKD_UPDATE_STATUS_URL` (default `https://update.wlkns.org/status`)
|
- `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.
|
||||||
|
|
||||||
|
|||||||
7
assets/branding/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#1F2A37"/>
|
||||||
|
<rect x="12" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||||
|
<rect x="40" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||||
|
<!-- Teal Keystone -->
|
||||||
|
<rect x="26" y="29" width="12" height="19" rx="2" fill="#0EA5A4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 428 B |
15
assets/branding/logo.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<svg width="250" height="60" viewBox="0 0 250 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Monolithic Symbol (V2 Optimized - Teal) -->
|
||||||
|
<g transform="translate(10, 10)">
|
||||||
|
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||||
|
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||||
|
<!-- Middle Block: Teal, 19px Height -->
|
||||||
|
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Wordmark -->
|
||||||
|
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
|
||||||
|
|
||||||
|
<!-- The Underline: Teal, aligned like V8 -->
|
||||||
|
<rect x="56" y="48" width="121" height="4" rx="1" fill="#0EA5A4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 764 B |
@ -3,6 +3,7 @@ from typing import List
|
|||||||
|
|
||||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
|
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
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
|
||||||
@ -15,9 +16,22 @@ from backend.auth import (
|
|||||||
issue_token,
|
issue_token,
|
||||||
list_manageable_users,
|
list_manageable_users,
|
||||||
)
|
)
|
||||||
from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
|
from backend.models import (
|
||||||
|
ActionRequest,
|
||||||
|
ActionResponse,
|
||||||
|
EnrollRequest,
|
||||||
|
EnrollResponse,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
UpdateActionResponse,
|
||||||
|
UpdateCheckResponse,
|
||||||
|
UpdateLogEntry,
|
||||||
|
UpdateStatus,
|
||||||
|
UserStatus,
|
||||||
|
)
|
||||||
from backend.oidc import OIDCClient, OIDCError
|
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,
|
||||||
@ -26,6 +40,8 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
@ -219,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})
|
||||||
|
|||||||
@ -34,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
|
||||||
|
|||||||
@ -40,11 +40,23 @@ class Settings:
|
|||||||
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_url: str = os.getenv("SKD_UPDATE_URL", "https://update.wlkns.org")
|
||||||
self.update_token: str = os.getenv("SKD_UPDATE_TOKEN", "")
|
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_interval: int = int(os.getenv("SKD_UPDATE_INTERVAL", "3600"))
|
||||||
self.update_status_url: str = os.getenv(
|
self.update_status_url: str = os.getenv(
|
||||||
"SKD_UPDATE_STATUS_URL", "https://update.wlkns.org/status"
|
"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")
|
||||||
@ -57,6 +69,18 @@ class Settings:
|
|||||||
def _parse_list(value: str) -> List[str]:
|
def _parse_list(value: str) -> List[str]:
|
||||||
return [item for item in (part.strip() for part in value.split(",")) if item]
|
return [item for item in (part.strip() for part in value.split(",")) if item]
|
||||||
|
|
||||||
|
def _load_update_token(self) -> str:
|
||||||
|
env_token = os.getenv("SKD_UPDATE_TOKEN", "")
|
||||||
|
if env_token:
|
||||||
|
return env_token
|
||||||
|
if os.path.exists(self.update_token_file):
|
||||||
|
try:
|
||||||
|
with open(self.update_token_file, "r", encoding="utf-8") as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
901
backend/static/styles.css
Executable file
@ -0,0 +1,901 @@
|
|||||||
|
/* Professional IT Asset Management - Best of Both Worlds with Dark Mode */
|
||||||
|
:root {
|
||||||
|
--bg-main: #f5f7fa;
|
||||||
|
--bg-panel: #ffffff;
|
||||||
|
--bg-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--text-main: #2d3748;
|
||||||
|
--text-secondary: #718096;
|
||||||
|
--text-light: #a0aec0;
|
||||||
|
--border-main: #e2e8f0;
|
||||||
|
--primary: #667eea;
|
||||||
|
--primary-hover: #5568d3;
|
||||||
|
--success: #48bb78;
|
||||||
|
--success-hover: #38a169;
|
||||||
|
--warning: #ed8936;
|
||||||
|
--danger: #f56565;
|
||||||
|
--danger-hover: #e53e3e;
|
||||||
|
--info: #4299e1;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode Theme */
|
||||||
|
body.dark-mode {
|
||||||
|
--bg-main: #1a202c;
|
||||||
|
--bg-panel: #2d3748;
|
||||||
|
--bg-header: linear-gradient(135deg, #4c51bf 0%, #6b46c1 100%);
|
||||||
|
--text-main: #f7fafc;
|
||||||
|
--text-secondary: #cbd5e0;
|
||||||
|
--text-light: #a0aec0;
|
||||||
|
--border-main: #4a5568;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0,0,0,0.3);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.6;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Toggle Button */
|
||||||
|
.theme-toggle {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 30px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
stroke: white;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle .sun-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .theme-toggle .moon-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .theme-toggle .sun-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-landing {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: radial-gradient(circle at 20% 10%, rgba(102, 126, 234, 0.15), transparent 45%),
|
||||||
|
radial-gradient(circle at 80% 20%, rgba(72, 187, 120, 0.12), transparent 40%),
|
||||||
|
var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand i {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header - Keep the gradient but more professional */
|
||||||
|
header {
|
||||||
|
background: var(--bg-header);
|
||||||
|
color: white;
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.95;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main i {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
height: 34px;
|
||||||
|
width: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-version {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Statistics Dashboard - More visual interest */
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: var(--primary);
|
||||||
|
opacity: 0.05;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(30%, -30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border-left-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls - Clean but distinctive */
|
||||||
|
.controls {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs - More refined */
|
||||||
|
input, select {
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 2px solid var(--border-main);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons - Keep gradient feel but professional */
|
||||||
|
button {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 11px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background: var(--success-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background: var(--danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Container - More polished */
|
||||||
|
.table-container {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--bg-main);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 2px solid var(--border-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-main);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
td strong {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges - More colorful but professional */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-available {
|
||||||
|
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
|
||||||
|
color: #22543d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-in_use {
|
||||||
|
background: linear-gradient(135deg, #bee3f8 0%, #90cdf4 100%);
|
||||||
|
color: #2c5282;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-maintenance {
|
||||||
|
background: linear-gradient(135deg, #feebc8 0%, #fbd38d 100%);
|
||||||
|
color: #7c2d12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-retired {
|
||||||
|
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%);
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-lost {
|
||||||
|
background: linear-gradient(135deg, #fed7d7 0%, #fc8181 100%);
|
||||||
|
color: #742a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal - Elegant overlay */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 2px solid var(--border-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: var(--border-main);
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form - Clean and accessible */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 2px solid var(--border-main);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 2px solid var(--border-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading & Empty States */
|
||||||
|
.loading, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state svg {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
opacity: 0.4;
|
||||||
|
stroke: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons button {
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Import Zone - Visual and inviting */
|
||||||
|
.import-zone {
|
||||||
|
border: 3px dashed var(--border-main);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-zone:hover, .import-zone.drag-over {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-zone.processing {
|
||||||
|
border-color: var(--success);
|
||||||
|
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-zone svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
stroke: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-zone h3 {
|
||||||
|
color: var(--text-main);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-zone p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Import Results */
|
||||||
|
.import-results {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-results.success {
|
||||||
|
background: #c6f6d5;
|
||||||
|
border-color: var(--success);
|
||||||
|
color: #22543d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-results.error {
|
||||||
|
background: #fed7d7;
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: #742a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar - More visual */
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--border-main);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary) 0%, var(--info) 100%);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NEW STYLES FOR ENHANCED UI */
|
||||||
|
|
||||||
|
.error-card {
|
||||||
|
background-color: var(--danger);
|
||||||
|
color: white;
|
||||||
|
border-left-color: var(--danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card h3 {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-left-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card details {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card summary:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flags-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flags-list li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flags-list li::before {
|
||||||
|
content: '›';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value.level-high {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value.level-medium {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value.level-low {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: var(--primary);
|
||||||
|
opacity: 0.05;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(30%, -30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border-left-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card .value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-section {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-section h3 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
@ -4,105 +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</h3>
|
|
||||||
<p>Bevorzugt OIDC nutzen, falls konfiguriert. Die Anmeldung öffnet den Identity Provider und setzt eine Session-Cookie.</p>
|
|
||||||
<button id="oidcLogin" type="button">Login via OIDC</button>
|
|
||||||
<hr />
|
|
||||||
<p>Lokale Anmeldung (PAM) ist immer moeglich:</p>
|
|
||||||
<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 (Token gespeichert)';
|
|
||||||
} 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}`;
|
||||||
@ -114,76 +229,265 @@
|
|||||||
const res = await fetch(path, { ...options, headers, credentials: 'same-origin' });
|
const res = await fetch(path, { ...options, headers, credentials: 'same-origin' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const error = new Error(text || `${res.status} ${res.statusText}`);
|
throw new Error(text || `${res.status} ${res.statusText}`);
|
||||||
error.status = res.status;
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toast notifications
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.innerHTML = `
|
||||||
|
<i data-lucide="${type === 'success' ? 'check-circle' : type === 'error' ? 'alert-circle' : 'alert-triangle'}"></i>
|
||||||
|
<span>${message}</span>
|
||||||
|
`;
|
||||||
|
container.appendChild(toast);
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.animation = 'slideIn 0.3s ease reverse';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal functions
|
||||||
|
function openActionModal(username, action) {
|
||||||
|
document.getElementById('modalUsername').value = username;
|
||||||
|
document.getElementById('modalAction').value = action;
|
||||||
|
document.getElementById('actionModal').classList.add('active');
|
||||||
|
|
||||||
|
const title = document.querySelector('#actionModal h3');
|
||||||
|
title.textContent = action === 'disable' ? `${username} deaktivieren` : `${username} aktivieren`;
|
||||||
|
|
||||||
|
// Hide countdown/sound options for enable action
|
||||||
|
const countdownGroup = document.getElementById('countdown').closest('.form-group');
|
||||||
|
const soundGroup = document.getElementById('sound').closest('.form-group');
|
||||||
|
const messageGroup = document.getElementById('message').closest('.form-group');
|
||||||
|
|
||||||
|
if (action === 'enable') {
|
||||||
|
countdownGroup.style.display = 'none';
|
||||||
|
soundGroup.style.display = 'none';
|
||||||
|
messageGroup.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
countdownGroup.style.display = 'block';
|
||||||
|
soundGroup.style.display = 'block';
|
||||||
|
messageGroup.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeActionModal() {
|
||||||
|
document.getElementById('actionModal').classList.remove('active');
|
||||||
|
document.getElementById('actionForm').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
function setPath(path) {
|
||||||
|
if (window.location.pathname !== path) {
|
||||||
|
history.replaceState(null, '', path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLanding() {
|
||||||
|
document.getElementById('loginLanding').classList.remove('hidden');
|
||||||
|
document.getElementById('appContainer').classList.add('hidden');
|
||||||
|
document.getElementById('headerVersion').textContent = 'v-';
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
setPath('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApp() {
|
||||||
|
document.getElementById('loginLanding').classList.add('hidden');
|
||||||
|
document.getElementById('appContainer').classList.remove('hidden');
|
||||||
|
setPath('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
async function checkSession() {
|
async function checkSession() {
|
||||||
try {
|
try {
|
||||||
const data = await api('/me');
|
const data = await api('/me');
|
||||||
loginStatus.textContent = `Angemeldet als ${data.user} (${data.auth_mode})`;
|
currentToken = sessionStorage.getItem('skdToken') || currentToken;
|
||||||
|
document.getElementById('currentUser').textContent = `Angemeldet als ${data.user} (${data.auth_mode})`;
|
||||||
|
showApp();
|
||||||
|
document.getElementById('userSection').classList.remove('hidden');
|
||||||
|
document.getElementById('updateSection').classList.remove('hidden');
|
||||||
|
await refreshUsers();
|
||||||
|
await refreshUpdateStatus();
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401) {
|
document.getElementById('currentUser').textContent = 'Nicht angemeldet';
|
||||||
loginStatus.textContent = 'Nicht angemeldet';
|
showLanding();
|
||||||
} else {
|
document.getElementById('userSection').classList.add('hidden');
|
||||||
loginStatus.textContent = `Session-Check fehlgeschlagen: ${err.message}`;
|
document.getElementById('updateSection').classList.add('hidden');
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkOidcStatus() {
|
async function checkOidcStatus() {
|
||||||
const button = document.getElementById('oidcLogin');
|
|
||||||
try {
|
try {
|
||||||
const data = await api('/login/oidc/status');
|
const data = await api('/login/oidc/status');
|
||||||
if (!data.enabled) {
|
const button = document.getElementById('oidcLogin');
|
||||||
|
if (data.enabled) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.title = 'Login via OIDC';
|
||||||
|
} else {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.title = 'OIDC nicht konfiguriert';
|
button.title = 'OIDC nicht konfiguriert oder nicht erreichbar';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const button = document.getElementById('oidcLogin');
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.title = 'OIDC-Status nicht erreichbar';
|
button.title = 'OIDC nicht erreichbar';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User management
|
||||||
async function refreshUsers() {
|
async function refreshUsers() {
|
||||||
statusDiv.textContent = 'Lade...';
|
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 {
|
try {
|
||||||
const data = await api('/users');
|
const data = await api('/users');
|
||||||
statusDiv.textContent = data.map(u => `${u.user}: ${u.account_locked ? 'deaktiviert' : 'aktiv'}, ${u.logged_in ? 'eingeloggt' : 'aus'}`).join('\n') || 'Keine Daten';
|
|
||||||
const select = document.getElementById('username');
|
// Update metrics
|
||||||
select.innerHTML = '<option value="">-- wählen --</option>';
|
document.querySelector('#metricUsers .value').textContent = data.length;
|
||||||
data.forEach(u => {
|
document.querySelector('#metricActive .value').textContent = data.filter(u => !u.account_locked).length;
|
||||||
const opt = document.createElement('option');
|
document.querySelector('#metricLocked .value').textContent = data.filter(u => u.account_locked).length;
|
||||||
opt.value = u.user;
|
|
||||||
opt.textContent = u.user;
|
if (data.length === 0) {
|
||||||
select.appendChild(opt);
|
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) {
|
} catch (err) {
|
||||||
statusDiv.textContent = `Fehler: ${err.message}`;
|
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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -191,36 +495,163 @@
|
|||||||
window.location.href = '/login/oidc/start';
|
window.location.href = '/login/oidc/start';
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('refreshBtn').addEventListener('click', refreshUsers);
|
document.getElementById('refreshUsersBtn').addEventListener('click', refreshUsers);
|
||||||
|
|
||||||
document.getElementById('actionForm').addEventListener('submit', async (e) => {
|
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();
|
checkSession();
|
||||||
checkOidcStatus();
|
checkOidcStatus();
|
||||||
|
|
||||||
|
// Dark Mode Toggle
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load dark mode preference
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
222
backend/update.py
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from backend.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _project_root() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_version() -> str:
|
||||||
|
try:
|
||||||
|
return (_project_root() / "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _status_path(settings: Settings) -> Path:
|
||||||
|
return Path(settings.update_status_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_path(settings: Settings) -> Path:
|
||||||
|
return Path(settings.update_log_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_parent(path: Path) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_status(settings: Settings, status: str, version: str, error: str | None = None) -> None:
|
||||||
|
status_path = _status_path(settings)
|
||||||
|
_ensure_parent(status_path)
|
||||||
|
payload = {
|
||||||
|
"device_id": os.uname().nodename,
|
||||||
|
"version": version,
|
||||||
|
"status": status,
|
||||||
|
"error": error or "",
|
||||||
|
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
}
|
||||||
|
status_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
_append_log(settings, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_log(settings: Settings, payload: Dict[str, Any]) -> None:
|
||||||
|
log_path = _log_path(settings)
|
||||||
|
_ensure_parent(log_path)
|
||||||
|
with log_path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(payload) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def get_status(settings: Settings) -> Dict[str, Any]:
|
||||||
|
current_version = _read_version()
|
||||||
|
status_path = _status_path(settings)
|
||||||
|
if status_path.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(status_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = {}
|
||||||
|
else:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current_version": current_version,
|
||||||
|
"last_status": data.get("status", "unknown"),
|
||||||
|
"last_error": data.get("error") or None,
|
||||||
|
"last_timestamp": data.get("timestamp"),
|
||||||
|
"enrolled": bool(settings.update_token),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enroll(settings: Settings) -> str:
|
||||||
|
if not settings.update_enroll_token:
|
||||||
|
raise ValueError("No enrollment token provided in settings")
|
||||||
|
|
||||||
|
enroll_url = f"{settings.update_service_url}/v1/enroll"
|
||||||
|
payload = {
|
||||||
|
"project_id": settings.update_project_id,
|
||||||
|
"client_id": os.uname().nodename,
|
||||||
|
"software_id": "safe-kiddo",
|
||||||
|
"enroll_token": settings.update_enroll_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
response = client.post(enroll_url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
token = data.get("token")
|
||||||
|
if not token:
|
||||||
|
raise ValueError("Enrollment response did not contain a token")
|
||||||
|
|
||||||
|
# Save token
|
||||||
|
token_path = Path(settings.update_token_file)
|
||||||
|
_ensure_parent(token_path)
|
||||||
|
token_path.write_text(token, encoding="utf-8")
|
||||||
|
# Update settings object for immediate use
|
||||||
|
settings.update_token = token
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version(value: str) -> List[int]:
|
||||||
|
return [int(part) for part in value.split(".")]
|
||||||
|
|
||||||
|
|
||||||
|
def check_update(settings: Settings) -> Dict[str, Any]:
|
||||||
|
if not settings.update_token:
|
||||||
|
raise ValueError("Client is not enrolled (missing update token)")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {settings.update_token}"}
|
||||||
|
manifest_url = (
|
||||||
|
f"{settings.update_service_url}/v1/projects/{settings.update_project_id}/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
response = client.get(manifest_url, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
manifest = response.json()
|
||||||
|
|
||||||
|
latest_version = manifest.get("version", "")
|
||||||
|
artifact_url = manifest.get("artifact_url", "")
|
||||||
|
sha256 = manifest.get("sha256", "")
|
||||||
|
message = manifest.get("message")
|
||||||
|
|
||||||
|
available = False
|
||||||
|
current_version = _read_version()
|
||||||
|
try:
|
||||||
|
available = _parse_version(latest_version) > _parse_version(current_version)
|
||||||
|
except ValueError:
|
||||||
|
if latest_version and latest_version != current_version:
|
||||||
|
available = True
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": available,
|
||||||
|
"latest_version": latest_version,
|
||||||
|
"artifact_url": artifact_url,
|
||||||
|
"sha256": sha256,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def report_status(
|
||||||
|
settings: Settings,
|
||||||
|
status: str,
|
||||||
|
version: str,
|
||||||
|
error: str | None = None,
|
||||||
|
duration_ms: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not settings.update_token:
|
||||||
|
return
|
||||||
|
|
||||||
|
report_url = (
|
||||||
|
f"{settings.update_service_url}/v1/projects/{settings.update_project_id}/status"
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"project_id": settings.update_project_id,
|
||||||
|
"version": version,
|
||||||
|
"status": status,
|
||||||
|
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"client_id": os.uname().nodename,
|
||||||
|
}
|
||||||
|
if error:
|
||||||
|
payload["error_code"] = error
|
||||||
|
payload["reason"] = error
|
||||||
|
if duration_ms is not None:
|
||||||
|
payload["duration_ms"] = duration_ms
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = {"Authorization": f"Bearer {settings.update_token}"}
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
client.post(report_url, json=payload, headers=headers).raise_for_status()
|
||||||
|
except Exception:
|
||||||
|
# We don't want to crash if status reporting fails
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run_async(script_path: Path, settings: Settings) -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["SKD_UPDATE_SERVICE_URL"] = settings.update_service_url
|
||||||
|
env["SKD_UPDATE_PROJECT_ID"] = settings.update_project_id
|
||||||
|
env["SKD_UPDATE_TOKEN"] = settings.update_token
|
||||||
|
env["SKD_UPDATE_STATUS_FILE"] = settings.update_status_file
|
||||||
|
env["SKD_UPDATE_LOG_FILE"] = settings.update_log_file
|
||||||
|
subprocess.Popen(
|
||||||
|
[str(script_path)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def start_update(settings: Settings, version: str | None = None) -> None:
|
||||||
|
current_version = version or _read_version()
|
||||||
|
_write_status(settings, "in_progress", current_version)
|
||||||
|
report_status(settings, "in_progress", current_version)
|
||||||
|
script = _project_root() / "scripts" / "update_client.sh"
|
||||||
|
_run_async(script, settings)
|
||||||
|
|
||||||
|
|
||||||
|
def start_rollback(settings: Settings) -> None:
|
||||||
|
current_version = _read_version()
|
||||||
|
_write_status(settings, "in_progress", current_version)
|
||||||
|
report_status(settings, "in_progress", current_version)
|
||||||
|
script = _project_root() / "scripts" / "rollback_client.sh"
|
||||||
|
_run_async(script, settings)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logs(settings: Settings, limit: int = 200) -> List[Dict[str, Any]]:
|
||||||
|
log_path = _log_path(settings)
|
||||||
|
if not log_path.exists():
|
||||||
|
return []
|
||||||
|
lines = log_path.read_text(encoding="utf-8").splitlines()
|
||||||
|
entries: List[Dict[str, Any]] = []
|
||||||
|
for line in lines[-limit:]:
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return entries
|
||||||
91
docs/admin-token-operations.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
ID: DOC_000006 | Version: 0.1.0 | Status: Draft
|
||||||
|
|
||||||
|
# Admin Token Operations
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This document describes how operators create and manage pre-shared enrollment tokens for clients.
|
||||||
|
|
||||||
|
## Pre-Shared Token Creation
|
||||||
|
Operators generate a single-use enrollment token and share it out-of-band with the client.
|
||||||
|
|
||||||
|
Recommended properties:
|
||||||
|
- Single-use only
|
||||||
|
- Short TTL (e.g., 24h)
|
||||||
|
- Scoped to `project_id` and optional `client_id`/`software_id`
|
||||||
|
|
||||||
|
## Admin Interfaces
|
||||||
|
We provide both an Admin API and a CLI tool for token operations. A frontend will be added later.
|
||||||
|
|
||||||
|
### Admin User and Access
|
||||||
|
- An admin user must exist to operate token workflows.
|
||||||
|
- Initial access uses a local admin token.
|
||||||
|
- Later, admin auth will be integrated with the OIDC service.
|
||||||
|
|
||||||
|
### CLI and Admin API Capabilities
|
||||||
|
- Create enrollment tokens
|
||||||
|
- List token metadata (no plaintext output)
|
||||||
|
- Revoke tokens
|
||||||
|
- Export a token as a file for client installation
|
||||||
|
|
||||||
|
### Local Admin Token (Initial Phase)
|
||||||
|
- Admin requests must include `Authorization: Bearer <ADMIN_TOKEN>`.
|
||||||
|
- The admin token is stored locally (e.g., `.env`) and never committed.
|
||||||
|
|
||||||
|
Example `.env` (local only):
|
||||||
|
```
|
||||||
|
ADMIN_TOKEN=change-me-please
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimal flow (first token):
|
||||||
|
1) Set `ADMIN_TOKEN` in `.env`.
|
||||||
|
2) Call `POST /v1/admin/enrollment-tokens` with the bearer token.
|
||||||
|
3) Export the returned one-time token to a file and hand it to the client.
|
||||||
|
|
||||||
|
## Admin API (Draft)
|
||||||
|
All admin endpoints are authenticated. Initial auth is local; later OIDC.
|
||||||
|
|
||||||
|
Base path:
|
||||||
|
- `/v1/admin`
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- `POST /v1/admin/enrollment-tokens`
|
||||||
|
- Create a pre-shared enrollment token.
|
||||||
|
- Request: `project_id`, optional `client_id`, optional `software_id`, optional `expires_at`.
|
||||||
|
- Response: token metadata + one-time plaintext token.
|
||||||
|
- `GET /v1/admin/enrollment-tokens`
|
||||||
|
- List token metadata (never return plaintext tokens).
|
||||||
|
- Supports filtering by `project_id`, `client_id`, `status` (active/used/expired).
|
||||||
|
- `POST /v1/admin/enrollment-tokens/{token_id}/revoke`
|
||||||
|
- Revoke a token (marks as revoked or sets `used_at`/`revoked_at`).
|
||||||
|
- `GET /v1/admin/enrollment-tokens/{token_id}/export`
|
||||||
|
- Export the one-time token to a file download (single use).
|
||||||
|
|
||||||
|
## CLI (Draft)
|
||||||
|
Example commands (names can be adjusted):
|
||||||
|
- `update-service admin token create --project <id> [--client <id>] [--software <id>] [--expires <iso8601>]`
|
||||||
|
- `update-service admin token list --project <id> [--status active|used|expired|revoked]`
|
||||||
|
- `update-service admin token revoke --id <token_id>`
|
||||||
|
- `update-service admin token export --id <token_id> --out ./enroll-token.txt`
|
||||||
|
|
||||||
|
Example format:
|
||||||
|
```
|
||||||
|
enroll_<random_32_bytes>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage and Safety
|
||||||
|
- Store only a hash of the enrollment token (never plaintext).
|
||||||
|
- Track `created_at`, `expires_at`, and `used_at`.
|
||||||
|
- Deny enrollment if `expires_at` is exceeded or `used_at` is set.
|
||||||
|
|
||||||
|
## Rotation and Revocation
|
||||||
|
- Revoke enrollment tokens by invalidating their stored hash.
|
||||||
|
- Issue a new enrollment token if the previous one expires or is leaked.
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
Preferred channels:
|
||||||
|
- One-time install code (copy/paste)
|
||||||
|
- QR code
|
||||||
|
- Encrypted file included in an install bundle
|
||||||
|
|
||||||
|
## Audit Expectations
|
||||||
|
- Log token creation and enrollment usage for traceability.
|
||||||
57
docs/architecture/openapi.yaml
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Update Webservice API
|
||||||
|
version: 0.1.0
|
||||||
|
servers:
|
||||||
|
- url: https://update.wlkns.org
|
||||||
|
- url: https://staging.update.wlkns.org
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
schemas:
|
||||||
|
Manifest:
|
||||||
|
$ref: './openapi/schemas/manifest.yaml'
|
||||||
|
StatusReport:
|
||||||
|
$ref: './openapi/schemas/status-report.yaml'
|
||||||
|
UploadResponse:
|
||||||
|
$ref: './openapi/schemas/upload-response.yaml'
|
||||||
|
EnrollRequest:
|
||||||
|
$ref: './openapi/schemas/enroll-request.yaml'
|
||||||
|
EnrollResponse:
|
||||||
|
$ref: './openapi/schemas/enroll-response.yaml'
|
||||||
|
EnrollmentToken:
|
||||||
|
$ref: './openapi/schemas/enrollment-token.yaml'
|
||||||
|
EnrollmentTokenCreateRequest:
|
||||||
|
$ref: './openapi/schemas/enrollment-token-create-request.yaml'
|
||||||
|
EnrollmentTokenCreateResponse:
|
||||||
|
$ref: './openapi/schemas/enrollment-token-create-response.yaml'
|
||||||
|
Error:
|
||||||
|
$ref: './openapi/schemas/error.yaml'
|
||||||
|
Limits:
|
||||||
|
$ref: './openapi/schemas/limits.yaml'
|
||||||
|
LimitsPolicy:
|
||||||
|
$ref: './openapi/schemas/limits-policy.yaml'
|
||||||
|
paths:
|
||||||
|
/v1/enroll:
|
||||||
|
$ref: './openapi/paths/enroll.yaml'
|
||||||
|
/v1/admin/enrollment-tokens:
|
||||||
|
$ref: './openapi/paths/admin-enrollment-tokens.yaml'
|
||||||
|
/v1/admin/enrollment-tokens/{token_id}/revoke:
|
||||||
|
$ref: './openapi/paths/admin-enrollment-tokens-revoke.yaml'
|
||||||
|
/v1/admin/enrollment-tokens/{token_id}/export:
|
||||||
|
$ref: './openapi/paths/admin-enrollment-tokens-export.yaml'
|
||||||
|
/v1/projects/{project_id}/manifest:
|
||||||
|
$ref: './openapi/paths/manifest.yaml'
|
||||||
|
/v1/projects/{project_id}/releases/{version}/artifact:
|
||||||
|
$ref: './openapi/paths/artifact.yaml'
|
||||||
|
/v1/projects/{project_id}/status:
|
||||||
|
$ref: './openapi/paths/status.yaml'
|
||||||
|
/v1/projects/{project_id}/releases:
|
||||||
|
$ref: './openapi/paths/releases.yaml'
|
||||||
|
/v1/limits:
|
||||||
|
$ref: './openapi/paths/limits.yaml'
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
get:
|
||||||
|
summary: Export enrollment token
|
||||||
|
x-auth-scopes: [admin]
|
||||||
|
parameters:
|
||||||
|
- name: token_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Token file
|
||||||
|
content:
|
||||||
|
text/plain:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: enroll_6f3d2c...
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
x-error-codes: [unauthorized]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
'404':
|
||||||
|
description: Not Found
|
||||||
|
x-error-codes: [not_found]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
post:
|
||||||
|
summary: Revoke enrollment token
|
||||||
|
x-auth-scopes: [admin]
|
||||||
|
parameters:
|
||||||
|
- name: token_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Revoked
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/enrollment-token.yaml'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
x-error-codes: [unauthorized]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
'404':
|
||||||
|
description: Not Found
|
||||||
|
x-error-codes: [not_found]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
69
docs/architecture/openapi/paths/admin-enrollment-tokens.yaml
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
get:
|
||||||
|
summary: List enrollment tokens
|
||||||
|
x-auth-scopes: [admin]
|
||||||
|
parameters:
|
||||||
|
- name: project_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: client_id
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: status
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [active, used, expired, revoked]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Token list
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '../schemas/enrollment-token.yaml'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
x-error-codes: [unauthorized]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
post:
|
||||||
|
summary: Create enrollment token
|
||||||
|
x-auth-scopes: [admin]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/enrollment-token-create-request.yaml'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/enrollment-token-create-response.yaml'
|
||||||
|
'400':
|
||||||
|
description: Bad Request
|
||||||
|
x-error-codes: [invalid_payload]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
x-error-codes: [unauthorized]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
58
docs/architecture/openapi/paths/artifact.yaml
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
get:
|
||||||
|
summary: Download artifact
|
||||||
|
x-auth-scopes: [read_manifest]
|
||||||
|
parameters:
|
||||||
|
- name: project_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: version
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Artifact tar.gz
|
||||||
|
content:
|
||||||
|
application/gzip:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
x-error-codes: [unauthorized]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
unauthorized:
|
||||||
|
value:
|
||||||
|
code: unauthorized
|
||||||
|
message: Missing or invalid token
|
||||||
|
'404':
|
||||||
|
description: Not Found
|
||||||
|
x-error-codes: [not_found]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
not_found:
|
||||||
|
value:
|
||||||
|
code: not_found
|
||||||
|
message: Artifact not found
|
||||||
|
'429':
|
||||||
|
description: Too Many Requests
|
||||||
|
x-error-codes: [rate_limited]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
rate_limited:
|
||||||
|
value:
|
||||||
|
code: rate_limited
|
||||||
|
message: Too many requests
|
||||||
65
docs/architecture/openapi/paths/enroll.yaml
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
post:
|
||||||
|
summary: Enroll client and issue long-term token
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/enroll-request.yaml'
|
||||||
|
examples:
|
||||||
|
enroll:
|
||||||
|
value:
|
||||||
|
project_id: demo
|
||||||
|
client_id: device-42
|
||||||
|
software_id: kiosk
|
||||||
|
enroll_token: enroll_6f3d2c...
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Enrollment successful
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/enroll-response.yaml'
|
||||||
|
examples:
|
||||||
|
issued:
|
||||||
|
value:
|
||||||
|
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
scope: read_manifest report_status
|
||||||
|
expires_at: 2026-12-30T10:00:00Z
|
||||||
|
'400':
|
||||||
|
description: Bad Request
|
||||||
|
x-error-codes: [invalid_payload]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
invalid_payload:
|
||||||
|
value:
|
||||||
|
code: invalid_payload
|
||||||
|
message: Missing required fields
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
x-error-codes: [unauthorized]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
unauthorized:
|
||||||
|
value:
|
||||||
|
code: unauthorized
|
||||||
|
message: Invalid or expired enrollment token
|
||||||
|
'409':
|
||||||
|
description: Conflict
|
||||||
|
x-error-codes: [already_enrolled]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
already_enrolled:
|
||||||
|
value:
|
||||||
|
code: already_enrolled
|
||||||
|
message: Client already enrolled
|
||||||
48
docs/architecture/openapi/paths/limits.yaml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
get:
|
||||||
|
summary: Get service limits
|
||||||
|
x-auth-scopes: [read_manifest]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Limits
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/limits-policy.yaml'
|
||||||
|
examples:
|
||||||
|
medium:
|
||||||
|
value:
|
||||||
|
tier: medium
|
||||||
|
limits:
|
||||||
|
upload_max_artifact_size_bytes_soft: 1073741824
|
||||||
|
upload_max_artifact_size_bytes_hard: 2147483648
|
||||||
|
read_max_requests_per_minute_soft: 300
|
||||||
|
read_max_requests_per_minute_hard: 600
|
||||||
|
upload_max_requests_per_minute_soft: 6
|
||||||
|
upload_max_requests_per_minute_hard: 12
|
||||||
|
report_max_requests_per_minute_soft: 120
|
||||||
|
report_max_requests_per_minute_hard: 240
|
||||||
|
burst_requests_per_minute: 1200
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
x-error-codes: [unauthorized]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
unauthorized:
|
||||||
|
value:
|
||||||
|
code: unauthorized
|
||||||
|
message: Missing or invalid token
|
||||||
|
'429':
|
||||||
|
description: Too Many Requests
|
||||||
|
x-error-codes: [rate_limited]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
rate_limited:
|
||||||
|
value:
|
||||||
|
code: rate_limited
|
||||||
|
message: Too many requests
|
||||||
47
docs/architecture/openapi/paths/manifest.yaml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
get:
|
||||||
|
summary: Get active manifest
|
||||||
|
x-auth-scopes: [read_manifest]
|
||||||
|
parameters:
|
||||||
|
- name: project_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Manifest
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/manifest.yaml'
|
||||||
|
examples:
|
||||||
|
default:
|
||||||
|
value:
|
||||||
|
version: 1.2.3
|
||||||
|
artifact_url: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/artifact
|
||||||
|
sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
||||||
|
sig_url: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/signature
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
x-error-codes: [unauthorized]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
unauthorized:
|
||||||
|
value:
|
||||||
|
code: unauthorized
|
||||||
|
message: Missing or invalid token
|
||||||
|
'429':
|
||||||
|
description: Too Many Requests
|
||||||
|
x-error-codes: [rate_limited]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
rate_limited:
|
||||||
|
value:
|
||||||
|
code: rate_limited
|
||||||
|
message: Too many requests
|
||||||
126
docs/architecture/openapi/paths/releases.yaml
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
post:
|
||||||
|
summary: Upload release
|
||||||
|
x-auth-scopes: [upload_release]
|
||||||
|
parameters:
|
||||||
|
- name: project_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- version
|
||||||
|
- sha256
|
||||||
|
- artifact
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
|
||||||
|
example: 1.2.3
|
||||||
|
sha256:
|
||||||
|
type: string
|
||||||
|
example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
||||||
|
sig_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
description: Optional reference to a detached signature
|
||||||
|
signature:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
description: Detached signature file (optional alternative to sig_url)
|
||||||
|
key_id:
|
||||||
|
type: string
|
||||||
|
description: Public key identifier for signature verification
|
||||||
|
artifact:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/upload-response.yaml'
|
||||||
|
examples:
|
||||||
|
created:
|
||||||
|
value:
|
||||||
|
version: 1.2.3
|
||||||
|
manifest_url: https://update.wlkns.org/v1/projects/demo/manifest
|
||||||
|
active: true
|
||||||
|
'400':
|
||||||
|
description: Bad Request
|
||||||
|
x-error-codes: [invalid_payload]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
invalid_payload:
|
||||||
|
value:
|
||||||
|
code: invalid_payload
|
||||||
|
message: Missing required fields
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
x-error-codes: [unauthorized]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
unauthorized:
|
||||||
|
value:
|
||||||
|
code: unauthorized
|
||||||
|
message: Missing or invalid token
|
||||||
|
'409':
|
||||||
|
description: Conflict
|
||||||
|
x-error-codes: [version_exists]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
version_exists:
|
||||||
|
value:
|
||||||
|
code: version_exists
|
||||||
|
message: Version already exists
|
||||||
|
'413':
|
||||||
|
description: Payload Too Large
|
||||||
|
x-error-codes: [payload_too_large]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
payload_too_large:
|
||||||
|
value:
|
||||||
|
code: payload_too_large
|
||||||
|
message: Artifact exceeds size limit
|
||||||
|
'422':
|
||||||
|
description: Unprocessable Entity (invalid checksum/signature/version)
|
||||||
|
x-error-codes: [checksum_mismatch, signature_invalid, signature_missing, version_invalid]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
checksum_mismatch:
|
||||||
|
value:
|
||||||
|
code: checksum_mismatch
|
||||||
|
message: SHA256 does not match artifact
|
||||||
|
'429':
|
||||||
|
description: Too Many Requests
|
||||||
|
x-error-codes: [rate_limited]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
rate_limited:
|
||||||
|
value:
|
||||||
|
code: rate_limited
|
||||||
|
message: Too many requests
|
||||||
84
docs/architecture/openapi/paths/status.yaml
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
post:
|
||||||
|
summary: Report update status
|
||||||
|
x-auth-scopes: [report_status]
|
||||||
|
parameters:
|
||||||
|
- name: project_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/status-report.yaml'
|
||||||
|
examples:
|
||||||
|
success:
|
||||||
|
value:
|
||||||
|
project_id: demo
|
||||||
|
version: 1.2.3
|
||||||
|
status: success
|
||||||
|
timestamp: 2025-12-28T10:15:30Z
|
||||||
|
client_id: device-42
|
||||||
|
duration_ms: 2450
|
||||||
|
failure:
|
||||||
|
value:
|
||||||
|
project_id: demo
|
||||||
|
version: 1.2.3
|
||||||
|
status: failed
|
||||||
|
timestamp: 2025-12-28T10:15:30Z
|
||||||
|
client_id: device-42
|
||||||
|
reason: checksum_mismatch
|
||||||
|
error_code: checksum_mismatch
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Accepted
|
||||||
|
'400':
|
||||||
|
description: Bad Request
|
||||||
|
x-error-codes: [invalid_payload]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
invalid_payload:
|
||||||
|
value:
|
||||||
|
code: invalid_payload
|
||||||
|
message: Missing required fields
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
x-error-codes: [unauthorized]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
unauthorized:
|
||||||
|
value:
|
||||||
|
code: unauthorized
|
||||||
|
message: Missing or invalid token
|
||||||
|
'422':
|
||||||
|
description: Unprocessable Entity (invalid version or status)
|
||||||
|
x-error-codes: [version_invalid, status_invalid]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
version_invalid:
|
||||||
|
value:
|
||||||
|
code: version_invalid
|
||||||
|
message: Version does not match SemVer
|
||||||
|
'429':
|
||||||
|
description: Too Many Requests
|
||||||
|
x-error-codes: [rate_limited]
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../schemas/error.yaml'
|
||||||
|
examples:
|
||||||
|
rate_limited:
|
||||||
|
value:
|
||||||
|
code: rate_limited
|
||||||
|
message: Too many requests
|
||||||
20
docs/architecture/openapi/schemas/enroll-request.yaml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- project_id
|
||||||
|
- client_id
|
||||||
|
- software_id
|
||||||
|
- enroll_token
|
||||||
|
properties:
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
example: demo
|
||||||
|
client_id:
|
||||||
|
type: string
|
||||||
|
example: device-42
|
||||||
|
software_id:
|
||||||
|
type: string
|
||||||
|
example: kiosk
|
||||||
|
enroll_token:
|
||||||
|
type: string
|
||||||
|
description: Pre-shared, single-use enrollment token
|
||||||
|
example: enroll_6f3d2c...
|
||||||
19
docs/architecture/openapi/schemas/enroll-response.yaml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- token
|
||||||
|
- scope
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: Long-term bearer token for client requests
|
||||||
|
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
description: Space-delimited scopes
|
||||||
|
example: read_manifest report_status
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
description: Null for non-expiring tokens
|
||||||
|
example: 2026-12-30T10:00:00Z
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- project_id
|
||||||
|
properties:
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
example: demo
|
||||||
|
client_id:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: device-42
|
||||||
|
software_id:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: kiosk
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
example: 2026-12-30T10:00:00Z
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- token
|
||||||
|
- token_meta
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: One-time plaintext enrollment token
|
||||||
|
example: enroll_6f3d2c...
|
||||||
|
token_meta:
|
||||||
|
$ref: './enrollment-token.yaml'
|
||||||
39
docs/architecture/openapi/schemas/enrollment-token.yaml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- project_id
|
||||||
|
- status
|
||||||
|
- created_at
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
example: tok_123
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
example: demo
|
||||||
|
client_id:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: device-42
|
||||||
|
software_id:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
example: kiosk
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [active, used, expired, revoked]
|
||||||
|
example: active
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
example: 2026-12-30T10:00:00Z
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: 2025-12-30T10:00:00Z
|
||||||
|
used_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
example: 2025-12-30T10:15:00Z
|
||||||
15
docs/architecture/openapi/schemas/error.yaml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: Error code (e.g., unauthorized, invalid_payload, already_enrolled)
|
||||||
|
example: unauthorized
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: Missing or invalid token
|
||||||
|
details:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
10
docs/architecture/openapi/schemas/limits-policy.yaml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tier
|
||||||
|
- limits
|
||||||
|
properties:
|
||||||
|
tier:
|
||||||
|
type: string
|
||||||
|
enum: [small, medium, large]
|
||||||
|
limits:
|
||||||
|
$ref: './limits.yaml'
|
||||||
39
docs/architecture/openapi/schemas/limits.yaml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- upload_max_artifact_size_bytes_soft
|
||||||
|
- upload_max_artifact_size_bytes_hard
|
||||||
|
- read_max_requests_per_minute_soft
|
||||||
|
- read_max_requests_per_minute_hard
|
||||||
|
- upload_max_requests_per_minute_soft
|
||||||
|
- upload_max_requests_per_minute_hard
|
||||||
|
- report_max_requests_per_minute_soft
|
||||||
|
- report_max_requests_per_minute_hard
|
||||||
|
- burst_requests_per_minute
|
||||||
|
properties:
|
||||||
|
upload_max_artifact_size_bytes_soft:
|
||||||
|
type: integer
|
||||||
|
default: 1073741824
|
||||||
|
upload_max_artifact_size_bytes_hard:
|
||||||
|
type: integer
|
||||||
|
default: 2147483648
|
||||||
|
read_max_requests_per_minute_soft:
|
||||||
|
type: integer
|
||||||
|
default: 300
|
||||||
|
read_max_requests_per_minute_hard:
|
||||||
|
type: integer
|
||||||
|
default: 600
|
||||||
|
upload_max_requests_per_minute_soft:
|
||||||
|
type: integer
|
||||||
|
default: 6
|
||||||
|
upload_max_requests_per_minute_hard:
|
||||||
|
type: integer
|
||||||
|
default: 12
|
||||||
|
report_max_requests_per_minute_soft:
|
||||||
|
type: integer
|
||||||
|
default: 120
|
||||||
|
report_max_requests_per_minute_hard:
|
||||||
|
type: integer
|
||||||
|
default: 240
|
||||||
|
burst_requests_per_minute:
|
||||||
|
type: integer
|
||||||
|
default: 1200
|
||||||
24
docs/architecture/openapi/schemas/manifest.yaml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- version
|
||||||
|
- artifact_url
|
||||||
|
- sha256
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: SemVer string (e.g., 1.2.3)
|
||||||
|
pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
|
||||||
|
example: 1.2.3
|
||||||
|
artifact_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
example: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/artifact
|
||||||
|
sha256:
|
||||||
|
type: string
|
||||||
|
description: Hex-encoded SHA256
|
||||||
|
example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
|
||||||
|
sig_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
nullable: true
|
||||||
|
example: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/signature
|
||||||
42
docs/architecture/openapi/schemas/status-report.yaml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- project_id
|
||||||
|
- version
|
||||||
|
- status
|
||||||
|
- timestamp
|
||||||
|
properties:
|
||||||
|
project_id:
|
||||||
|
type: string
|
||||||
|
example: demo
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
example: 1.2.3
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [success, failed, in_progress]
|
||||||
|
example: success
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
example: 2025-12-28T10:15:30Z
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
example: checksum_mismatch
|
||||||
|
client_id:
|
||||||
|
type: string
|
||||||
|
example: device-42
|
||||||
|
client_version:
|
||||||
|
type: string
|
||||||
|
example: 1.2.2
|
||||||
|
device_type:
|
||||||
|
type: string
|
||||||
|
example: kiosk
|
||||||
|
update_channel:
|
||||||
|
type: string
|
||||||
|
example: stable
|
||||||
|
duration_ms:
|
||||||
|
type: integer
|
||||||
|
example: 2450
|
||||||
|
error_code:
|
||||||
|
type: string
|
||||||
|
example: checksum_mismatch
|
||||||
16
docs/architecture/openapi/schemas/upload-response.yaml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- version
|
||||||
|
- manifest_url
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
example: 1.2.3
|
||||||
|
manifest_url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
example: https://update.wlkns.org/v1/projects/demo/manifest
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
description: True if release is active
|
||||||
|
example: true
|
||||||
44
docs/client-quickstart.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
ID: DOC_000008 | Version: 0.1.0 | Status: Draft
|
||||||
|
|
||||||
|
# Client Quickstart
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Enroll a client, store the long-term token, fetch the manifest, and report status.
|
||||||
|
|
||||||
|
## 1) Get a Pre-Shared Token
|
||||||
|
Request a one-time enrollment token from an admin/operator.
|
||||||
|
|
||||||
|
## 2) Enroll and Receive Long-Term Token
|
||||||
|
```
|
||||||
|
curl -X POST https://update.wlkns.org/v1/enroll \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"project_id": "safe-kiddo-control",
|
||||||
|
"client_id": "kiddo-001",
|
||||||
|
"software_id": "kiddo-agent",
|
||||||
|
"enroll_token": "<pre_shared_token>"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Store the returned token locally (file or secret store). Example:
|
||||||
|
```
|
||||||
|
echo "<long_term_token>" > ./update-token.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) Fetch Manifest
|
||||||
|
```
|
||||||
|
curl -H "Authorization: Bearer $(cat ./update-token.txt)" \
|
||||||
|
https://update.wlkns.org/v1/projects/safe-kiddo-control/manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) Report Status
|
||||||
|
```
|
||||||
|
curl -H "Authorization: Bearer $(cat ./update-token.txt)" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"project_id":"safe-kiddo-control","version":"0.1.2","status":"success","timestamp":"2025-12-30T10:00:00Z"}' \
|
||||||
|
https://update.wlkns.org/v1/projects/safe-kiddo-control/status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- All endpoints require `Authorization: Bearer <token>` except `/v1/enroll`.
|
||||||
|
- Status values: `success`, `failed`, `in_progress`.
|
||||||
135
docs/third-party-api.md
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
ID: DOC_000005 | Version: 0.1.0 | Status: Draft
|
||||||
|
|
||||||
|
# Third-Party API Guide
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This document explains how third-party services integrate with the Update Webservice: obtaining tokens, fetching manifests, downloading artifacts, and reporting status.
|
||||||
|
|
||||||
|
## Quick Start (First Client)
|
||||||
|
1) Request a pre-shared enrollment token from an admin/operator.
|
||||||
|
2) Enroll once to obtain a long-term token.
|
||||||
|
3) Store the long-term token locally and use it for all API calls.
|
||||||
|
|
||||||
|
## Base URLs
|
||||||
|
- Production: `https://update.wlkns.org`
|
||||||
|
- Staging: `https://staging.update.wlkns.org`
|
||||||
|
|
||||||
|
All endpoints are versioned under `/v1`.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
All endpoints require `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
### Enrollment (Pre-Shared Token -> Long-Term Token)
|
||||||
|
Clients obtain a long-term token by exchanging a pre-shared token provided by an admin/operator.
|
||||||
|
|
||||||
|
Request (example):
|
||||||
|
```
|
||||||
|
POST /v1/enroll
|
||||||
|
{
|
||||||
|
"project_id": "<project>",
|
||||||
|
"client_id": "<client>",
|
||||||
|
"software_id": "<software>",
|
||||||
|
"enroll_token": "<pre_shared_token>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (example):
|
||||||
|
```
|
||||||
|
200 OK
|
||||||
|
{
|
||||||
|
"token": "<long_term_token>",
|
||||||
|
"scope": "read_manifest report_status",
|
||||||
|
"expires_at": "<iso8601 or null>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Enrollment tokens are single-use and must be invalidated after a successful exchange.
|
||||||
|
- If the token is invalid or reused, the server responds with `unauthorized` or `invalid_payload`.
|
||||||
|
- Enrollment does not require an existing bearer token.
|
||||||
|
- If the client is already enrolled, the server responds with `already_enrolled` (HTTP 409).
|
||||||
|
|
||||||
|
## Client API (Read + Report)
|
||||||
|
|
||||||
|
### Get Manifest
|
||||||
|
```
|
||||||
|
GET /v1/projects/{project_id}/manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"version": "0.1.2",
|
||||||
|
"artifact_url": "https://update.wlkns.org/v1/projects/<project_id>/releases/0.1.2/artifact",
|
||||||
|
"sha256": "<hex>",
|
||||||
|
"sig_url": "<optional>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required scope: `read_manifest`
|
||||||
|
|
||||||
|
### Download Artifact
|
||||||
|
```
|
||||||
|
GET /v1/projects/{project_id}/releases/{version}/artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
Required scope: `read_manifest`
|
||||||
|
|
||||||
|
### Report Status
|
||||||
|
```
|
||||||
|
POST /v1/projects/{project_id}/status
|
||||||
|
{
|
||||||
|
"project_id": "<project_id>",
|
||||||
|
"version": "<semver>",
|
||||||
|
"status": "success|failed|in_progress",
|
||||||
|
"timestamp": "<iso8601>",
|
||||||
|
"client_id": "<optional>",
|
||||||
|
"duration_ms": "<optional>",
|
||||||
|
"error_code": "<optional>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Required scope: `report_status`
|
||||||
|
|
||||||
|
## Release API (Upload)
|
||||||
|
|
||||||
|
### Upload Release
|
||||||
|
```
|
||||||
|
POST /v1/projects/{project_id}/releases
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
Required scope: `upload_release`
|
||||||
|
|
||||||
|
Required fields:
|
||||||
|
- `version` (SemVer)
|
||||||
|
- `artifact` (file)
|
||||||
|
- `sha256` (hex)
|
||||||
|
|
||||||
|
Optional fields:
|
||||||
|
- `sig_url` or inline signature
|
||||||
|
- `key_id`
|
||||||
|
|
||||||
|
## Error Codes
|
||||||
|
Common error codes:
|
||||||
|
`unauthorized`, `rate_limited`, `not_found`, `invalid_payload`, `version_invalid`,
|
||||||
|
`version_exists`, `checksum_mismatch`, `signature_missing`, `signature_invalid`,
|
||||||
|
`payload_too_large`, `status_invalid`
|
||||||
|
|
||||||
|
## Rate Limits
|
||||||
|
Limits are tiered by scope. See `docs/architecture/ARCHITECTURE.md` for current values.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
Fetch manifest:
|
||||||
|
```
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
https://update.wlkns.org/v1/projects/$PROJECT_ID/manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
Report status:
|
||||||
|
```
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"project_id":"'"$PROJECT_ID"'","version":"0.1.2","status":"success","timestamp":"2025-12-30T10:00:00Z"}' \
|
||||||
|
https://update.wlkns.org/v1/projects/$PROJECT_ID/status
|
||||||
|
```
|
||||||
60
docs/update-api.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
ID: DOC_000006 | Version: 0.1.0 | Status: Draft
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# Update API (Kiddo Backend)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Definiert interne API-Endpunkte fuer Update-Status, Check, Apply, Rollback und Logs.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### GET /update/status
|
||||||
|
Antwort:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"current_version": "0.1.2",
|
||||||
|
"last_status": "success|failed|unknown",
|
||||||
|
"last_error": "<optional>",
|
||||||
|
"last_timestamp": "2025-12-28T12:34:56Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /update/check
|
||||||
|
Antwort:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"available": true,
|
||||||
|
"latest_version": "0.1.3",
|
||||||
|
"artifact_url": "https://update.wlkns.org/kiddo/kiddo-0.1.3.tar.gz",
|
||||||
|
"sha256": "<hex>",
|
||||||
|
"message": "<optional>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /update/apply
|
||||||
|
Body (optional):
|
||||||
|
```json
|
||||||
|
{ "version": "0.1.3" }
|
||||||
|
```
|
||||||
|
Antwort:
|
||||||
|
```json
|
||||||
|
{ "started": true, "message": "update started" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /update/rollback
|
||||||
|
Antwort:
|
||||||
|
```json
|
||||||
|
{ "started": true, "message": "rollback started" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /update/logs
|
||||||
|
Antwort:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"timestamp":"2025-12-28T12:34:56Z","status":"success","message":"updated to 0.1.2"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Alle Endpunkte erfordern Auth (Session/Bearer).
|
||||||
|
- Apply/Rollback starten async; UI pollt /update/status.
|
||||||
@ -46,3 +46,4 @@ Beispiel:
|
|||||||
## Status Reporting
|
## Status Reporting
|
||||||
- Status wird per HTTP POST an `https://update.wlkns.org/status` gemeldet.
|
- Status wird per HTTP POST an `https://update.wlkns.org/status` gemeldet.
|
||||||
- Schema siehe `docs/update-status.md`.
|
- Schema siehe `docs/update-status.md`.
|
||||||
|
- Lokaler Status/Logs liegen unter `/var/lib/skd` (konfigurierbar via ENV).
|
||||||
|
|||||||
@ -26,5 +26,7 @@ SKD_UPDATE_URL=https://update.wlkns.org
|
|||||||
SKD_UPDATE_TOKEN=
|
SKD_UPDATE_TOKEN=
|
||||||
SKD_UPDATE_INTERVAL=3600
|
SKD_UPDATE_INTERVAL=3600
|
||||||
SKD_UPDATE_STATUS_URL=https://update.wlkns.org/status
|
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
|
||||||
|
|||||||
@ -54,8 +54,8 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
|
|||||||
- [x] TASK_000010: Serve UI template
|
- [x] TASK_000010: Serve UI template
|
||||||
- [x] US_000022: Web-UI Aktionen ausfuehren
|
- [x] US_000022: Web-UI Aktionen ausfuehren
|
||||||
- [x] TASK_000022: UI login and actions
|
- [x] TASK_000022: UI login and actions
|
||||||
- [ ] US_000024: Watchtower Theme fuer Web-UI (zurueckgestellt)
|
- [x] US_000024: Watchtower Theme fuer Web-UI
|
||||||
- [ ] TASK_000024: Apply Watchtower theme (zurueckgestellt)
|
- [x] TASK_000024: Apply Watchtower theme
|
||||||
|
|
||||||
### EPIC_000005: Automation Scripts
|
### EPIC_000005: Automation Scripts
|
||||||
- [x] US_000011: Virtualenv und Abhaengigkeiten erstellen
|
- [x] US_000011: Virtualenv und Abhaengigkeiten erstellen
|
||||||
@ -86,18 +86,34 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
|
|||||||
- [x] TASK_000023: README runbook notes
|
- [x] TASK_000023: README runbook notes
|
||||||
|
|
||||||
### EPIC_000008: Client-Side Update Mechanism
|
### EPIC_000008: Client-Side Update Mechanism
|
||||||
- [ ] US_000026: Client bezieht Updates (Pull)
|
- [x] US_000026: Client bezieht Updates (Pull)
|
||||||
- [ ] TASK_000027: Update endpoint config
|
- [x] TASK_000027: Update endpoint config
|
||||||
- [ ] US_000027: Client verifiziert und wendet Updates an
|
- [x] US_000027: Client verifiziert und wendet Updates an
|
||||||
- [ ] TASK_000028: Verify and apply update
|
- [x] TASK_000028: Verify and apply update
|
||||||
- [ ] US_000028: Client meldet Update-Status
|
- [x] US_000028: Client meldet Update-Status
|
||||||
- [ ] TASK_000029: Report 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)
|
### EPIC_000009: Update Webservice (External Team)
|
||||||
- [ ] US_000026: Client bezieht Updates (Pull)
|
- [ ] US_000026: Client bezieht Updates (Pull)
|
||||||
- [ ] US_000027: Client verifiziert und wendet Updates an
|
- [ ] US_000027: Client verifiziert und wendet Updates an
|
||||||
- [ ] US_000028: Client meldet Update-Status
|
- [ ] 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
|
## Offene Risiken / Abhaengigkeiten
|
||||||
- Betrieb erfordert Root/sudo und lokale System-Tools (notify-send, sound player, uvicorn).
|
- Betrieb erfordert Root/sudo und lokale System-Tools (notify-send, sound player, uvicorn).
|
||||||
- OIDC-Validierung blockiert bis IdP bereit und Service laeuft.
|
- OIDC-Validierung blockiert bis IdP bereit und Service laeuft.
|
||||||
|
|||||||
729
project-management/feedback/static/styles.css
Executable file
@ -0,0 +1,729 @@
|
|||||||
|
/* Professional IT Asset Management - Best of Both Worlds with Dark Mode */
|
||||||
|
:root {
|
||||||
|
--bg-main: #f5f7fa;
|
||||||
|
--bg-panel: #ffffff;
|
||||||
|
--bg-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--text-main: #2d3748;
|
||||||
|
--text-secondary: #718096;
|
||||||
|
--text-light: #a0aec0;
|
||||||
|
--border-main: #e2e8f0;
|
||||||
|
--primary: #667eea;
|
||||||
|
--primary-hover: #5568d3;
|
||||||
|
--success: #48bb78;
|
||||||
|
--success-hover: #38a169;
|
||||||
|
--warning: #ed8936;
|
||||||
|
--danger: #f56565;
|
||||||
|
--danger-hover: #e53e3e;
|
||||||
|
--info: #4299e1;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode Theme */
|
||||||
|
body.dark-mode {
|
||||||
|
--bg-main: #1a202c;
|
||||||
|
--bg-panel: #2d3748;
|
||||||
|
--bg-header: linear-gradient(135deg, #4c51bf 0%, #6b46c1 100%);
|
||||||
|
--text-main: #f7fafc;
|
||||||
|
--text-secondary: #cbd5e0;
|
||||||
|
--text-light: #a0aec0;
|
||||||
|
--border-main: #4a5568;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0,0,0,0.3);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.6;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Toggle Button */
|
||||||
|
.theme-toggle {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
right: 30px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
stroke: white;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle .sun-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .theme-toggle .moon-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .theme-toggle .sun-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header - Keep the gradient but more professional */
|
||||||
|
header {
|
||||||
|
background: var(--bg-header);
|
||||||
|
color: white;
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0.95;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Statistics Dashboard - More visual interest */
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: var(--primary);
|
||||||
|
opacity: 0.05;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(30%, -30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
border-left-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls - Clean but distinctive */
|
||||||
|
.controls {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs - More refined */
|
||||||
|
input, select {
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 2px solid var(--border-main);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons - Keep gradient feel but professional */
|
||||||
|
button {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 11px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background: var(--success-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background: var(--danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Container - More polished */
|
||||||
|
.table-container {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--bg-main);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 2px solid var(--border-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid var(--border-main);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
td strong {
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges - More colorful but professional */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-available {
|
||||||
|
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
|
||||||
|
color: #22543d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-in_use {
|
||||||
|
background: linear-gradient(135deg, #bee3f8 0%, #90cdf4 100%);
|
||||||
|
color: #2c5282;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-maintenance {
|
||||||
|
background: linear-gradient(135deg, #feebc8 0%, #fbd38d 100%);
|
||||||
|
color: #7c2d12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-retired {
|
||||||
|
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%);
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-lost {
|
||||||
|
background: linear-gradient(135deg, #fed7d7 0%, #fc8181 100%);
|
||||||
|
color: #742a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal - Elegant overlay */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 2px solid var(--border-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: var(--border-main);
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form - Clean and accessible */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 2px solid var(--border-main);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 2px solid var(--border-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading & Empty States */
|
||||||
|
.loading, .empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state svg {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
opacity: 0.4;
|
||||||
|
stroke: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons button {
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Import Zone - Visual and inviting */
|
||||||
|
.import-zone {
|
||||||
|
border: 3px dashed var(--border-main);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-zone:hover, .import-zone.drag-over {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-zone.processing {
|
||||||
|
border-color: var(--success);
|
||||||
|
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-zone svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
stroke: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-zone h3 {
|
||||||
|
color: var(--text-main);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-zone p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Import Results */
|
||||||
|
.import-results {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-results.success {
|
||||||
|
background: #c6f6d5;
|
||||||
|
border-color: var(--success);
|
||||||
|
color: #22543d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-results.error {
|
||||||
|
background: #fed7d7;
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: #742a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar - More visual */
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--border-main);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary) 0%, var(--info) 100%);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NEW STYLES FOR ENHANCED UI */
|
||||||
|
|
||||||
|
.error-card {
|
||||||
|
background-color: var(--danger);
|
||||||
|
color: white;
|
||||||
|
border-left-color: var(--danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-card h3 {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-left-color: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card details {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card summary:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flags-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flags-list li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flags-list li::before {
|
||||||
|
content: '›';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value.level-high {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value.level-medium {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value.level-low {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
10
project-management/feedback/teal/app_icon.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- V2 App Icon (Teal Optimized) -->
|
||||||
|
<rect width="512" height="512" rx="115" fill="#1F2A37"/>
|
||||||
|
<g transform="translate(120, 96) scale(8)">
|
||||||
|
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||||
|
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||||
|
<!-- Teal Keystone -->
|
||||||
|
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 539 B |
9
project-management/feedback/teal/app_icon_ai.svg
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- V12 App Icon (Teal) -->
|
||||||
|
<rect width="512" height="512" rx="115" fill="#1F2A37"/>
|
||||||
|
<g transform="translate(120, 96) scale(8)">
|
||||||
|
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||||
|
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||||
|
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 503 B |
7
project-management/feedback/teal/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="64" height="64" rx="12" fill="#1F2A37"/>
|
||||||
|
<rect x="12" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||||
|
<rect x="40" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
|
||||||
|
<!-- Teal Keystone -->
|
||||||
|
<rect x="26" y="29" width="12" height="19" rx="2" fill="#0EA5A4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 428 B |
15
project-management/feedback/teal/logo.svg
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<svg width="250" height="60" viewBox="0 0 250 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Monolithic Symbol (V2 Optimized - Teal) -->
|
||||||
|
<g transform="translate(10, 10)">
|
||||||
|
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||||
|
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||||
|
<!-- Middle Block: Teal, 19px Height -->
|
||||||
|
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Wordmark -->
|
||||||
|
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
|
||||||
|
|
||||||
|
<!-- The Underline: Teal, aligned like V8 -->
|
||||||
|
<rect x="56" y="48" width="121" height="4" rx="1" fill="#0EA5A4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 764 B |
16
project-management/feedback/teal/logo_ai.svg
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<svg width="340" height="60" viewBox="0 0 340 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- V12: ALL TEAL -->
|
||||||
|
<g transform="translate(10, 10)">
|
||||||
|
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||||
|
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
|
||||||
|
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
|
||||||
|
</g>
|
||||||
|
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
|
||||||
|
<rect x="188" y="27" width="6" height="6" rx="1" fill="#4B5563"/>
|
||||||
|
|
||||||
|
<!-- AI: Teal -->
|
||||||
|
<text x="206" y="42" fill="#0EA5A4" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">AI</text>
|
||||||
|
|
||||||
|
<!-- Underline: Teal -->
|
||||||
|
<rect x="56" y="48" width="186" height="4" rx="1" fill="#0EA5A4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 900 B |
91
project-management/feedback/templates/admin/clients.html
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Client Administration</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>OIDC Clients</h1>
|
||||||
|
<p>Manage OIDC clients - Logged in as: <strong>{{ admin_user.username }}</strong></p>
|
||||||
|
</header>
|
||||||
|
{% if message %}
|
||||||
|
<div class="import-results success" style="max-width: 100%; margin-bottom: 20px;">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="controls">
|
||||||
|
<a href="/admin/client/create" style="text-decoration: none;">
|
||||||
|
<button class="secondary">Create New Client</button>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/users" style="text-decoration: none;">
|
||||||
|
<button>Manage Users</button>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/logout" style="text-decoration: none;">
|
||||||
|
<button class="danger">Logout</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Client ID</th>
|
||||||
|
<th>Client Name</th>
|
||||||
|
<th>Redirect URIs</th>
|
||||||
|
<th>Allowed Scopes</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for client in clients %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ client.id }}</strong></td>
|
||||||
|
<td><code>{{ client.client_id }}</code></td>
|
||||||
|
<td>{{ client.client_name }}</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
{% for uri in client.get_redirect_uris() %}
|
||||||
|
<li>{{ uri }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
<td>{{ client.get_allowed_scopes()|join(', ') }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a href="/admin/client/{{ client.id }}/edit" style="text-decoration: none;">
|
||||||
|
<button type="button" style="padding: 7px 14px; font-size: 0.8rem;">Edit</button>
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="/admin/client/{{ client.id }}/delete" style="display: inline;" onsubmit="return confirm('Delete client {{ client.client_name }}?');">
|
||||||
|
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Create New Client</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Create New OIDC Client</h1>
|
||||||
|
<p>Add a new client application to the system</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
|
||||||
|
{% if error %}
|
||||||
|
<div class="import-results error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" style="margin-top: 24px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="client_name">Client Name</label>
|
||||||
|
<input type="text" id="client_name" name="client_name" placeholder="My Awesome App" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="client_id">Client ID</label>
|
||||||
|
<input type="text" id="client_id" name="client_id" placeholder="leave blank to auto-generate" >
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="client_secret">Client Secret</label>
|
||||||
|
<input type="text" id="client_secret" name="client_secret" placeholder="leave blank to auto-generate">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="redirect_uris">Redirect URIs (one per line)</label>
|
||||||
|
<textarea id="redirect_uris" name="redirect_uris" rows="3" placeholder="https://app.example.com/callback" required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="allowed_scopes">Allowed Scopes (comma-separated)</label>
|
||||||
|
<input type="text" id="allowed_scopes" name="allowed_scopes" value="openid, profile, email" placeholder="e.g. openid, profile, email">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="/admin/clients">
|
||||||
|
<button type="button" class="danger">Cancel</button>
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="secondary">Create Client</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
106
project-management/feedback/templates/admin/create_user.html
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Create New User</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Create New User</h1>
|
||||||
|
<p>Add a new user to the system</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
|
||||||
|
{% if error %}
|
||||||
|
<div class="import-results error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" style="margin-top: 24px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="Enter username" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="user@example.com" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Full Name</label>
|
||||||
|
<input type="text" id="name" name="name" placeholder="John Doe" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Enter password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="role">Role</label>
|
||||||
|
<select id="role" name="role" required>
|
||||||
|
<option value="user" selected>User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="moderator">Moderator</option>
|
||||||
|
<option value="readonly">Read-Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="permissions">Permissions (comma-separated)</label>
|
||||||
|
<input type="text" id="permissions" name="permissions" placeholder="e.g. read:data, write:data">
|
||||||
|
<small style="color: var(--text-secondary); display: block; margin-top: 8px;">
|
||||||
|
Common permissions: read:data, write:data, manage:users, manage:settings
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_admin">
|
||||||
|
Admin User
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_active" checked>
|
||||||
|
Account Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="/admin/users">
|
||||||
|
<button type="button" class="danger">Cancel</button>
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="secondary">Create User</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
158
project-management/feedback/templates/admin/dashboard.html
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>User Administration</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>User Administration</h1>
|
||||||
|
<p>Manage OIDC users - Logged in as: <strong>{{ admin_user.username }}</strong></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="import-results success" style="max-width: 100%; margin-bottom: 20px;">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Total Users</h3>
|
||||||
|
<div class="value">{{ total_users }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Active Users</h3>
|
||||||
|
<div class="value value.level-low">{{ active_users }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Admin Users</h3>
|
||||||
|
<div class="value">{{ admin_users }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Inactive Users</h3>
|
||||||
|
<div class="value value.level-medium">{{ inactive_users }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<a href="/admin/analytics" style="text-decoration: none;">
|
||||||
|
<button>📊 Analytics</button>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/clients" style="text-decoration: none;">
|
||||||
|
<button>Manage Clients</button>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/user/create" style="text-decoration: none;">
|
||||||
|
<button class="secondary">Create New User</button>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/logout" style="text-decoration: none;">
|
||||||
|
<button class="danger">Logout</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Permissions</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for user in users %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ user.id }}</strong></td>
|
||||||
|
<td>{{ user.username }}</td>
|
||||||
|
<td>{{ user.name }}</td>
|
||||||
|
<td>{{ user.email }}</td>
|
||||||
|
<td>
|
||||||
|
{% if user.is_active %}
|
||||||
|
<span class="status-badge status-available">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge status-retired">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if user.role == 'admin' %}
|
||||||
|
<span class="status-badge status-in_use">{{ user.role|capitalize }}</span>
|
||||||
|
{% elif user.role == 'moderator' %}
|
||||||
|
<span class="status-badge status-available">{{ user.role|capitalize }}</span>
|
||||||
|
{% elif user.role == 'readonly' %}
|
||||||
|
<span class="status-badge status-retired">{{ user.role|capitalize }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge">{{ user.role|capitalize }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="font-size: 0.85rem;">
|
||||||
|
{% if user.get_permissions()|length > 0 %}
|
||||||
|
{{ user.get_permissions()|join(', ') }}
|
||||||
|
{% else %}
|
||||||
|
<em style="color: var(--text-secondary);">None</em>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a href="/admin/user/{{ user.id }}/edit" style="text-decoration: none;">
|
||||||
|
<button type="button" style="padding: 7px 14px; font-size: 0.8rem;">Edit</button>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if user.is_active %}
|
||||||
|
<form method="POST" action="/admin/user/{{ user.id }}/deactivate" style="display: inline;">
|
||||||
|
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Deactivate</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="POST" action="/admin/user/{{ user.id }}/activate" style="display: inline;">
|
||||||
|
<button type="submit" class="secondary" style="padding: 7px 14px; font-size: 0.8rem;">Activate</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not user.is_admin or admin_count > 1 %}
|
||||||
|
<form method="POST" action="/admin/user/{{ user.id }}/delete" style="display: inline;" onsubmit="return confirm('Delete user {{ user.username }}?');">
|
||||||
|
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="/admin/user/{{ user.id }}/tokens" style="text-decoration: none;">
|
||||||
|
<button type="button" class="secondary" style="padding: 7px 14px; font-size: 0.8rem;">Tokens</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
79
project-management/feedback/templates/admin/edit_client.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Edit Client - {{ client.client_name }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Edit OIDC Client</h1>
|
||||||
|
<p>Modify details for client: <strong>{{ client.client_name }}</strong></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
|
||||||
|
{% if error %}
|
||||||
|
<div class="import-results error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" style="margin-top: 24px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="client_name">Client Name</label>
|
||||||
|
<input type="text" id="client_name" name="client_name" value="{{ client.client_name }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="client_id">Client ID</label>
|
||||||
|
<input type="text" id="client_id" name="client_id" value="{{ client.client_id }}" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_client_secret">New Client Secret (leave empty to keep current)</label>
|
||||||
|
<input type="text" id="new_client_secret" name="new_client_secret" placeholder="Optional: Set new secret">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="redirect_uris">Redirect URIs (one per line)</label>
|
||||||
|
<textarea id="redirect_uris" name="redirect_uris" rows="3" required>{{ client.get_redirect_uris()|join('\n') }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="allowed_scopes">Allowed Scopes (comma-separated)</label>
|
||||||
|
<input type="text" id="allowed_scopes" name="allowed_scopes" value="{{ client.get_allowed_scopes()|join(', ') }}" placeholder="e.g. openid, profile, email">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="/admin/clients">
|
||||||
|
<button type="button" class="danger">Cancel</button>
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="secondary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
106
project-management/feedback/templates/admin/edit_user.html
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Edit User - {{ user.username }}</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Edit User</h1>
|
||||||
|
<p>Modify user details for: <strong>{{ user.username }}</strong></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
|
||||||
|
{% if error %}
|
||||||
|
<div class="import-results error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" style="margin-top: 24px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" value="{{ user.username }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" value="{{ user.email }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Full Name</label>
|
||||||
|
<input type="text" id="name" name="name" value="{{ user.name }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_admin" {% if user.is_admin %}checked{% endif %}>
|
||||||
|
Admin User
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="is_active" {% if user.is_active %}checked{% endif %}>
|
||||||
|
Account Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="role">Role</label>
|
||||||
|
<select id="role" name="role" required>
|
||||||
|
<option value="user" {% if user.role == 'user' %}selected{% endif %}>User</option>
|
||||||
|
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>Admin</option>
|
||||||
|
<option value="moderator" {% if user.role == 'moderator' %}selected{% endif %}>Moderator</option>
|
||||||
|
<option value="readonly" {% if user.role == 'readonly' %}selected{% endif %}>Read-Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="permissions">Permissions (comma-separated)</label>
|
||||||
|
<input type="text" id="permissions" name="permissions" value="{{ user.get_permissions()|join(', ') }}" placeholder="e.g. read:data, write:data, manage:users">
|
||||||
|
<small style="color: var(--text-secondary); display: block; margin-top: 8px;">
|
||||||
|
Common permissions: read:data, write:data, manage:users, manage:settings
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_password">New Password (leave empty to keep current)</label>
|
||||||
|
<input type="password" id="new_password" name="new_password" placeholder="Optional: Set new password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<a href="/admin/users">
|
||||||
|
<button type="button" class="danger">Cancel</button>
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="secondary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
63
project-management/feedback/templates/admin/login.html
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Login</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
<p>User Administration Access</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 450px; margin: 0 auto;">
|
||||||
|
{% if error %}
|
||||||
|
<div class="import-results error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" style="margin-top: 24px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Admin Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="Enter admin username" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Enter password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" style="width: 100%; margin-top: 8px;">Admin Login</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
|
||||||
|
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
99
project-management/feedback/templates/admin/user_tokens.html
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>User Tokens</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Access Tokens for {{ target_user.username }}</h1>
|
||||||
|
<p>Logged in as admin: <strong>{{ admin_user.username }}</strong></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="import-results success" style="max-width: 100%; margin-bottom: 16px;">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if new_token %}
|
||||||
|
<div class="import-results warning" style="max-width: 100%; margin-bottom: 16px; word-break: break-all;">
|
||||||
|
New Token (copy now): <code>{{ new_token }}</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="import-results" style="background: #3b1a1a; color: #f0b6b6; max-width: 100%; margin-bottom: 16px;">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 640px; margin: 0 auto;">
|
||||||
|
<h2>Create Initial Access Token</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Scope</label>
|
||||||
|
<input type="text" name="scope" value="dcr:register">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>TTL (days)</label>
|
||||||
|
<input type="number" name="ttl_days" value="365" min="1" max="3650">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Create Token</button>
|
||||||
|
<a href="/admin/users" style="margin-left: 8px;">Back</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container" style="margin-top: 24px;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Token</th>
|
||||||
|
<th>Scope</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for token in tokens %}
|
||||||
|
<tr>
|
||||||
|
<td style="max-width: 320px; word-break: break-all;"><code>{{ token.token }}</code></td>
|
||||||
|
<td>{{ token.scope }}</td>
|
||||||
|
<td>{{ token.expires_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if token.revoked %}
|
||||||
|
<span class="status-badge status-retired">Revoked</span>
|
||||||
|
{% elif token.is_valid %}
|
||||||
|
<span class="status-badge status-available">Valid</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge status-retired">Expired</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
|
||||||
|
<input type="hidden" name="action" value="revoke">
|
||||||
|
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;">Revoke</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
|
||||||
|
<input type="hidden" name="action" value="update">
|
||||||
|
<input type="text" name="scope" value="{{ token.scope }}" style="width: 140px; font-size: 0.8rem;" aria-label="Scope">
|
||||||
|
<input type="number" name="ttl_days" value="365" min="1" max="3650" style="width: 70px; font-size: 0.8rem;" aria-label="TTL days">
|
||||||
|
<button type="submit" class="secondary" style="padding: 6px 10px; font-size: 0.8rem;">Update</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block;">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;" onclick="return confirm('Delete this token?');">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
79
project-management/feedback/templates/change_password.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OIDC IdP - Change Password</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Change Password</h1>
|
||||||
|
<p>Update your account security</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 500px; margin: 0 auto;">
|
||||||
|
{% if error %}
|
||||||
|
<div class="import-results error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div class="import-results success">
|
||||||
|
<strong>Success:</strong> {{ success }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" style="margin-top: 24px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="Your username" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="current_password">Current Password</label>
|
||||||
|
<input type="password" id="current_password" name="current_password" placeholder="Enter current password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_password">New Password</label>
|
||||||
|
<input type="password" id="new_password" name="new_password" placeholder="Min. 8 characters" required minlength="8">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_password_confirm">Confirm New Password</label>
|
||||||
|
<input type="password" id="new_password_confirm" name="new_password_confirm" placeholder="Repeat new password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" style="width: 100%; margin-top: 8px;">Update Password</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
|
||||||
|
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
86
project-management/feedback/templates/dashboard.html
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>User Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>👤 User Dashboard</h1>
|
||||||
|
<p>Logged in as: <strong>{{ user.username }}</strong></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: var(--text-main); margin-bottom: 20px;">Your Information</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" value="{{ user.username }}" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="text" value="{{ user.email }}" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Full Name</label>
|
||||||
|
<input type="text" value="{{ user.name }}" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Role</label>
|
||||||
|
<input type="text" value="{{ user.role }}" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Permissions</label>
|
||||||
|
<input type="text" value="{{ user.get_permissions()|join(', ') }}" readonly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Account Status</label>
|
||||||
|
<input type="text" value="{% if user.is_active %}Active{% else %}Inactive{% endif %}" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls" style="margin-top: 24px;">
|
||||||
|
<a href="/my-sessions" style="text-decoration: none;">
|
||||||
|
<button>📊 My Sessions</button>
|
||||||
|
</a>
|
||||||
|
<a href="/change-password" style="text-decoration: none;">
|
||||||
|
<button>🔑 Change Password</button>
|
||||||
|
</a>
|
||||||
|
<a href="/my-tokens" style="text-decoration: none;">
|
||||||
|
<button>🎟️ My Tokens</button>
|
||||||
|
</a>
|
||||||
|
<a href="/logout" style="text-decoration: none;">
|
||||||
|
<button class="danger">Logout</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
60
project-management/feedback/templates/index.html
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OIDC Identity Provider</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🔐 OIDC Identity Provider</h1>
|
||||||
|
<p>Secure authentication server for your services</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 700px; margin: 0 auto;">
|
||||||
|
<h2 style="color: var(--text-main); margin-bottom: 20px;">Welcome</h2>
|
||||||
|
<p style="color: var(--text-secondary); line-height: 1.6;">
|
||||||
|
This is an OpenID Connect (OIDC) Identity Provider that enables secure authentication
|
||||||
|
for your applications using industry-standard protocols.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls" style="margin-top: 32px; justify-content: center;">
|
||||||
|
<a href="/login" style="text-decoration: none;">
|
||||||
|
<button>🔑 Login</button>
|
||||||
|
</a>
|
||||||
|
<a href="/register" style="text-decoration: none;">
|
||||||
|
<button class="secondary">📝 Register</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
|
||||||
|
<p style="color: var(--text-secondary); font-size: 0.9rem;">
|
||||||
|
Administrators: <a href="/admin/login" style="color: var(--primary); text-decoration: none;">Access admin panel</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
71
project-management/feedback/templates/login.html
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OIDC IdP - Login</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🔐 Homelab OIDC Login</h1>
|
||||||
|
<p>Secure authentication for your homelab services</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 450px; margin: 0 auto;">
|
||||||
|
{% if error %}
|
||||||
|
<div class="import-results error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if success %}
|
||||||
|
<div class="import-results success">
|
||||||
|
<strong>Success:</strong> {{ success }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" style="margin-top: 24px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="Enter your username" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Enter your password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" style="width: 100%; margin-top: 8px;">Sign In</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">Don't have an account?</p>
|
||||||
|
<a href="/register" style="color: var(--primary); text-decoration: none; font-weight: 600;">Create new account →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved theme
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
79
project-management/feedback/templates/register.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OIDC IdP - Registration</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Create New Account</h1>
|
||||||
|
<p>Join your homelab authentication system</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 500px; margin: 0 auto;">
|
||||||
|
{% if error %}
|
||||||
|
<div class="import-results error">
|
||||||
|
<strong>Error:</strong> {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" style="margin-top: 24px;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="Choose a username" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email" placeholder="your.email@homelab.local" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Full Name</label>
|
||||||
|
<input type="text" id="name" name="name" placeholder="John Doe" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" placeholder="Min. 8 characters" required minlength="8">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password_confirm">Confirm Password</label>
|
||||||
|
<input type="password" id="password_confirm" name="password_confirm" placeholder="Repeat your password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="secondary" style="width: 100%; margin-top: 8px;">Create Account</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">Already have an account?</p>
|
||||||
|
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
103
project-management/feedback/templates/user_analytics.html
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>My Active Sessions</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>My Active Sessions</h1>
|
||||||
|
<p>{{ user.name }} ({{ user.email }})</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 24px;">
|
||||||
|
<a href="/dashboard" style="text-decoration: none;">
|
||||||
|
<button>Back to Dashboard</button>
|
||||||
|
</a>
|
||||||
|
<a href="/logout" style="text-decoration: none;">
|
||||||
|
<button class="danger">Logout</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="analytics-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 32px;">
|
||||||
|
<div class="analytics-card" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px;">
|
||||||
|
<h3 style="font-size: 14px; color: var(--text-secondary); margin-bottom: 8px;">Active Sessions</h3>
|
||||||
|
<div class="metric" style="font-size: 32px; font-weight: 600; color: var(--primary-color);">{{ summary.total_active_sessions }}</div>
|
||||||
|
<div class="label" style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">Currently active</div>
|
||||||
|
</div>
|
||||||
|
<div class="analytics-card" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px;">
|
||||||
|
<h3 style="font-size: 14px; color: var(--text-secondary); margin-bottom: 8px;">Applications</h3>
|
||||||
|
<div class="metric" style="font-size: 32px; font-weight: 600; color: var(--primary-color);">{{ summary.total_clients }}</div>
|
||||||
|
<div class="label" style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">You're using</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Sessions -->
|
||||||
|
<h2 style="margin-bottom: 20px;">Active Sessions</h2>
|
||||||
|
|
||||||
|
{% if active_sessions %}
|
||||||
|
{% set current_client = namespace(value='') %}
|
||||||
|
{% for session in active_sessions %}
|
||||||
|
{% if session.client_name != current_client.value %}
|
||||||
|
{% set current_client.value = session.client_name %}
|
||||||
|
{% if not loop.first %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="client-section" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px; margin-bottom: 16px;">
|
||||||
|
<h3 style="margin-bottom: 16px;">{{ session.client_name }}</h3>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="session-item" style="padding: 16px; background: var(--bg-primary); border-radius: 6px; margin-bottom: 12px;">
|
||||||
|
<div class="session-info" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<div>
|
||||||
|
<strong>Session</strong>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge status-available" style="padding: 4px 12px; background: #10b981; color: white; border-radius: 4px; font-size: 12px;">Active</span>
|
||||||
|
</div>
|
||||||
|
<div class="session-meta" style="font-size: 14px; color: var(--text-secondary);">
|
||||||
|
Created: {{ session.created_at.strftime('%Y-%m-%d %H:%M:%S') }} |
|
||||||
|
Expires: {{ session.expires_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if loop.last %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="import-results" style="background: var(--bg-secondary); padding: 20px; border-radius: 8px;">
|
||||||
|
No active sessions. Log in to an application to see sessions here.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
setTimeout(function() {
|
||||||
|
location.reload();
|
||||||
|
}, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
99
project-management/feedback/templates/user_tokens.html
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>My Tokens</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Initial Access Tokens</h1>
|
||||||
|
<p>Logged in as: <strong>{{ user.username }}</strong></p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="import-results success" style="max-width: 100%; margin-bottom: 16px;">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if new_token %}
|
||||||
|
<div class="import-results warning" style="max-width: 100%; margin-bottom: 16px; word-break: break-all;">
|
||||||
|
New Token (copy now): <code>{{ new_token }}</code>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="import-results" style="background: #3b1a1a; color: #f0b6b6; max-width: 100%; margin-bottom: 16px;">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="modal-content" style="max-width: 640px; margin: 0 auto;">
|
||||||
|
<h2>Create Initial Access Token</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Scope</label>
|
||||||
|
<input type="text" name="scope" value="dcr:register">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>TTL (days)</label>
|
||||||
|
<input type="number" name="ttl_days" value="365" min="1" max="3650">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Create Token</button>
|
||||||
|
<a href="/dashboard" style="margin-left: 8px;">Back</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container" style="margin-top: 24px;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Token</th>
|
||||||
|
<th>Scope</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for token in tokens %}
|
||||||
|
<tr>
|
||||||
|
<td style="max-width: 320px; word-break: break-all;"><code>{{ token.token }}</code></td>
|
||||||
|
<td>{{ token.scope }}</td>
|
||||||
|
<td>{{ token.expires_at.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if token.revoked %}
|
||||||
|
<span class="status-badge status-retired">Revoked</span>
|
||||||
|
{% elif token.is_valid %}
|
||||||
|
<span class="status-badge status-available">Valid</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge status-retired">Expired</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
|
||||||
|
<input type="hidden" name="action" value="revoke">
|
||||||
|
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;">Revoke</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
|
||||||
|
<input type="hidden" name="action" value="update">
|
||||||
|
<input type="text" name="scope" value="{{ token.scope }}" style="width: 140px; font-size: 0.8rem;" aria-label="Scope">
|
||||||
|
<input type="number" name="ttl_days" value="365" min="1" max="3650" style="width: 70px; font-size: 0.8rem;" aria-label="TTL days">
|
||||||
|
<button type="submit" class="secondary" style="padding: 6px 10px; font-size: 0.8rem;">Update</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block;">
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;" onclick="return confirm('Delete this token?');">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -39,3 +39,8 @@ Ermoegliche einen robusten Client-Update-Flow mit Verifikation und Rollback.
|
|||||||
- US_000026: Client bezieht Updates (Pull)
|
- US_000026: Client bezieht Updates (Pull)
|
||||||
- US_000027: Client verifiziert und wendet Updates an
|
- US_000027: Client verifiziert und wendet Updates an
|
||||||
- US_000028: Client meldet Update-Status
|
- 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
|
||||||
|
|||||||
45
project-management/requirements/epics/EPIC_000010.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
ID: EPIC_000010 | Version: 0.1.5 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# EPIC_000010: Update-Service v1 Migration (Major Release)
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
Migration des Update-Clients auf den neuen v1 Update-Service mit verpflichtender Authentifizierung
|
||||||
|
und Enrollment-Flow fuer Langzeit-Tokens. Diese Umstellung ist ein Major Release.
|
||||||
|
|
||||||
|
## Ziel / Business Value
|
||||||
|
Sicheres, standardisiertes Update-Management mit verpflichtender Auth und nachvollziehbarem Status-Reporting.
|
||||||
|
|
||||||
|
## Mission Statement
|
||||||
|
Stelle sicher, dass der Client die v1 Endpunkte nutzen kann, inkl. Enrollment und
|
||||||
|
neuem Status-Schema.
|
||||||
|
|
||||||
|
## Business Value & Metriken
|
||||||
|
- Security: Auth ist obligatorisch fuer alle Requests.
|
||||||
|
- Erfolgsmetrik: 100% der Clients koennen per v1 manifest/artifact/status arbeiten.
|
||||||
|
|
||||||
|
## In-Scope (Kiddo Team)
|
||||||
|
- Enrollment-Flow fuer Langzeit-Token (mit Pre-Shared Token).
|
||||||
|
- Update-Client auf v1 Endpunkte umstellen.
|
||||||
|
- Status-Payload auf v1 Schema umstellen.
|
||||||
|
- Migration-Notiz/Docs fuer Client-Dev.
|
||||||
|
|
||||||
|
## Out-of-Scope
|
||||||
|
- Betrieb/Hosting des Update-Services.
|
||||||
|
- Ausgabe/Verwaltung von Pre-Shared Tokens auf Server-Seite.
|
||||||
|
|
||||||
|
## High-Level Akzeptanzkriterien
|
||||||
|
- Auth ist Pflicht (Bearer Token) fuer Manifest, Artifact und Status.
|
||||||
|
- Enrollment liefert Langzeit-Token, der lokal gespeichert wird.
|
||||||
|
- v1 Endpunkte werden genutzt:
|
||||||
|
- GET /v1/projects/{project_id}/manifest
|
||||||
|
- GET /v1/projects/{project_id}/releases/{version}/artifact
|
||||||
|
- POST /v1/projects/{project_id}/status
|
||||||
|
|
||||||
|
## Technische Constraints & Risiken
|
||||||
|
- Major Release: Rollout-Strategie und Backward Compatibility klaeren.
|
||||||
|
- Token-Handling und sichere lokale Speicherung.
|
||||||
|
|
||||||
|
## Zugeordnete User Stories
|
||||||
|
- US_000034: Enrollment fuer Langzeit-Token
|
||||||
|
- US_000035: v1 Update-Endpoints und Status-Schema
|
||||||
@ -1,9 +1,9 @@
|
|||||||
ID: US_000024 | Version: 0.1.0 | Status: Draft
|
ID: US_000024 | Version: 0.1.0 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# US_000024: Watchtower Theme fuer Web-UI
|
# US_000024: Watchtower Theme fuer Web-UI
|
||||||
|
|
||||||
Status: Zurueckgestellt
|
Status: Done
|
||||||
|
|
||||||
Als Admin moechte ich das Watchtower-Design verwenden, damit die Web-UI dem vereinbarten Dark-Mode-Branding entspricht.
|
Als Admin moechte ich das Watchtower-Design verwenden, damit die Web-UI dem vereinbarten Dark-Mode-Branding entspricht.
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
ID: US_000026 | Version: 0.1.0 | Status: Draft
|
ID: US_000026 | Version: 0.1.0 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# US_000026: Client bezieht Updates (Pull)
|
# US_000026: Client bezieht Updates (Pull)
|
||||||
|
|
||||||
Status: In Progress
|
Status: Done
|
||||||
|
|
||||||
Als Betreiber moechte ich, dass der Client Updates per Pull von einem Update-Service bezieht, damit Deployments ohne SSH moeglich sind.
|
Als Betreiber moechte ich, dass der Client Updates per Pull von einem Update-Service bezieht, damit Deployments ohne SSH moeglich sind.
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
ID: US_000027 | Version: 0.1.0 | Status: Draft
|
ID: US_000027 | Version: 0.1.0 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# US_000027: Client verifiziert und wendet Updates an
|
# US_000027: Client verifiziert und wendet Updates an
|
||||||
|
|
||||||
Status: In Progress
|
Status: Done
|
||||||
|
|
||||||
Als Betreiber moechte ich, dass der Client Updates verifiziert und sicher anwendet, damit fehlerhafte Pakete keine Ausfaelle verursachen.
|
Als Betreiber moechte ich, dass der Client Updates verifiziert und sicher anwendet, damit fehlerhafte Pakete keine Ausfaelle verursachen.
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
ID: US_000028 | Version: 0.1.0 | Status: Draft
|
ID: US_000028 | Version: 0.1.0 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# US_000028: Client meldet Update-Status
|
# US_000028: Client meldet Update-Status
|
||||||
|
|
||||||
Status: In Progress
|
Status: Done
|
||||||
|
|
||||||
Als Betreiber moechte ich Statusmeldungen vom Client erhalten, damit Update-Ergebnisse nachvollziehbar sind.
|
Als Betreiber moechte ich Statusmeldungen vom Client erhalten, damit Update-Ergebnisse nachvollziehbar sind.
|
||||||
|
|
||||||
|
|||||||
17
project-management/requirements/stories/US_000029.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
ID: US_000029 | Version: 0.1.0 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000029: Update-Status im Web-UI anzeigen
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich die aktuelle Version und den Update-Status im Web-UI sehen, damit ich den Zustand schnell pruefen kann.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given die Web-UI ist erreichbar
|
||||||
|
- When ich den Update-Bereich aufrufe
|
||||||
|
- Then ich sehe die aktuell laufende Version
|
||||||
|
- And ich sehe den letzten Update-Status (success/failed) mit Zeitstempel
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000030: UI update status view (Details bei Story-Start)
|
||||||
17
project-management/requirements/stories/US_000030.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
ID: US_000030 | Version: 0.1.0 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000030: Update-Check im Web-UI ausloesen
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich manuell nach Updates suchen koennen, damit ich Updates sofort pruefen kann.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given die Web-UI ist erreichbar
|
||||||
|
- When ich auf "Nach Updates suchen" klicke
|
||||||
|
- Then wird ein Check gegen `https://update.wlkns.org` gestartet
|
||||||
|
- And das Ergebnis (neue Version verfuegbar/keine Updates/Fehler) wird angezeigt
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000031: UI update check trigger (Details bei Story-Start)
|
||||||
19
project-management/requirements/stories/US_000031.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
ID: US_000031 | Version: 0.1.0 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000031: Update im Web-UI anstossen
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich ein Update im Web-UI anstossen, damit der Client die neue Version installiert.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given ein Update ist verfuegbar
|
||||||
|
- And eine vorherige Version ist gesichert oder wird vor dem Start gesichert
|
||||||
|
- When ich "Update installieren" ausloese
|
||||||
|
- Then wird der Update-Client gestartet
|
||||||
|
- And der Fortschritt/Status wird im UI angezeigt
|
||||||
|
- And Fehler werden klar im UI gemeldet
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000032: UI update apply action (Details bei Story-Start)
|
||||||
17
project-management/requirements/stories/US_000032.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
ID: US_000032 | Version: 0.1.0 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000032: Update-Logs im Web-UI anzeigen
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich Update-Logs im Web-UI einsehen, damit Fehler nachvollziehbar sind.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given ein Update-Versuch wurde ausgefuehrt
|
||||||
|
- When ich die Update-Logs oeffne
|
||||||
|
- Then sehe ich eine chronologische Liste mit Zeitstempel und Ergebnis
|
||||||
|
- And sensible Daten (Tokens) werden nicht angezeigt
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000033: UI update logs view (Details bei Story-Start)
|
||||||
19
project-management/requirements/stories/US_000033.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
ID: US_000033 | Version: 0.1.0 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000033: Rollback im Web-UI anstossen
|
||||||
|
|
||||||
|
Status: Done
|
||||||
|
|
||||||
|
Als Admin moechte ich einen Rollback im Web-UI anstossen, damit ich nach einem fehlerhaften Update schnell zur letzten Version zurueckkehre.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given ein vorheriger Update-Stand ist verfuegbar und wurde gesichert
|
||||||
|
- And der Rollback darf nur starten, wenn das Backup verfuegbar ist
|
||||||
|
- When ich "Rollback" ausloese
|
||||||
|
- Then wird der Rollback-Mechanismus gestartet
|
||||||
|
- And der Status/Fortschritt wird im UI angezeigt
|
||||||
|
- And Fehler werden klar im UI gemeldet
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000034: UI rollback action (Details bei Story-Start)
|
||||||
17
project-management/requirements/stories/US_000034.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
ID: US_000034 | Version: 0.1.5 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000034: Enrollment fuer Langzeit-Token
|
||||||
|
|
||||||
|
Als Betreiber moechte ich, dass der Client einmalig einen Langzeit-Token per Enrollment bezieht,
|
||||||
|
damit alle Update-Requests verpflichtend authentifiziert sind.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given ein Pre-Shared Token (von Admin bereitgestellt)
|
||||||
|
- When der Client einen Enrollment-Request stellt
|
||||||
|
- Then erhaelt er einen Langzeit-Token
|
||||||
|
- And der Client speichert den Token lokal und nutzt ihn fuer alle Update-Requests
|
||||||
|
- And Enrollment-Endpoint/Details werden per Spezifikation festgelegt
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000040: Enrollment-Flow implementieren (Details bei Story-Start)
|
||||||
22
project-management/requirements/stories/US_000035.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
ID: US_000035 | Version: 0.1.5 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# US_000035: v1 Update-Endpoints und Status-Schema
|
||||||
|
|
||||||
|
Als Betreiber moechte ich, dass der Client die neuen v1 Endpunkte fuer Manifest, Artefakt-Download
|
||||||
|
und Status-Reporting nutzt, damit der Update-Service konsistent und sicher angesprochen wird.
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- Given ein konfiguriertes project_id und Bearer Token
|
||||||
|
- When der Client Updates prueft
|
||||||
|
- Then nutzt er GET /v1/projects/{project_id}/manifest
|
||||||
|
- And das Manifest enthaelt version, artifact_url, sha256, optional sig_url
|
||||||
|
- When der Client ein Artefakt herunterlaedt
|
||||||
|
- Then nutzt er GET /v1/projects/{project_id}/releases/{version}/artifact
|
||||||
|
- When der Client Status meldet
|
||||||
|
- Then nutzt er POST /v1/projects/{project_id}/status
|
||||||
|
- And die Payload enthaelt project_id, version (SemVer), status (definierte Werte),
|
||||||
|
optional client_id, duration_ms, error_code
|
||||||
|
|
||||||
|
## Task-Platzhalter
|
||||||
|
- TASK_000041: v1 Endpunkte im Update-Client umstellen (Details bei Story-Start)
|
||||||
@ -1,4 +1,4 @@
|
|||||||
ID: TASK_000027 | Version: 0.1.0 | Status: In Progress
|
ID: TASK_000027 | Version: 0.1.0 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# TASK_000027: Update endpoint config
|
# TASK_000027: Update endpoint config
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
ID: TASK_000028 | Version: 0.1.0 | Status: In Progress
|
ID: TASK_000028 | Version: 0.1.0 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# TASK_000028: Verify and apply update
|
# TASK_000028: Verify and apply update
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
ID: TASK_000029 | Version: 0.1.0 | Status: In Progress
|
ID: TASK_000029 | Version: 0.1.0 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# TASK_000029: Report update status
|
# TASK_000029: Report update status
|
||||||
|
|||||||
19
project-management/requirements/tasks/TASK_000030.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
ID: TASK_000030 | Version: 0.1.0 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000030: UI update status view
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Web-UI zeigt aktuelle Version und letzten Update-Status.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000029
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- Update-Status und Version im UI anzeigen.
|
||||||
|
- Letzten Status mit Zeitstempel visualisieren.
|
||||||
|
- API-Basis: `GET /update/status` (siehe `docs/update-api.md`).
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- UI zeigt Version + letzten Status.
|
||||||
|
- Fehler/keine Daten werden sauber angezeigt.
|
||||||
19
project-management/requirements/tasks/TASK_000031.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
ID: TASK_000031 | Version: 0.1.0 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000031: UI update check trigger
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Web-UI kann einen Update-Check gegen `https://update.wlkns.org` ausloesen.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000030
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- Button/Action fuer "Nach Updates suchen".
|
||||||
|
- Ergebnisanzeige (Update verfuegbar/keine Updates/Fehler).
|
||||||
|
- API-Basis: `POST /update/check` (siehe `docs/update-api.md`).
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- UI zeigt Ergebnis des Update-Checks.
|
||||||
|
- Fehler werden klar angezeigt.
|
||||||
21
project-management/requirements/tasks/TASK_000032.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
ID: TASK_000032 | Version: 0.1.0 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000032: UI update apply action
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Web-UI kann ein Update installieren.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000031
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- "Update installieren" Action.
|
||||||
|
- Vor Start Backup-Pruefung/Backup-Erstellung erzwingen.
|
||||||
|
- Fortschritt/Status im UI.
|
||||||
|
- API-Basis: `POST /update/apply` (siehe `docs/update-api.md`).
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- Update startet nur mit vorhandenem Backup.
|
||||||
|
- UI zeigt Fortschritt/Status.
|
||||||
|
- Fehler sind sichtbar.
|
||||||
19
project-management/requirements/tasks/TASK_000033.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
ID: TASK_000033 | Version: 0.1.0 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000033: UI update logs view
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Web-UI zeigt Update-Logs chronologisch.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000032
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- Logliste mit Zeitstempel und Ergebnis.
|
||||||
|
- Keine sensiblen Daten anzeigen.
|
||||||
|
- API-Basis: `GET /update/logs` (siehe `docs/update-api.md`).
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- Logs sind sichtbar und chronologisch sortiert.
|
||||||
|
- Sensible Daten sind ausgefiltert.
|
||||||
20
project-management/requirements/tasks/TASK_000034.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
ID: TASK_000034 | Version: 0.1.0 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000034: UI rollback action
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Web-UI kann einen Rollback ausloesen.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000033
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- "Rollback" Action.
|
||||||
|
- Nur aktiv, wenn Backup vorhanden.
|
||||||
|
- Fortschritt/Status im UI.
|
||||||
|
- API-Basis: `POST /update/rollback` (siehe `docs/update-api.md`).
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- Rollback startet nur bei verfuegbarem Backup.
|
||||||
|
- UI zeigt Status/Fehler.
|
||||||
20
project-management/requirements/tasks/TASK_000035.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
ID: TASK_000035 | Version: 0.1.1 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000035: Login-Landing und Header-Version
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Login ist die Landing-Page; nach erfolgreicher Auth wird auf die Hauptansicht gewechselt und die Version ist oben rechts im Header sichtbar.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000022
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- Login-Screen als Startansicht.
|
||||||
|
- Nach erfolgreicher Anmeldung: Umschalten auf Hauptansicht.
|
||||||
|
- Versionsnummer im Header oben rechts anzeigen (Datenquelle: Update-Status).
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- Landing-Login ist Standardansicht ohne aktive Session.
|
||||||
|
- Nach erfolgreichem Login wird die Hauptseite angezeigt.
|
||||||
|
- Header zeigt die aktuelle Version.
|
||||||
20
project-management/requirements/tasks/TASK_000036.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
ID: TASK_000036 | Version: 0.1.2 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000036: Login-Landing und Dashboard-Redirect
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Beim Aufruf der URL wird eine Login-Landing-Page gezeigt; nach erfolgreicher Anmeldung erfolgt die Weiterleitung auf das Dashboard unter eigener URL.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000022
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- Zusaetzliche Routen `/login` und `/dashboard` liefern die Web-UI.
|
||||||
|
- Nicht-authentifizierte Zugriffe auf `/dashboard` wechseln auf `/login`.
|
||||||
|
- Nach erfolgreichem Login wird die URL auf `/dashboard` gesetzt.
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- Login-Landing ist Standardansicht ohne aktive Session.
|
||||||
|
- Nach Login wird `/dashboard` angezeigt.
|
||||||
|
- `/login` und `/dashboard` laden die UI.
|
||||||
18
project-management/requirements/tasks/TASK_000037.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
ID: TASK_000037 | Version: 0.1.3 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000037: OIDC-Button neben Anmelden
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Der OIDC-Login-Button sitzt neben dem Anmelde-Button und ist nur aktiv, wenn der OIDC-Server erreichbar ist.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000022
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- OIDC-Button unterhalb der Login-Felder neben dem Anmelde-Button platzieren.
|
||||||
|
- Button deaktivieren, wenn `/login/oidc/status` keinen aktiven Server meldet oder nicht erreichbar ist.
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- OIDC-Button ist neben dem Anmelde-Button sichtbar.
|
||||||
|
- OIDC-Button ist standardmaessig deaktiviert und wird nur bei erreichbarem OIDC aktiviert.
|
||||||
18
project-management/requirements/tasks/TASK_000038.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
ID: TASK_000038 | Version: 0.1.4 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000038: Logo und Favicon einbinden
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Header zeigt das Safe-Kiddo-Logo unter dem Schriftzug und ein Favicon ist gesetzt.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000022
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- Logo (`assets/branding/logo.svg`) im Header unter der Titelzeile platzieren.
|
||||||
|
- Favicon (`assets/branding/favicon.svg`) einbinden.
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- Logo ist im Header sichtbar.
|
||||||
|
- Favicon wird im Browser-Tab angezeigt.
|
||||||
20
project-management/requirements/tasks/TASK_000039.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
ID: TASK_000039 | Version: 0.1.5 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000039: Login-Text und Panel-Layout
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Login-Text reduziert, Buttons symmetrisch, und Metrics/Nutzer-Panel wirken konsistent mit dem restlichen Design.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000022
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- "Anmeldung erforderlich" und Hinweistext im Login entfernen.
|
||||||
|
- ODER-Trenner entfernen.
|
||||||
|
- Login-Buttons gleichbreit nebeneinander ausrichten.
|
||||||
|
- Metrics und Sections als Panels gestalten.
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- Login-Text reduziert und Buttons symmetrisch.
|
||||||
|
- Metrics/Sections fügen sich visuell in das UI ein.
|
||||||
21
project-management/requirements/tasks/TASK_000040.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
ID: TASK_000040 | Version: 0.1.5 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000040: Enrollment-Flow implementieren
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Der Update-Client kann einmalig per Enrollment einen Langzeit-Token beziehen und lokal speichern.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000034
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- POST /v1/enroll mit { project_id, client_id, software_id, enroll_token }.
|
||||||
|
- Langzeit-Token in lokaler Datei speichern (Pfad und Berechtigung definieren).
|
||||||
|
- Token fuer alle weiteren Update-Requests nutzen.
|
||||||
|
- Fehlercodes behandeln (unauthorized, invalid_payload, already_enrolled).
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- Enrollment laeuft mit Pre-Shared Token durch und liefert Langzeit-Token.
|
||||||
|
- Token ist persistent und wird fuer Manifest/Artifact/Status verwendet.
|
||||||
|
- Fehlerszenarien sind nachvollziehbar geloggt.
|
||||||
22
project-management/requirements/tasks/TASK_000041.md
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
ID: TASK_000041 | Version: 0.1.5 | Status: Done
|
||||||
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
|
# TASK_000041: v1 Endpunkte im Update-Client umstellen
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
Manifest, Artefakt-Download und Status-Reporting laufen ueber die v1 Endpunkte des Update-Services.
|
||||||
|
|
||||||
|
## Story-Bezug
|
||||||
|
US_000035
|
||||||
|
|
||||||
|
## Beschreibung
|
||||||
|
- Manifest: GET /v1/projects/{project_id}/manifest.
|
||||||
|
- Artifact: GET /v1/projects/{project_id}/releases/{version}/artifact.
|
||||||
|
- Status: POST /v1/projects/{project_id}/status mit neuem Payload.
|
||||||
|
- Auth: Bearer Token ist Pflicht fuer alle Requests.
|
||||||
|
- project_id ist fix: safe-kiddo-control.
|
||||||
|
|
||||||
|
## Definition of Done (DoD)
|
||||||
|
- Update-Client nutzt v1 Endpunkte fuer alle Requests.
|
||||||
|
- Status-Payload entspricht dem neuen Schema.
|
||||||
|
- Fehlermeldungen sind klar und retry-faehig.
|
||||||
100
scripts/rollback_client.sh
Executable file
@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVICE_NAME="${SERVICE_NAME:-skd}"
|
||||||
|
UPDATE_SERVICE_URL="${SKD_UPDATE_SERVICE_URL:-https://update.wlkns.org}"
|
||||||
|
PROJECT_ID="${SKD_UPDATE_PROJECT_ID:-safe-kiddo-control}"
|
||||||
|
UPDATE_TOKEN="${SKD_UPDATE_TOKEN:-}"
|
||||||
|
STATUS_URL="${UPDATE_SERVICE_URL}/v1/projects/${PROJECT_ID}/status"
|
||||||
|
INSTALL_DIR="${INSTALL_DIR:-/opt/sk}"
|
||||||
|
STATUS_FILE="${SKD_UPDATE_STATUS_FILE:-/var/lib/skd/update_status.json}"
|
||||||
|
LOG_FILE="${SKD_UPDATE_LOG_FILE:-/var/lib/skd/update_logs.jsonl}"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_status() {
|
||||||
|
local status="$1"
|
||||||
|
local error="${2:-}"
|
||||||
|
local version="$3"
|
||||||
|
local timestamp
|
||||||
|
timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||||
|
SKD_STATUS="${status}" SKD_ERROR="${error}" SKD_VERSION="${version}" \
|
||||||
|
SKD_STATUS_FILE="${STATUS_FILE}" SKD_LOG_FILE="${LOG_FILE}" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
status = os.environ["SKD_STATUS"]
|
||||||
|
error = os.environ.get("SKD_ERROR", "")
|
||||||
|
version = os.environ.get("SKD_VERSION", "unknown")
|
||||||
|
status_file = Path(os.environ["SKD_STATUS_FILE"])
|
||||||
|
log_file = Path(os.environ["SKD_LOG_FILE"])
|
||||||
|
|
||||||
|
status_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"device_id": os.uname().nodename,
|
||||||
|
"version": version,
|
||||||
|
"status": status,
|
||||||
|
"error": error,
|
||||||
|
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
}
|
||||||
|
status_file.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
with log_file.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(payload) + "\n")
|
||||||
|
PY
|
||||||
|
local payload
|
||||||
|
payload=$(cat <<JSON
|
||||||
|
{
|
||||||
|
"project_id": "${PROJECT_ID}",
|
||||||
|
"version": "${version}",
|
||||||
|
"status": "${status}",
|
||||||
|
"timestamp": "${timestamp}",
|
||||||
|
"client_id": "$(hostname)",
|
||||||
|
"error_code": "${error}",
|
||||||
|
"reason": "${error}"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)
|
||||||
|
if [[ -n "${UPDATE_TOKEN}" ]]; then
|
||||||
|
curl -sS -X POST -H "Authorization: Bearer ${UPDATE_TOKEN}" -H "Content-Type: application/json" \
|
||||||
|
-d "${payload}" "${STATUS_URL}" >/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
LATEST_BACKUP="$(ls -dt /opt/sk_backup_* 2>/dev/null | head -1 || true)"
|
||||||
|
if [[ -z "${LATEST_BACKUP}" ]]; then
|
||||||
|
log "No backup found; rollback aborted."
|
||||||
|
write_status "failed" "no backup found" "unknown"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="unknown"
|
||||||
|
if [[ -f "${LATEST_BACKUP}/VERSION" ]]; then
|
||||||
|
VERSION="$(cat "${LATEST_BACKUP}/VERSION" | tr -d '\n')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Stopping service ${SERVICE_NAME}..."
|
||||||
|
sudo systemctl stop "${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
FAILED_DIR="${INSTALL_DIR}_failed_$(date +%s)"
|
||||||
|
log "Swapping ${INSTALL_DIR} -> ${FAILED_DIR}..."
|
||||||
|
if [[ -d "${INSTALL_DIR}" ]]; then
|
||||||
|
sudo mv "${INSTALL_DIR}" "${FAILED_DIR}"
|
||||||
|
fi
|
||||||
|
sudo mv "${LATEST_BACKUP}" "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
log "Starting service ${SERVICE_NAME}..."
|
||||||
|
if sudo systemctl start "${SERVICE_NAME}.service"; then
|
||||||
|
log "Rollback completed."
|
||||||
|
write_status "success" "" "${VERSION}"
|
||||||
|
else
|
||||||
|
log "Rollback failed."
|
||||||
|
write_status "failed" "service start failed" "${VERSION}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@ -2,9 +2,13 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SERVICE_NAME="${SERVICE_NAME:-skd}"
|
SERVICE_NAME="${SERVICE_NAME:-skd}"
|
||||||
UPDATE_URL="${SKD_UPDATE_URL:-https://update.wlkns.org}"
|
UPDATE_SERVICE_URL="${SKD_UPDATE_SERVICE_URL:-https://update.wlkns.org}"
|
||||||
|
PROJECT_ID="${SKD_UPDATE_PROJECT_ID:-safe-kiddo-control}"
|
||||||
UPDATE_TOKEN="${SKD_UPDATE_TOKEN:-}"
|
UPDATE_TOKEN="${SKD_UPDATE_TOKEN:-}"
|
||||||
STATUS_URL="${SKD_UPDATE_STATUS_URL:-https://update.wlkns.org/status}"
|
MANIFEST_URL="${UPDATE_SERVICE_URL}/v1/projects/${PROJECT_ID}/manifest"
|
||||||
|
STATUS_URL="${UPDATE_SERVICE_URL}/v1/projects/${PROJECT_ID}/status"
|
||||||
|
STATUS_FILE="${SKD_UPDATE_STATUS_FILE:-/var/lib/skd/update_status.json}"
|
||||||
|
LOG_FILE="${SKD_UPDATE_LOG_FILE:-/var/lib/skd/update_logs.jsonl}"
|
||||||
INSTALL_DIR="${INSTALL_DIR:-/opt/sk}"
|
INSTALL_DIR="${INSTALL_DIR:-/opt/sk}"
|
||||||
STAGING_DIR=""
|
STAGING_DIR=""
|
||||||
MANIFEST_FILE=""
|
MANIFEST_FILE=""
|
||||||
@ -20,9 +24,46 @@ report_status() {
|
|||||||
local error="${2:-}"
|
local error="${2:-}"
|
||||||
local timestamp
|
local timestamp
|
||||||
timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
device_id = os.uname().nodename
|
||||||
|
version = os.environ.get("SKD_VERSION", "unknown")
|
||||||
|
status = os.environ.get("SKD_STATUS", "unknown")
|
||||||
|
error = os.environ.get("SKD_ERROR", "")
|
||||||
|
timestamp = os.environ.get("SKD_TIMESTAMP") or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
status_file = Path(os.environ["SKD_STATUS_FILE"])
|
||||||
|
log_file = Path(os.environ["SKD_LOG_FILE"])
|
||||||
|
|
||||||
|
status_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"device_id": device_id,
|
||||||
|
"version": version,
|
||||||
|
"status": status,
|
||||||
|
"error": error,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
status_file.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
with log_file.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(payload) + "\n")
|
||||||
|
PY
|
||||||
local payload
|
local payload
|
||||||
payload=$(cat <<JSON
|
payload=$(cat <<JSON
|
||||||
{"device_id":"$(hostname)","version":"${VERSION}","status":"${status}","error":"${error}","timestamp":"${timestamp}"}
|
{
|
||||||
|
"project_id": "${PROJECT_ID}",
|
||||||
|
"version": "${VERSION}",
|
||||||
|
"status": "${status}",
|
||||||
|
"timestamp": "${timestamp}",
|
||||||
|
"client_id": "$(hostname)",
|
||||||
|
"error_code": "${error}",
|
||||||
|
"reason": "${error}"
|
||||||
|
}
|
||||||
JSON
|
JSON
|
||||||
)
|
)
|
||||||
if [[ -n "${UPDATE_TOKEN}" ]]; then
|
if [[ -n "${UPDATE_TOKEN}" ]]; then
|
||||||
@ -58,12 +99,12 @@ require_cmd tar
|
|||||||
require_cmd sha256sum
|
require_cmd sha256sum
|
||||||
require_cmd python3
|
require_cmd python3
|
||||||
|
|
||||||
log "Fetching update manifest from ${UPDATE_URL}..."
|
log "Fetching update manifest from ${MANIFEST_URL}..."
|
||||||
MANIFEST_FILE="$(mktemp)"
|
MANIFEST_FILE="$(mktemp)"
|
||||||
if [[ -n "${UPDATE_TOKEN}" ]]; then
|
if [[ -n "${UPDATE_TOKEN}" ]]; then
|
||||||
curl -fsS -H "Authorization: Bearer ${UPDATE_TOKEN}" "${UPDATE_URL}" -o "${MANIFEST_FILE}"
|
curl -fsS -H "Authorization: Bearer ${UPDATE_TOKEN}" "${MANIFEST_URL}" -o "${MANIFEST_FILE}"
|
||||||
else
|
else
|
||||||
curl -fsS "${UPDATE_URL}" -o "${MANIFEST_FILE}"
|
curl -fsS "${MANIFEST_URL}" -o "${MANIFEST_FILE}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
read_manifest() {
|
read_manifest() {
|
||||||
@ -99,7 +140,9 @@ log "Verifying checksum..."
|
|||||||
CALC_SHA="$(sha256sum "${ARTIFACT_FILE}" | awk '{print $1}')"
|
CALC_SHA="$(sha256sum "${ARTIFACT_FILE}" | awk '{print $1}')"
|
||||||
if [[ "${CALC_SHA}" != "${ARTIFACT_SHA256}" ]]; then
|
if [[ "${CALC_SHA}" != "${ARTIFACT_SHA256}" ]]; then
|
||||||
echo "Checksum mismatch: expected ${ARTIFACT_SHA256} got ${CALC_SHA}" >&2
|
echo "Checksum mismatch: expected ${ARTIFACT_SHA256} got ${CALC_SHA}" >&2
|
||||||
report_status "failed" "checksum mismatch"
|
SKD_VERSION="${VERSION}" SKD_STATUS="failed" SKD_ERROR="checksum mismatch" \
|
||||||
|
SKD_TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" SKD_STATUS_FILE="${STATUS_FILE}" \
|
||||||
|
SKD_LOG_FILE="${LOG_FILE}" report_status "failed" "checksum mismatch"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -121,7 +164,9 @@ STAGING_DIR=""
|
|||||||
log "Starting service ${SERVICE_NAME}..."
|
log "Starting service ${SERVICE_NAME}..."
|
||||||
if sudo systemctl start "${SERVICE_NAME}.service"; then
|
if sudo systemctl start "${SERVICE_NAME}.service"; then
|
||||||
log "Update applied successfully."
|
log "Update applied successfully."
|
||||||
report_status "success" ""
|
SKD_VERSION="${VERSION}" SKD_STATUS="success" SKD_ERROR="" \
|
||||||
|
SKD_TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" SKD_STATUS_FILE="${STATUS_FILE}" \
|
||||||
|
SKD_LOG_FILE="${LOG_FILE}" report_status "success" ""
|
||||||
else
|
else
|
||||||
log "Service failed to start, rolling back..."
|
log "Service failed to start, rolling back..."
|
||||||
sudo rm -rf "${INSTALL_DIR}"
|
sudo rm -rf "${INSTALL_DIR}"
|
||||||
@ -129,6 +174,8 @@ else
|
|||||||
sudo mv "${BACKUP_DIR}" "${INSTALL_DIR}"
|
sudo mv "${BACKUP_DIR}" "${INSTALL_DIR}"
|
||||||
fi
|
fi
|
||||||
sudo systemctl start "${SERVICE_NAME}.service" || true
|
sudo systemctl start "${SERVICE_NAME}.service" || true
|
||||||
report_status "failed" "service start failed"
|
SKD_VERSION="${VERSION}" SKD_STATUS="failed" SKD_ERROR="service start failed" \
|
||||||
|
SKD_TIMESTAMP="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" SKD_STATUS_FILE="${STATUS_FILE}" \
|
||||||
|
SKD_LOG_FILE="${LOG_FILE}" report_status "failed" "service start failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||