Files
christianandClaude Opus 5 09d9837dec feat: kundenspezifisches Logo unter Einstellungen
Bisher war das Logo fest im Repository verdrahtet. Damit hat ein Update
das Logo eines Kunden ueberschrieben - beim Sprung auf den modularen
Stand zum Beispiel von logo.svg auf die mitgelieferte logo.png.

Unter Einstellungen > Logo laesst sich jetzt ein eigenes Logo hochladen.
Es erscheint in der Seitenleiste, auf der Login-Seite und auf der
Zentrale-Seite, mit Vorschau auf hellem und dunklem Grund.

- Datei liegt unter static/custom/ und ist per .gitignore von der
  Versionierung ausgenommen, ein git pull ueberschreibt sie also nicht
- PNG, JPG, SVG, WEBP, GIF bis 2 MB; Format und Groesse werden geprueft
- es bleibt immer genau eine Datei liegen, alte Endungen werden entfernt
- fehlt die Datei, greift automatisch das mitgelieferte Logo
- Upload und Zuruecksetzen liegen hinter dem Admin-Login
- logo_url() ist als Jinja-Global registriert, damit auch die Seiten ohne
  _ctx() (Login, Zentrale) dasselbe Logo zeigen

.gitignore ergaenzt: static/custom/ sowie die bisher fehlenden
busylight.db-shm, busylight.db-wal und Status.csv - letztere lagen auf
dem Produktivsystem als untracked herum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 08:55:16 +00:00

337 lines
18 KiB
HTML

{% extends "base.html" %}
{% block title %}Einstellungen - Busylight{% endblock %}
{% block content %}
<h4 class="mb-4">Einstellungen</h4>
{% if request.query_params.get('saved') %}
<div class="alert alert-success alert-dismissible fade show">
<i class="bi bi-check-circle"></i> Einstellungen gespeichert.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
<form method="post" action="/api/settings">
<div class="row g-4">
<!-- Azure AD -->
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-microsoft"></i> Microsoft Entra ID (Azure AD)</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Anwendungs-ID (Client)</label>
<input type="text" name="azure_client_id" class="form-control font-monospace"
value="{{ settings.azure_client_id }}"
placeholder="z.B. b19325df-9905-4e93-8419-30639376277d">
<small class="text-muted">Azure Portal > App-Registrierungen > Uebersicht > Anwendungs-ID (Client)</small>
</div>
<div class="mb-3">
<label class="form-label">Verzeichnis-ID (Mandant)</label>
<input type="text" name="azure_tenant_id" class="form-control font-monospace"
value="{{ settings.azure_tenant_id }}"
placeholder="z.B. 6a0349fc-7bb3-4868-a711-27a0d1c58c83">
<input type="hidden" name="azure_auth_tenant" value="{{ settings.azure_tenant_id }}">
<input type="hidden" name="azure_client_secret" value="{{ settings.azure_client_secret }}">
<small class="text-muted">Azure Portal > App-Registrierungen > Uebersicht > Verzeichnis-ID (Mandant)</small>
</div>
<div class="mb-3">
<label class="form-label">Graph API Berechtigungen</label>
<input type="text" name="azure_scopes" class="form-control"
value="{{ settings.azure_scopes }}"
placeholder="User.Read User.Read.All Presence.Read Presence.Read.All">
<small class="text-muted">Standard: User.Read User.Read.All Presence.Read Presence.Read.All</small>
</div>
<div id="auth-status" class="border rounded p-3 bg-light">
{% if authenticated %}
<i class="bi bi-check-circle text-success"></i>
<strong class="text-success">Verbunden</strong>
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-warning" onclick="startAuth()">
<i class="bi bi-arrow-repeat"></i> Neu verbinden
</button>
</div>
{% else %}
<i class="bi bi-x-circle text-danger"></i>
<strong class="text-danger">Nicht verbunden</strong>
<div class="mt-2">
<button type="button" class="btn btn-sm btn-primary" onclick="startAuth()">
<i class="bi bi-microsoft"></i> Mit Azure verbinden
</button>
</div>
{% endif %}
</div>
<div id="auth-flow" class="border rounded p-3 bg-light mt-2" style="display:none;">
<div id="auth-waiting" style="display:none;">
<p class="mb-2"><strong>Schritt 1:</strong> Oeffne den folgenden Link:</p>
<p><a id="auth-url" href="#" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up-right"></i> Bei Microsoft anmelden
</a></p>
<p class="mb-2"><strong>Schritt 2:</strong> Gib diesen Code ein:</p>
<p class="text-center">
<span id="auth-code" class="badge bg-dark fs-4 font-monospace p-3"></span>
</p>
<p class="small text-muted mb-0">
<i class="bi bi-hourglass-split"></i> Warte auf Anmeldung...
</p>
</div>
<div id="auth-starting" style="display:none;">
<i class="bi bi-hourglass-split"></i> Verbindung wird hergestellt...
</div>
<div id="auth-success" style="display:none;">
<i class="bi bi-check-circle text-success"></i>
<strong class="text-success">Erfolgreich verbunden!</strong>
<p class="small text-muted mt-1 mb-0">Seite wird neu geladen...</p>
</div>
<div id="auth-error" style="display:none;">
<i class="bi bi-exclamation-triangle text-danger"></i>
<strong class="text-danger">Fehler:</strong>
<span id="auth-error-msg"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Polling & Zeiterfassung -->
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-clock"></i> Polling</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Abfrage-Intervall (Sekunden)</label>
<input type="number" name="poll_interval" class="form-control"
value="{{ settings.poll_interval }}" min="5" max="300">
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-shield-lock"></i> Zugangsschutz</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Admin-Passwort</label>
<input type="password" name="admin_password" class="form-control"
placeholder="Neues Passwort eingeben (leer = nicht aendern)">
{% if settings.admin_password %}
<small class="text-success"><i class="bi bi-check-circle"></i> Passwort ist gesetzt</small>
{% else %}
<small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Kein Passwort gesetzt - alle Seiten sind frei zugaenglich</small>
{% endif %}
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> Zeiterfassung (CSV)</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">CSV-Pfad</label>
<input type="text" name="attendance_csv_path" class="form-control font-monospace"
value="{{ settings.attendance_csv_path }}"
placeholder="/opt/busylight/status.csv">
<small class="text-muted">Pfad zur Anwesenheits-CSV (optional, wenn REST API genutzt wird)</small>
</div>
<div class="mb-3">
<label class="form-label">CSV-Code: Anwesend</label>
<input type="text" name="attendance_status_anwesend" class="form-control"
value="{{ settings.attendance_status_anwesend }}"
placeholder="~~1~~">
</div>
<div class="mb-3">
<label class="form-label">CSV-Code: Abwesend</label>
<input type="text" name="attendance_status_abwesend" class="form-control"
value="{{ settings.attendance_status_abwesend }}"
placeholder="~~0~~">
</div>
<div class="mb-3">
<label class="form-label">CSV-Code: Homeoffice</label>
<input type="text" name="attendance_status_homeoffice" class="form-control"
value="{{ settings.attendance_status_homeoffice }}"
placeholder="~~2~~">
</div>
<div class="border rounded p-3 bg-light">
<small class="text-muted">
<strong>Alternativ: REST API</strong><br>
Status kann auch per API gesetzt werden (Port 8081):<br>
<code>POST /api/attendance/{personal_nr}</code><br>
Body: <code>{"status": "anwesend"}</code>
</small>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-headset"></i> Zentrale (Self-Service)</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Busylight der Zentrale</label>
<select name="selfservice_device_id" class="form-select">
<option value="">-- nicht eingerichtet --</option>
{% for d in devices %}
<option value="{{ d.id }}" {% if settings.selfservice_device_id == d.id|string %}selected{% endif %}>
{{ d.name or d.hostname }} ({{ d.hostname }})
</option>
{% endfor %}
</select>
<small class="text-muted">Das Busylight im Empfangsbereich, das wechselnd besetzt wird.</small>
</div>
<div class="mb-3">
<label class="form-label">Segment</label>
<input type="number" name="selfservice_segment" class="form-control"
min="0" step="1" value="{{ settings.selfservice_segment or '0' }}">
<small class="text-muted">0 = unten, 1 = oben</small>
</div>
<div class="border rounded p-3 bg-light">
<small class="text-muted">
<strong>Self-Service-Seite</strong><br>
Mitarbeiter melden sich per Durchwahl an, ohne Admin-Passwort:<br>
<code>/zentrale</code><br>
Direktlink bzw. QR-Code pro Person:<br>
<code>/zentrale/{durchwahl}</code>
</small>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Einstellungen speichern
</button>
</div>
</form>
<!-- Logo: eigener Abschnitt, weil ein Datei-Upload nicht in das obige Formular passt -->
<div class="row g-4 mt-1">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-image"></i> Logo</h6>
</div>
<div class="card-body">
{% if request.query_params.get('logo_saved') %}
<div class="alert alert-success py-2"><small><i class="bi bi-check-circle"></i> Logo gespeichert.</small></div>
{% elif request.query_params.get('logo_reset') %}
<div class="alert alert-success py-2"><small><i class="bi bi-check-circle"></i> Standardlogo wiederhergestellt.</small></div>
{% elif request.query_params.get('logo_error') == 'format' %}
<div class="alert alert-danger py-2"><small><i class="bi bi-x-circle"></i> Nicht unterstuetztes Format. Erlaubt: PNG, JPG, SVG, WEBP, GIF.</small></div>
{% elif request.query_params.get('logo_error') == 'size' %}
<div class="alert alert-danger py-2"><small><i class="bi bi-x-circle"></i> Datei ist zu gross (maximal 2 MB).</small></div>
{% elif request.query_params.get('logo_error') == 'empty' %}
<div class="alert alert-danger py-2"><small><i class="bi bi-x-circle"></i> Die Datei war leer.</small></div>
{% endif %}
<div class="mb-3">
<label class="form-label">Aktuelles Logo</label>
<div class="d-flex gap-2">
<div class="border rounded p-3 bg-white text-center flex-fill">
<img src="{{ logo_url() }}" alt="Logo" style="max-height:40px; max-width:100%;">
<div><small class="text-muted">auf hell</small></div>
</div>
<div class="border rounded p-3 bg-dark text-center flex-fill">
<img src="{{ logo_url() }}" alt="Logo" style="max-height:40px; max-width:100%;">
<div><small class="text-secondary">auf dunkel (Seitenleiste)</small></div>
</div>
</div>
<small class="text-muted">
{% if settings.logo_file %}
Kundenlogo aktiv: <code>{{ settings.logo_file }}</code>
{% else %}
Standardlogo aus dem Repository
{% endif %}
</small>
</div>
<form method="post" action="/api/settings/logo" enctype="multipart/form-data" class="mb-2">
<label class="form-label">Eigenes Logo hochladen</label>
<div class="input-group">
<input type="file" name="logo" class="form-control"
accept=".png,.jpg,.jpeg,.svg,.webp,.gif" required>
<button type="submit" class="btn btn-primary">
<i class="bi bi-upload"></i> Hochladen
</button>
</div>
<small class="text-muted">
PNG, JPG, SVG, WEBP oder GIF, maximal 2 MB. Wird in der Seitenleiste, auf der
Login-Seite und auf der Zentrale-Seite angezeigt. Eine Hoehe von etwa 40 Pixeln
wird dargestellt - die Seitenleiste ist dunkel, ein Logo mit heller Schrift oder
transparentem Hintergrund wirkt dort am besten.
</small>
</form>
{% if settings.logo_file %}
<form method="post" action="/api/settings/logo/reset">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-counterclockwise"></i> Zurueck zum Standardlogo
</button>
</form>
{% endif %}
<div class="border rounded p-3 bg-light mt-3">
<small class="text-muted">
Das Logo liegt unter <code>static/custom/</code> und ist von der Versionierung
ausgenommen. Ein <code>git pull</code> ueberschreibt es also nicht.
</small>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let authPollInterval = null;
function startAuth() {
document.getElementById('auth-flow').style.display = 'block';
document.getElementById('auth-starting').style.display = 'block';
document.getElementById('auth-waiting').style.display = 'none';
document.getElementById('auth-success').style.display = 'none';
document.getElementById('auth-error').style.display = 'none';
fetch('/api/azure/auth', {method: 'POST'})
.then(() => {
authPollInterval = setInterval(pollAuthStatus, 1000);
});
}
function pollAuthStatus() {
fetch('/api/azure/auth/status')
.then(r => r.json())
.then(data => {
if (data.status === 'waiting') {
document.getElementById('auth-starting').style.display = 'none';
document.getElementById('auth-waiting').style.display = 'block';
document.getElementById('auth-code').textContent = data.user_code;
const url = document.getElementById('auth-url');
url.href = data.verification_uri;
} else if (data.status === 'success') {
clearInterval(authPollInterval);
document.getElementById('auth-waiting').style.display = 'none';
document.getElementById('auth-starting').style.display = 'none';
document.getElementById('auth-success').style.display = 'block';
setTimeout(() => location.reload(), 2000);
} else if (data.status === 'error') {
clearInterval(authPollInterval);
document.getElementById('auth-waiting').style.display = 'none';
document.getElementById('auth-starting').style.display = 'none';
document.getElementById('auth-error').style.display = 'block';
document.getElementById('auth-error-msg').textContent = data.error;
}
});
}
</script>
{% endblock %}