Compare commits

...

13 Commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 11:59:24 +01:00
69 changed files with 5158 additions and 297 deletions

View File

@ -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)
# Projekt-Logbuch (Changelog)
@ -39,6 +39,16 @@ 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

View File

@ -1 +1 @@
0.1.0
0.2.0

View File

@ -0,0 +1,7 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#1F2A37"/>
<rect x="12" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="40" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
<!-- Teal Keystone -->
<rect x="26" y="29" width="12" height="19" rx="2" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

15
assets/branding/logo.svg Normal file
View File

@ -0,0 +1,15 @@
<svg width="250" height="60" viewBox="0 0 250 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Monolithic Symbol (V2 Optimized - Teal) -->
<g transform="translate(10, 10)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<!-- Middle Block: Teal, 19px Height -->
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
<!-- Wordmark -->
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
<!-- The Underline: Teal, aligned like V8 -->
<rect x="56" y="48" width="121" height="4" rx="1" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@ -3,6 +3,7 @@ from typing import List
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from backend import actions
@ -18,6 +19,8 @@ from backend.auth import (
from backend.models import (
ActionRequest,
ActionResponse,
EnrollRequest,
EnrollResponse,
LoginRequest,
LoginResponse,
UpdateActionResponse,
@ -37,6 +40,8 @@ logging.basicConfig(
logger = logging.getLogger("skd")
app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
app.mount("/assets", StaticFiles(directory="assets"), name="assets")
templates = Jinja2Templates(directory="backend/templates")
@ -236,6 +241,24 @@ def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus:
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:
@ -292,5 +315,7 @@ def update_logs(settings: Settings = Depends(get_settings), limit: int = 200) ->
@app.get("/", response_class=HTMLResponse)
@app.get("/login", response_class=HTMLResponse)
@app.get("/dashboard", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse("index.html", {"request": request})

View File

@ -41,6 +41,16 @@ class UpdateStatus(BaseModel):
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):

View File

@ -40,7 +40,13 @@ class Settings:
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
self.dry_run: bool = os.getenv("SKD_DRY_RUN", "false").lower() == "true"
self.update_url: str = os.getenv("SKD_UPDATE_URL", "https://update.wlkns.org")
self.update_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_status_url: str = os.getenv(
"SKD_UPDATE_STATUS_URL", "https://update.wlkns.org/status"
@ -63,6 +69,18 @@ class Settings:
def _parse_list(value: str) -> List[str]:
return [item for item in (part.strip() for part in value.split(",")) if item]
def _load_update_token(self) -> str:
env_token = os.getenv("SKD_UPDATE_TOKEN", "")
if env_token:
return env_token
if os.path.exists(self.update_token_file):
try:
with open(self.update_token_file, "r", encoding="utf-8") as f:
return f.read().strip()
except OSError:
pass
return ""
@lru_cache(maxsize=1)
def get_settings() -> Settings:

901
backend/static/styles.css Executable file
View File

@ -0,0 +1,901 @@
/* Professional IT Asset Management - Best of Both Worlds with Dark Mode */
:root {
--bg-main: #f5f7fa;
--bg-panel: #ffffff;
--bg-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--text-main: #2d3748;
--text-secondary: #718096;
--text-light: #a0aec0;
--border-main: #e2e8f0;
--primary: #667eea;
--primary-hover: #5568d3;
--success: #48bb78;
--success-hover: #38a169;
--warning: #ed8936;
--danger: #f56565;
--danger-hover: #e53e3e;
--info: #4299e1;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
}
/* Dark Mode Theme */
body.dark-mode {
--bg-main: #1a202c;
--bg-panel: #2d3748;
--bg-header: linear-gradient(135deg, #4c51bf 0%, #6b46c1 100%);
--text-main: #f7fafc;
--text-secondary: #cbd5e0;
--text-light: #a0aec0;
--border-main: #4a5568;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 6px rgba(0,0,0,0.3);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--bg-main);
color: var(--text-main);
line-height: 1.6;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
bottom: 30px;
right: 30px;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary);
border: none;
cursor: pointer;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
z-index: 999;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.theme-toggle svg {
width: 24px;
height: 24px;
stroke: white;
fill: none;
}
.theme-toggle .sun-icon {
display: none;
}
body.dark-mode .theme-toggle .moon-icon {
display: none;
}
body.dark-mode .theme-toggle .sun-icon {
display: block;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.hidden {
display: none !important;
}
.login-landing {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: radial-gradient(circle at 20% 10%, rgba(102, 126, 234, 0.15), transparent 45%),
radial-gradient(circle at 80% 20%, rgba(72, 187, 120, 0.12), transparent 40%),
var(--bg-main);
}
.login-card {
width: min(520px, 100%);
background: var(--bg-panel);
border: 1px solid var(--border-main);
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-lg);
}
.login-brand {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.login-brand i {
width: 40px;
height: 40px;
color: var(--primary);
}
.login-brand h1 {
font-size: 1.6rem;
margin-bottom: 4px;
}
.login-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: center;
}
.login-actions button {
width: 100%;
}
/* Header - Keep the gradient but more professional */
header {
background: var(--bg-header);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
header p {
font-size: 1rem;
opacity: 0.95;
font-weight: 400;
}
.header-main {
display: flex;
align-items: center;
gap: 12px;
flex-direction: column;
align-items: flex-start;
}
.header-main i {
width: 32px;
height: 32px;
}
.header-logo {
height: 34px;
width: auto;
display: block;
}
.header-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.header-version {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
}
/* Statistics Dashboard - More visual interest */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-panel);
padding: 24px;
border-radius: 12px;
box-shadow: var(--shadow-md);
border-left: 4px solid var(--primary);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
background: var(--primary);
opacity: 0.05;
border-radius: 50%;
transform: translate(30%, -30%);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-left-width: 6px;
}
.stat-card h3 {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.stat-card .value {
font-size: 2.5rem;
font-weight: 700;
color: var(--primary);
position: relative;
z-index: 1;
}
/* Controls - Clean but distinctive */
.controls {
background: var(--bg-panel);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
border: 1px solid var(--border-main);
}
/* Inputs - More refined */
input, select {
padding: 11px 14px;
border: 2px solid var(--border-main);
border-radius: 8px;
font-size: 0.9rem;
background: var(--bg-panel);
color: var(--text-main);
transition: all 0.2s ease;
font-family: inherit;
}
input:focus, select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
input::placeholder {
color: var(--text-light);
}
.search-box {
flex: 1;
min-width: 250px;
}
/* Buttons - Keep gradient feel but professional */
button {
background: var(--primary);
color: white;
border: none;
padding: 11px 20px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
button:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
button:active {
transform: translateY(0);
}
button.secondary {
background: var(--success);
}
button.secondary:hover {
background: var(--success-hover);
}
button.danger {
background: var(--danger);
}
button.danger:hover {
background: var(--danger-hover);
}
/* Table Container - More polished */
.table-container {
background: var(--bg-panel);
border-radius: 12px;
box-shadow: var(--shadow-md);
overflow: hidden;
border: 1px solid var(--border-main);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 16px;
text-align: left;
}
th {
background: var(--bg-main);
font-weight: 600;
color: var(--text-main);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--border-main);
}
tbody tr {
border-bottom: 1px solid var(--border-main);
transition: background-color 0.15s ease;
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: var(--bg-main);
}
td {
color: var(--text-main);
}
td strong {
color: var(--primary);
font-weight: 600;
}
/* Status Badges - More colorful but professional */
.status-badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-available {
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
color: #22543d;
}
.status-in_use {
background: linear-gradient(135deg, #bee3f8 0%, #90cdf4 100%);
color: #2c5282;
}
.status-maintenance {
background: linear-gradient(135deg, #feebc8 0%, #fbd38d 100%);
color: #7c2d12;
}
.status-retired {
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%);
color: #2d3748;
}
.status-lost {
background: linear-gradient(135deg, #fed7d7 0%, #fc8181 100%);
color: #742a2a;
}
/* Modal - Elegant overlay */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal.active {
display: flex;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: var(--bg-panel);
border-radius: 16px;
padding: 32px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid var(--border-main);
}
.modal-header h2 {
color: var(--text-main);
font-size: 1.5rem;
font-weight: 700;
}
.close-btn {
background: var(--border-main);
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
width: 36px;
height: 36px;
line-height: 1;
border-radius: 8px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--primary);
color: white;
transform: rotate(90deg);
}
/* Form - Clean and accessible */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: var(--text-main);
font-size: 0.875rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
}
textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
padding: 11px 14px;
border: 2px solid var(--border-main);
border-radius: 8px;
font-size: 0.9rem;
background: var(--bg-panel);
color: var(--text-main);
transition: all 0.2s ease;
}
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 28px;
padding-top: 20px;
border-top: 2px solid var(--border-main);
}
/* Loading & Empty States */
.loading, .empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 20px;
opacity: 0.4;
stroke: var(--text-secondary);
}
.empty-state h3 {
color: var(--text-main);
margin-bottom: 8px;
font-weight: 600;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 8px;
}
.action-buttons button {
padding: 7px 14px;
font-size: 0.8rem;
}
/* Import Zone - Visual and inviting */
.import-zone {
border: 3px dashed var(--border-main);
border-radius: 12px;
padding: 48px;
text-align: center;
margin: 20px 0;
transition: all 0.3s ease;
cursor: pointer;
background: var(--bg-main);
}
.import-zone:hover, .import-zone.drag-over {
border-color: var(--primary);
background: var(--bg-panel);
transform: scale(1.02);
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
}
.import-zone.processing {
border-color: var(--success);
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
}
.import-zone svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
stroke: var(--primary);
}
.import-zone h3 {
color: var(--text-main);
font-weight: 600;
margin-bottom: 8px;
font-size: 1.125rem;
}
.import-zone p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.file-input {
display: none;
}
/* Import Results */
.import-results {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
border-left: 4px solid;
box-shadow: var(--shadow-sm);
}
.import-results.success {
background: #c6f6d5;
border-color: var(--success);
color: #22543d;
}
.import-results.error {
background: #fed7d7;
border-color: var(--danger);
color: #742a2a;
}
/* Progress Bar - More visual */
.progress-bar {
width: 100%;
height: 10px;
background: var(--border-main);
border-radius: 8px;
overflow: hidden;
margin: 12px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, var(--info) 100%);
transition: width 0.3s ease;
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.container {
padding: 12px;
}
header {
padding: 24px 20px;
}
header h1 {
font-size: 1.5rem;
}
.stats {
grid-template-columns: 1fr;
gap: 12px;
}
.controls {
flex-direction: column;
padding: 16px;
}
.search-box {
width: 100%;
}
button {
width: 100%;
}
.theme-toggle {
bottom: 20px;
right: 20px;
width: 48px;
height: 48px;
}
table {
font-size: 0.85rem;
}
th, td {
padding: 12px 8px;
}
.action-buttons {
flex-direction: column;
}
.action-buttons button {
width: 100%;
}
.modal-content {
padding: 24px 20px;
}
.login-card {
padding: 24px;
}
.header-meta {
align-items: flex-start;
}
.login-actions {
grid-template-columns: 1fr;
}
}
/* NEW STYLES FOR ENHANCED UI */
.error-card {
background-color: var(--danger);
color: white;
border-left-color: var(--danger-hover);
}
.error-card h3 {
color: white;
}
.loading-card {
text-align: center;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.stat-card details {
margin-top: 16px;
}
.stat-card summary {
cursor: pointer;
font-weight: 600;
color: var(--text-main);
margin-bottom: 8px;
}
.stat-card summary:hover {
color: var(--primary);
}
.flags-list {
list-style-type: none;
padding-left: 8px;
font-size: 0.9rem;
}
.flags-list li {
margin-bottom: 6px;
padding-left: 16px;
position: relative;
}
.flags-list li::before {
content: '›';
position: absolute;
left: 0;
color: var(--primary);
font-weight: bold;
}
.value.level-high {
color: var(--danger);
}
.value.level-medium {
color: var(--warning);
}
.value.level-low {
color: var(--success);
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.metric-card {
background: var(--bg-panel);
padding: 24px;
border-radius: 12px;
box-shadow: var(--shadow-md);
border-left: 4px solid var(--primary);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.metric-card::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
background: var(--primary);
opacity: 0.05;
border-radius: 50%;
transform: translate(30%, -30%);
}
.metric-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-left-width: 6px;
}
.metric-card .label {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.metric-card .value {
font-size: 2.5rem;
font-weight: 700;
color: var(--primary);
position: relative;
z-index: 1;
}
.panel-section {
background: var(--bg-panel);
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
border: 1px solid var(--border-main);
margin-bottom: 24px;
}
.panel-section h3 {
margin-bottom: 12px;
}

View File

@ -4,126 +4,220 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Safe Kiddo Control</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
<style>
body { max-width: 960px; margin: auto; padding: 1.5rem; }
.log { white-space: pre-line; }
form { margin-bottom: 1rem; }
</style>
<link rel="stylesheet" href="/static/styles.css" />
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg" />
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<!-- Dark Mode Toggle -->
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div id="loginLanding" class="login-landing">
<div class="login-card">
<div class="login-brand">
<i data-lucide="shield-check"></i>
<div>
<h1>Safe Kiddo Control</h1>
</div>
</div>
<section id="loginSection">
<h3><i data-lucide="log-in"></i> Anmeldung</h3>
<form id="loginForm">
<div class="grid">
<div class="form-group">
<label for="loginUser">Benutzer</label>
<input id="loginUser" name="loginUser" autocomplete="username" required />
</div>
<div class="form-group">
<label for="loginPass">Passwort</label>
<input id="loginPass" name="loginPass" type="password" autocomplete="current-password" required />
</div>
</div>
<div class="login-actions">
<button type="submit">
<i data-lucide="log-in"></i>
Anmelden
</button>
<button id="oidcLogin" type="button" class="secondary" disabled>
<i data-lucide="key-round"></i>
OIDC Login
</button>
</div>
</form>
</section>
</div>
</div>
<div id="appContainer" class="container hidden">
<!-- Header -->
<header>
<h1>Safe Kiddo Control</h1>
<p>Steuere Nutzerkonten über die lokale API. Stelle sicher, dass der API-Token gesetzt ist.</p>
<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>
<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">
<div class="grid">
<div>
<label for="loginUser">Benutzer</label>
<input id="loginUser" name="loginUser" autocomplete="username" required />
</div>
<div>
<label for="loginPass">Passwort</label>
<input id="loginPass" name="loginPass" type="password" autocomplete="current-password" required />
</div>
</div>
<button type="submit">Anmelden</button>
</form>
<div id="loginStatus" class="log"></div>
<!-- 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>
<h3>Status abrufen</h3>
<button id="refreshBtn">Status laden</button>
<div id="status" class="log"></div>
</section>
<!-- Update Management Section -->
<section id="updateSection" class="panel-section hidden">
<h3><i data-lucide="download"></i> Update-Verwaltung</h3>
<section>
<h3>Aktion ausführen</h3>
<form id="actionForm">
<div class="grid">
<div>
<label for="username">Benutzer</label>
<select id="username" name="username" required>
<option value="">-- wählen --</option>
</select>
</div>
<div>
<label for="action">Aktion</label>
<select id="action" name="action">
<option value="disable">Disable</option>
<option value="enable">Enable</option>
</select>
</div>
</div>
<div class="grid">
<div>
<label for="countdown">Countdown (Sekunden, optional)</label>
<input id="countdown" name="countdown" type="number" min="0" />
</div>
<div>
<label for="sound">Sound</label>
<select id="sound" name="sound">
<option value="">Default</option>
<option value="true">An</option>
<option value="false">Aus</option>
</select>
</div>
</div>
<label for="message">Nachricht (optional)</label>
<input id="message" name="message" />
<button type="submit">Senden</button>
</form>
<div id="result" class="log"></div>
</section>
<section>
<h3>Update-Verwaltung</h3>
<div id="updateStatus" class="log" style="margin-bottom: 1rem; padding: 1rem; background: var(--pico-card-background-color); border-radius: var(--pico-border-radius);">
Lade Update-Status...
<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">Nach Updates suchen</button>
<button id="applyUpdateBtn" type="button" disabled>Update installieren</button>
<button id="rollbackBtn" type="button">Rollback durchführen</button>
<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="log" style="margin-top: 1rem;"></div>
<div id="updateResult" class="mt-1"></div>
<details style="margin-top: 1.5rem;">
<summary>Update-Logs anzeigen</summary>
<button id="refreshLogsBtn" type="button" style="margin-top: 0.5rem;">Logs neu laden</button>
<div id="updateLogs" class="log" style="margin-top: 1rem; max-height: 400px; overflow-y: auto;"></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>
<script>
const statusDiv = document.getElementById('status');
const resultDiv = document.getElementById('result');
const loginStatus = document.getElementById('loginStatus');
const tokenKey = 'skdToken';
let currentToken = sessionStorage.getItem(tokenKey) || '';
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
function setToken(token) {
currentToken = token;
if (token) {
sessionStorage.setItem(tokenKey, token);
loginStatus.textContent = 'Angemeldet (Token gespeichert)';
} else {
sessionStorage.removeItem(tokenKey);
loginStatus.textContent = 'Nicht angemeldet';
}
}
setToken(currentToken);
<!-- Action Modal -->
<div class="modal" id="actionModal">
<div class="modal-content">
<div class="modal-header">
<h3>Aktion ausführen</h3>
<button class="secondary small" onclick="closeActionModal()">
<i data-lucide="x"></i>
</button>
</div>
<form id="actionForm">
<input type="hidden" id="modalUsername" />
<input type="hidden" id="modalAction" />
<div class="form-group">
<label for="countdown">Countdown (Sekunden)</label>
<input id="countdown" name="countdown" type="number" min="0" value="60" />
</div>
<div class="form-group">
<label for="sound">Sound abspielen</label>
<select id="sound" name="sound">
<option value="">Standard</option>
<option value="true">Ja</option>
<option value="false">Nein</option>
</select>
</div>
<div class="form-group">
<label for="message">Nachricht (optional)</label>
<input id="message" name="message" placeholder="z.B. Speichere deine Arbeit!" />
</div>
<div class="modal-actions">
<button type="button" class="secondary" onclick="closeActionModal()">Abbrechen</button>
<button type="submit" id="actionSubmitBtn">
<i data-lucide="play"></i>
Ausführen
</button>
</div>
</form>
</div>
</div>
<script>
// Initialize Lucide icons
lucide.createIcons();
// Global state
let currentToken = sessionStorage.getItem('skdToken') || '';
let latestUpdateCheck = null;
// Helper functions
function authHeaders() {
const headers = { 'Content-Type': 'application/json' };
if (currentToken) headers['Authorization'] = `Bearer ${currentToken}`;
@ -135,76 +229,265 @@
const res = await fetch(path, { ...options, headers, credentials: 'same-origin' });
if (!res.ok) {
const text = await res.text();
const error = new Error(text || `${res.status} ${res.statusText}`);
error.status = res.status;
throw error;
throw new Error(text || `${res.status} ${res.statusText}`);
}
return res.json();
}
// Toast notifications
function showToast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<i data-lucide="${type === 'success' ? 'check-circle' : type === 'error' ? 'alert-circle' : 'alert-triangle'}"></i>
<span>${message}</span>
`;
container.appendChild(toast);
lucide.createIcons();
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
// Modal functions
function openActionModal(username, action) {
document.getElementById('modalUsername').value = username;
document.getElementById('modalAction').value = action;
document.getElementById('actionModal').classList.add('active');
const title = document.querySelector('#actionModal h3');
title.textContent = action === 'disable' ? `${username} deaktivieren` : `${username} aktivieren`;
// Hide countdown/sound options for enable action
const countdownGroup = document.getElementById('countdown').closest('.form-group');
const soundGroup = document.getElementById('sound').closest('.form-group');
const messageGroup = document.getElementById('message').closest('.form-group');
if (action === 'enable') {
countdownGroup.style.display = 'none';
soundGroup.style.display = 'none';
messageGroup.style.display = 'none';
} else {
countdownGroup.style.display = 'block';
soundGroup.style.display = 'block';
messageGroup.style.display = 'block';
}
}
function closeActionModal() {
document.getElementById('actionModal').classList.remove('active');
document.getElementById('actionForm').reset();
}
// Authentication
function setPath(path) {
if (window.location.pathname !== path) {
history.replaceState(null, '', path);
}
}
function showLanding() {
document.getElementById('loginLanding').classList.remove('hidden');
document.getElementById('appContainer').classList.add('hidden');
document.getElementById('headerVersion').textContent = 'v-';
if (window.location.pathname !== '/login') {
setPath('/login');
}
}
function showApp() {
document.getElementById('loginLanding').classList.add('hidden');
document.getElementById('appContainer').classList.remove('hidden');
setPath('/dashboard');
}
async function checkSession() {
try {
const data = await api('/me');
loginStatus.textContent = `Angemeldet als ${data.user} (${data.auth_mode})`;
currentToken = sessionStorage.getItem('skdToken') || currentToken;
document.getElementById('currentUser').textContent = `Angemeldet als ${data.user} (${data.auth_mode})`;
showApp();
document.getElementById('userSection').classList.remove('hidden');
document.getElementById('updateSection').classList.remove('hidden');
await refreshUsers();
await refreshUpdateStatus();
return true;
} catch (err) {
if (err.status === 401) {
loginStatus.textContent = 'Nicht angemeldet';
} else {
loginStatus.textContent = `Session-Check fehlgeschlagen: ${err.message}`;
}
document.getElementById('currentUser').textContent = 'Nicht angemeldet';
showLanding();
document.getElementById('userSection').classList.add('hidden');
document.getElementById('updateSection').classList.add('hidden');
return false;
}
}
async function checkOidcStatus() {
const button = document.getElementById('oidcLogin');
try {
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.title = 'OIDC nicht konfiguriert';
button.title = 'OIDC nicht konfiguriert oder nicht erreichbar';
}
} catch (err) {
const button = document.getElementById('oidcLogin');
button.disabled = true;
button.title = 'OIDC-Status nicht erreichbar';
button.title = 'OIDC nicht erreichbar';
}
}
// User management
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 {
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');
select.innerHTML = '<option value="">-- wählen --</option>';
data.forEach(u => {
const opt = document.createElement('option');
opt.value = u.user;
opt.textContent = u.user;
select.appendChild(opt);
});
// Update metrics
document.querySelector('#metricUsers .value').textContent = data.length;
document.querySelector('#metricActive .value').textContent = data.filter(u => !u.account_locked).length;
document.querySelector('#metricLocked .value').textContent = data.filter(u => u.account_locked).length;
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Keine verwaltbaren Benutzer gefunden.</td></tr>';
return;
}
tbody.innerHTML = data.map(u => `
<tr>
<td><strong>${u.user}</strong></td>
<td>
<span class="badge ${u.account_locked ? 'error' : 'success'}">
<i data-lucide="${u.account_locked ? 'lock' : 'unlock'}"></i>
${u.account_locked ? 'Gesperrt' : 'Aktiv'}
</span>
</td>
<td>
<span class="badge ${u.logged_in ? 'success' : 'neutral'}">
<i data-lucide="${u.logged_in ? 'monitor' : 'monitor-off'}"></i>
${u.logged_in ? 'Online' : 'Offline'}
</span>
</td>
<td>
${u.account_locked
? `<button class="small secondary" onclick="openActionModal('${u.user}', 'enable')"><i data-lucide="unlock"></i> Entsperren</button>`
: `<button class="small danger" onclick="openActionModal('${u.user}', 'disable')"><i data-lucide="lock"></i> Sperren</button>`
}
</td>
</tr>
`).join('');
lucide.createIcons();
} catch (err) {
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) => {
e.preventDefault();
loginStatus.textContent = 'Anmeldung...';
const username = document.getElementById('loginUser').value.trim();
const password = document.getElementById('loginPass').value;
const btn = e.target.querySelector('button[type="submit"]');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="spinner"></div> Anmeldung...';
btn.disabled = true;
try {
const username = document.getElementById('loginUser').value.trim();
const password = document.getElementById('loginPass').value;
const data = await api('/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
setToken(data.token);
loginStatus.textContent = 'Anmeldung erfolgreich';
await refreshUsers();
currentToken = data.token;
sessionStorage.setItem('skdToken', data.token);
showToast('Anmeldung erfolgreich', 'success');
await checkSession();
} catch (err) {
setToken('');
loginStatus.textContent = `Login fehlgeschlagen: ${err.message}`;
showToast('Login fehlgeschlagen: ' + err.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
lucide.createIcons();
}
});
@ -212,199 +495,163 @@
window.location.href = '/login/oidc/start';
});
document.getElementById('refreshBtn').addEventListener('click', refreshUsers);
document.getElementById('refreshUsersBtn').addEventListener('click', refreshUsers);
document.getElementById('actionForm').addEventListener('submit', async (e) => {
e.preventDefault();
resultDiv.textContent = 'Sende...';
const username = document.getElementById('username').value;
const action = document.getElementById('action').value;
const username = document.getElementById('modalUsername').value;
const action = document.getElementById('modalAction').value;
const countdown = document.getElementById('countdown').value;
const sound = document.getElementById('sound').value;
const message = document.getElementById('message').value.trim();
const body = {};
if (countdown) body.countdown = Number(countdown);
if (sound === 'true') body.sound = true;
if (sound === 'false') body.sound = false;
if (message) body.message = message;
const btn = document.getElementById('actionSubmitBtn');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="spinner"></div> Sende...';
btn.disabled = true;
try {
const body = {};
if (countdown) body.countdown = Number(countdown);
if (sound === 'true') body.sound = true;
if (sound === 'false') body.sound = false;
if (message) body.message = message;
const data = await api(`/users/${encodeURIComponent(username)}/${action}`, {
method: 'POST',
body: Object.keys(body).length ? JSON.stringify(body) : '{}'
});
resultDiv.textContent = `${data.action} ${data.user}: ${data.steps.join('; ')}`;
showToast(`${action === 'disable' ? 'Deaktivierung' : 'Aktivierung'} von ${username} erfolgreich`, 'success');
closeActionModal();
await refreshUsers();
} catch (err) {
resultDiv.textContent = `Fehler: ${err.message}`;
showToast('Fehler: ' + err.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
lucide.createIcons();
}
});
checkSession();
checkOidcStatus();
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;
// ==================== Update Management ====================
const updateStatusDiv = document.getElementById('updateStatus');
const updateResultDiv = document.getElementById('updateResult');
const updateLogsDiv = document.getElementById('updateLogs');
const checkUpdateBtn = document.getElementById('checkUpdateBtn');
const applyUpdateBtn = document.getElementById('applyUpdateBtn');
const rollbackBtn = document.getElementById('rollbackBtn');
const refreshLogsBtn = document.getElementById('refreshLogsBtn');
let latestUpdateCheck = null;
async function refreshUpdateStatus() {
try {
const data = await api('/update/status');
const statusText = `
Version: ${data.current_version}
Letzter Status: ${data.last_status || 'unbekannt'}
${data.last_error ? `Fehler: ${data.last_error}` : ''}
${data.last_timestamp ? `Zeitstempel: ${new Date(data.last_timestamp).toLocaleString('de-DE')}` : ''}
`.trim();
updateStatusDiv.textContent = statusText;
} catch (err) {
updateStatusDiv.textContent = `Fehler beim Laden des Update-Status: ${err.message}`;
}
}
checkUpdateBtn.addEventListener('click', async () => {
updateResultDiv.textContent = 'Prüfe auf Updates...';
checkUpdateBtn.disabled = true;
try {
const data = await api('/update/check', { method: 'POST' });
latestUpdateCheck = data;
if (data.available) {
updateResultDiv.textContent = `
✅ Update verfügbar!
Version: ${data.latest_version}
${data.message ? `Info: ${data.message}` : ''}
Klicke auf "Update installieren" um fortzufahren.
`.trim();
applyUpdateBtn.disabled = false;
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 {
updateResultDiv.textContent = `✓ Keine Updates verfügbar. Aktuelle Version ist aktuell.`;
applyUpdateBtn.disabled = true;
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) {
updateResultDiv.textContent = `❌ Fehler beim Update-Check: ${err.message}`;
applyUpdateBtn.disabled = true;
resultDiv.innerHTML = `<span class="badge error"><i data-lucide="alert-circle"></i> Fehler: ${err.message}</span>`;
lucide.createIcons();
showToast('Update-Check fehlgeschlagen', 'error');
} finally {
checkUpdateBtn.disabled = false;
btn.innerHTML = originalHTML;
btn.disabled = false;
lucide.createIcons();
}
});
applyUpdateBtn.addEventListener('click', async () => {
document.getElementById('applyUpdateBtn').addEventListener('click', async () => {
if (!latestUpdateCheck || !latestUpdateCheck.available) {
updateResultDiv.textContent = '❌ Bitte zuerst nach Updates suchen.';
showToast('Bitte zuerst nach Updates suchen', 'warning');
return;
}
const confirmed = 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\n` +
`Fortfahren?`
);
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;
}
if (!confirmed) return;
updateResultDiv.textContent = 'Update wird gestartet... (läuft im Hintergrund)';
applyUpdateBtn.disabled = true;
const btn = document.getElementById('applyUpdateBtn');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="spinner"></div> Starte...';
btn.disabled = true;
try {
const data = await api('/update/apply', {
await api('/update/apply', {
method: 'POST',
body: JSON.stringify({ version: latestUpdateCheck.latest_version })
});
updateResultDiv.textContent = `
✓ ${data.message}
Das Update läuft jetzt im Hintergrund.
Aktualisiere den Status in wenigen Sekunden, um den Fortschritt zu sehen.
`.trim();
// Auto-refresh nach 5 Sekunden
setTimeout(() => {
refreshUpdateStatus();
applyUpdateBtn.disabled = true;
}, 5000);
showToast('Update gestartet (läuft im Hintergrund)', 'success');
setTimeout(() => refreshUpdateStatus(), 5000);
} catch (err) {
updateResultDiv.textContent = `❌ Fehler beim Starten des Updates: ${err.message}`;
applyUpdateBtn.disabled = false;
}
});
rollbackBtn.addEventListener('click', async () => {
const confirmed = 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\n` +
`Fortfahren?`
);
if (!confirmed) return;
updateResultDiv.textContent = 'Rollback wird gestartet... (läuft im Hintergrund)';
rollbackBtn.disabled = true;
try {
const data = await api('/update/rollback', { method: 'POST' });
updateResultDiv.textContent = `
✓ ${data.message}
Der Rollback läuft jetzt im Hintergrund.
Aktualisiere den Status in wenigen Sekunden.
`.trim();
// Auto-refresh nach 5 Sekunden
setTimeout(() => {
refreshUpdateStatus();
}, 5000);
} catch (err) {
updateResultDiv.textContent = `❌ Fehler beim Rollback: ${err.message}`;
showToast('Fehler beim Starten des Updates: ' + err.message, 'error');
} finally {
rollbackBtn.disabled = false;
btn.innerHTML = originalHTML;
btn.disabled = false;
lucide.createIcons();
}
});
async function refreshUpdateLogs() {
updateLogsDiv.textContent = 'Lade Logs...';
try {
const logs = await api('/update/logs');
if (!logs || logs.length === 0) {
updateLogsDiv.textContent = 'Keine Logs vorhanden.';
return;
}
// Reverse chronological (newest first)
const logEntries = 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}`;
});
updateLogsDiv.textContent = logEntries.join('\n\n');
} catch (err) {
updateLogsDiv.textContent = `Fehler beim Laden der Logs: ${err.message}`;
document.getElementById('rollbackBtn').addEventListener('click', async () => {
if (!confirm(`Rollback zum letzten Backup durchführen?\n\n⚠️ WICHTIG:\n- Dies stellt die vorherige Version wieder her\n- Der Service wird neu gestartet\n- Ein Backup muss vorhanden sein\n\nFortfahren?`)) {
return;
}
const btn = document.getElementById('rollbackBtn');
const originalHTML = btn.innerHTML;
btn.innerHTML = '<div class="spinner"></div> Starte...';
btn.disabled = true;
try {
await api('/update/rollback', { method: 'POST' });
showToast('Rollback gestartet (läuft im Hintergrund)', 'success');
setTimeout(() => refreshUpdateStatus(), 5000);
} catch (err) {
showToast('Fehler beim Rollback: ' + err.message, 'error');
} finally {
btn.innerHTML = originalHTML;
btn.disabled = false;
lucide.createIcons();
}
});
document.getElementById('refreshLogsBtn').addEventListener('click', refreshUpdateLogs);
// Initialize
checkSession();
checkOidcStatus();
// Dark Mode Toggle
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
refreshLogsBtn.addEventListener('click', refreshUpdateLogs);
// Initial load
refreshUpdateStatus();
// Load dark mode preference
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</div>
</body>
</html>

View File

@ -70,20 +70,56 @@ def get_status(settings: Settings) -> Dict[str, Any]:
"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]:
headers = {}
if settings.update_token:
headers["Authorization"] = f"Bearer {settings.update_token}"
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(settings.update_url, headers=headers)
response = client.get(manifest_url, headers=headers)
response.raise_for_status()
manifest = response.json()
@ -109,21 +145,65 @@ def check_update(settings: Settings) -> Dict[str, Any]:
}
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)
subprocess.Popen(
[str(script_path)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
def start_update(settings: Settings, version: str | None = None) -> None:
_write_status(settings, "in_progress", version or _read_version())
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:
_write_status(settings, "in_progress", _read_version())
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)

View File

@ -0,0 +1,91 @@
ID: DOC_000006 | Version: 0.1.0 | Status: Draft
# Admin Token Operations
## Purpose
This document describes how operators create and manage pre-shared enrollment tokens for clients.
## Pre-Shared Token Creation
Operators generate a single-use enrollment token and share it out-of-band with the client.
Recommended properties:
- Single-use only
- Short TTL (e.g., 24h)
- Scoped to `project_id` and optional `client_id`/`software_id`
## Admin Interfaces
We provide both an Admin API and a CLI tool for token operations. A frontend will be added later.
### Admin User and Access
- An admin user must exist to operate token workflows.
- Initial access uses a local admin token.
- Later, admin auth will be integrated with the OIDC service.
### CLI and Admin API Capabilities
- Create enrollment tokens
- List token metadata (no plaintext output)
- Revoke tokens
- Export a token as a file for client installation
### Local Admin Token (Initial Phase)
- Admin requests must include `Authorization: Bearer <ADMIN_TOKEN>`.
- The admin token is stored locally (e.g., `.env`) and never committed.
Example `.env` (local only):
```
ADMIN_TOKEN=change-me-please
```
Minimal flow (first token):
1) Set `ADMIN_TOKEN` in `.env`.
2) Call `POST /v1/admin/enrollment-tokens` with the bearer token.
3) Export the returned one-time token to a file and hand it to the client.
## Admin API (Draft)
All admin endpoints are authenticated. Initial auth is local; later OIDC.
Base path:
- `/v1/admin`
Endpoints:
- `POST /v1/admin/enrollment-tokens`
- Create a pre-shared enrollment token.
- Request: `project_id`, optional `client_id`, optional `software_id`, optional `expires_at`.
- Response: token metadata + one-time plaintext token.
- `GET /v1/admin/enrollment-tokens`
- List token metadata (never return plaintext tokens).
- Supports filtering by `project_id`, `client_id`, `status` (active/used/expired).
- `POST /v1/admin/enrollment-tokens/{token_id}/revoke`
- Revoke a token (marks as revoked or sets `used_at`/`revoked_at`).
- `GET /v1/admin/enrollment-tokens/{token_id}/export`
- Export the one-time token to a file download (single use).
## CLI (Draft)
Example commands (names can be adjusted):
- `update-service admin token create --project <id> [--client <id>] [--software <id>] [--expires <iso8601>]`
- `update-service admin token list --project <id> [--status active|used|expired|revoked]`
- `update-service admin token revoke --id <token_id>`
- `update-service admin token export --id <token_id> --out ./enroll-token.txt`
Example format:
```
enroll_<random_32_bytes>
```
## Storage and Safety
- Store only a hash of the enrollment token (never plaintext).
- Track `created_at`, `expires_at`, and `used_at`.
- Deny enrollment if `expires_at` is exceeded or `used_at` is set.
## Rotation and Revocation
- Revoke enrollment tokens by invalidating their stored hash.
- Issue a new enrollment token if the previous one expires or is leaked.
## Distribution
Preferred channels:
- One-time install code (copy/paste)
- QR code
- Encrypted file included in an install bundle
## Audit Expectations
- Log token creation and enrollment usage for traceability.

View File

@ -0,0 +1,57 @@
openapi: 3.0.3
info:
title: Update Webservice API
version: 0.1.0
servers:
- url: https://update.wlkns.org
- url: https://staging.update.wlkns.org
security:
- bearerAuth: []
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
Manifest:
$ref: './openapi/schemas/manifest.yaml'
StatusReport:
$ref: './openapi/schemas/status-report.yaml'
UploadResponse:
$ref: './openapi/schemas/upload-response.yaml'
EnrollRequest:
$ref: './openapi/schemas/enroll-request.yaml'
EnrollResponse:
$ref: './openapi/schemas/enroll-response.yaml'
EnrollmentToken:
$ref: './openapi/schemas/enrollment-token.yaml'
EnrollmentTokenCreateRequest:
$ref: './openapi/schemas/enrollment-token-create-request.yaml'
EnrollmentTokenCreateResponse:
$ref: './openapi/schemas/enrollment-token-create-response.yaml'
Error:
$ref: './openapi/schemas/error.yaml'
Limits:
$ref: './openapi/schemas/limits.yaml'
LimitsPolicy:
$ref: './openapi/schemas/limits-policy.yaml'
paths:
/v1/enroll:
$ref: './openapi/paths/enroll.yaml'
/v1/admin/enrollment-tokens:
$ref: './openapi/paths/admin-enrollment-tokens.yaml'
/v1/admin/enrollment-tokens/{token_id}/revoke:
$ref: './openapi/paths/admin-enrollment-tokens-revoke.yaml'
/v1/admin/enrollment-tokens/{token_id}/export:
$ref: './openapi/paths/admin-enrollment-tokens-export.yaml'
/v1/projects/{project_id}/manifest:
$ref: './openapi/paths/manifest.yaml'
/v1/projects/{project_id}/releases/{version}/artifact:
$ref: './openapi/paths/artifact.yaml'
/v1/projects/{project_id}/status:
$ref: './openapi/paths/status.yaml'
/v1/projects/{project_id}/releases:
$ref: './openapi/paths/releases.yaml'
/v1/limits:
$ref: './openapi/paths/limits.yaml'

View File

@ -0,0 +1,31 @@
get:
summary: Export enrollment token
x-auth-scopes: [admin]
parameters:
- name: token_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Token file
content:
text/plain:
schema:
type: string
example: enroll_6f3d2c...
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
'404':
description: Not Found
x-error-codes: [not_found]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'

View File

@ -0,0 +1,30 @@
post:
summary: Revoke enrollment token
x-auth-scopes: [admin]
parameters:
- name: token_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Revoked
content:
application/json:
schema:
$ref: '../schemas/enrollment-token.yaml'
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
'404':
description: Not Found
x-error-codes: [not_found]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'

View File

@ -0,0 +1,69 @@
get:
summary: List enrollment tokens
x-auth-scopes: [admin]
parameters:
- name: project_id
in: query
required: false
schema:
type: string
- name: client_id
in: query
required: false
schema:
type: string
- name: status
in: query
required: false
schema:
type: string
enum: [active, used, expired, revoked]
responses:
'200':
description: Token list
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '../schemas/enrollment-token.yaml'
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
post:
summary: Create enrollment token
x-auth-scopes: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/enrollment-token-create-request.yaml'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '../schemas/enrollment-token-create-response.yaml'
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'

View File

@ -0,0 +1,58 @@
get:
summary: Download artifact
x-auth-scopes: [read_manifest]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: version
in: path
required: true
schema:
type: string
responses:
'200':
description: Artifact tar.gz
content:
application/gzip:
schema:
type: string
format: binary
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'404':
description: Not Found
x-error-codes: [not_found]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
not_found:
value:
code: not_found
message: Artifact not found
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,65 @@
post:
summary: Enroll client and issue long-term token
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/enroll-request.yaml'
examples:
enroll:
value:
project_id: demo
client_id: device-42
software_id: kiosk
enroll_token: enroll_6f3d2c...
responses:
'200':
description: Enrollment successful
content:
application/json:
schema:
$ref: '../schemas/enroll-response.yaml'
examples:
issued:
value:
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
scope: read_manifest report_status
expires_at: 2026-12-30T10:00:00Z
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
invalid_payload:
value:
code: invalid_payload
message: Missing required fields
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Invalid or expired enrollment token
'409':
description: Conflict
x-error-codes: [already_enrolled]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
already_enrolled:
value:
code: already_enrolled
message: Client already enrolled

View File

@ -0,0 +1,48 @@
get:
summary: Get service limits
x-auth-scopes: [read_manifest]
responses:
'200':
description: Limits
content:
application/json:
schema:
$ref: '../schemas/limits-policy.yaml'
examples:
medium:
value:
tier: medium
limits:
upload_max_artifact_size_bytes_soft: 1073741824
upload_max_artifact_size_bytes_hard: 2147483648
read_max_requests_per_minute_soft: 300
read_max_requests_per_minute_hard: 600
upload_max_requests_per_minute_soft: 6
upload_max_requests_per_minute_hard: 12
report_max_requests_per_minute_soft: 120
report_max_requests_per_minute_hard: 240
burst_requests_per_minute: 1200
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,47 @@
get:
summary: Get active manifest
x-auth-scopes: [read_manifest]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Manifest
content:
application/json:
schema:
$ref: '../schemas/manifest.yaml'
examples:
default:
value:
version: 1.2.3
artifact_url: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/artifact
sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
sig_url: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/signature
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,126 @@
post:
summary: Upload release
x-auth-scopes: [upload_release]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required:
- version
- sha256
- artifact
properties:
version:
type: string
pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
example: 1.2.3
sha256:
type: string
example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
sig_url:
type: string
format: uri
description: Optional reference to a detached signature
signature:
type: string
format: binary
description: Detached signature file (optional alternative to sig_url)
key_id:
type: string
description: Public key identifier for signature verification
artifact:
type: string
format: binary
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '../schemas/upload-response.yaml'
examples:
created:
value:
version: 1.2.3
manifest_url: https://update.wlkns.org/v1/projects/demo/manifest
active: true
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
invalid_payload:
value:
code: invalid_payload
message: Missing required fields
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'409':
description: Conflict
x-error-codes: [version_exists]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
version_exists:
value:
code: version_exists
message: Version already exists
'413':
description: Payload Too Large
x-error-codes: [payload_too_large]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
payload_too_large:
value:
code: payload_too_large
message: Artifact exceeds size limit
'422':
description: Unprocessable Entity (invalid checksum/signature/version)
x-error-codes: [checksum_mismatch, signature_invalid, signature_missing, version_invalid]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
checksum_mismatch:
value:
code: checksum_mismatch
message: SHA256 does not match artifact
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,84 @@
post:
summary: Report update status
x-auth-scopes: [report_status]
parameters:
- name: project_id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '../schemas/status-report.yaml'
examples:
success:
value:
project_id: demo
version: 1.2.3
status: success
timestamp: 2025-12-28T10:15:30Z
client_id: device-42
duration_ms: 2450
failure:
value:
project_id: demo
version: 1.2.3
status: failed
timestamp: 2025-12-28T10:15:30Z
client_id: device-42
reason: checksum_mismatch
error_code: checksum_mismatch
responses:
'202':
description: Accepted
'400':
description: Bad Request
x-error-codes: [invalid_payload]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
invalid_payload:
value:
code: invalid_payload
message: Missing required fields
'401':
description: Unauthorized
x-error-codes: [unauthorized]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
unauthorized:
value:
code: unauthorized
message: Missing or invalid token
'422':
description: Unprocessable Entity (invalid version or status)
x-error-codes: [version_invalid, status_invalid]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
version_invalid:
value:
code: version_invalid
message: Version does not match SemVer
'429':
description: Too Many Requests
x-error-codes: [rate_limited]
content:
application/json:
schema:
$ref: '../schemas/error.yaml'
examples:
rate_limited:
value:
code: rate_limited
message: Too many requests

View File

@ -0,0 +1,20 @@
type: object
required:
- project_id
- client_id
- software_id
- enroll_token
properties:
project_id:
type: string
example: demo
client_id:
type: string
example: device-42
software_id:
type: string
example: kiosk
enroll_token:
type: string
description: Pre-shared, single-use enrollment token
example: enroll_6f3d2c...

View File

@ -0,0 +1,19 @@
type: object
required:
- token
- scope
properties:
token:
type: string
description: Long-term bearer token for client requests
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
scope:
type: string
description: Space-delimited scopes
example: read_manifest report_status
expires_at:
type: string
format: date-time
nullable: true
description: Null for non-expiring tokens
example: 2026-12-30T10:00:00Z

View File

@ -0,0 +1,20 @@
type: object
required:
- project_id
properties:
project_id:
type: string
example: demo
client_id:
type: string
nullable: true
example: device-42
software_id:
type: string
nullable: true
example: kiosk
expires_at:
type: string
format: date-time
nullable: true
example: 2026-12-30T10:00:00Z

View File

@ -0,0 +1,11 @@
type: object
required:
- token
- token_meta
properties:
token:
type: string
description: One-time plaintext enrollment token
example: enroll_6f3d2c...
token_meta:
$ref: './enrollment-token.yaml'

View File

@ -0,0 +1,39 @@
type: object
required:
- id
- project_id
- status
- created_at
properties:
id:
type: string
example: tok_123
project_id:
type: string
example: demo
client_id:
type: string
nullable: true
example: device-42
software_id:
type: string
nullable: true
example: kiosk
status:
type: string
enum: [active, used, expired, revoked]
example: active
expires_at:
type: string
format: date-time
nullable: true
example: 2026-12-30T10:00:00Z
created_at:
type: string
format: date-time
example: 2025-12-30T10:00:00Z
used_at:
type: string
format: date-time
nullable: true
example: 2025-12-30T10:15:00Z

View File

@ -0,0 +1,15 @@
type: object
required:
- code
- message
properties:
code:
type: string
description: Error code (e.g., unauthorized, invalid_payload, already_enrolled)
example: unauthorized
message:
type: string
example: Missing or invalid token
details:
type: object
additionalProperties: true

View File

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

View File

@ -0,0 +1,39 @@
type: object
required:
- upload_max_artifact_size_bytes_soft
- upload_max_artifact_size_bytes_hard
- read_max_requests_per_minute_soft
- read_max_requests_per_minute_hard
- upload_max_requests_per_minute_soft
- upload_max_requests_per_minute_hard
- report_max_requests_per_minute_soft
- report_max_requests_per_minute_hard
- burst_requests_per_minute
properties:
upload_max_artifact_size_bytes_soft:
type: integer
default: 1073741824
upload_max_artifact_size_bytes_hard:
type: integer
default: 2147483648
read_max_requests_per_minute_soft:
type: integer
default: 300
read_max_requests_per_minute_hard:
type: integer
default: 600
upload_max_requests_per_minute_soft:
type: integer
default: 6
upload_max_requests_per_minute_hard:
type: integer
default: 12
report_max_requests_per_minute_soft:
type: integer
default: 120
report_max_requests_per_minute_hard:
type: integer
default: 240
burst_requests_per_minute:
type: integer
default: 1200

View File

@ -0,0 +1,24 @@
type: object
required:
- version
- artifact_url
- sha256
properties:
version:
type: string
description: SemVer string (e.g., 1.2.3)
pattern: '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
example: 1.2.3
artifact_url:
type: string
format: uri
example: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/artifact
sha256:
type: string
description: Hex-encoded SHA256
example: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
sig_url:
type: string
format: uri
nullable: true
example: https://update.wlkns.org/v1/projects/demo/releases/1.2.3/signature

View File

@ -0,0 +1,42 @@
type: object
required:
- project_id
- version
- status
- timestamp
properties:
project_id:
type: string
example: demo
version:
type: string
example: 1.2.3
status:
type: string
enum: [success, failed, in_progress]
example: success
timestamp:
type: string
format: date-time
example: 2025-12-28T10:15:30Z
reason:
type: string
example: checksum_mismatch
client_id:
type: string
example: device-42
client_version:
type: string
example: 1.2.2
device_type:
type: string
example: kiosk
update_channel:
type: string
example: stable
duration_ms:
type: integer
example: 2450
error_code:
type: string
example: checksum_mismatch

View File

@ -0,0 +1,16 @@
type: object
required:
- version
- manifest_url
properties:
version:
type: string
example: 1.2.3
manifest_url:
type: string
format: uri
example: https://update.wlkns.org/v1/projects/demo/manifest
active:
type: boolean
description: True if release is active
example: true

44
docs/client-quickstart.md Normal file
View File

@ -0,0 +1,44 @@
ID: DOC_000008 | Version: 0.1.0 | Status: Draft
# Client Quickstart
## Goal
Enroll a client, store the long-term token, fetch the manifest, and report status.
## 1) Get a Pre-Shared Token
Request a one-time enrollment token from an admin/operator.
## 2) Enroll and Receive Long-Term Token
```
curl -X POST https://update.wlkns.org/v1/enroll \
-H "Content-Type: application/json" \
-d '{
"project_id": "safe-kiddo-control",
"client_id": "kiddo-001",
"software_id": "kiddo-agent",
"enroll_token": "<pre_shared_token>"
}'
```
Store the returned token locally (file or secret store). Example:
```
echo "<long_term_token>" > ./update-token.txt
```
## 3) Fetch Manifest
```
curl -H "Authorization: Bearer $(cat ./update-token.txt)" \
https://update.wlkns.org/v1/projects/safe-kiddo-control/manifest
```
## 4) Report Status
```
curl -H "Authorization: Bearer $(cat ./update-token.txt)" \
-H "Content-Type: application/json" \
-d '{"project_id":"safe-kiddo-control","version":"0.1.2","status":"success","timestamp":"2025-12-30T10:00:00Z"}' \
https://update.wlkns.org/v1/projects/safe-kiddo-control/status
```
## Notes
- All endpoints require `Authorization: Bearer <token>` except `/v1/enroll`.
- Status values: `success`, `failed`, `in_progress`.

135
docs/third-party-api.md Normal file
View File

@ -0,0 +1,135 @@
ID: DOC_000005 | Version: 0.1.0 | Status: Draft
# Third-Party API Guide
## Purpose
This document explains how third-party services integrate with the Update Webservice: obtaining tokens, fetching manifests, downloading artifacts, and reporting status.
## Quick Start (First Client)
1) Request a pre-shared enrollment token from an admin/operator.
2) Enroll once to obtain a long-term token.
3) Store the long-term token locally and use it for all API calls.
## Base URLs
- Production: `https://update.wlkns.org`
- Staging: `https://staging.update.wlkns.org`
All endpoints are versioned under `/v1`.
## Authentication
All endpoints require `Authorization: Bearer <token>`.
### Enrollment (Pre-Shared Token -> Long-Term Token)
Clients obtain a long-term token by exchanging a pre-shared token provided by an admin/operator.
Request (example):
```
POST /v1/enroll
{
"project_id": "<project>",
"client_id": "<client>",
"software_id": "<software>",
"enroll_token": "<pre_shared_token>"
}
```
Response (example):
```
200 OK
{
"token": "<long_term_token>",
"scope": "read_manifest report_status",
"expires_at": "<iso8601 or null>"
}
```
Notes:
- Enrollment tokens are single-use and must be invalidated after a successful exchange.
- If the token is invalid or reused, the server responds with `unauthorized` or `invalid_payload`.
- Enrollment does not require an existing bearer token.
- If the client is already enrolled, the server responds with `already_enrolled` (HTTP 409).
## Client API (Read + Report)
### Get Manifest
```
GET /v1/projects/{project_id}/manifest
```
Response:
```
{
"version": "0.1.2",
"artifact_url": "https://update.wlkns.org/v1/projects/<project_id>/releases/0.1.2/artifact",
"sha256": "<hex>",
"sig_url": "<optional>"
}
```
Required scope: `read_manifest`
### Download Artifact
```
GET /v1/projects/{project_id}/releases/{version}/artifact
```
Required scope: `read_manifest`
### Report Status
```
POST /v1/projects/{project_id}/status
{
"project_id": "<project_id>",
"version": "<semver>",
"status": "success|failed|in_progress",
"timestamp": "<iso8601>",
"client_id": "<optional>",
"duration_ms": "<optional>",
"error_code": "<optional>"
}
```
Required scope: `report_status`
## Release API (Upload)
### Upload Release
```
POST /v1/projects/{project_id}/releases
Content-Type: multipart/form-data
```
Required scope: `upload_release`
Required fields:
- `version` (SemVer)
- `artifact` (file)
- `sha256` (hex)
Optional fields:
- `sig_url` or inline signature
- `key_id`
## Error Codes
Common error codes:
`unauthorized`, `rate_limited`, `not_found`, `invalid_payload`, `version_invalid`,
`version_exists`, `checksum_mismatch`, `signature_missing`, `signature_invalid`,
`payload_too_large`, `status_invalid`
## Rate Limits
Limits are tiered by scope. See `docs/architecture/ARCHITECTURE.md` for current values.
## Examples
Fetch manifest:
```
curl -H "Authorization: Bearer $TOKEN" \
https://update.wlkns.org/v1/projects/$PROJECT_ID/manifest
```
Report status:
```
curl -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id":"'"$PROJECT_ID"'","version":"0.1.2","status":"success","timestamp":"2025-12-30T10:00:00Z"}' \
https://update.wlkns.org/v1/projects/$PROJECT_ID/status
```

View File

@ -54,8 +54,8 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
- [x] TASK_000010: Serve UI template
- [x] US_000022: Web-UI Aktionen ausfuehren
- [x] TASK_000022: UI login and actions
- [ ] US_000024: Watchtower Theme fuer Web-UI (zurueckgestellt)
- [ ] TASK_000024: Apply Watchtower theme (zurueckgestellt)
- [x] US_000024: Watchtower Theme fuer Web-UI
- [x] TASK_000024: Apply Watchtower theme
### EPIC_000005: Automation Scripts
- [x] US_000011: Virtualenv und Abhaengigkeiten erstellen
@ -108,6 +108,12 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
- [ ] US_000027: Client verifiziert und wendet Updates an
- [ ] US_000028: Client meldet Update-Status
### EPIC_000010: Update-Service v1 Migration (Major Release)
- [x] US_000034: Enrollment fuer Langzeit-Token
- [x] TASK_000040: Enrollment-Flow implementieren
- [x] US_000035: v1 Update-Endpoints und Status-Schema
- [x] TASK_000041: v1 Endpunkte im Update-Client umstellen
## Offene Risiken / Abhaengigkeiten
- Betrieb erfordert Root/sudo und lokale System-Tools (notify-send, sound player, uvicorn).
- OIDC-Validierung blockiert bis IdP bereit und Service laeuft.

View File

@ -0,0 +1,729 @@
/* Professional IT Asset Management - Best of Both Worlds with Dark Mode */
:root {
--bg-main: #f5f7fa;
--bg-panel: #ffffff;
--bg-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--text-main: #2d3748;
--text-secondary: #718096;
--text-light: #a0aec0;
--border-main: #e2e8f0;
--primary: #667eea;
--primary-hover: #5568d3;
--success: #48bb78;
--success-hover: #38a169;
--warning: #ed8936;
--danger: #f56565;
--danger-hover: #e53e3e;
--info: #4299e1;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
--shadow-md: 0 4px 6px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.1);
}
/* Dark Mode Theme */
body.dark-mode {
--bg-main: #1a202c;
--bg-panel: #2d3748;
--bg-header: linear-gradient(135deg, #4c51bf 0%, #6b46c1 100%);
--text-main: #f7fafc;
--text-secondary: #cbd5e0;
--text-light: #a0aec0;
--border-main: #4a5568;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
--shadow-md: 0 4px 6px rgba(0,0,0,0.3);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: var(--bg-main);
color: var(--text-main);
line-height: 1.6;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
bottom: 30px;
right: 30px;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary);
border: none;
cursor: pointer;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
z-index: 999;
}
.theme-toggle:hover {
transform: scale(1.1);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.theme-toggle svg {
width: 24px;
height: 24px;
stroke: white;
fill: none;
}
.theme-toggle .sun-icon {
display: none;
}
body.dark-mode .theme-toggle .moon-icon {
display: none;
}
body.dark-mode .theme-toggle .sun-icon {
display: block;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* Header - Keep the gradient but more professional */
header {
background: var(--bg-header);
color: white;
padding: 32px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: var(--shadow-md);
}
header h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 8px;
}
header p {
font-size: 1rem;
opacity: 0.95;
font-weight: 400;
}
/* Statistics Dashboard - More visual interest */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-panel);
padding: 24px;
border-radius: 12px;
box-shadow: var(--shadow-md);
border-left: 4px solid var(--primary);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
background: var(--primary);
opacity: 0.05;
border-radius: 50%;
transform: translate(30%, -30%);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-left-width: 6px;
}
.stat-card h3 {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.5px;
}
.stat-card .value {
font-size: 2.5rem;
font-weight: 700;
color: var(--primary);
position: relative;
z-index: 1;
}
/* Controls - Clean but distinctive */
.controls {
background: var(--bg-panel);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
border: 1px solid var(--border-main);
}
/* Inputs - More refined */
input, select {
padding: 11px 14px;
border: 2px solid var(--border-main);
border-radius: 8px;
font-size: 0.9rem;
background: var(--bg-panel);
color: var(--text-main);
transition: all 0.2s ease;
font-family: inherit;
}
input:focus, select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
input::placeholder {
color: var(--text-light);
}
.search-box {
flex: 1;
min-width: 250px;
}
/* Buttons - Keep gradient feel but professional */
button {
background: var(--primary);
color: white;
border: none;
padding: 11px 20px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.875rem;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
button:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
button:active {
transform: translateY(0);
}
button.secondary {
background: var(--success);
}
button.secondary:hover {
background: var(--success-hover);
}
button.danger {
background: var(--danger);
}
button.danger:hover {
background: var(--danger-hover);
}
/* Table Container - More polished */
.table-container {
background: var(--bg-panel);
border-radius: 12px;
box-shadow: var(--shadow-md);
overflow: hidden;
border: 1px solid var(--border-main);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 16px;
text-align: left;
}
th {
background: var(--bg-main);
font-weight: 600;
color: var(--text-main);
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--border-main);
}
tbody tr {
border-bottom: 1px solid var(--border-main);
transition: background-color 0.15s ease;
}
tbody tr:last-child {
border-bottom: none;
}
tbody tr:hover {
background: var(--bg-main);
}
td {
color: var(--text-main);
}
td strong {
color: var(--primary);
font-weight: 600;
}
/* Status Badges - More colorful but professional */
.status-badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-available {
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
color: #22543d;
}
.status-in_use {
background: linear-gradient(135deg, #bee3f8 0%, #90cdf4 100%);
color: #2c5282;
}
.status-maintenance {
background: linear-gradient(135deg, #feebc8 0%, #fbd38d 100%);
color: #7c2d12;
}
.status-retired {
background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%);
color: #2d3748;
}
.status-lost {
background: linear-gradient(135deg, #fed7d7 0%, #fc8181 100%);
color: #742a2a;
}
/* Modal - Elegant overlay */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.modal.active {
display: flex;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: var(--bg-panel);
border-radius: 16px;
padding: 32px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid var(--border-main);
}
.modal-header h2 {
color: var(--text-main);
font-size: 1.5rem;
font-weight: 700;
}
.close-btn {
background: var(--border-main);
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
width: 36px;
height: 36px;
line-height: 1;
border-radius: 8px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--primary);
color: white;
transform: rotate(90deg);
}
/* Form - Clean and accessible */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: var(--text-main);
font-size: 0.875rem;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
}
textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
padding: 11px 14px;
border: 2px solid var(--border-main);
border-radius: 8px;
font-size: 0.9rem;
background: var(--bg-panel);
color: var(--text-main);
transition: all 0.2s ease;
}
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 28px;
padding-top: 20px;
border-top: 2px solid var(--border-main);
}
/* Loading & Empty States */
.loading, .empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state svg {
width: 80px;
height: 80px;
margin-bottom: 20px;
opacity: 0.4;
stroke: var(--text-secondary);
}
.empty-state h3 {
color: var(--text-main);
margin-bottom: 8px;
font-weight: 600;
}
/* Action Buttons */
.action-buttons {
display: flex;
gap: 8px;
}
.action-buttons button {
padding: 7px 14px;
font-size: 0.8rem;
}
/* Import Zone - Visual and inviting */
.import-zone {
border: 3px dashed var(--border-main);
border-radius: 12px;
padding: 48px;
text-align: center;
margin: 20px 0;
transition: all 0.3s ease;
cursor: pointer;
background: var(--bg-main);
}
.import-zone:hover, .import-zone.drag-over {
border-color: var(--primary);
background: var(--bg-panel);
transform: scale(1.02);
box-shadow: 0 0 20px rgba(102, 126, 234, 0.2);
}
.import-zone.processing {
border-color: var(--success);
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
}
.import-zone svg {
width: 64px;
height: 64px;
margin-bottom: 16px;
stroke: var(--primary);
}
.import-zone h3 {
color: var(--text-main);
font-weight: 600;
margin-bottom: 8px;
font-size: 1.125rem;
}
.import-zone p {
color: var(--text-secondary);
font-size: 0.9rem;
}
.file-input {
display: none;
}
/* Import Results */
.import-results {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
border-left: 4px solid;
box-shadow: var(--shadow-sm);
}
.import-results.success {
background: #c6f6d5;
border-color: var(--success);
color: #22543d;
}
.import-results.error {
background: #fed7d7;
border-color: var(--danger);
color: #742a2a;
}
/* Progress Bar - More visual */
.progress-bar {
width: 100%;
height: 10px;
background: var(--border-main);
border-radius: 8px;
overflow: hidden;
margin: 12px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, var(--info) 100%);
transition: width 0.3s ease;
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.container {
padding: 12px;
}
header {
padding: 24px 20px;
}
header h1 {
font-size: 1.5rem;
}
.stats {
grid-template-columns: 1fr;
gap: 12px;
}
.controls {
flex-direction: column;
padding: 16px;
}
.search-box {
width: 100%;
}
button {
width: 100%;
}
.theme-toggle {
bottom: 20px;
right: 20px;
width: 48px;
height: 48px;
}
table {
font-size: 0.85rem;
}
th, td {
padding: 12px 8px;
}
.action-buttons {
flex-direction: column;
}
.action-buttons button {
width: 100%;
}
.modal-content {
padding: 24px 20px;
}
}
/* NEW STYLES FOR ENHANCED UI */
.error-card {
background-color: var(--danger);
color: white;
border-left-color: var(--danger-hover);
}
.error-card h3 {
color: white;
}
.loading-card {
text-align: center;
}
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.stat-card details {
margin-top: 16px;
}
.stat-card summary {
cursor: pointer;
font-weight: 600;
color: var(--text-main);
margin-bottom: 8px;
}
.stat-card summary:hover {
color: var(--primary);
}
.flags-list {
list-style-type: none;
padding-left: 8px;
font-size: 0.9rem;
}
.flags-list li {
margin-bottom: 6px;
padding-left: 16px;
position: relative;
}
.flags-list li::before {
content: '›';
position: absolute;
left: 0;
color: var(--primary);
font-weight: bold;
}
.value.level-high {
color: var(--danger);
}
.value.level-medium {
color: var(--warning);
}
.value.level-low {
color: var(--success);
}

View File

@ -0,0 +1,10 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- V2 App Icon (Teal Optimized) -->
<rect width="512" height="512" rx="115" fill="#1F2A37"/>
<g transform="translate(120, 96) scale(8)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<!-- Teal Keystone -->
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 539 B

View File

@ -0,0 +1,9 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- V12 App Icon (Teal) -->
<rect width="512" height="512" rx="115" fill="#1F2A37"/>
<g transform="translate(120, 96) scale(8)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 503 B

View File

@ -0,0 +1,7 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="64" height="64" rx="12" fill="#1F2A37"/>
<rect x="12" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
<rect x="40" y="16" width="12" height="32" rx="2" fill="#FFFFFF" fill-opacity="0.9"/>
<!-- Teal Keystone -->
<rect x="26" y="29" width="12" height="19" rx="2" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@ -0,0 +1,15 @@
<svg width="250" height="60" viewBox="0 0 250 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Monolithic Symbol (V2 Optimized - Teal) -->
<g transform="translate(10, 10)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<!-- Middle Block: Teal, 19px Height -->
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
<!-- Wordmark -->
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
<!-- The Underline: Teal, aligned like V8 -->
<rect x="56" y="48" width="121" height="4" rx="1" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@ -0,0 +1,16 @@
<svg width="340" height="60" viewBox="0 0 340 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- V12: ALL TEAL -->
<g transform="translate(10, 10)">
<rect x="0" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="24" y="4" width="10" height="32" rx="1.5" fill="#1F2A37"/>
<rect x="12" y="17" width="10" height="19" rx="1.5" fill="#0EA5A4"/>
</g>
<text x="55" y="42" fill="#1F2A37" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">WLKNS</text>
<rect x="188" y="27" width="6" height="6" rx="1" fill="#4B5563"/>
<!-- AI: Teal -->
<text x="206" y="42" fill="#0EA5A4" font-family="'Inter', sans-serif" font-weight="800" font-size="32" letter-spacing="-0.04em" text-anchor="start">AI</text>
<!-- Underline: Teal -->
<rect x="56" y="48" width="186" height="4" rx="1" fill="#0EA5A4"/>
</svg>

After

Width:  |  Height:  |  Size: 900 B

View File

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Client Administration</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>OIDC Clients</h1>
<p>Manage OIDC clients - Logged in as: <strong>{{ admin_user.username }}</strong></p>
</header>
{% if message %}
<div class="import-results success" style="max-width: 100%; margin-bottom: 20px;">
{{ message }}
</div>
{% endif %}
<div class="controls">
<a href="/admin/client/create" style="text-decoration: none;">
<button class="secondary">Create New Client</button>
</a>
<a href="/admin/users" style="text-decoration: none;">
<button>Manage Users</button>
</a>
<a href="/admin/logout" style="text-decoration: none;">
<button class="danger">Logout</button>
</a>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Client ID</th>
<th>Client Name</th>
<th>Redirect URIs</th>
<th>Allowed Scopes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr>
<td><strong>{{ client.id }}</strong></td>
<td><code>{{ client.client_id }}</code></td>
<td>{{ client.client_name }}</td>
<td>
<ul>
{% for uri in client.get_redirect_uris() %}
<li>{{ uri }}</li>
{% endfor %}
</ul>
</td>
<td>{{ client.get_allowed_scopes()|join(', ') }}</td>
<td>
<div class="action-buttons">
<a href="/admin/client/{{ client.id }}/edit" style="text-decoration: none;">
<button type="button" style="padding: 7px 14px; font-size: 0.8rem;">Edit</button>
</a>
<form method="POST" action="/admin/client/{{ client.id }}/delete" style="display: inline;" onsubmit="return confirm('Delete client {{ client.client_name }}?');">
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New Client</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Create New OIDC Client</h1>
<p>Add a new client application to the system</p>
</header>
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="client_name">Client Name</label>
<input type="text" id="client_name" name="client_name" placeholder="My Awesome App" required autofocus>
</div>
<div class="form-group">
<label for="client_id">Client ID</label>
<input type="text" id="client_id" name="client_id" placeholder="leave blank to auto-generate" >
</div>
<div class="form-group">
<label for="client_secret">Client Secret</label>
<input type="text" id="client_secret" name="client_secret" placeholder="leave blank to auto-generate">
</div>
<div class="form-group">
<label for="redirect_uris">Redirect URIs (one per line)</label>
<textarea id="redirect_uris" name="redirect_uris" rows="3" placeholder="https://app.example.com/callback" required></textarea>
</div>
<div class="form-group">
<label for="allowed_scopes">Allowed Scopes (comma-separated)</label>
<input type="text" id="allowed_scopes" name="allowed_scopes" value="openid, profile, email" placeholder="e.g. openid, profile, email">
</div>
<div class="form-actions">
<a href="/admin/clients">
<button type="button" class="danger">Cancel</button>
</a>
<button type="submit" class="secondary">Create Client</button>
</div>
</form>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create New User</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Create New User</h1>
<p>Add a new user to the system</p>
</header>
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Enter username" required autofocus>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" placeholder="user@example.com" required>
</div>
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" name="name" placeholder="John Doe" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter password" required>
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role" name="role" required>
<option value="user" selected>User</option>
<option value="admin">Admin</option>
<option value="moderator">Moderator</option>
<option value="readonly">Read-Only</option>
</select>
</div>
<div class="form-group">
<label for="permissions">Permissions (comma-separated)</label>
<input type="text" id="permissions" name="permissions" placeholder="e.g. read:data, write:data">
<small style="color: var(--text-secondary); display: block; margin-top: 8px;">
Common permissions: read:data, write:data, manage:users, manage:settings
</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_admin">
Admin User
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_active" checked>
Account Active
</label>
</div>
<div class="form-actions">
<a href="/admin/users">
<button type="button" class="danger">Cancel</button>
</a>
<button type="submit" class="secondary">Create User</button>
</div>
</form>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Administration</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>User Administration</h1>
<p>Manage OIDC users - Logged in as: <strong>{{ admin_user.username }}</strong></p>
</header>
{% if message %}
<div class="import-results success" style="max-width: 100%; margin-bottom: 20px;">
{{ message }}
</div>
{% endif %}
<div class="stats">
<div class="stat-card">
<h3>Total Users</h3>
<div class="value">{{ total_users }}</div>
</div>
<div class="stat-card">
<h3>Active Users</h3>
<div class="value value.level-low">{{ active_users }}</div>
</div>
<div class="stat-card">
<h3>Admin Users</h3>
<div class="value">{{ admin_users }}</div>
</div>
<div class="stat-card">
<h3>Inactive Users</h3>
<div class="value value.level-medium">{{ inactive_users }}</div>
</div>
</div>
<div class="controls">
<a href="/admin/analytics" style="text-decoration: none;">
<button>📊 Analytics</button>
</a>
<a href="/admin/clients" style="text-decoration: none;">
<button>Manage Clients</button>
</a>
<a href="/admin/user/create" style="text-decoration: none;">
<button class="secondary">Create New User</button>
</a>
<a href="/admin/logout" style="text-decoration: none;">
<button class="danger">Logout</button>
</a>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Permissions</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td><strong>{{ user.id }}</strong></td>
<td>{{ user.username }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>
{% if user.is_active %}
<span class="status-badge status-available">Active</span>
{% else %}
<span class="status-badge status-retired">Inactive</span>
{% endif %}
</td>
<td>
{% if user.role == 'admin' %}
<span class="status-badge status-in_use">{{ user.role|capitalize }}</span>
{% elif user.role == 'moderator' %}
<span class="status-badge status-available">{{ user.role|capitalize }}</span>
{% elif user.role == 'readonly' %}
<span class="status-badge status-retired">{{ user.role|capitalize }}</span>
{% else %}
<span class="status-badge">{{ user.role|capitalize }}</span>
{% endif %}
</td>
<td style="font-size: 0.85rem;">
{% if user.get_permissions()|length > 0 %}
{{ user.get_permissions()|join(', ') }}
{% else %}
<em style="color: var(--text-secondary);">None</em>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<div class="action-buttons">
<a href="/admin/user/{{ user.id }}/edit" style="text-decoration: none;">
<button type="button" style="padding: 7px 14px; font-size: 0.8rem;">Edit</button>
</a>
{% if user.is_active %}
<form method="POST" action="/admin/user/{{ user.id }}/deactivate" style="display: inline;">
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Deactivate</button>
</form>
{% else %}
<form method="POST" action="/admin/user/{{ user.id }}/activate" style="display: inline;">
<button type="submit" class="secondary" style="padding: 7px 14px; font-size: 0.8rem;">Activate</button>
</form>
{% endif %}
{% if not user.is_admin or admin_count > 1 %}
<form method="POST" action="/admin/user/{{ user.id }}/delete" style="display: inline;" onsubmit="return confirm('Delete user {{ user.username }}?');">
<button type="submit" class="danger" style="padding: 7px 14px; font-size: 0.8rem;">Delete</button>
</form>
{% endif %}
<a href="/admin/user/{{ user.id }}/tokens" style="text-decoration: none;">
<button type="button" class="secondary" style="padding: 7px 14px; font-size: 0.8rem;">Tokens</button>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Client - {{ client.client_name }}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Edit OIDC Client</h1>
<p>Modify details for client: <strong>{{ client.client_name }}</strong></p>
</header>
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="client_name">Client Name</label>
<input type="text" id="client_name" name="client_name" value="{{ client.client_name }}" required>
</div>
<div class="form-group">
<label for="client_id">Client ID</label>
<input type="text" id="client_id" name="client_id" value="{{ client.client_id }}" readonly>
</div>
<div class="form-group">
<label for="new_client_secret">New Client Secret (leave empty to keep current)</label>
<input type="text" id="new_client_secret" name="new_client_secret" placeholder="Optional: Set new secret">
</div>
<div class="form-group">
<label for="redirect_uris">Redirect URIs (one per line)</label>
<textarea id="redirect_uris" name="redirect_uris" rows="3" required>{{ client.get_redirect_uris()|join('\n') }}</textarea>
</div>
<div class="form-group">
<label for="allowed_scopes">Allowed Scopes (comma-separated)</label>
<input type="text" id="allowed_scopes" name="allowed_scopes" value="{{ client.get_allowed_scopes()|join(', ') }}" placeholder="e.g. openid, profile, email">
</div>
<div class="form-actions">
<a href="/admin/clients">
<button type="button" class="danger">Cancel</button>
</a>
<button type="submit" class="secondary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit User - {{ user.username }}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Edit User</h1>
<p>Modify user details for: <strong>{{ user.username }}</strong></p>
</header>
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ user.username }}" required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" value="{{ user.email }}" required>
</div>
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" name="name" value="{{ user.name }}" required>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_admin" {% if user.is_admin %}checked{% endif %}>
Admin User
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_active" {% if user.is_active %}checked{% endif %}>
Account Active
</label>
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role" name="role" required>
<option value="user" {% if user.role == 'user' %}selected{% endif %}>User</option>
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>Admin</option>
<option value="moderator" {% if user.role == 'moderator' %}selected{% endif %}>Moderator</option>
<option value="readonly" {% if user.role == 'readonly' %}selected{% endif %}>Read-Only</option>
</select>
</div>
<div class="form-group">
<label for="permissions">Permissions (comma-separated)</label>
<input type="text" id="permissions" name="permissions" value="{{ user.get_permissions()|join(', ') }}" placeholder="e.g. read:data, write:data, manage:users">
<small style="color: var(--text-secondary); display: block; margin-top: 8px;">
Common permissions: read:data, write:data, manage:users, manage:settings
</small>
</div>
<div class="form-group">
<label for="new_password">New Password (leave empty to keep current)</label>
<input type="password" id="new_password" name="new_password" placeholder="Optional: Set new password">
</div>
<div class="form-actions">
<a href="/admin/users">
<button type="button" class="danger">Cancel</button>
</a>
<button type="submit" class="secondary">Save Changes</button>
</div>
</form>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Admin Login</h1>
<p>User Administration Access</p>
</header>
<div class="modal-content" style="max-width: 450px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Admin Username</label>
<input type="text" id="username" name="username" placeholder="Enter admin username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter password" required>
</div>
<button type="submit" style="width: 100%; margin-top: 8px;">Admin Login</button>
</form>
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Home</a>
</div>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Tokens</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Access Tokens for {{ target_user.username }}</h1>
<p>Logged in as admin: <strong>{{ admin_user.username }}</strong></p>
</header>
{% if message %}
<div class="import-results success" style="max-width: 100%; margin-bottom: 16px;">
{{ message }}
</div>
{% endif %}
{% if new_token %}
<div class="import-results warning" style="max-width: 100%; margin-bottom: 16px; word-break: break-all;">
New Token (copy now): <code>{{ new_token }}</code>
</div>
{% endif %}
{% if error %}
<div class="import-results" style="background: #3b1a1a; color: #f0b6b6; max-width: 100%; margin-bottom: 16px;">
{{ error }}
</div>
{% endif %}
<div class="modal-content" style="max-width: 640px; margin: 0 auto;">
<h2>Create Initial Access Token</h2>
<form method="POST">
<div class="form-group">
<label>Scope</label>
<input type="text" name="scope" value="dcr:register">
</div>
<div class="form-group">
<label>TTL (days)</label>
<input type="number" name="ttl_days" value="365" min="1" max="3650">
</div>
<button type="submit">Create Token</button>
<a href="/admin/users" style="margin-left: 8px;">Back</a>
</form>
</div>
<div class="table-container" style="margin-top: 24px;">
<table>
<thead>
<tr>
<th>Token</th>
<th>Scope</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for token in tokens %}
<tr>
<td style="max-width: 320px; word-break: break-all;"><code>{{ token.token }}</code></td>
<td>{{ token.scope }}</td>
<td>{{ token.expires_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if token.revoked %}
<span class="status-badge status-retired">Revoked</span>
{% elif token.is_valid %}
<span class="status-badge status-available">Valid</span>
{% else %}
<span class="status-badge status-retired">Expired</span>
{% endif %}
</td>
<td>
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
<input type="hidden" name="action" value="revoke">
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;">Revoke</button>
</form>
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
<input type="hidden" name="action" value="update">
<input type="text" name="scope" value="{{ token.scope }}" style="width: 140px; font-size: 0.8rem;" aria-label="Scope">
<input type="number" name="ttl_days" value="365" min="1" max="3650" style="width: 70px; font-size: 0.8rem;" aria-label="TTL days">
<button type="submit" class="secondary" style="padding: 6px 10px; font-size: 0.8rem;">Update</button>
</form>
<form method="POST" action="/admin/user/{{ target_user.id }}/tokens/{{ token.id }}" style="display: inline-block;">
<input type="hidden" name="action" value="delete">
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;" onclick="return confirm('Delete this token?');">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC IdP - Change Password</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Change Password</h1>
<p>Update your account security</p>
</header>
<div class="modal-content" style="max-width: 500px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
{% if success %}
<div class="import-results success">
<strong>Success:</strong> {{ success }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Your username" required autofocus>
</div>
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password" placeholder="Enter current password" required>
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" placeholder="Min. 8 characters" required minlength="8">
</div>
<div class="form-group">
<label for="new_password_confirm">Confirm New Password</label>
<input type="password" id="new_password_confirm" name="new_password_confirm" placeholder="Repeat new password" required>
</div>
<button type="submit" style="width: 100%; margin-top: 8px;">Update Password</button>
</form>
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Login</a>
</div>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Dashboard</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>👤 User Dashboard</h1>
<p>Logged in as: <strong>{{ user.username }}</strong></p>
</header>
<div class="modal-content" style="max-width: 600px; margin: 0 auto;">
<h2 style="color: var(--text-main); margin-bottom: 20px;">Your Information</h2>
<div class="form-group">
<label>Username</label>
<input type="text" value="{{ user.username }}" readonly>
</div>
<div class="form-group">
<label>Email</label>
<input type="text" value="{{ user.email }}" readonly>
</div>
<div class="form-group">
<label>Full Name</label>
<input type="text" value="{{ user.name }}" readonly>
</div>
<div class="form-group">
<label>Role</label>
<input type="text" value="{{ user.role }}" readonly>
</div>
<div class="form-group">
<label>Permissions</label>
<input type="text" value="{{ user.get_permissions()|join(', ') }}" readonly>
</div>
<div class="form-group">
<label>Account Status</label>
<input type="text" value="{% if user.is_active %}Active{% else %}Inactive{% endif %}" readonly>
</div>
</div>
<div class="controls" style="margin-top: 24px;">
<a href="/my-sessions" style="text-decoration: none;">
<button>📊 My Sessions</button>
</a>
<a href="/change-password" style="text-decoration: none;">
<button>🔑 Change Password</button>
</a>
<a href="/my-tokens" style="text-decoration: none;">
<button>🎟️ My Tokens</button>
</a>
<a href="/logout" style="text-decoration: none;">
<button class="danger">Logout</button>
</a>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC Identity Provider</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>🔐 OIDC Identity Provider</h1>
<p>Secure authentication server for your services</p>
</header>
<div class="modal-content" style="max-width: 700px; margin: 0 auto;">
<h2 style="color: var(--text-main); margin-bottom: 20px;">Welcome</h2>
<p style="color: var(--text-secondary); line-height: 1.6;">
This is an OpenID Connect (OIDC) Identity Provider that enables secure authentication
for your applications using industry-standard protocols.
</p>
</div>
<div class="controls" style="margin-top: 32px; justify-content: center;">
<a href="/login" style="text-decoration: none;">
<button>🔑 Login</button>
</a>
<a href="/register" style="text-decoration: none;">
<button class="secondary">📝 Register</button>
</a>
</div>
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
<p style="color: var(--text-secondary); font-size: 0.9rem;">
Administrators: <a href="/admin/login" style="color: var(--primary); text-decoration: none;">Access admin panel</a>
</p>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC IdP - Login</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>🔐 Homelab OIDC Login</h1>
<p>Secure authentication for your homelab services</p>
</header>
<div class="modal-content" style="max-width: 450px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
{% if success %}
<div class="import-results success">
<strong>Success:</strong> {{ success }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Enter your username" required autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter your password" required>
</div>
<button type="submit" style="width: 100%; margin-top: 8px;">Sign In</button>
</form>
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
<p style="color: var(--text-secondary); margin-bottom: 12px;">Don't have an account?</p>
<a href="/register" style="color: var(--primary); text-decoration: none; font-weight: 600;">Create new account →</a>
</div>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
// Load saved theme
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OIDC IdP - Registration</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>Create New Account</h1>
<p>Join your homelab authentication system</p>
</header>
<div class="modal-content" style="max-width: 500px; margin: 0 auto;">
{% if error %}
<div class="import-results error">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<form method="POST" style="margin-top: 24px;">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Choose a username" required autofocus>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="your.email@homelab.local" required>
</div>
<div class="form-group">
<label for="name">Full Name</label>
<input type="text" id="name" name="name" placeholder="John Doe" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Min. 8 characters" required minlength="8">
</div>
<div class="form-group">
<label for="password_confirm">Confirm Password</label>
<input type="password" id="password_confirm" name="password_confirm" placeholder="Repeat your password" required>
</div>
<button type="submit" class="secondary" style="width: 100%; margin-top: 8px;">Create Account</button>
</form>
<div style="text-align: center; margin-top: 24px; padding-top: 24px; border-top: 2px solid var(--border-main);">
<p style="color: var(--text-secondary); margin-bottom: 12px;">Already have an account?</p>
<a href="/" style="color: var(--primary); text-decoration: none; font-weight: 600;">← Back to Login</a>
</div>
</div>
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
</script>
</body>
</html>

View File

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Active Sessions</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
</button>
<div class="container">
<header>
<h1>My Active Sessions</h1>
<p>{{ user.name }} ({{ user.email }})</p>
</header>
<div style="margin-bottom: 24px;">
<a href="/dashboard" style="text-decoration: none;">
<button>Back to Dashboard</button>
</a>
<a href="/logout" style="text-decoration: none;">
<button class="danger">Logout</button>
</a>
</div>
<!-- Summary Stats -->
<div class="analytics-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 32px;">
<div class="analytics-card" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px;">
<h3 style="font-size: 14px; color: var(--text-secondary); margin-bottom: 8px;">Active Sessions</h3>
<div class="metric" style="font-size: 32px; font-weight: 600; color: var(--primary-color);">{{ summary.total_active_sessions }}</div>
<div class="label" style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">Currently active</div>
</div>
<div class="analytics-card" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px;">
<h3 style="font-size: 14px; color: var(--text-secondary); margin-bottom: 8px;">Applications</h3>
<div class="metric" style="font-size: 32px; font-weight: 600; color: var(--primary-color);">{{ summary.total_clients }}</div>
<div class="label" style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">You're using</div>
</div>
</div>
<!-- Active Sessions -->
<h2 style="margin-bottom: 20px;">Active Sessions</h2>
{% if active_sessions %}
{% set current_client = namespace(value='') %}
{% for session in active_sessions %}
{% if session.client_name != current_client.value %}
{% set current_client.value = session.client_name %}
{% if not loop.first %}
</div>
{% endif %}
<div class="client-section" style="background: var(--bg-secondary); padding: 24px; border-radius: 8px; margin-bottom: 16px;">
<h3 style="margin-bottom: 16px;">{{ session.client_name }}</h3>
{% endif %}
<div class="session-item" style="padding: 16px; background: var(--bg-primary); border-radius: 6px; margin-bottom: 12px;">
<div class="session-info" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div>
<strong>Session</strong>
</div>
<span class="status-badge status-available" style="padding: 4px 12px; background: #10b981; color: white; border-radius: 4px; font-size: 12px;">Active</span>
</div>
<div class="session-meta" style="font-size: 14px; color: var(--text-secondary);">
Created: {{ session.created_at.strftime('%Y-%m-%d %H:%M:%S') }} |
Expires: {{ session.expires_at.strftime('%Y-%m-%d %H:%M:%S') }}
</div>
</div>
{% if loop.last %}
</div>
{% endif %}
{% endfor %}
{% else %}
<div class="import-results" style="background: var(--bg-secondary); padding: 20px; border-radius: 8px;">
No active sessions. Log in to an application to see sessions here.
</div>
{% endif %}
</div>
<script>
function toggleTheme() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
}
if (localStorage.getItem('darkMode') === 'true') {
document.body.classList.add('dark-mode');
}
// Auto-refresh every 30 seconds
setTimeout(function() {
location.reload();
}, 30000);
</script>
</body>
</html>

View File

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Tokens</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="container">
<header>
<h1>Initial Access Tokens</h1>
<p>Logged in as: <strong>{{ user.username }}</strong></p>
</header>
{% if message %}
<div class="import-results success" style="max-width: 100%; margin-bottom: 16px;">
{{ message }}
</div>
{% endif %}
{% if new_token %}
<div class="import-results warning" style="max-width: 100%; margin-bottom: 16px; word-break: break-all;">
New Token (copy now): <code>{{ new_token }}</code>
</div>
{% endif %}
{% if error %}
<div class="import-results" style="background: #3b1a1a; color: #f0b6b6; max-width: 100%; margin-bottom: 16px;">
{{ error }}
</div>
{% endif %}
<div class="modal-content" style="max-width: 640px; margin: 0 auto;">
<h2>Create Initial Access Token</h2>
<form method="POST">
<div class="form-group">
<label>Scope</label>
<input type="text" name="scope" value="dcr:register">
</div>
<div class="form-group">
<label>TTL (days)</label>
<input type="number" name="ttl_days" value="365" min="1" max="3650">
</div>
<button type="submit">Create Token</button>
<a href="/dashboard" style="margin-left: 8px;">Back</a>
</form>
</div>
<div class="table-container" style="margin-top: 24px;">
<table>
<thead>
<tr>
<th>Token</th>
<th>Scope</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for token in tokens %}
<tr>
<td style="max-width: 320px; word-break: break-all;"><code>{{ token.token }}</code></td>
<td>{{ token.scope }}</td>
<td>{{ token.expires_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if token.revoked %}
<span class="status-badge status-retired">Revoked</span>
{% elif token.is_valid %}
<span class="status-badge status-available">Valid</span>
{% else %}
<span class="status-badge status-retired">Expired</span>
{% endif %}
</td>
<td>
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
<input type="hidden" name="action" value="revoke">
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;">Revoke</button>
</form>
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block; margin-right: 6px;">
<input type="hidden" name="action" value="update">
<input type="text" name="scope" value="{{ token.scope }}" style="width: 140px; font-size: 0.8rem;" aria-label="Scope">
<input type="number" name="ttl_days" value="365" min="1" max="3650" style="width: 70px; font-size: 0.8rem;" aria-label="TTL days">
<button type="submit" class="secondary" style="padding: 6px 10px; font-size: 0.8rem;">Update</button>
</form>
<form method="POST" action="/my-tokens/{{ token.id }}" style="display: inline-block;">
<input type="hidden" name="action" value="delete">
<button type="submit" class="danger" style="padding: 6px 10px; font-size: 0.8rem;" onclick="return confirm('Delete this token?');">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,45 @@
ID: EPIC_000010 | Version: 0.1.5 | Status: Done
By: Codex (GPT-5)
# EPIC_000010: Update-Service v1 Migration (Major Release)
## Beschreibung
Migration des Update-Clients auf den neuen v1 Update-Service mit verpflichtender Authentifizierung
und Enrollment-Flow fuer Langzeit-Tokens. Diese Umstellung ist ein Major Release.
## Ziel / Business Value
Sicheres, standardisiertes Update-Management mit verpflichtender Auth und nachvollziehbarem Status-Reporting.
## Mission Statement
Stelle sicher, dass der Client die v1 Endpunkte nutzen kann, inkl. Enrollment und
neuem Status-Schema.
## Business Value & Metriken
- Security: Auth ist obligatorisch fuer alle Requests.
- Erfolgsmetrik: 100% der Clients koennen per v1 manifest/artifact/status arbeiten.
## In-Scope (Kiddo Team)
- Enrollment-Flow fuer Langzeit-Token (mit Pre-Shared Token).
- Update-Client auf v1 Endpunkte umstellen.
- Status-Payload auf v1 Schema umstellen.
- Migration-Notiz/Docs fuer Client-Dev.
## Out-of-Scope
- Betrieb/Hosting des Update-Services.
- Ausgabe/Verwaltung von Pre-Shared Tokens auf Server-Seite.
## High-Level Akzeptanzkriterien
- Auth ist Pflicht (Bearer Token) fuer Manifest, Artifact und Status.
- Enrollment liefert Langzeit-Token, der lokal gespeichert wird.
- v1 Endpunkte werden genutzt:
- GET /v1/projects/{project_id}/manifest
- GET /v1/projects/{project_id}/releases/{version}/artifact
- POST /v1/projects/{project_id}/status
## Technische Constraints & Risiken
- Major Release: Rollout-Strategie und Backward Compatibility klaeren.
- Token-Handling und sichere lokale Speicherung.
## Zugeordnete User Stories
- US_000034: Enrollment fuer Langzeit-Token
- US_000035: v1 Update-Endpoints und Status-Schema

View File

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

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

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

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

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

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

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

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

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

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

View File

@ -2,6 +2,10 @@
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}"
@ -14,6 +18,8 @@ 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
@ -41,7 +47,25 @@ 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

View File

@ -2,9 +2,11 @@
set -euo pipefail
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:-}"
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}"
@ -53,7 +55,15 @@ with log_file.open("a", encoding="utf-8") as handle:
PY
local payload
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
)
if [[ -n "${UPDATE_TOKEN}" ]]; then
@ -89,12 +99,12 @@ require_cmd tar
require_cmd sha256sum
require_cmd python3
log "Fetching update manifest from ${UPDATE_URL}..."
log "Fetching update manifest from ${MANIFEST_URL}..."
MANIFEST_FILE="$(mktemp)"
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
curl -fsS "${UPDATE_URL}" -o "${MANIFEST_FILE}"
curl -fsS "${MANIFEST_URL}" -o "${MANIFEST_FILE}"
fi
read_manifest() {