Dieser Commit ist enthalten in:
Claude Project Manager
2025-07-05 17:51:16 +02:00
Commit 0d7d888502
1594 geänderte Dateien mit 122839 neuen und 0 gelöschten Zeilen

Datei anzeigen

@ -0,0 +1,439 @@
{% extends "base.html" %}
{% block title %}Ressourcen hinzufügen{% endblock %}
{% block extra_css %}
<style>
/* Card Styling */
.main-card {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border: none;
}
/* Preview Section */
.preview-card {
background-color: #f8f9fa;
border: 2px dashed #dee2e6;
transition: all 0.3s ease;
}
.preview-card.active {
border-color: #28a745;
background-color: #e8f5e9;
}
/* Format Examples */
.example-card {
height: 100%;
transition: transform 0.2s ease;
}
.example-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.example-code {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.25rem;
padding: 1rem;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
margin: 0;
}
/* Resource Type Selector */
.resource-type-selector {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.resource-type-option {
padding: 1.5rem;
border: 2px solid #e9ecef;
border-radius: 0.5rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background-color: #fff;
}
.resource-type-option:hover {
border-color: #007bff;
background-color: #f8f9fa;
}
.resource-type-option.selected {
border-color: #007bff;
background-color: #e7f3ff;
}
.resource-type-option .icon {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
/* Textarea Styling */
.resource-input {
font-family: 'Courier New', monospace;
background-color: #f8f9fa;
border: 2px solid #dee2e6;
transition: border-color 0.3s ease;
}
.resource-input:focus {
background-color: #fff;
border-color: #80bdff;
}
/* Stats Display */
.stats-display {
display: flex;
justify-content: space-around;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 0.5rem;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: #007bff;
}
.stat-label {
font-size: 0.875rem;
color: #6c757d;
text-transform: uppercase;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row justify-content-center">
<div class="col-lg-10">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-0">Ressourcen hinzufügen</h1>
<p class="text-muted mb-0">Fügen Sie neue Domains, IPs oder Telefonnummern zum Pool hinzu</p>
</div>
<a href="{{ url_for('resources', show_test=show_test) }}" class="btn btn-secondary">
← Zurück zur Übersicht
</a>
</div>
<form method="post" action="{{ url_for('add_resources', show_test=show_test) }}" id="addResourceForm">
<!-- Resource Type Selection -->
<div class="card main-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">1⃣ Ressourcentyp wählen</h5>
</div>
<div class="card-body">
<input type="hidden" name="resource_type" id="resource_type" required>
<div class="resource-type-selector">
<div class="resource-type-option" data-type="domain">
<div class="icon">🌐</div>
<h6 class="mb-0">Domain</h6>
<small class="text-muted">Webseiten-Adressen</small>
</div>
<div class="resource-type-option" data-type="ipv4">
<div class="icon">🖥️</div>
<h6 class="mb-0">IPv4</h6>
<small class="text-muted">IP-Adressen</small>
</div>
<div class="resource-type-option" data-type="phone">
<div class="icon">📱</div>
<h6 class="mb-0">Telefon</h6>
<small class="text-muted">Telefonnummern</small>
</div>
</div>
</div>
</div>
<!-- Resource Input -->
<div class="card main-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">2⃣ Ressourcen eingeben</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="resources_text" class="form-label">
Ressourcen (eine pro Zeile)
</label>
<textarea name="resources_text"
id="resources_text"
class="form-control resource-input"
rows="12"
required
placeholder="Bitte wählen Sie zuerst einen Ressourcentyp aus..."></textarea>
<div class="form-text">
<i class="fas fa-info-circle"></i>
Geben Sie jede Ressource in eine neue Zeile ein. Duplikate werden automatisch übersprungen.
</div>
</div>
<!-- Live Preview -->
<div class="preview-card p-3" id="preview">
<h6 class="mb-3">📊 Live-Vorschau</h6>
<div class="stats-display">
<div class="stat-item">
<div class="stat-value" id="validCount">0</div>
<div class="stat-label">Gültig</div>
</div>
<div class="stat-item">
<div class="stat-value text-warning" id="duplicateCount">0</div>
<div class="stat-label">Duplikate</div>
</div>
<div class="stat-item">
<div class="stat-value text-danger" id="invalidCount">0</div>
<div class="stat-label">Ungültig</div>
</div>
</div>
<div id="errorList" class="mt-3" style="display: none;">
<div class="alert alert-danger">
<strong>Fehler gefunden:</strong>
<ul id="errorMessages" class="mb-0"></ul>
</div>
</div>
</div>
</div>
</div>
<!-- Format Examples -->
<div class="card main-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">💡 Format-Beispiele</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<div class="card example-card h-100">
<div class="card-body">
<h6 class="card-title">
<span class="text-primary">🌐</span> Domains
</h6>
<pre class="example-code">example.com
test-domain.net
meine-seite.de
subdomain.example.org
my-website.io</pre>
<div class="alert alert-info mt-3 mb-0">
<small>
<strong>Format:</strong> Ohne http(s)://<br>
<strong>Erlaubt:</strong> Buchstaben, Zahlen, Punkt, Bindestrich
</small>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card example-card h-100">
<div class="card-body">
<h6 class="card-title">
<span class="text-primary">🖥️</span> IPv4-Adressen
</h6>
<pre class="example-code">192.168.1.10
192.168.1.11
10.0.0.1
172.16.0.5
8.8.8.8</pre>
<div class="alert alert-info mt-3 mb-0">
<small>
<strong>Format:</strong> xxx.xxx.xxx.xxx<br>
<strong>Bereich:</strong> 0-255 pro Oktett
</small>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card example-card h-100">
<div class="card-body">
<h6 class="card-title">
<span class="text-primary">📱</span> Telefonnummern
</h6>
<pre class="example-code">+491701234567
+493012345678
+33123456789
+441234567890
+12125551234</pre>
<div class="alert alert-info mt-3 mb-0">
<small>
<strong>Format:</strong> Mit Ländervorwahl<br>
<strong>Start:</strong> Immer mit +
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Test Data Option -->
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="is_test" name="is_test" {% if show_test %}checked{% endif %}>
<label class="form-check-label" for="is_test">
Als Testdaten markieren
</label>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="d-flex justify-content-between">
<button type="button" class="btn btn-secondary" onclick="window.location.href='{{ url_for('resources', show_test=show_test) }}'">
<i class="fas fa-times"></i> Abbrechen
</button>
<button type="submit" class="btn btn-success btn-lg" id="submitBtn" disabled>
<i class="fas fa-plus-circle"></i> Ressourcen hinzufügen
</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const typeOptions = document.querySelectorAll('.resource-type-option');
const typeInput = document.getElementById('resource_type');
const textArea = document.getElementById('resources_text');
const submitBtn = document.getElementById('submitBtn');
const form = document.getElementById('addResourceForm');
// Preview elements
const validCount = document.getElementById('validCount');
const duplicateCount = document.getElementById('duplicateCount');
const invalidCount = document.getElementById('invalidCount');
const errorList = document.getElementById('errorList');
const errorMessages = document.getElementById('errorMessages');
const preview = document.getElementById('preview');
let selectedType = null;
// Placeholder texts for different types
const placeholders = {
domain: `example.com
test-site.net
my-domain.org
subdomain.example.com`,
ipv4: `192.168.1.1
10.0.0.1
172.16.0.1
8.8.8.8`,
phone: `+491234567890
+4930123456
+33123456789
+12125551234`
};
// Resource type selection
typeOptions.forEach(option => {
option.addEventListener('click', function() {
typeOptions.forEach(opt => opt.classList.remove('selected'));
this.classList.add('selected');
selectedType = this.dataset.type;
typeInput.value = selectedType;
textArea.placeholder = placeholders[selectedType] || '';
textArea.disabled = false;
updatePreview();
});
});
// Validation functions
function validateDomain(domain) {
const domainRegex = /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9][a-zA-Z0-9-_]+\.[a-zA-Z]{2,}$/;
return domainRegex.test(domain);
}
function validateIPv4(ip) {
const ipRegex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipRegex.test(ip);
}
function validatePhone(phone) {
const phoneRegex = /^\+[1-9]\d{6,14}$/;
return phoneRegex.test(phone);
}
// Update preview function
function updatePreview() {
if (!selectedType) {
preview.classList.remove('active');
submitBtn.disabled = true;
return;
}
const lines = textArea.value.split('\n').filter(line => line.trim() !== '');
const uniqueResources = new Set();
const errors = [];
let valid = 0;
let duplicates = 0;
let invalid = 0;
lines.forEach((line, index) => {
const trimmed = line.trim();
if (uniqueResources.has(trimmed)) {
duplicates++;
return;
}
let isValid = false;
switch(selectedType) {
case 'domain':
isValid = validateDomain(trimmed);
break;
case 'ipv4':
isValid = validateIPv4(trimmed);
break;
case 'phone':
isValid = validatePhone(trimmed);
break;
}
if (isValid) {
valid++;
uniqueResources.add(trimmed);
} else {
invalid++;
errors.push(`Zeile ${index + 1}: "${trimmed}"`);
}
});
// Update counts
validCount.textContent = valid;
duplicateCount.textContent = duplicates;
invalidCount.textContent = invalid;
// Show/hide error list
if (errors.length > 0) {
errorList.style.display = 'block';
errorMessages.innerHTML = errors.map(err => `<li>${err}</li>`).join('');
} else {
errorList.style.display = 'none';
}
// Enable/disable submit button
submitBtn.disabled = valid === 0 || invalid > 0;
// Update preview appearance
if (lines.length > 0) {
preview.classList.add('active');
} else {
preview.classList.remove('active');
}
}
// Live validation
textArea.addEventListener('input', updatePreview);
// Form submission
form.addEventListener('submit', function(e) {
if (submitBtn.disabled) {
e.preventDefault();
alert('Bitte beheben Sie alle Fehler bevor Sie fortfahren.');
}
});
// Initial state
textArea.disabled = true;
});
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,318 @@
{% extends "base.html" %}
{% block title %}Log{% endblock %}
{% macro sortable_header(label, field, current_sort, current_order) %}
<th>
{% if current_sort == field %}
<a href="{{ url_for('audit_log', sort=field, order='desc' if current_order == 'asc' else 'asc', user=filter_user, action=filter_action, entity=filter_entity, page=1) }}"
class="server-sortable">
{% else %}
<a href="{{ url_for('audit_log', sort=field, order='asc', user=filter_user, action=filter_action, entity=filter_entity, page=1) }}"
class="server-sortable">
{% endif %}
{{ label }}
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
{% if current_sort == field %}
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
{% else %}
{% endif %}
</span>
</a>
</th>
{% endmacro %}
{% block extra_css %}
<style>
.audit-details {
font-size: 0.85em;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
.json-display {
background-color: #f8f9fa;
padding: 5px;
border-radius: 3px;
font-family: monospace;
font-size: 0.8em;
max-height: 100px;
overflow-y: auto;
}
.action-CREATE { color: #28a745; }
.action-UPDATE { color: #007bff; }
.action-DELETE { color: #dc3545; }
.action-LOGIN { color: #17a2b8; }
.action-LOGOUT { color: #6c757d; }
.action-AUTO_LOGOUT { color: #fd7e14; }
.action-EXPORT { color: #ffc107; }
.action-GENERATE_KEY { color: #20c997; }
.action-CREATE_BATCH { color: #6610f2; }
.action-BACKUP { color: #5a67d8; }
.action-LOGIN_2FA_SUCCESS { color: #00a86b; }
.action-LOGIN_2FA_BACKUP { color: #059862; }
.action-LOGIN_2FA_FAILED { color: #e53e3e; }
.action-LOGIN_BLOCKED { color: #b91c1c; }
.action-RESTORE { color: #4299e1; }
.action-PASSWORD_CHANGE { color: #805ad5; }
.action-2FA_ENABLED { color: #38a169; }
.action-2FA_DISABLED { color: #e53e3e; }
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<h2>📝 Log</h2>
</div>
<!-- Filter -->
<div class="card mb-3">
<div class="card-body">
<form method="get" action="/audit" id="auditFilterForm">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label for="user" class="form-label">Benutzer</label>
<input type="text" class="form-control" id="user" name="user"
placeholder="Benutzername..." value="{{ filter_user }}">
</div>
<div class="col-md-3">
<label for="action" class="form-label">Aktion</label>
<select class="form-select" id="action" name="action">
<option value="">Alle Aktionen</option>
<option value="CREATE" {% if filter_action == 'CREATE' %}selected{% endif %}> Erstellt</option>
<option value="UPDATE" {% if filter_action == 'UPDATE' %}selected{% endif %}>✏️ Bearbeitet</option>
<option value="DELETE" {% if filter_action == 'DELETE' %}selected{% endif %}>🗑️ Gelöscht</option>
<option value="LOGIN" {% if filter_action == 'LOGIN' %}selected{% endif %}>🔑 Anmeldung</option>
<option value="LOGOUT" {% if filter_action == 'LOGOUT' %}selected{% endif %}>🚪 Abmeldung</option>
<option value="AUTO_LOGOUT" {% if filter_action == 'AUTO_LOGOUT' %}selected{% endif %}>⏰ Auto-Logout</option>
<option value="EXPORT" {% if filter_action == 'EXPORT' %}selected{% endif %}>📥 Export</option>
<option value="GENERATE_KEY" {% if filter_action == 'GENERATE_KEY' %}selected{% endif %}>🔑 Key generiert</option>
<option value="CREATE_BATCH" {% if filter_action == 'CREATE_BATCH' %}selected{% endif %}>🔑 Batch erstellt</option>
<option value="BACKUP" {% if filter_action == 'BACKUP' %}selected{% endif %}>💾 Backup</option>
<option value="LOGIN_2FA_SUCCESS" {% if filter_action == 'LOGIN_2FA_SUCCESS' %}selected{% endif %}>🔐 2FA-Anmeldung</option>
<option value="LOGIN_2FA_BACKUP" {% if filter_action == 'LOGIN_2FA_BACKUP' %}selected{% endif %}>🔒 2FA-Backup-Code</option>
<option value="LOGIN_2FA_FAILED" {% if filter_action == 'LOGIN_2FA_FAILED' %}selected{% endif %}>⛔ 2FA-Fehlgeschlagen</option>
<option value="LOGIN_BLOCKED" {% if filter_action == 'LOGIN_BLOCKED' %}selected{% endif %}>🚫 Login-Blockiert</option>
<option value="RESTORE" {% if filter_action == 'RESTORE' %}selected{% endif %}>🔄 Wiederhergestellt</option>
<option value="PASSWORD_CHANGE" {% if filter_action == 'PASSWORD_CHANGE' %}selected{% endif %}>🔐 Passwort geändert</option>
<option value="2FA_ENABLED" {% if filter_action == '2FA_ENABLED' %}selected{% endif %}>✅ 2FA aktiviert</option>
<option value="2FA_DISABLED" {% if filter_action == '2FA_DISABLED' %}selected{% endif %}>❌ 2FA deaktiviert</option>
</select>
</div>
<div class="col-md-3">
<label for="entity" class="form-label">Entität</label>
<select class="form-select" id="entity" name="entity">
<option value="">Alle Entitäten</option>
<option value="license" {% if filter_entity == 'license' %}selected{% endif %}>Lizenz</option>
<option value="customer" {% if filter_entity == 'customer' %}selected{% endif %}>Kunde</option>
<option value="user" {% if filter_entity == 'user' %}selected{% endif %}>Benutzer</option>
<option value="session" {% if filter_entity == 'session' %}selected{% endif %}>Session</option>
<option value="database" {% if filter_entity == 'database' %}selected{% endif %}>Datenbank</option>
</select>
</div>
<div class="col-md-3">
<div class="d-flex gap-2">
<a href="/audit" class="btn btn-outline-secondary">Zurücksetzen</a>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-download"></i> Export
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/export/audit?format=excel&user={{ filter_user }}&action={{ filter_action }}&entity={{ filter_entity }}">
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
<li><a class="dropdown-item" href="/export/audit?format=csv&user={{ filter_user }}&action={{ filter_action }}&entity={{ filter_entity }}">
<i class="bi bi-file-earmark-text"></i> CSV Export</a></li>
</ul>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- Audit Log Tabelle -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
{{ sortable_header('Zeitstempel', 'timestamp', sort, order) }}
{{ sortable_header('Benutzer', 'username', sort, order) }}
{{ sortable_header('Aktion', 'action', sort, order) }}
{{ sortable_header('Entität', 'entity', sort, order) }}
<th>Details</th>
{{ sortable_header('IP-Adresse', 'ip', sort, order) }}
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log[1].strftime('%d.%m.%Y %H:%M:%S') }}</td>
<td><strong>{{ log[2] }}</strong></td>
<td>
<span class="action-{{ log[3] }}">
{% if log[3] == 'CREATE' %} Erstellt
{% elif log[3] == 'UPDATE' %}✏️ Bearbeitet
{% elif log[3] == 'DELETE' %}🗑️ Gelöscht
{% elif log[3] == 'LOGIN' %}🔑 Anmeldung
{% elif log[3] == 'LOGOUT' %}🚪 Abmeldung
{% elif log[3] == 'AUTO_LOGOUT' %}⏰ Auto-Logout
{% elif log[3] == 'EXPORT' %}📥 Export
{% elif log[3] == 'GENERATE_KEY' %}🔑 Key generiert
{% elif log[3] == 'CREATE_BATCH' %}🔑 Batch erstellt
{% elif log[3] == 'BACKUP' %}💾 Backup erstellt
{% elif log[3] == 'LOGIN_2FA_SUCCESS' %}🔐 2FA-Anmeldung
{% elif log[3] == 'LOGIN_2FA_BACKUP' %}🔒 2FA-Backup-Code
{% elif log[3] == 'LOGIN_2FA_FAILED' %}⛔ 2FA-Fehlgeschlagen
{% elif log[3] == 'LOGIN_BLOCKED' %}🚫 Login-Blockiert
{% elif log[3] == 'RESTORE' %}🔄 Wiederhergestellt
{% elif log[3] == 'PASSWORD_CHANGE' %}🔐 Passwort geändert
{% elif log[3] == '2FA_ENABLED' %}✅ 2FA aktiviert
{% elif log[3] == '2FA_DISABLED' %}❌ 2FA deaktiviert
{% else %}{{ log[3] }}
{% endif %}
</span>
</td>
<td>
{{ log[4] }}
{% if log[5] %}
<small class="text-muted">#{{ log[5] }}</small>
{% endif %}
</td>
<td class="audit-details">
{% if log[10] %}
<div class="mb-1"><small class="text-muted">{{ log[10] }}</small></div>
{% endif %}
{% if log[6] and log[3] == 'DELETE' %}
<details>
<summary>Gelöschte Werte</summary>
<div class="json-display">
{% for key, value in log[6].items() %}
<strong>{{ key }}:</strong> {{ value }}<br>
{% endfor %}
</div>
</details>
{% elif log[6] and log[7] and log[3] == 'UPDATE' %}
<details>
<summary>Änderungen anzeigen</summary>
<div class="json-display">
<strong>Vorher:</strong><br>
{% for key, value in log[6].items() %}
{% if log[7][key] != value %}
{{ key }}: {{ value }}<br>
{% endif %}
{% endfor %}
<hr class="my-1">
<strong>Nachher:</strong><br>
{% for key, value in log[7].items() %}
{% if log[6][key] != value %}
{{ key }}: {{ value }}<br>
{% endif %}
{% endfor %}
</div>
</details>
{% elif log[7] and log[3] == 'CREATE' %}
<details>
<summary>Erstellte Werte</summary>
<div class="json-display">
{% for key, value in log[7].items() %}
<strong>{{ key }}:</strong> {{ value }}<br>
{% endfor %}
</div>
</details>
{% endif %}
</td>
<td>
<small class="text-muted">{{ log[8] or '-' }}</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not logs %}
<div class="text-center py-5">
<p class="text-muted">Keine Audit-Log-Einträge gefunden.</p>
</div>
{% endif %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav aria-label="Seitennavigation" class="mt-3">
<ul class="pagination justify-content-center">
<!-- Erste Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('audit_log', page=1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">Erste</a>
</li>
<!-- Vorherige Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('audit_log', page=page-1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}"></a>
</li>
<!-- Seitenzahlen -->
{% for p in range(1, total_pages + 1) %}
{% if p >= page - 2 and p <= page + 2 %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('audit_log', page=p, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">{{ p }}</a>
</li>
{% endif %}
{% endfor %}
<!-- Nächste Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('audit_log', page=page+1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}"></a>
</li>
<!-- Letzte Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('audit_log', page=total_pages, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">Letzte</a>
</li>
</ul>
<p class="text-center text-muted">
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Einträge
</p>
</nav>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Live Filtering für Audit Log
document.addEventListener('DOMContentLoaded', function() {
const filterForm = document.getElementById('auditFilterForm');
const userInput = document.getElementById('user');
const actionSelect = document.getElementById('action');
const entitySelect = document.getElementById('entity');
// Debounce timer für Textfelder
let searchTimeout;
// Live-Filter für Benutzer-Textfeld (mit 300ms Verzögerung)
userInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
filterForm.submit();
}, 300);
});
// Live-Filter für Dropdowns (sofort)
actionSelect.addEventListener('change', function() {
filterForm.submit();
});
entitySelect.addEventListener('change', function() {
filterForm.submit();
});
});
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,228 @@
{% extends "base.html" %}
{% block title %}Backup-Codes{% endblock %}
{% block extra_css %}
<style>
.success-icon {
font-size: 5rem;
animation: bounce 0.5s ease-in-out;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-20px); }
}
.backup-code {
font-family: 'Courier New', monospace;
font-size: 1.3rem;
font-weight: bold;
letter-spacing: 2px;
padding: 8px 15px;
background-color: #f8f9fa;
border: 2px dashed #dee2e6;
border-radius: 5px;
display: inline-block;
margin: 5px;
}
.backup-codes-container {
background-color: #fff;
border: 2px solid #dee2e6;
border-radius: 10px;
padding: 30px;
margin: 20px 0;
}
.action-buttons .btn {
margin: 5px;
}
@media print {
.no-print {
display: none !important;
}
.backup-codes-container {
border: 1px solid #000;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row">
<div class="col-md-8 offset-md-2">
<!-- Success Message -->
<div class="text-center mb-4">
<div class="success-icon text-success"></div>
<h1 class="mt-3">2FA erfolgreich aktiviert!</h1>
<p class="lead text-muted">Ihre Zwei-Faktor-Authentifizierung ist jetzt aktiv.</p>
</div>
<!-- Backup Codes Card -->
<div class="card shadow">
<div class="card-header bg-warning text-dark">
<h4 class="mb-0">
<span style="font-size: 1.5rem; vertical-align: middle;">⚠️</span>
Wichtig: Ihre Backup-Codes
</h4>
</div>
<div class="card-body">
<div class="alert alert-info mb-4">
<strong>Was sind Backup-Codes?</strong><br>
Diese Codes ermöglichen Ihnen den Zugang zu Ihrem Account, falls Sie keinen Zugriff auf Ihre Authenticator-App haben.
<strong>Jeder Code kann nur einmal verwendet werden.</strong>
</div>
<!-- Backup Codes Display -->
<div class="backup-codes-container text-center">
<h5 class="mb-4">Ihre 8 Backup-Codes:</h5>
<div class="row justify-content-center">
{% for code in backup_codes %}
<div class="col-md-6 col-lg-4 mb-3">
<div class="backup-code">{{ code }}</div>
</div>
{% endfor %}
</div>
</div>
<!-- Action Buttons -->
<div class="text-center action-buttons no-print">
<button type="button" class="btn btn-primary btn-lg" onclick="downloadCodes()">
💾 Als Datei speichern
</button>
<button type="button" class="btn btn-secondary btn-lg" onclick="printCodes()">
🖨️ Drucken
</button>
<button type="button" class="btn btn-info btn-lg" onclick="copyCodes()">
📋 Alle kopieren
</button>
</div>
<hr class="my-4">
<!-- Security Tips -->
<div class="row">
<div class="col-md-6">
<div class="alert alert-danger">
<h6>❌ Nicht empfohlen:</h6>
<ul class="mb-0 small">
<li>Im selben Passwort-Manager wie Ihr Passwort</li>
<li>Als Foto auf Ihrem Handy</li>
<li>In einer unverschlüsselten Datei</li>
<li>Per E-Mail an sich selbst</li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-success">
<h6>✅ Empfohlen:</h6>
<ul class="mb-0 small">
<li>Ausgedruckt in einem Safe</li>
<li>In einem separaten Passwort-Manager</li>
<li>Verschlüsselt auf einem USB-Stick</li>
<li>An einem sicheren Ort zu Hause</li>
</ul>
</div>
</div>
</div>
<!-- Confirmation -->
<div class="text-center mt-4 no-print">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="confirmSaved" onchange="checkConfirmation()">
<label class="form-check-label" for="confirmSaved">
Ich habe die Backup-Codes sicher gespeichert
</label>
</div>
<a href="{{ url_for('profile') }}" class="btn btn-lg btn-success" id="continueBtn" style="display: none;">
✅ Weiter zum Profil
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const backupCodes = {{ backup_codes | tojson }};
function checkConfirmation() {
const checkbox = document.getElementById('confirmSaved');
const continueBtn = document.getElementById('continueBtn');
continueBtn.style.display = checkbox.checked ? 'inline-block' : 'none';
}
function downloadCodes() {
const content = `V2 Admin Panel - Backup Codes
=====================================
Generiert am: ${new Date().toLocaleString('de-DE')}
WICHTIG: Bewahren Sie diese Codes sicher auf!
Jeder Code kann nur einmal verwendet werden.
Ihre 8 Backup-Codes:
--------------------
${backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}
Sicherheitshinweise:
-------------------
✓ Bewahren Sie diese Codes getrennt von Ihrem Passwort auf
✓ Speichern Sie sie an einem sicheren physischen Ort
✓ Teilen Sie diese Codes niemals mit anderen
✓ Jeder Code funktioniert nur einmal
Bei Verlust Ihrer Authenticator-App:
------------------------------------
1. Gehen Sie zur Login-Seite
2. Geben Sie Benutzername und Passwort ein
3. Klicken Sie auf "Backup-Code verwenden"
4. Geben Sie einen dieser Codes ein
Nach Verwendung eines Codes sollten Sie neue Backup-Codes generieren.`;
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `v2-backup-codes-${new Date().toISOString().split('T')[0]}.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Visual feedback
const btn = event.target;
const originalText = btn.innerHTML;
btn.innerHTML = '✅ Heruntergeladen!';
btn.classList.remove('btn-primary');
btn.classList.add('btn-success');
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-primary');
}, 2000);
}
function printCodes() {
window.print();
}
function copyCodes() {
const codesText = backupCodes.join('\n');
navigator.clipboard.writeText(codesText).then(function() {
// Visual feedback
const btn = event.target;
const originalText = btn.innerHTML;
btn.innerHTML = '✅ Kopiert!';
btn.classList.remove('btn-info');
btn.classList.add('btn-success');
setTimeout(() => {
btn.innerHTML = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-info');
}, 2000);
}).catch(function(err) {
alert('Fehler beim Kopieren. Bitte manuell kopieren.');
});
}
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,301 @@
{% extends "base.html" %}
{% block title %}Backup-Verwaltung{% endblock %}
{% block extra_css %}
<style>
.status-success { color: #28a745; }
.status-failed { color: #dc3545; }
.status-in_progress { color: #ffc107; }
.backup-actions { white-space: nowrap; }
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>💾 Backup-Verwaltung</h2>
<div>
</div>
</div>
<!-- Backup-Info -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">📅 Letztes erfolgreiches Backup</h5>
{% if last_backup %}
<p class="mb-1"><strong>Zeitpunkt:</strong> {{ last_backup[0].strftime('%d.%m.%Y %H:%M:%S') }}</p>
<p class="mb-1"><strong>Größe:</strong> {{ (last_backup[1] / 1024 / 1024)|round(2) }} MB</p>
<p class="mb-0"><strong>Dauer:</strong> {{ last_backup[2]|round(1) }} Sekunden</p>
{% else %}
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">🔧 Backup-Aktionen</h5>
<button id="createBackupBtn" class="btn btn-primary" onclick="createBackup()">
💾 Backup jetzt erstellen
</button>
<p class="text-muted mt-2 mb-0">
<small>Automatische Backups: Täglich um 03:00 Uhr</small>
</p>
</div>
</div>
</div>
</div>
<!-- Backup-Historie -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">📋 Backup-Historie</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover sortable-table">
<thead>
<tr>
<th class="sortable" data-type="date">Zeitstempel</th>
<th class="sortable">Dateiname</th>
<th class="sortable" data-type="numeric">Größe</th>
<th class="sortable">Typ</th>
<th class="sortable">Status</th>
<th class="sortable">Erstellt von</th>
<th>Details</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for backup in backups %}
<tr>
<td>{{ backup[6].strftime('%d.%m.%Y %H:%M:%S') }}</td>
<td>
<small>{{ backup[1] }}</small>
{% if backup[11] %}
<span class="badge bg-info ms-1">🔒 Verschlüsselt</span>
{% endif %}
</td>
<td>
{% if backup[2] %}
{{ (backup[2] / 1024 / 1024)|round(2) }} MB
{% else %}
-
{% endif %}
</td>
<td>
{% if backup[3] == 'manual' %}
<span class="badge bg-primary">Manuell</span>
{% else %}
<span class="badge bg-secondary">Automatisch</span>
{% endif %}
</td>
<td>
{% if backup[4] == 'success' %}
<span class="status-success">✅ Erfolgreich</span>
{% elif backup[4] == 'failed' %}
<span class="status-failed" title="{{ backup[5] }}">❌ Fehlgeschlagen</span>
{% else %}
<span class="status-in_progress">⏳ In Bearbeitung</span>
{% endif %}
</td>
<td>{{ backup[7] }}</td>
<td>
{% if backup[8] and backup[9] %}
<small>
{{ backup[8] }} Tabellen<br>
{{ backup[9] }} Datensätze<br>
{% if backup[10] %}
{{ backup[10]|round(1) }}s
{% endif %}
</small>
{% else %}
-
{% endif %}
</td>
<td class="backup-actions">
{% if backup[4] == 'success' %}
<div class="btn-group btn-group-sm" role="group">
<a href="/backup/download/{{ backup[0] }}"
class="btn btn-outline-primary"
title="Backup herunterladen">
📥 Download
</a>
<button class="btn btn-outline-success"
onclick="restoreBackup({{ backup[0] }}, '{{ backup[1] }}')"
title="Backup wiederherstellen">
🔄 Wiederherstellen
</button>
<button class="btn btn-outline-danger"
onclick="deleteBackup({{ backup[0] }}, '{{ backup[1] }}')"
title="Backup löschen">
🗑️ Löschen
</button>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not backups %}
<div class="text-center py-5">
<p class="text-muted">Noch keine Backups vorhanden.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Wiederherstellungs-Modal -->
<div class="modal fade" id="restoreModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">🔄 Backup wiederherstellen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>⚠️ Warnung:</strong> Bei der Wiederherstellung werden alle aktuellen Daten überschrieben!
</div>
<p>Backup: <strong id="restoreFilename"></strong></p>
<form id="restoreForm">
<input type="hidden" id="restoreBackupId">
<div class="mb-3">
<label for="encryptionKey" class="form-label">Verschlüsselungs-Passwort (optional)</label>
<input type="password" class="form-control" id="encryptionKey"
placeholder="Leer lassen für Standard-Passwort">
<small class="text-muted">
Falls das Backup mit einem anderen Passwort verschlüsselt wurde
</small>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-danger" onclick="confirmRestore()">
⚠️ Wiederherstellen
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function createBackup() {
const btn = document.getElementById('createBackupBtn');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '⏳ Backup wird erstellt...';
fetch('/backup/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message);
location.reload();
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler beim Erstellen des Backups: ' + error);
})
.finally(() => {
btn.disabled = false;
btn.innerHTML = originalText;
});
}
function restoreBackup(backupId, filename) {
document.getElementById('restoreBackupId').value = backupId;
document.getElementById('restoreFilename').textContent = filename;
document.getElementById('encryptionKey').value = '';
const modal = new bootstrap.Modal(document.getElementById('restoreModal'));
modal.show();
}
function confirmRestore() {
if (!confirm('Wirklich wiederherstellen? Alle aktuellen Daten werden überschrieben!')) {
return;
}
const backupId = document.getElementById('restoreBackupId').value;
const encryptionKey = document.getElementById('encryptionKey').value;
const formData = new FormData();
if (encryptionKey) {
formData.append('encryption_key', encryptionKey);
}
// Modal schließen
bootstrap.Modal.getInstance(document.getElementById('restoreModal')).hide();
// Loading anzeigen
const loadingDiv = document.createElement('div');
loadingDiv.className = 'position-fixed top-50 start-50 translate-middle';
loadingDiv.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
document.body.appendChild(loadingDiv);
fetch(`/backup/restore/${backupId}`, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message + '\n\nDie Seite wird neu geladen...');
window.location.href = '/';
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler bei der Wiederherstellung: ' + error);
})
.finally(() => {
document.body.removeChild(loadingDiv);
});
}
function deleteBackup(backupId, filename) {
if (!confirm(`Soll das Backup "${filename}" wirklich gelöscht werden?\n\nDieser Vorgang kann nicht rückgängig gemacht werden!`)) {
return;
}
fetch(`/backup/delete/${backupId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('✅ ' + data.message);
location.reload();
} else {
alert('❌ ' + data.message);
}
})
.catch(error => {
alert('❌ Fehler beim Löschen des Backups: ' + error);
});
}
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,679 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Panel{% endblock %} - Lizenzverwaltung</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
{% block extra_css %}{% endblock %}
<style>
/* Global Status Colors */
:root {
--status-active: #28a745;
--status-warning: #ffc107;
--status-danger: #dc3545;
--status-inactive: #6c757d;
--status-info: #17a2b8;
--sidebar-width: 250px;
--sidebar-collapsed: 60px;
}
/* Status Classes - Global */
.status-aktiv { color: var(--status-active) !important; }
.status-ablaufend { color: var(--status-warning) !important; }
.status-abgelaufen { color: var(--status-danger) !important; }
.status-deaktiviert { color: var(--status-inactive) !important; }
/* Badge Variants */
.badge-aktiv { background-color: var(--status-active) !important; }
.badge-ablaufend { background-color: var(--status-warning) !important; color: #000 !important; }
.badge-abgelaufen { background-color: var(--status-danger) !important; }
.badge-deaktiviert { background-color: var(--status-inactive) !important; }
/* Session Timer Styles */
#session-timer {
font-family: monospace;
font-weight: bold;
font-size: 1.1rem;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
transition: all 0.3s ease;
}
.timer-normal {
background-color: var(--status-active);
color: white;
}
.timer-warning {
background-color: var(--status-warning);
color: #000;
}
.timer-danger {
background-color: var(--status-danger);
color: white;
animation: pulse 1s infinite;
}
.timer-critical {
background-color: var(--status-danger);
color: white;
animation: blink 0.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
.session-warning-modal {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
max-width: 400px;
}
/* Table Improvements */
.table-container {
max-height: 600px;
overflow-y: auto;
position: relative;
}
.table-sticky thead {
position: sticky;
top: 0;
background-color: #fff;
z-index: 10;
box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1);
}
.table-sticky thead th {
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
}
/* Inline Actions */
.btn-copy {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-copy:hover {
background-color: #e9ecef;
}
.btn-copy.copied {
background-color: var(--status-active);
color: white;
}
/* Toggle Switch */
.form-switch-custom {
display: inline-block;
}
.form-switch-custom .form-check-input {
cursor: pointer;
width: 3em;
height: 1.5em;
}
.form-switch-custom .form-check-input:checked {
background-color: var(--status-active);
border-color: var(--status-active);
}
/* Bulk Actions Bar */
.bulk-actions {
position: sticky;
bottom: 0;
background-color: #212529;
color: white;
padding: 1rem;
display: none;
z-index: 100;
box-shadow: 0 -4px 6px rgba(0, 0, 0, 0.1);
}
.bulk-actions.show {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Checkbox Styling */
.checkbox-cell {
width: 40px;
}
.form-check-input-custom {
cursor: pointer;
width: 1.2em;
height: 1.2em;
}
/* Sortable Table Styles */
.sortable-table th.sortable {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 25px;
}
.sortable-table th.sortable:hover {
background-color: #e9ecef;
}
.sortable-table th.sortable::after {
content: '↕';
position: absolute;
right: 8px;
opacity: 0.3;
}
.sortable-table th.sortable.asc::after {
content: '↑';
opacity: 1;
color: var(--status-active);
}
.sortable-table th.sortable.desc::after {
content: '↓';
opacity: 1;
color: var(--status-active);
}
/* Server-side sortable styles */
.server-sortable {
cursor: pointer;
text-decoration: none;
color: inherit;
display: flex;
align-items: center;
justify-content: space-between;
}
.server-sortable:hover {
color: var(--bs-primary);
text-decoration: none;
}
.sort-indicator {
margin-left: 5px;
font-size: 0.8em;
}
.sort-indicator.active {
color: var(--bs-primary);
}
/* Sidebar Navigation */
.sidebar {
position: fixed;
top: 56px;
left: 0;
height: calc(100vh - 56px);
width: var(--sidebar-width);
background-color: #f8f9fa;
border-right: 1px solid #dee2e6;
overflow-y: auto;
transition: all 0.3s;
z-index: 100;
}
.sidebar.collapsed {
width: var(--sidebar-collapsed);
}
.sidebar-header {
padding: 1rem;
background-color: #e9ecef;
border-bottom: 1px solid #dee2e6;
font-weight: 600;
}
.sidebar-nav {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-nav .nav-item {
border-bottom: 1px solid #e9ecef;
}
.sidebar-nav .nav-link {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
color: #495057;
text-decoration: none;
transition: all 0.2s;
}
.sidebar-nav .nav-link:hover {
background-color: #e9ecef;
color: #212529;
}
.sidebar-nav .nav-link.active {
background-color: var(--bs-primary);
color: white;
}
.sidebar-nav .nav-link i {
margin-right: 0.5rem;
font-size: 1.2rem;
width: 24px;
text-align: center;
}
.sidebar.collapsed .sidebar-nav .nav-link span {
display: none;
}
.sidebar-submenu {
list-style: none;
padding: 0;
margin: 0;
background-color: #f1f3f4;
max-height: none;
overflow: visible;
}
.sidebar-submenu .nav-link {
padding-left: 2.5rem;
font-size: 0.9rem;
}
/* Arrow indicator for items with submenus */
.nav-link.has-submenu::after {
content: '▾';
float: right;
opacity: 0.5;
transform: rotate(180deg);
}
/* Main Content with Sidebar */
.main-content {
margin-left: var(--sidebar-width);
transition: all 0.3s;
min-height: calc(100vh - 56px);
}
.main-content.expanded {
margin-left: var(--sidebar-collapsed);
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.show {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-dark bg-dark navbar-expand-lg">
<div class="container-fluid">
<a href="/" class="navbar-brand text-decoration-none">🎛️ AccountForger - Admin Panel</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<!-- Navigation removed - access via sidebar -->
</ul>
<div class="d-flex align-items-center">
<div id="session-timer" class="timer-normal me-3">
⏱️ <span id="timer-display">5:00</span>
</div>
<span class="text-white me-3">Angemeldet als: {{ username }}</span>
<a href="/profile" class="btn btn-outline-light btn-sm me-2">👤 Profil</a>
<a href="/logout" class="btn btn-outline-light btn-sm">Abmelden</a>
</div>
</div>
</div>
</nav>
<!-- Sidebar Navigation -->
<aside class="sidebar" id="sidebar">
<ul class="sidebar-nav">
<li class="nav-item {% if request.endpoint in ['customers_licenses', 'edit_customer', 'create_customer', 'edit_license', 'create_license', 'batch_licenses'] %}has-active-child{% endif %}">
<a class="nav-link has-submenu {% if request.endpoint == 'customers_licenses' %}active{% endif %}" href="/customers-licenses">
<i class="bi bi-people"></i>
<span>Kunden & Lizenzen</span>
</a>
<ul class="sidebar-submenu">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'create_customer' %}active{% endif %}" href="/customer/create">
<i class="bi bi-person-plus"></i>
<span>Neuer Kunde</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'create_license' %}active{% endif %}" href="/create">
<i class="bi bi-plus-circle"></i>
<span>Neue Lizenz</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'batch_licenses' %}active{% endif %}" href="/batch">
<i class="bi bi-stack"></i>
<span>Batch-Erstellung</span>
</a>
</li>
</ul>
</li>
<li class="nav-item {% if request.endpoint in ['resources', 'add_resources'] %}has-active-child{% endif %}">
<a class="nav-link has-submenu {% if request.endpoint == 'resources' %}active{% endif %}" href="/resources">
<i class="bi bi-box-seam"></i>
<span>Resource Pool</span>
</a>
<ul class="sidebar-submenu">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'add_resources' %}active{% endif %}" href="/resources/add">
<i class="bi bi-plus-square"></i>
<span>Ressourcen hinzufügen</span>
</a>
</li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}" href="/audit">
<i class="bi bi-journal-text"></i>
<span>Audit-Log</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'sessions' %}active{% endif %}" href="/sessions">
<i class="bi bi-people"></i>
<span>Sitzungen</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'backups' %}active{% endif %}" href="/backups">
<i class="bi bi-cloud-download"></i>
<span>Backups</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'blocked_ips' %}active{% endif %}" href="/security/blocked-ips">
<i class="bi bi-shield-lock"></i>
<span>Sicherheit</span>
</a>
</li>
</ul>
</aside>
<!-- Main Content Area -->
<div class="main-content" id="main-content">
<!-- Page Content -->
<div class="container-fluid p-4">
{% block content %}{% endblock %}
</div>
</div>
<!-- Session Warning Modal -->
<div id="session-warning" class="session-warning-modal" style="display: none;">
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong>⚠️ Session läuft ab!</strong><br>
Ihre Session läuft in weniger als 1 Minute ab.<br>
<button type="button" class="btn btn-sm btn-success mt-2" onclick="extendSession()">
Session verlängern
</button>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
// Session-Timer Konfiguration
const SESSION_TIMEOUT = 5 * 60; // 5 Minuten in Sekunden
let timeRemaining = SESSION_TIMEOUT;
let timerInterval;
let warningShown = false;
let lastActivity = Date.now();
// Timer Display Update
function updateTimerDisplay() {
const minutes = Math.floor(timeRemaining / 60);
const seconds = timeRemaining % 60;
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
document.getElementById('timer-display').textContent = display;
// Timer-Farbe ändern
const timerElement = document.getElementById('session-timer');
timerElement.className = timerElement.className.replace(/timer-\w+/, '');
if (timeRemaining <= 30) {
timerElement.classList.add('timer-critical');
} else if (timeRemaining <= 60) {
timerElement.classList.add('timer-danger');
if (!warningShown) {
showSessionWarning();
warningShown = true;
}
} else if (timeRemaining <= 120) {
timerElement.classList.add('timer-warning');
} else {
timerElement.classList.add('timer-normal');
warningShown = false;
hideSessionWarning();
}
}
// Session Warning anzeigen
function showSessionWarning() {
document.getElementById('session-warning').style.display = 'block';
}
// Session Warning verstecken
function hideSessionWarning() {
document.getElementById('session-warning').style.display = 'none';
}
// Timer zurücksetzen
function resetTimer() {
timeRemaining = SESSION_TIMEOUT;
lastActivity = Date.now();
updateTimerDisplay();
}
// Session verlängern
function extendSession() {
fetch('/heartbeat', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'ok') {
resetTimer();
}
})
.catch(error => console.error('Heartbeat error:', error));
}
// Timer Countdown
function countdown() {
timeRemaining--;
updateTimerDisplay();
if (timeRemaining <= 0) {
clearInterval(timerInterval);
window.location.href = '/logout';
}
}
// Aktivitäts-Tracking
function trackActivity() {
const now = Date.now();
// Nur wenn mehr als 5 Sekunden seit letzter Aktivität
if (now - lastActivity > 5000) {
lastActivity = now;
extendSession(); // resetTimer() wird in extendSession nach erfolgreicher Response aufgerufen
}
}
// Event Listeners für Benutzeraktivität
document.addEventListener('click', trackActivity);
document.addEventListener('keypress', trackActivity);
document.addEventListener('mousemove', () => {
const now = Date.now();
// Mausbewegung nur alle 30 Sekunden tracken
if (now - lastActivity > 30000) {
trackActivity();
}
});
// AJAX Interceptor für automatische Session-Verlängerung
const originalFetch = window.fetch;
window.fetch = function(...args) {
// Nur für non-heartbeat requests den Timer verlängern
if (!args[0].includes('/heartbeat')) {
trackActivity();
}
return originalFetch.apply(this, args);
};
// Timer starten
timerInterval = setInterval(countdown, 1000);
updateTimerDisplay();
// Initial Heartbeat
extendSession();
// Preserve show_test parameter across navigation
function preserveShowTestParameter() {
const urlParams = new URLSearchParams(window.location.search);
const showTest = urlParams.get('show_test');
if (showTest === 'true') {
// Update all internal links to include show_test parameter
document.querySelectorAll('a[href^="/"]').forEach(link => {
const href = link.getAttribute('href');
// Skip if already has parameters or is just a fragment
if (!href.includes('?') && !href.startsWith('#')) {
link.setAttribute('href', href + '?show_test=true');
} else if (href.includes('?') && !href.includes('show_test=')) {
link.setAttribute('href', href + '&show_test=true');
}
});
}
}
// Client-side table sorting
document.addEventListener('DOMContentLoaded', function() {
// Preserve show_test parameter on page load
preserveShowTestParameter();
// Initialize all sortable tables
const sortableTables = document.querySelectorAll('.sortable-table');
sortableTables.forEach(table => {
const headers = table.querySelectorAll('th.sortable');
headers.forEach((header, index) => {
header.addEventListener('click', function() {
sortTable(table, index, header);
});
});
});
});
function sortTable(table, columnIndex, header) {
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
const isNumeric = header.dataset.type === 'numeric';
const isDate = header.dataset.type === 'date';
// Determine sort direction
let direction = 'asc';
if (header.classList.contains('asc')) {
direction = 'desc';
}
// Remove all sort classes from headers
table.querySelectorAll('th.sortable').forEach(th => {
th.classList.remove('asc', 'desc');
});
// Add appropriate class to clicked header
header.classList.add(direction);
// Sort rows
rows.sort((a, b) => {
let aValue = a.cells[columnIndex].textContent.trim();
let bValue = b.cells[columnIndex].textContent.trim();
// Handle different data types
if (isNumeric) {
aValue = parseFloat(aValue.replace(/[^0-9.-]/g, '')) || 0;
bValue = parseFloat(bValue.replace(/[^0-9.-]/g, '')) || 0;
} else if (isDate) {
// Parse German date format (DD.MM.YYYY HH:MM)
aValue = parseGermanDate(aValue);
bValue = parseGermanDate(bValue);
} else {
// Text comparison with locale support for umlauts
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (aValue < bValue) return direction === 'asc' ? -1 : 1;
if (aValue > bValue) return direction === 'asc' ? 1 : -1;
return 0;
});
// Reorder rows in DOM
rows.forEach(row => tbody.appendChild(row));
}
function parseGermanDate(dateStr) {
// Handle DD.MM.YYYY HH:MM format
const parts = dateStr.match(/(\d{2})\.(\d{2})\.(\d{4})\s*(\d{2}:\d{2})?/);
if (parts) {
const [_, day, month, year, time] = parts;
const timeStr = time || '00:00';
return new Date(`${year}-${month}-${day}T${timeStr}`);
}
return new Date(0);
}
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

Datei anzeigen

@ -0,0 +1,464 @@
{% extends "base.html" %}
{% block title %}Batch-Lizenzen erstellen{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>🔑 Batch-Lizenzen erstellen</h2>
<a href="/customers-licenses" class="btn btn-secondary">← Zurück zur Übersicht</a>
</div>
<div class="alert alert-info">
<strong> Batch-Generierung:</strong> Erstellen Sie mehrere Lizenzen auf einmal für einen Kunden.
Die Lizenzen werden automatisch generiert und können anschließend als CSV exportiert werden.
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post" action="/batch" accept-charset="UTF-8">
<div class="row g-3">
<div class="col-md-12">
<label for="customerSelect" class="form-label">Kunde auswählen</label>
<select class="form-select" id="customerSelect" name="customer_id" required>
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
<option value="new"> Neuer Kunde</option>
</select>
</div>
<div class="col-md-6" id="customerNameDiv" style="display: none;">
<label for="customerName" class="form-label">Kundenname</label>
<input type="text" class="form-control" id="customerName" name="customer_name"
placeholder="Firma GmbH" accept-charset="UTF-8">
</div>
<div class="col-md-6" id="emailDiv" style="display: none;">
<label for="email" class="form-label">E-Mail</label>
<input type="email" class="form-control" id="email" name="email"
placeholder="kontakt@firma.de" accept-charset="UTF-8">
</div>
<div class="col-md-3">
<label for="quantity" class="form-label">Anzahl Lizenzen</label>
<input type="number" class="form-control" id="quantity" name="quantity"
min="1" max="100" value="10" required>
<div class="form-text">Max. 100 Lizenzen pro Batch</div>
</div>
<div class="col-md-3">
<label for="licenseType" class="form-label">Lizenztyp</label>
<select class="form-select" id="licenseType" name="license_type" required>
<option value="full">Vollversion</option>
<option value="test">Testversion</option>
</select>
</div>
<div class="col-md-2">
<label for="validFrom" class="form-label">Gültig ab</label>
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
</div>
<div class="col-md-1">
<label for="duration" class="form-label">Laufzeit</label>
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
</div>
<div class="col-md-2">
<label for="durationType" class="form-label">Einheit</label>
<select class="form-select" id="durationType" name="duration_type" required>
<option value="days">Tage</option>
<option value="months">Monate</option>
<option value="years" selected>Jahre</option>
</select>
</div>
<div class="col-md-2">
<label for="validUntil" class="form-label">Gültig bis</label>
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
</div>
</div>
<!-- Resource Pool Allocation -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-server"></i> Ressourcen-Zuweisung pro Lizenz
<small class="text-muted float-end" id="resourceStatus"></small>
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="domainCount" class="form-label">
<i class="fas fa-globe"></i> Domains
</label>
<select class="form-select" id="domainCount" name="domain_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
| Benötigt: <span id="domainsNeeded" class="fw-bold">-</span>
</small>
</div>
<div class="col-md-4">
<label for="ipv4Count" class="form-label">
<i class="fas fa-network-wired"></i> IPv4-Adressen
</label>
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
| Benötigt: <span id="ipv4Needed" class="fw-bold">-</span>
</small>
</div>
<div class="col-md-4">
<label for="phoneCount" class="form-label">
<i class="fas fa-phone"></i> Telefonnummern
</label>
<select class="form-select" id="phoneCount" name="phone_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
| Benötigt: <span id="phoneNeeded" class="fw-bold">-</span>
</small>
</div>
</div>
<div class="alert alert-warning mt-3 mb-0" role="alert">
<i class="fas fa-exclamation-triangle"></i>
<strong>Batch-Ressourcen:</strong> Jede Lizenz erhält die angegebene Anzahl an Ressourcen.
Bei 10 Lizenzen mit je 2 Domains werden insgesamt 20 Domains benötigt.
</div>
</div>
</div>
<!-- Device Limit -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-laptop"></i> Gerätelimit pro Lizenz
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label for="deviceLimit" class="form-label">
Maximale Anzahl Geräte pro Lizenz
</label>
<select class="form-select" id="deviceLimit" name="device_limit" required>
{% for i in range(1, 11) %}
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Jede generierte Lizenz kann auf maximal dieser Anzahl von Geräten gleichzeitig aktiviert werden.
</small>
</div>
</div>
</div>
</div>
<!-- Test Data Checkbox -->
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="isTest" name="is_test">
<label class="form-check-label" for="isTest">
<i class="fas fa-flask"></i> Als Testdaten markieren
<small class="text-muted">(wird von der Software ignoriert)</small>
</label>
</div>
<div class="mt-4 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-lg">
🔑 Batch generieren
</button>
<button type="button" class="btn btn-outline-secondary" onclick="previewKeys()">
👁️ Vorschau
</button>
</div>
</form>
<!-- Vorschau Modal -->
<div class="modal fade" id="previewModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Vorschau der generierten Keys</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Es werden <strong id="previewQuantity">10</strong> Lizenzen im folgenden Format generiert:</p>
<div class="bg-light p-3 rounded font-monospace" id="previewFormat">
AF-F-YYYYMM-XXXX-YYYY-ZZZZ
</div>
<p class="mt-3 mb-0">Die Keys werden automatisch eindeutig generiert und in der Datenbank gespeichert.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
</div>
<script>
// Funktion zur Berechnung des Ablaufdatums
function calculateValidUntil() {
const validFrom = document.getElementById('validFrom').value;
const duration = parseInt(document.getElementById('duration').value) || 1;
const durationType = document.getElementById('durationType').value;
if (!validFrom) return;
const startDate = new Date(validFrom);
let endDate = new Date(startDate);
switch(durationType) {
case 'days':
endDate.setDate(endDate.getDate() + duration);
break;
case 'months':
endDate.setMonth(endDate.getMonth() + duration);
break;
case 'years':
endDate.setFullYear(endDate.getFullYear() + duration);
break;
}
// Ein Tag abziehen, da der Starttag mitgezählt wird
endDate.setDate(endDate.getDate() - 1);
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
}
// Event Listener für Änderungen
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
document.getElementById('duration').addEventListener('input', calculateValidUntil);
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
// Setze heutiges Datum als Standard
document.addEventListener('DOMContentLoaded', function() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('validFrom').value = today;
// Berechne initiales Ablaufdatum
calculateValidUntil();
// Initialisiere Select2 für Kundenauswahl
$('#customerSelect').select2({
theme: 'bootstrap-5',
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
allowClear: true,
ajax: {
url: '/api/customers',
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term,
page: params.page || 1
};
},
processResults: function (data, params) {
params.page = params.page || 1;
// "Neuer Kunde" Option immer oben anzeigen
const results = data.results || [];
if (params.page === 1) {
results.unshift({
id: 'new',
text: ' Neuer Kunde',
isNew: true
});
}
return {
results: results,
pagination: data.pagination
};
},
cache: true
},
minimumInputLength: 0,
language: {
inputTooShort: function() { return ''; },
noResults: function() { return 'Keine Kunden gefunden'; },
searching: function() { return 'Suche...'; },
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
}
});
// Event Handler für Kundenauswahl
$('#customerSelect').on('select2:select', function (e) {
const selectedValue = e.params.data.id;
const nameDiv = document.getElementById('customerNameDiv');
const emailDiv = document.getElementById('emailDiv');
const nameInput = document.getElementById('customerName');
const emailInput = document.getElementById('email');
if (selectedValue === 'new') {
// Zeige Eingabefelder für neuen Kunden
nameDiv.style.display = 'block';
emailDiv.style.display = 'block';
nameInput.required = true;
emailInput.required = true;
} else {
// Verstecke Eingabefelder bei bestehendem Kunden
nameDiv.style.display = 'none';
emailDiv.style.display = 'none';
nameInput.required = false;
emailInput.required = false;
nameInput.value = '';
emailInput.value = '';
}
});
// Clear handler
$('#customerSelect').on('select2:clear', function (e) {
document.getElementById('customerNameDiv').style.display = 'none';
document.getElementById('emailDiv').style.display = 'none';
document.getElementById('customerName').required = false;
document.getElementById('email').required = false;
});
// Resource Availability Check
checkResourceAvailability();
// Event Listener für Resource Count und Quantity Änderungen
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
document.getElementById('quantity').addEventListener('input', checkResourceAvailability);
});
// Vorschau-Funktion
function previewKeys() {
const quantity = document.getElementById('quantity').value;
const type = document.getElementById('licenseType').value;
const typeChar = type === 'full' ? 'F' : 'T';
const date = new Date();
const dateStr = date.getFullYear() + ('0' + (date.getMonth() + 1)).slice(-2);
document.getElementById('previewQuantity').textContent = quantity;
document.getElementById('previewFormat').textContent = `AF-${typeChar}-${dateStr}-XXXX-YYYY-ZZZZ`;
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
modal.show();
}
// Validierung
document.getElementById('quantity').addEventListener('input', function(e) {
if (e.target.value > 100) {
e.target.value = 100;
}
if (e.target.value < 1) {
e.target.value = 1;
}
});
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit für Batch
function checkResourceAvailability() {
const quantity = parseInt(document.getElementById('quantity').value) || 1;
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
// Berechne Gesamtbedarf
const totalDomains = domainCount * quantity;
const totalIpv4 = ipv4Count * quantity;
const totalPhones = phoneCount * quantity;
// Update "Benötigt" Anzeigen
document.getElementById('domainsNeeded').textContent = totalDomains;
document.getElementById('ipv4Needed').textContent = totalIpv4;
document.getElementById('phoneNeeded').textContent = totalPhones;
// API-Call zur Verfügbarkeitsprüfung
fetch(`/api/resources/check-availability?domain=${totalDomains}&ipv4=${totalIpv4}&phone=${totalPhones}`)
.then(response => response.json())
.then(data => {
// Update der Verfügbarkeitsanzeigen
updateAvailabilityDisplay('domainsAvailable', data.domain_available, totalDomains);
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, totalIpv4);
updateAvailabilityDisplay('phoneAvailable', data.phone_available, totalPhones);
// Gesamtstatus aktualisieren
updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity);
})
.catch(error => {
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
});
}
// Hilfsfunktion zur Anzeige der Verfügbarkeit
function updateAvailabilityDisplay(elementId, available, requested) {
const element = document.getElementById(elementId);
element.textContent = available;
const neededElement = element.parentElement.querySelector('.fw-bold:last-child');
if (requested > 0 && available < requested) {
element.classList.remove('text-success');
element.classList.add('text-danger');
neededElement.classList.add('text-danger');
neededElement.classList.remove('text-success');
} else if (available < 50) {
element.classList.remove('text-success', 'text-danger');
element.classList.add('text-warning');
} else {
element.classList.remove('text-danger', 'text-warning');
element.classList.add('text-success');
neededElement.classList.remove('text-danger');
neededElement.classList.add('text-success');
}
}
// Gesamtstatus der Ressourcen-Verfügbarkeit für Batch
function updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity) {
const statusElement = document.getElementById('resourceStatus');
let hasIssue = false;
let message = '';
if (totalDomains > 0 && data.domain_available < totalDomains) {
hasIssue = true;
message = `⚠️ Nicht genügend Domains (${data.domain_available}/${totalDomains})`;
} else if (totalIpv4 > 0 && data.ipv4_available < totalIpv4) {
hasIssue = true;
message = `⚠️ Nicht genügend IPv4-Adressen (${data.ipv4_available}/${totalIpv4})`;
} else if (totalPhones > 0 && data.phone_available < totalPhones) {
hasIssue = true;
message = `⚠️ Nicht genügend Telefonnummern (${data.phone_available}/${totalPhones})`;
} else {
message = `✅ Ressourcen für ${quantity} Lizenzen verfügbar`;
}
statusElement.textContent = message;
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
// Disable submit button if not enough resources
const submitButton = document.querySelector('button[type="submit"]');
submitButton.disabled = hasIssue;
if (hasIssue) {
submitButton.classList.add('btn-secondary');
submitButton.classList.remove('btn-primary');
} else {
submitButton.classList.add('btn-primary');
submitButton.classList.remove('btn-secondary');
}
}
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,156 @@
{% extends "base.html" %}
{% block title %}Batch-Lizenzen generiert{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>✅ Batch-Lizenzen erfolgreich generiert</h2>
<div>
<a href="/batch" class="btn btn-primary">🔑 Weitere Batch erstellen</a>
</div>
</div>
<div class="alert alert-success">
<h5 class="alert-heading">🎉 {{ licenses|length }} Lizenzen wurden erfolgreich generiert!</h5>
<p class="mb-0">Die Lizenzen wurden in der Datenbank gespeichert und dem Kunden zugeordnet.</p>
</div>
<!-- Kunden-Info -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">📋 Kundeninformationen</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Kunde:</strong> {{ customer }}</p>
<p><strong>E-Mail:</strong> {{ email or 'Nicht angegeben' }}</p>
</div>
<div class="col-md-6">
<p><strong>Gültig von:</strong> {{ valid_from }}</p>
<p><strong>Gültig bis:</strong> {{ valid_until }}</p>
</div>
</div>
</div>
</div>
<!-- Export-Optionen -->
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">📥 Export-Optionen</h5>
</div>
<div class="card-body">
<p>Exportieren Sie die generierten Lizenzen für den Kunden:</p>
<div class="d-flex gap-2">
<a href="/batch/export" class="btn btn-success">
📄 Als CSV exportieren
</a>
<button class="btn btn-outline-primary" onclick="copyAllKeys()">
📋 Alle Keys kopieren
</button>
<button class="btn btn-outline-secondary" onclick="window.print()">
🖨️ Drucken
</button>
</div>
</div>
</div>
<!-- Generierte Lizenzen -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">🔑 Generierte Lizenzen</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th width="50">#</th>
<th>Lizenzschlüssel</th>
<th width="120">Typ</th>
<th width="100">Aktionen</th>
</tr>
</thead>
<tbody>
{% for license in licenses %}
<tr>
<td>{{ loop.index }}</td>
<td class="font-monospace">{{ license.key }}</td>
<td>
{% if license.type == 'full' %}
<span class="badge bg-success">Vollversion</span>
{% else %}
<span class="badge bg-info">Testversion</span>
{% endif %}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary"
onclick="copyKey('{{ license.key }}')"
title="Kopieren">
📋
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Hinweis -->
<div class="alert alert-info mt-4">
<strong>💡 Tipp:</strong> Die generierten Lizenzen sind sofort aktiv und können verwendet werden.
Sie finden alle Lizenzen auch in der <a href="/licenses?search={{ customer }}">Lizenzübersicht</a>.
</div>
</div>
<!-- Textarea für Kopieren (unsichtbar) -->
<textarea id="copyArea" style="position: absolute; left: -9999px;"></textarea>
<style>
@media print {
.btn, .alert-info, .card-header h5 { display: none !important; }
.card { border: 1px solid #000 !important; }
}
</style>
<script>
// Einzelnen Key kopieren
function copyKey(key) {
navigator.clipboard.writeText(key).then(function() {
// Visuelles Feedback
event.target.textContent = '✓';
setTimeout(() => {
event.target.textContent = '📋';
}, 2000);
});
}
// Alle Keys kopieren
function copyAllKeys() {
const keys = [];
{% for license in licenses %}
keys.push('{{ license.key }}');
{% endfor %}
const text = keys.join('\n');
const textarea = document.getElementById('copyArea');
textarea.value = text;
textarea.select();
try {
document.execCommand('copy');
// Visuelles Feedback
event.target.textContent = '✓ Kopiert!';
setTimeout(() => {
event.target.textContent = '📋 Alle Keys kopieren';
}, 2000);
} catch (err) {
// Fallback für moderne Browser
navigator.clipboard.writeText(text);
}
}
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}Gesperrte IPs{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>🔒 Gesperrte IPs</h1>
<div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">IP-Sperrverwaltung</h5>
</div>
<div class="card-body">
{% if blocked_ips %}
<div class="table-responsive">
<table class="table table-hover sortable-table">
<thead>
<tr>
<th class="sortable">IP-Adresse</th>
<th class="sortable" data-type="numeric">Versuche</th>
<th class="sortable" data-type="date">Erster Versuch</th>
<th class="sortable" data-type="date">Letzter Versuch</th>
<th class="sortable" data-type="date">Gesperrt bis</th>
<th class="sortable">Letzter User</th>
<th class="sortable">Letzte Meldung</th>
<th class="sortable">Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for ip in blocked_ips %}
<tr class="{% if ip.is_active %}table-danger{% else %}table-secondary{% endif %}">
<td><code>{{ ip.ip_address }}</code></td>
<td><span class="badge bg-danger">{{ ip.attempt_count }}</span></td>
<td>{{ ip.first_attempt }}</td>
<td>{{ ip.last_attempt }}</td>
<td>{{ ip.blocked_until }}</td>
<td>{{ ip.last_username or '-' }}</td>
<td><strong>{{ ip.last_error or '-' }}</strong></td>
<td>
{% if ip.is_active %}
<span class="badge bg-danger">GESPERRT</span>
{% else %}
<span class="badge bg-secondary">ABGELAUFEN</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
{% if ip.is_active %}
<form method="post" action="/security/unblock-ip" class="d-inline">
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
<button type="submit" class="btn btn-success"
onclick="return confirm('IP {{ ip.ip_address }} wirklich entsperren?')">
🔓 Entsperren
</button>
</form>
{% endif %}
<form method="post" action="/security/clear-attempts" class="d-inline ms-1">
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
<button type="submit" class="btn btn-warning"
onclick="return confirm('Alle Versuche für IP {{ ip.ip_address }} zurücksetzen?')">
🗑️ Reset
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
<strong>Keine gesperrten IPs vorhanden.</strong>
Das System läuft ohne Sicherheitsvorfälle.
</div>
{% endif %}
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<h5 class="card-title"> Informationen</h5>
<ul class="mb-0">
<li>IPs werden nach <strong>{{ 5 }} fehlgeschlagenen Login-Versuchen</strong> für <strong>24 Stunden</strong> gesperrt.</li>
<li>Nach <strong>2 Versuchen</strong> wird ein CAPTCHA angezeigt.</li>
<li>Bei <strong>5 Versuchen</strong> wird eine E-Mail-Benachrichtigung gesendet (wenn aktiviert).</li>
<li>Gesperrte IPs können manuell entsperrt werden.</li>
<li>Die Fehlermeldungen werden zufällig ausgewählt für zusätzliche Verwirrung.</li>
</ul>
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Neuer Kunde{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>👤 Neuer Kunde anlegen</h2>
<a href="/customers-licenses" class="btn btn-secondary">← Zurück zur Übersicht</a>
</div>
<div class="card">
<div class="card-body">
<form method="post" action="/customer/create" accept-charset="UTF-8">
<div class="row g-3">
<div class="col-md-6">
<label for="name" class="form-label">Kundenname <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name"
placeholder="Firmenname oder Vor- und Nachname"
accept-charset="UTF-8" required autofocus>
<div class="form-text">Der Name des Kunden oder der Firma</div>
</div>
<div class="col-md-6">
<label for="email" class="form-label">E-Mail <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="email" name="email"
placeholder="kunde@beispiel.de"
accept-charset="UTF-8" required>
<div class="form-text">Kontakt-E-Mail-Adresse des Kunden</div>
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="isTest" name="is_test">
<label class="form-check-label" for="isTest">
<i class="fas fa-flask"></i> Als Testdaten markieren
<small class="text-muted">(Kunde wird von der Software ignoriert)</small>
</label>
</div>
<div class="alert alert-info mt-4" role="alert">
<i class="fas fa-info-circle"></i>
<strong>Hinweis:</strong> Nach dem Anlegen des Kunden können Sie direkt Lizenzen für diesen Kunden erstellen.
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">💾 Kunde anlegen</button>
<a href="/customers-licenses" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
{% for category, message in messages %}
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% endblock %}

Datei anzeigen

@ -0,0 +1,176 @@
{% extends "base.html" %}
{% block title %}Kundenverwaltung{% endblock %}
{% macro sortable_header(label, field, current_sort, current_order) %}
<th>
{% if current_sort == field %}
<a href="{{ url_for('customers', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, page=1) }}"
class="server-sortable">
{% else %}
<a href="{{ url_for('customers', sort=field, order='asc', search=search, page=1) }}"
class="server-sortable">
{% endif %}
{{ label }}
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
{% if current_sort == field %}
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
{% else %}
{% endif %}
</span>
</a>
</th>
{% endmacro %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<h2>Kundenverwaltung</h2>
</div>
<!-- Suchformular -->
<div class="card mb-3">
<div class="card-body">
<form method="get" action="/customers" id="customerSearchForm" class="row g-3 align-items-end">
<div class="col-md-10">
<label for="search" class="form-label">🔍 Suchen</label>
<input type="text" class="form-control" id="search" name="search"
placeholder="Kundenname oder E-Mail..."
value="{{ search }}" autofocus>
</div>
<div class="col-md-2">
<a href="/customers" class="btn btn-outline-secondary w-100">Zurücksetzen</a>
</div>
</form>
{% if search %}
<div class="mt-2">
<small class="text-muted">Suchergebnisse für: <strong>{{ search }}</strong></small>
<a href="/customers" class="btn btn-sm btn-outline-secondary ms-2">✖ Suche zurücksetzen</a>
</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
{{ sortable_header('ID', 'id', sort, order) }}
{{ sortable_header('Name', 'name', sort, order) }}
{{ sortable_header('E-Mail', 'email', sort, order) }}
{{ sortable_header('Erstellt am', 'created_at', sort, order) }}
{{ sortable_header('Lizenzen (Aktiv/Gesamt)', 'licenses', sort, order) }}
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for customer in customers %}
<tr>
<td>{{ customer[0] }}</td>
<td>
{{ customer[1] }}
{% if customer[4] %}
<span class="badge bg-secondary ms-1" title="Testdaten">🧪</span>
{% endif %}
</td>
<td>{{ customer[2] or '-' }}</td>
<td>{{ customer[3].strftime('%d.%m.%Y %H:%M') }}</td>
<td>
<span class="badge bg-info">{{ customer[6] }}/{{ customer[5] }}</span>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/customer/edit/{{ customer[0] }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
{% if customer[5] == 0 %}
<form method="post" action="/customer/delete/{{ customer[0] }}" style="display: inline;" onsubmit="return confirm('Kunde wirklich löschen?');">
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
</form>
{% else %}
<button class="btn btn-outline-danger" disabled title="Kunde hat Lizenzen">🗑️ Löschen</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not customers %}
<div class="text-center py-5">
{% if search %}
<p class="text-muted">Keine Kunden gefunden für: <strong>{{ search }}</strong></p>
<a href="/customers" class="btn btn-secondary">Alle Kunden anzeigen</a>
{% else %}
<p class="text-muted">Noch keine Kunden vorhanden.</p>
<a href="/create" class="btn btn-primary">Erste Lizenz erstellen</a>
{% endif %}
</div>
{% endif %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav aria-label="Seitennavigation" class="mt-3">
<ul class="pagination justify-content-center">
<!-- Erste Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('customers', page=1, search=search, sort=sort, order=order) }}">Erste</a>
</li>
<!-- Vorherige Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('customers', page=page-1, search=search, sort=sort, order=order) }}"></a>
</li>
<!-- Seitenzahlen -->
{% for p in range(1, total_pages + 1) %}
{% if p >= page - 2 and p <= page + 2 %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('customers', page=p, search=search, sort=sort, order=order) }}">{{ p }}</a>
</li>
{% endif %}
{% endfor %}
<!-- Nächste Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('customers', page=page+1, search=search, sort=sort, order=order) }}"></a>
</li>
<!-- Letzte Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('customers', page=total_pages, search=search, sort=sort, order=order) }}">Letzte</a>
</li>
</ul>
<p class="text-center text-muted">
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Kunden
</p>
</nav>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Live Search für Kunden
document.addEventListener('DOMContentLoaded', function() {
const searchForm = document.getElementById('customerSearchForm');
const searchInput = document.getElementById('search');
// Debounce timer für Suchfeld
let searchTimeout;
// Live-Suche mit 300ms Verzögerung
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchForm.submit();
}, 300);
});
});
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,488 @@
{% extends "base.html" %}
{% block title %}Kunden & Lizenzen - AccountForger Admin{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Kunden & Lizenzen</h2>
<div>
<a href="/create" class="btn btn-success">
<i class="bi bi-plus-circle"></i> Neue Lizenz
</a>
<a href="/batch" class="btn btn-primary">
<i class="bi bi-stack"></i> Batch-Lizenzen
</a>
<div class="btn-group">
<button type="button" class="btn btn-info dropdown-toggle" data-bs-toggle="dropdown">
<i class="bi bi-download"></i> Export
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/export/licenses?format=excel"><i class="bi bi-file-earmark-excel"></i> Excel</a></li>
<li><a class="dropdown-item" href="/export/licenses?format=csv"><i class="bi bi-file-earmark-csv"></i> CSV</a></li>
</ul>
</div>
</div>
</div>
<div class="row">
<!-- Kundenliste (Links) -->
<div class="col-md-4 col-lg-3">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-people"></i> Kunden
<span class="badge bg-secondary float-end">{{ customers|length if customers else 0 }}</span>
</h5>
</div>
<div class="card-body p-0">
<!-- Suchfeld -->
<div class="p-3 border-bottom">
<input type="text" class="form-control mb-2" id="customerSearch"
placeholder="Kunde suchen..." autocomplete="off">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="showTestCustomers"
{% if request.args.get('show_test', 'false').lower() == 'true' %}checked{% endif %}
onchange="toggleTestCustomers()">
<label class="form-check-label" for="showTestCustomers">
<small class="text-muted">Testkunden anzeigen</small>
</label>
</div>
</div>
<!-- Kundenliste -->
<div class="customer-list" style="max-height: 600px; overflow-y: auto;">
{% if customers %}
{% for customer in customers %}
<div class="customer-item p-3 border-bottom {% if customer[0] == selected_customer_id %}active{% endif %}"
data-customer-id="{{ customer[0] }}"
data-customer-name="{{ customer[1]|lower }}"
data-customer-email="{{ customer[2]|lower }}"
onclick="loadCustomerLicenses({{ customer[0] }})"
style="cursor: pointer;">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">{{ customer[1] }}</h6>
<small class="text-muted">{{ customer[2] }}</small>
</div>
<div class="text-end">
<span class="badge bg-primary">{{ customer[4] }}</span>
{% if customer[5] > 0 %}
<span class="badge bg-success">{{ customer[5] }}</span>
{% endif %}
{% if customer[6] > 0 %}
<span class="badge bg-danger">{{ customer[6] }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-inbox" style="font-size: 3rem; opacity: 0.3;"></i>
<p class="mt-3 mb-2">Keine Kunden vorhanden</p>
<small class="d-block mb-3">Erstellen Sie eine neue Lizenz, um automatisch einen Kunden anzulegen.</small>
<a href="/create" class="btn btn-sm btn-primary">
<i class="bi bi-plus-circle"></i> Neue Lizenz erstellen
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Lizenzdetails (Rechts) -->
<div class="col-md-8 col-lg-9">
<div class="card">
<div class="card-header bg-light">
{% if selected_customer %}
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">{{ selected_customer[1] }}</h5>
<small class="text-muted">{{ selected_customer[2] }}</small>
</div>
<div>
<a href="/customer/edit/{{ selected_customer[0] }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> Bearbeiten
</a>
<button class="btn btn-sm btn-success" onclick="showNewLicenseModal({{ selected_customer[0] }})">
<i class="bi bi-plus"></i> Neue Lizenz
</button>
</div>
</div>
{% else %}
<h5 class="mb-0">Wählen Sie einen Kunden aus</h5>
{% endif %}
</div>
<div class="card-body">
<div id="licenseContainer">
{% if selected_customer %}
{% if licenses %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Lizenzschlüssel</th>
<th>Typ</th>
<th>Gültig von</th>
<th>Gültig bis</th>
<th>Status</th>
<th>Ressourcen</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for license in licenses %}
<tr>
<td>
<code>{{ license[1] }}</code>
<button class="btn btn-sm btn-link" onclick="copyToClipboard('{{ license[1] }}')">
<i class="bi bi-clipboard"></i>
</button>
</td>
<td>
<span class="badge {% if license[2] == 'full' %}bg-primary{% else %}bg-secondary{% endif %}">
{{ license[2]|upper }}
</span>
</td>
<td>{{ license[3].strftime('%d.%m.%Y') if license[3] else '-' }}</td>
<td>{{ license[4].strftime('%d.%m.%Y') if license[4] else '-' }}</td>
<td>
<span class="badge
{% if license[6] == 'aktiv' %}bg-success
{% elif license[6] == 'läuft bald ab' %}bg-warning
{% elif license[6] == 'abgelaufen' %}bg-danger
{% else %}bg-secondary{% endif %}">
{{ license[6] }}
</span>
</td>
<td>
{% if license[7] > 0 %}🌐 {{ license[7] }}{% endif %}
{% if license[8] > 0 %}📡 {{ license[8] }}{% endif %}
{% if license[9] > 0 %}📱 {{ license[9] }}{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="toggleLicenseStatus({{ license[0] }}, {{ license[5] }})">
<i class="bi bi-power"></i>
</button>
<a href="/license/edit/{{ license[0] }}" class="btn btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">Keine Lizenzen für diesen Kunden vorhanden</p>
<button class="btn btn-success" onclick="showNewLicenseModal({{ selected_customer[0] }})">
<i class="bi bi-plus"></i> Erste Lizenz erstellen
</button>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-arrow-left text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">Wählen Sie einen Kunden aus der Liste aus</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal für neue Lizenz -->
<div class="modal fade" id="newLicenseModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Neue Lizenz erstellen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Möchten Sie eine neue Lizenz für <strong id="modalCustomerName"></strong> erstellen?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-success" id="createLicenseBtn">Zur Lizenzerstellung</button>
</div>
</div>
</div>
</div>
<style>
.customer-item {
transition: all 0.2s ease;
border-left: 3px solid transparent;
}
.customer-item:hover {
background-color: #f8f9fa;
border-left-color: #dee2e6;
}
.customer-item.active {
background-color: #e7f3ff;
border-left-color: #0d6efd;
}
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
}
.table-hover tbody tr:hover {
background-color: #f8f9fa;
}
</style>
{% endblock %}
{% block extra_js %}
<script>
// Globale Variablen und Funktionen
let currentCustomerId = {{ selected_customer_id or 'null' }};
// Lade Lizenzen eines Kunden
function loadCustomerLicenses(customerId) {
const searchTerm = e.target.value.toLowerCase();
const customerItems = document.querySelectorAll('.customer-item');
customerItems.forEach(item => {
const name = item.dataset.customerName;
const email = item.dataset.customerEmail;
if (name.includes(searchTerm) || email.includes(searchTerm)) {
item.style.display = 'block';
} else {
item.style.display = 'none';
}
});
});
// Aktiven Status aktualisieren
document.querySelectorAll('.customer-item').forEach(item => {
item.classList.remove('active');
});
document.querySelector(`[data-customer-id="${customerId}"]`).classList.add('active');
// URL aktualisieren ohne Reload (behalte show_test Parameter)
const currentUrl = new URL(window.location);
currentUrl.searchParams.set('customer_id', customerId);
window.history.pushState({}, '', currentUrl.toString());
// Lade Lizenzen via AJAX
const container = document.getElementById('licenseContainer');
const cardHeader = document.querySelector('.card-header.bg-light');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary" role="status"></div></div>';
fetch(`/api/customer/${customerId}/licenses`)
.then(response => response.json())
.then(data => {
if (data.success) {
// Update header with customer info
const customerItem = document.querySelector(`[data-customer-id="${customerId}"]`);
const customerName = customerItem.querySelector('h6').textContent;
const customerEmail = customerItem.querySelector('small').textContent;
cardHeader.innerHTML = `
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">${customerName}</h5>
<small class="text-muted">${customerEmail}</small>
</div>
<div>
<a href="/customer/edit/${customerId}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> Bearbeiten
</a>
<button class="btn btn-sm btn-success" onclick="showNewLicenseModal(${customerId})">
<i class="bi bi-plus"></i> Neue Lizenz
</button>
</div>
</div>`;
updateLicenseView(customerId, data.licenses);
}
})
.catch(error => {
console.error('Error:', error);
container.innerHTML = '<div class="alert alert-danger">Fehler beim Laden der Lizenzen</div>';
});
}
// Aktualisiere Lizenzansicht
function updateLicenseView(customerId, licenses) {
currentCustomerId = customerId;
const container = document.getElementById('licenseContainer');
if (licenses.length === 0) {
container.innerHTML = `
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-3">Keine Lizenzen für diesen Kunden vorhanden</p>
<button class="btn btn-success" onclick="showNewLicenseModal(${customerId})">
<i class="bi bi-plus"></i> Erste Lizenz erstellen
</button>
</div>`;
return;
}
let html = `
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Lizenzschlüssel</th>
<th>Typ</th>
<th>Gültig von</th>
<th>Gültig bis</th>
<th>Status</th>
<th>Ressourcen</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>`;
licenses.forEach(license => {
const statusClass = license.status === 'aktiv' ? 'bg-success' :
license.status === 'läuft bald ab' ? 'bg-warning' :
license.status === 'abgelaufen' ? 'bg-danger' : 'bg-secondary';
const typeClass = license.license_type === 'full' ? 'bg-primary' : 'bg-secondary';
html += `
<tr>
<td>
<code>${license.license_key}</code>
<button class="btn btn-sm btn-link" onclick="copyToClipboard('${license.license_key}')">
<i class="bi bi-clipboard"></i>
</button>
</td>
<td><span class="badge ${typeClass}">${license.license_type.toUpperCase()}</span></td>
<td>${license.valid_from || '-'}</td>
<td>${license.valid_until || '-'}</td>
<td><span class="badge ${statusClass}">${license.status}</span></td>
<td>
${license.domain_count > 0 ? '🌐 ' + license.domain_count : ''}
${license.ipv4_count > 0 ? '📡 ' + license.ipv4_count : ''}
${license.phone_count > 0 ? '📱 ' + license.phone_count : ''}
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick="toggleLicenseStatus(${license.id}, ${license.is_active})">
<i class="bi bi-power"></i>
</button>
<a href="/license/edit/${license.id}" class="btn btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
</div>
</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
// Toggle Lizenzstatus
function toggleLicenseStatus(licenseId, currentStatus) {
const newStatus = !currentStatus;
fetch(`/api/license/${licenseId}/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_active: newStatus })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Reload current customer licenses
if (currentCustomerId) {
loadCustomerLicenses(currentCustomerId);
}
}
})
.catch(error => console.error('Error:', error));
}
// Zeige Modal für neue Lizenz
function showNewLicenseModal(customerId) {
const customerItem = document.querySelector(`[data-customer-id="${customerId}"]`);
if (!customerItem) {
console.error('Kunde nicht gefunden:', customerId);
return;
}
const customerName = customerItem.querySelector('h6').textContent;
document.getElementById('modalCustomerName').textContent = customerName;
document.getElementById('createLicenseBtn').onclick = function() {
window.location.href = `/create?customer_id=${customerId}`;
};
// Check if bootstrap is loaded
if (typeof bootstrap === 'undefined') {
console.error('Bootstrap nicht geladen!');
// Fallback: Direkt zur Erstellung
if (confirm(`Neue Lizenz für ${customerName} erstellen?`)) {
window.location.href = `/create?customer_id=${customerId}`;
}
return;
}
const modalElement = document.getElementById('newLicenseModal');
const modal = new bootstrap.Modal(modalElement);
modal.show();
}
// Copy to clipboard
function copyToClipboard(text) {
const button = event.currentTarget;
navigator.clipboard.writeText(text).then(() => {
// Zeige kurz Feedback
button.innerHTML = '<i class="bi bi-check"></i>';
setTimeout(() => {
button.innerHTML = '<i class="bi bi-clipboard"></i>';
}, 1000);
}).catch(err => {
console.error('Fehler beim Kopieren:', err);
alert('Konnte nicht in die Zwischenablage kopieren');
});
}
// Toggle Testkunden
function toggleTestCustomers() {
const showTest = document.getElementById('showTestCustomers').checked;
const currentUrl = new URL(window.location);
currentUrl.searchParams.set('show_test', showTest);
window.location.href = currentUrl.toString();
}
// Keyboard navigation
document.addEventListener('keydown', function(e) {
if (e.target.id === 'customerSearch') return; // Nicht bei Suche
const activeItem = document.querySelector('.customer-item.active');
if (!activeItem) return;
let targetItem = null;
if (e.key === 'ArrowUp') {
targetItem = activeItem.previousElementSibling;
} else if (e.key === 'ArrowDown') {
targetItem = activeItem.nextElementSibling;
}
if (targetItem && targetItem.classList.contains('customer-item')) {
e.preventDefault();
const customerId = parseInt(targetItem.dataset.customerId);
loadCustomerLicenses(customerId);
}
});
}); // Ende DOMContentLoaded
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,433 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block extra_css %}
<style>
.stat-card {
transition: all 0.3s ease;
cursor: pointer;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.stat-card .card-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
opacity: 0.8;
}
.stat-card .card-value {
font-size: 2.5rem;
font-weight: bold;
margin: 0.5rem 0;
}
.stat-card .card-label {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
}
a:hover .stat-card {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.text-decoration-none:hover {
text-decoration: none !important;
}
/* Session pulse effect */
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
100% { transform: scale(1); opacity: 1; }
}
.pulse-effect {
animation: pulse 2s infinite;
}
/* Progress bar styles */
.progress-custom {
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.progress-bar-custom {
background-color: #28a745;
transition: width 0.3s ease;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<h1 class="mb-4">Dashboard</h1>
<!-- Statistik-Karten -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<a href="/customers-licenses" class="text-decoration-none">
<div class="card stat-card h-100">
<div class="card-body text-center">
<div class="card-icon text-primary">👥</div>
<div class="card-value text-primary">{{ stats.total_customers }}</div>
<div class="card-label text-muted">Kunden Gesamt</div>
</div>
</div>
</a>
</div>
<div class="col-md-4">
<a href="/customers-licenses" class="text-decoration-none">
<div class="card stat-card h-100">
<div class="card-body text-center">
<div class="card-icon text-info">📋</div>
<div class="card-value text-info">{{ stats.total_licenses }}</div>
<div class="card-label text-muted">Lizenzen Gesamt</div>
</div>
</div>
</a>
</div>
<div class="col-md-4">
<a href="/sessions" class="text-decoration-none">
<div class="card stat-card h-100">
<div class="card-body text-center">
<div class="card-icon text-success{% if stats.active_sessions > 0 %} pulse-effect{% endif %}">🟢</div>
<div class="card-value text-success">{{ stats.active_sessions }}</div>
<div class="card-label text-muted">Aktive Sessions</div>
</div>
</div>
</a>
</div>
</div>
<!-- Lizenztypen -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Lizenztypen</h5>
<div class="row">
<div class="col-6 text-center">
<h3 class="text-success">{{ stats.full_licenses }}</h3>
<p class="text-muted">Vollversionen</p>
</div>
<div class="col-6 text-center">
<h3 class="text-warning">{{ stats.test_licenses }}</h3>
<p class="text-muted">Testversionen</p>
</div>
</div>
{% if stats.test_data_count > 0 or stats.test_customers_count > 0 or stats.test_resources_count > 0 %}
<div class="alert alert-info mt-3 mb-0">
<small>
<i class="fas fa-flask"></i> Testdaten:
{{ stats.test_data_count }} Lizenzen,
{{ stats.test_customers_count }} Kunden,
{{ stats.test_resources_count }} Ressourcen
</small>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Lizenzstatus</h5>
<div class="row">
<div class="col-4 text-center">
<h3 class="text-success">{{ stats.active_licenses }}</h3>
<p class="text-muted">Aktiv</p>
</div>
<div class="col-4 text-center">
<h3 class="text-danger">{{ stats.expired_licenses }}</h3>
<p class="text-muted">Abgelaufen</p>
</div>
<div class="col-4 text-center">
<h3 class="text-secondary">{{ stats.inactive_licenses }}</h3>
<p class="text-muted">Deaktiviert</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Backup-Status und Sicherheit nebeneinander -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<a href="/backups" class="text-decoration-none">
<div class="card stat-card h-100">
<div class="card-body">
<h5 class="card-title">💾 Backup-Status</h5>
{% if stats.last_backup %}
{% if stats.last_backup[4] == 'success' %}
<div class="d-flex align-items-center mb-2">
<span class="text-success me-2"></span>
<small>{{ stats.last_backup[0].strftime('%d.%m.%Y %H:%M') }}</small>
</div>
<div class="progress-custom">
<div class="progress-bar-custom" style="width: 100%;"></div>
</div>
<small class="text-muted mt-1 d-block">
{{ (stats.last_backup[1] / 1024 / 1024)|round(1) }} MB • {{ stats.last_backup[2]|round(0)|int }}s
</small>
{% else %}
<div class="d-flex align-items-center">
<span class="text-danger me-2"></span>
<small>Backup fehlgeschlagen</small>
</div>
{% endif %}
{% else %}
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
{% endif %}
</div>
</div>
</a>
</div>
<!-- Sicherheitsstatus -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">🔒 Sicherheitsstatus</h5>
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Sicherheitslevel:</span>
<span class="badge bg-{{ stats.security_level }} fs-6">{{ stats.security_level_text }}</span>
</div>
<div class="row text-center">
<div class="col-6">
<h4 class="text-danger mb-0">{{ stats.blocked_ips_count }}</h4>
<small class="text-muted">Gesperrte IPs</small>
</div>
<div class="col-6">
<h4 class="text-warning mb-0">{{ stats.failed_attempts_today }}</h4>
<small class="text-muted">Fehlversuche heute</small>
</div>
</div>
<a href="/security/blocked-ips" class="btn btn-sm btn-outline-danger mt-3">IP-Verwaltung →</a>
</div>
</div>
</div>
</div>
<!-- Sicherheitsereignisse -->
{% if stats.recent_security_events %}
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-dark text-white">
<h6 class="mb-0">🚨 Letzte Sicherheitsereignisse</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0 sortable-table">
<thead>
<tr>
<th class="sortable" data-type="date">Zeit</th>
<th class="sortable">IP-Adresse</th>
<th class="sortable" data-type="numeric">Versuche</th>
<th class="sortable">Fehlermeldung</th>
<th class="sortable">Status</th>
</tr>
</thead>
<tbody>
{% for event in stats.recent_security_events %}
<tr>
<td>{{ event.last_attempt }}</td>
<td><code>{{ event.ip_address }}</code></td>
<td><span class="badge bg-secondary">{{ event.attempt_count }}</span></td>
<td><strong class="text-danger">{{ event.error_message }}</strong></td>
<td>
{% if event.blocked_until %}
<span class="badge bg-danger">Gesperrt bis {{ event.blocked_until }}</span>
{% else %}
<span class="badge bg-warning">Aktiv</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Resource Pool Status -->
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="fas fa-server"></i> Resource Pool Status
</h5>
</div>
<div class="card-body">
<div class="row">
{% if resource_stats %}
{% for type, data in resource_stats.items() %}
<div class="col-md-4 mb-3">
<div class="d-flex align-items-center">
<div class="me-3">
<a href="/resources?type={{ type }}{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
class="text-decoration-none">
<i class="fas fa-{{ 'globe' if type == 'domain' else ('network-wired' if type == 'ipv4' else 'phone') }} fa-2x text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"></i>
</a>
</div>
<div class="flex-grow-1">
<h6 class="mb-1">
<a href="/resources?type={{ type }}{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
class="text-decoration-none text-dark">{{ type|upper }}</a>
</h6>
<div class="d-flex justify-content-between align-items-center">
<span>
<strong class="text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">{{ data.available }}</strong> / {{ data.total }} verfügbar
</span>
<span class="badge bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">
{{ data.available_percent }}%
</span>
</div>
<div class="progress mt-1" style="height: 8px;">
<div class="progress-bar bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"
style="width: {{ data.available_percent }}%"
data-bs-toggle="tooltip"
title="{{ data.available }} von {{ data.total }} verfügbar"></div>
</div>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">
<a href="/resources?type={{ type }}&status=allocated{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
class="text-decoration-none text-muted">
{{ data.allocated }} zugeteilt
</a>
</small>
{% if data.quarantine > 0 %}
<small>
<a href="/resources?type={{ type }}&status=quarantine{{ '&show_test=true' if request.args.get('show_test') == 'true' else '' }}"
class="text-decoration-none text-warning">
<i class="bi bi-exclamation-triangle"></i> {{ data.quarantine }} in Quarantäne
</a>
</small>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12 text-center text-muted">
<i class="fas fa-inbox fa-3x mb-3"></i>
<p>Keine Ressourcen im Pool vorhanden.</p>
<a href="/resources/add{{ '?show_test=true' if request.args.get('show_test') == 'true' else '' }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Ressourcen hinzufügen
</a>
</div>
{% endif %}
</div>
{% if resource_warning %}
<div class="alert alert-danger mt-3 mb-0 d-flex justify-content-between align-items-center" role="alert">
<div>
<i class="fas fa-exclamation-triangle"></i>
<strong>Kritisch:</strong> {{ resource_warning }}
</div>
<a href="/resources/add{{ '?show_test=true' if request.args.get('show_test') == 'true' else '' }}"
class="btn btn-sm btn-danger">
<i class="bi bi-plus"></i> Ressourcen auffüllen
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Bald ablaufende Lizenzen -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0">⏰ Bald ablaufende Lizenzen</h5>
</div>
<div class="card-body">
{% if stats.expiring_licenses %}
<div class="table-responsive">
<table class="table table-sm sortable-table">
<thead>
<tr>
<th class="sortable">Kunde</th>
<th class="sortable">Lizenz</th>
<th class="sortable" data-type="numeric">Tage</th>
</tr>
</thead>
<tbody>
{% for license in stats.expiring_licenses %}
<tr>
<td>{{ license[2] }}</td>
<td><small><code>{{ license[1][:8] }}...</code></small></td>
<td><span class="badge bg-warning">{{ license[4] }} Tage</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Keine Lizenzen laufen in den nächsten 30 Tagen ab.</p>
{% endif %}
</div>
</div>
</div>
<!-- Letzte Lizenzen -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="mb-0">🆕 Zuletzt erstellte Lizenzen</h5>
</div>
<div class="card-body">
{% if stats.recent_licenses %}
<div class="table-responsive">
<table class="table table-sm sortable-table">
<thead>
<tr>
<th class="sortable">Kunde</th>
<th class="sortable">Lizenz</th>
<th class="sortable">Status</th>
</tr>
</thead>
<tbody>
{% for license in stats.recent_licenses %}
<tr>
<td>{{ license[2] }}</td>
<td><small><code>{{ license[1][:8] }}...</code></small></td>
<td>
{% if license[4] == 'deaktiviert' %}
<span class="status-deaktiviert">🚫 Deaktiviert</span>
{% elif license[4] == 'abgelaufen' %}
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
{% elif license[4] == 'läuft bald ab' %}
<span class="status-ablaufend">⏰ Läuft bald ab</span>
{% else %}
<span class="status-aktiv">✅ Aktiv</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Noch keine Lizenzen erstellt.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}Kunde bearbeiten{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Kunde bearbeiten</h2>
<div>
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">👥 Zurück zur Übersicht</a>
</div>
</div>
<div class="card mb-4">
<div class="card-body">
<form method="post" action="/customer/edit/{{ customer[0] }}" accept-charset="UTF-8">
{% if request.args.get('show_test') == 'true' %}
<input type="hidden" name="show_test" value="true">
{% endif %}
<div class="row g-3">
<div class="col-md-6">
<label for="name" class="form-label">Kundenname</label>
<input type="text" class="form-control" id="name" name="name" value="{{ customer[1] }}" accept-charset="UTF-8" required>
</div>
<div class="col-md-6">
<label for="email" class="form-label">E-Mail</label>
<input type="email" class="form-control" id="email" name="email" value="{{ customer[2] or '' }}" accept-charset="UTF-8">
</div>
<div class="col-12">
<label class="form-label text-muted">Erstellt am</label>
<p class="form-control-plaintext">{{ customer[3].strftime('%d.%m.%Y %H:%M') }}</p>
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="isTest" name="is_test" {% if customer[4] %}checked{% endif %}>
<label class="form-check-label" for="isTest">
<i class="fas fa-flask"></i> Als Testdaten markieren
<small class="text-muted">(Kunde und seine Lizenzen werden von der Software ignoriert)</small>
</label>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Lizenzen des Kunden</h5>
</div>
<div class="card-body">
{% if licenses %}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Lizenzschlüssel</th>
<th>Typ</th>
<th>Gültig von</th>
<th>Gültig bis</th>
<th>Aktiv</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for license in licenses %}
<tr>
<td><code>{{ license[1] }}</code></td>
<td>
{% if license[2] == 'full' %}
<span class="badge bg-success">Vollversion</span>
{% else %}
<span class="badge bg-warning">Testversion</span>
{% endif %}
</td>
<td>{{ license[3].strftime('%d.%m.%Y') }}</td>
<td>{{ license[4].strftime('%d.%m.%Y') }}</td>
<td>
{% if license[5] %}
<span class="text-success"></span>
{% else %}
<span class="text-danger"></span>
{% endif %}
</td>
<td>
<a href="/license/edit/{{ license[0] }}" class="btn btn-outline-primary btn-sm">Bearbeiten</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Dieser Kunde hat noch keine Lizenzen.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Lizenz bearbeiten{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Lizenz bearbeiten</h2>
<div>
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">📋 Zurück zur Übersicht</a>
</div>
</div>
<div class="card">
<div class="card-body">
<form method="post" action="/license/edit/{{ license[0] }}" accept-charset="UTF-8">
{% if request.args.get('show_test') == 'true' %}
<input type="hidden" name="show_test" value="true">
{% endif %}
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Kunde</label>
<input type="text" class="form-control" value="{{ license[2] }}" disabled>
<small class="text-muted">Kunde kann nicht geändert werden</small>
</div>
<div class="col-md-6">
<label class="form-label">E-Mail</label>
<input type="email" class="form-control" value="{{ license[3] or '-' }}" disabled>
</div>
<div class="col-md-6">
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
<input type="text" class="form-control" id="licenseKey" name="license_key" value="{{ license[1] }}" required>
</div>
<div class="col-md-6">
<label for="licenseType" class="form-label">Lizenztyp</label>
<select class="form-select" id="licenseType" name="license_type" required>
<option value="full" {% if license[4] == 'full' %}selected{% endif %}>Vollversion</option>
<option value="test" {% if license[4] == 'test' %}selected{% endif %}>Testversion</option>
</select>
</div>
<div class="col-md-4">
<label for="validFrom" class="form-label">Gültig von</label>
<input type="date" class="form-control" id="validFrom" name="valid_from" value="{{ license[5].strftime('%Y-%m-%d') }}" required>
</div>
<div class="col-md-4">
<label for="validUntil" class="form-label">Gültig bis</label>
<input type="date" class="form-control" id="validUntil" name="valid_until" value="{{ license[6].strftime('%Y-%m-%d') }}" required>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isActive" name="is_active" {% if license[7] %}checked{% endif %}>
<label class="form-check-label" for="isActive">
Lizenz ist aktiv
</label>
</div>
</div>
<div class="col-md-6">
<label for="deviceLimit" class="form-label">Gerätelimit</label>
<select class="form-select" id="deviceLimit" name="device_limit" required>
{% for i in range(1, 11) %}
<option value="{{ i }}" {% if license[10] == i %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
{% endfor %}
</select>
<small class="form-text text-muted">Maximale Anzahl gleichzeitig aktiver Geräte</small>
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="isTest" name="is_test" {% if license[9] %}checked{% endif %}>
<label class="form-check-label" for="isTest">
<i class="fas fa-flask"></i> Als Testdaten markieren
<small class="text-muted">(wird von der Software ignoriert)</small>
</label>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
<a href="/customers-licenses{% if request.args.get('show_test') == 'true' %}?show_test=true{% endif %}" class="btn btn-secondary">Abbrechen</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@ -0,0 +1,533 @@
{% extends "base.html" %}
{% block title %}Admin Panel{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Neue Lizenz erstellen</h2>
<a href="/customers-licenses" class="btn btn-secondary">← Zurück zur Übersicht</a>
</div>
<form method="post" action="/create" accept-charset="UTF-8">
<div class="row g-3">
<div class="col-md-12">
<label for="customerSelect" class="form-label">Kunde auswählen</label>
<select class="form-select" id="customerSelect" name="customer_id" required>
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
<option value="new"> Neuer Kunde</option>
</select>
</div>
<div class="col-md-6" id="customerNameDiv" style="display: none;">
<label for="customerName" class="form-label">Kundenname</label>
<input type="text" class="form-control" id="customerName" name="customer_name" accept-charset="UTF-8">
</div>
<div class="col-md-6" id="emailDiv" style="display: none;">
<label for="email" class="form-label">E-Mail</label>
<input type="email" class="form-control" id="email" name="email" accept-charset="UTF-8">
</div>
<div class="col-md-4">
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
<div class="input-group">
<input type="text" class="form-control" id="licenseKey" name="license_key"
placeholder="AF-F-YYYYMM-XXXX-YYYY-ZZZZ" required
pattern="AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}"
title="Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ">
<button type="button" class="btn btn-outline-primary" onclick="generateLicenseKey()">
🔑 Generieren
</button>
</div>
<div class="form-text">Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)</div>
</div>
<div class="col-md-4">
<label for="licenseType" class="form-label">Lizenztyp</label>
<select class="form-select" id="licenseType" name="license_type" required>
<option value="full">Vollversion</option>
<option value="test">Testversion</option>
</select>
</div>
<div class="col-md-2">
<label for="validFrom" class="form-label">Kaufdatum</label>
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
</div>
<div class="col-md-1">
<label for="duration" class="form-label">Laufzeit</label>
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
</div>
<div class="col-md-1">
<label for="durationType" class="form-label">Einheit</label>
<select class="form-select" id="durationType" name="duration_type" required>
<option value="days">Tage</option>
<option value="months">Monate</option>
<option value="years" selected>Jahre</option>
</select>
</div>
<div class="col-md-2">
<label for="validUntil" class="form-label">Ablaufdatum</label>
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
</div>
</div>
<!-- Resource Pool Allocation -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-server"></i> Ressourcen-Zuweisung
<small class="text-muted float-end" id="resourceStatus"></small>
</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="domainCount" class="form-label">
<i class="fas fa-globe"></i> Domains
</label>
<select class="form-select" id="domainCount" name="domain_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
</small>
</div>
<div class="col-md-4">
<label for="ipv4Count" class="form-label">
<i class="fas fa-network-wired"></i> IPv4-Adressen
</label>
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
</small>
</div>
<div class="col-md-4">
<label for="phoneCount" class="form-label">
<i class="fas fa-phone"></i> Telefonnummern
</label>
<select class="form-select" id="phoneCount" name="phone_count" required>
{% for i in range(11) %}
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
</small>
</div>
</div>
<div class="alert alert-info mt-3 mb-0" role="alert">
<i class="fas fa-info-circle"></i>
Die Ressourcen werden bei der Lizenzerstellung automatisch aus dem Pool zugewiesen.
Wählen Sie 0, wenn für diesen Typ keine Ressourcen benötigt werden.
</div>
</div>
</div>
<!-- Device Limit -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-laptop"></i> Gerätelimit
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<label for="deviceLimit" class="form-label">
Maximale Anzahl Geräte
</label>
<select class="form-select" id="deviceLimit" name="device_limit" required>
{% for i in range(1, 11) %}
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
{% endfor %}
</select>
<small class="form-text text-muted">
Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann.
</small>
</div>
</div>
</div>
</div>
<!-- Test Data Checkbox -->
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="isTest" name="is_test">
<label class="form-check-label" for="isTest">
<i class="fas fa-flask"></i> Als Testdaten markieren
<small class="text-muted">(wird von der Software ignoriert)</small>
</label>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary"> Lizenz erstellen</button>
</div>
</form>
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
{% for category, message in messages %}
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
{{ message }}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<script>
// License Key Generator
function generateLicenseKey() {
const licenseType = document.getElementById('licenseType').value;
// Zeige Ladeindikator
const button = event.target;
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '⏳ Generiere...';
// API-Call
fetch('/api/generate-license-key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({type: licenseType})
})
.then(response => response.json())
.then(data => {
if (data.success) {
document.getElementById('licenseKey').value = data.key;
// Visuelles Feedback
document.getElementById('licenseKey').classList.add('border-success');
setTimeout(() => {
document.getElementById('licenseKey').classList.remove('border-success');
}, 2000);
} else {
alert('Fehler bei der Key-Generierung: ' + (data.error || 'Unbekannter Fehler'));
}
})
.catch(error => {
console.error('Fehler:', error);
alert('Netzwerkfehler bei der Key-Generierung');
})
.finally(() => {
// Button zurücksetzen
button.disabled = false;
button.innerHTML = originalText;
});
}
// Event Listener für Lizenztyp-Änderung
document.getElementById('licenseType').addEventListener('change', function() {
const keyField = document.getElementById('licenseKey');
if (keyField.value && keyField.value.startsWith('AF-')) {
// Prüfe ob der Key zum neuen Typ passt
const currentType = this.value;
const keyType = keyField.value.charAt(3); // Position des F/T im Key (AF-F-...)
if ((currentType === 'full' && keyType === 'T') ||
(currentType === 'test' && keyType === 'F')) {
if (confirm('Der aktuelle Key passt nicht zum gewählten Lizenztyp. Neuen Key generieren?')) {
generateLicenseKey();
}
}
}
});
// Auto-Uppercase für License Key Input
document.getElementById('licenseKey').addEventListener('input', function(e) {
e.target.value = e.target.value.toUpperCase();
});
// Funktion zur Berechnung des Ablaufdatums
function calculateValidUntil() {
const validFrom = document.getElementById('validFrom').value;
const duration = parseInt(document.getElementById('duration').value) || 1;
const durationType = document.getElementById('durationType').value;
if (!validFrom) return;
const startDate = new Date(validFrom);
let endDate = new Date(startDate);
switch(durationType) {
case 'days':
endDate.setDate(endDate.getDate() + duration);
break;
case 'months':
endDate.setMonth(endDate.getMonth() + duration);
break;
case 'years':
endDate.setFullYear(endDate.getFullYear() + duration);
break;
}
// Ein Tag abziehen, da der Starttag mitgezählt wird
endDate.setDate(endDate.getDate() - 1);
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
}
// Event Listener für Änderungen
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
document.getElementById('duration').addEventListener('input', calculateValidUntil);
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
// Setze heutiges Datum als Standard für valid_from
document.addEventListener('DOMContentLoaded', function() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('validFrom').value = today;
// Berechne initiales Ablaufdatum
calculateValidUntil();
// Initialisiere Select2 für Kundenauswahl
$('#customerSelect').select2({
theme: 'bootstrap-5',
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
allowClear: true,
ajax: {
url: '/api/customers',
dataType: 'json',
delay: 250,
data: function (params) {
return {
q: params.term,
page: params.page || 1
};
},
processResults: function (data, params) {
params.page = params.page || 1;
// "Neuer Kunde" Option immer oben anzeigen
const results = data.results || [];
if (params.page === 1) {
results.unshift({
id: 'new',
text: ' Neuer Kunde',
isNew: true
});
}
return {
results: results,
pagination: data.pagination
};
},
cache: true
},
minimumInputLength: 0,
language: {
inputTooShort: function() { return ''; },
noResults: function() { return 'Keine Kunden gefunden'; },
searching: function() { return 'Suche...'; },
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
}
});
// Vorausgewählten Kunden setzen (falls von kombinierter Ansicht kommend)
{% if preselected_customer_id %}
// Lade Kundendetails und setze Auswahl
fetch('/api/customers?id={{ preselected_customer_id }}')
.then(response => response.json())
.then(data => {
if (data.results && data.results.length > 0) {
const customer = data.results[0];
// Erstelle Option und setze sie als ausgewählt
const option = new Option(customer.text, customer.id, true, true);
$('#customerSelect').append(option).trigger('change');
// Verstecke die Eingabefelder
document.getElementById('customerNameDiv').style.display = 'none';
document.getElementById('emailDiv').style.display = 'none';
}
});
{% endif %}
// Event Handler für Kundenauswahl
$('#customerSelect').on('select2:select', function (e) {
const selectedValue = e.params.data.id;
const nameDiv = document.getElementById('customerNameDiv');
const emailDiv = document.getElementById('emailDiv');
const nameInput = document.getElementById('customerName');
const emailInput = document.getElementById('email');
if (selectedValue === 'new') {
// Zeige Eingabefelder für neuen Kunden
nameDiv.style.display = 'block';
emailDiv.style.display = 'block';
nameInput.required = true;
emailInput.required = true;
} else {
// Verstecke Eingabefelder bei bestehendem Kunden
nameDiv.style.display = 'none';
emailDiv.style.display = 'none';
nameInput.required = false;
emailInput.required = false;
nameInput.value = '';
emailInput.value = '';
}
});
// Clear handler
$('#customerSelect').on('select2:clear', function (e) {
document.getElementById('customerNameDiv').style.display = 'none';
document.getElementById('emailDiv').style.display = 'none';
document.getElementById('customerName').required = false;
document.getElementById('email').required = false;
});
// Resource Availability Check
checkResourceAvailability();
// Event Listener für Resource Count Änderungen
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
});
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit
function checkResourceAvailability() {
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
// API-Call zur Verfügbarkeitsprüfung
fetch(`/api/resources/check-availability?domain=${domainCount}&ipv4=${ipv4Count}&phone=${phoneCount}`)
.then(response => response.json())
.then(data => {
// Update der Verfügbarkeitsanzeigen
updateAvailabilityDisplay('domainsAvailable', data.domain_available, domainCount);
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, ipv4Count);
updateAvailabilityDisplay('phoneAvailable', data.phone_available, phoneCount);
// Gesamtstatus aktualisieren
updateResourceStatus(data, domainCount, ipv4Count, phoneCount);
})
.catch(error => {
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
});
}
// Hilfsfunktion zur Anzeige der Verfügbarkeit
function updateAvailabilityDisplay(elementId, available, requested) {
const element = document.getElementById(elementId);
const container = element.parentElement;
// Verfügbarkeit mit Prozentanzeige
const percent = Math.round((available / (available + requested + 50)) * 100);
let statusHtml = `<strong>${available}</strong>`;
if (requested > 0 && available < requested) {
element.classList.remove('text-success', 'text-warning');
element.classList.add('text-danger');
statusHtml += ` <i class="bi bi-exclamation-triangle"></i>`;
// Füge Warnung hinzu
if (!container.querySelector('.availability-warning')) {
const warning = document.createElement('div');
warning.className = 'availability-warning text-danger small mt-1';
warning.innerHTML = `<i class="bi bi-x-circle"></i> Nicht genügend Ressourcen verfügbar!`;
container.appendChild(warning);
}
} else {
// Entferne Warnung wenn vorhanden
const warning = container.querySelector('.availability-warning');
if (warning) warning.remove();
if (available < 20) {
element.classList.remove('text-success');
element.classList.add('text-danger');
statusHtml += ` <span class="badge bg-danger">Kritisch</span>`;
} else if (available < 50) {
element.classList.remove('text-success', 'text-danger');
element.classList.add('text-warning');
statusHtml += ` <span class="badge bg-warning text-dark">Niedrig</span>`;
} else {
element.classList.remove('text-danger', 'text-warning');
element.classList.add('text-success');
statusHtml += ` <span class="badge bg-success">OK</span>`;
}
}
element.innerHTML = statusHtml;
// Zeige Fortschrittsbalken
updateResourceProgressBar(elementId.replace('Available', ''), available, requested);
}
// Fortschrittsbalken für Ressourcen
function updateResourceProgressBar(resourceType, available, requested) {
const progressId = `${resourceType}Progress`;
let progressBar = document.getElementById(progressId);
// Erstelle Fortschrittsbalken wenn nicht vorhanden
if (!progressBar) {
const container = document.querySelector(`#${resourceType}Available`).parentElement.parentElement;
const progressDiv = document.createElement('div');
progressDiv.className = 'mt-2';
progressDiv.innerHTML = `
<div class="progress" style="height: 20px;" id="${progressId}">
<div class="progress-bar bg-success" role="progressbar" style="width: 0%">
<span class="progress-text"></span>
</div>
</div>
`;
container.appendChild(progressDiv);
progressBar = document.getElementById(progressId);
}
const total = available + requested;
const availablePercent = total > 0 ? (available / total) * 100 : 100;
const bar = progressBar.querySelector('.progress-bar');
const text = progressBar.querySelector('.progress-text');
// Setze Farbe basierend auf Verfügbarkeit
bar.classList.remove('bg-success', 'bg-warning', 'bg-danger');
if (requested > 0 && available < requested) {
bar.classList.add('bg-danger');
} else if (availablePercent < 30) {
bar.classList.add('bg-warning');
} else {
bar.classList.add('bg-success');
}
// Animiere Fortschrittsbalken
bar.style.width = `${availablePercent}%`;
text.textContent = requested > 0 ? `${available} von ${total}` : `${available} verfügbar`;
}
// Gesamtstatus der Ressourcen-Verfügbarkeit
function updateResourceStatus(data, domainCount, ipv4Count, phoneCount) {
const statusElement = document.getElementById('resourceStatus');
let hasIssue = false;
let message = '';
if (domainCount > 0 && data.domain_available < domainCount) {
hasIssue = true;
message = '⚠️ Nicht genügend Domains';
} else if (ipv4Count > 0 && data.ipv4_available < ipv4Count) {
hasIssue = true;
message = '⚠️ Nicht genügend IPv4-Adressen';
} else if (phoneCount > 0 && data.phone_available < phoneCount) {
hasIssue = true;
message = '⚠️ Nicht genügend Telefonnummern';
} else {
message = '✅ Alle Ressourcen verfügbar';
}
statusElement.textContent = message;
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
}
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,375 @@
{% extends "base.html" %}
{% block title %}Lizenzübersicht{% endblock %}
{% macro sortable_header(label, field, current_sort, current_order) %}
<th>
{% if current_sort == field %}
<a href="{{ url_for('licenses', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, type=filter_type, status=filter_status, page=1) }}"
class="server-sortable">
{% else %}
<a href="{{ url_for('licenses', sort=field, order='asc', search=search, type=filter_type, status=filter_status, page=1) }}"
class="server-sortable">
{% endif %}
{{ label }}
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
{% if current_sort == field %}
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
{% else %}
{% endif %}
</span>
</a>
</th>
{% endmacro %}
{% block extra_css %}
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="mb-4">
<h2>Lizenzübersicht</h2>
</div>
<!-- Such- und Filterformular -->
<div class="card mb-3">
<div class="card-body">
<form method="get" action="/licenses" id="filterForm">
<div class="row g-3 align-items-end">
<div class="col-md-4">
<label for="search" class="form-label">🔍 Suchen</label>
<input type="text" class="form-control" id="search" name="search"
placeholder="Lizenzschlüssel, Kunde, E-Mail..."
value="{{ search }}">
</div>
<div class="col-md-2">
<label for="type" class="form-label">Typ</label>
<select class="form-select" id="type" name="type">
<option value="">Alle Typen</option>
<option value="full" {% if filter_type == 'full' %}selected{% endif %}>Vollversion</option>
<option value="test" {% if filter_type == 'test' %}selected{% endif %}>Testversion</option>
<option value="test_data" {% if filter_type == 'test_data' %}selected{% endif %}>🧪 Testdaten</option>
<option value="live_data" {% if filter_type == 'live_data' %}selected{% endif %}>🚀 Live-Daten</option>
</select>
</div>
<div class="col-md-3">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
<option value="">Alle Status</option>
<option value="active" {% if filter_status == 'active' %}selected{% endif %}>✅ Aktiv</option>
<option value="expiring" {% if filter_status == 'expiring' %}selected{% endif %}>⏰ Läuft bald ab</option>
<option value="expired" {% if filter_status == 'expired' %}selected{% endif %}>⚠️ Abgelaufen</option>
<option value="inactive" {% if filter_status == 'inactive' %}selected{% endif %}>❌ Deaktiviert</option>
</select>
</div>
<div class="col-md-3">
<a href="/licenses" class="btn btn-outline-secondary">Zurücksetzen</a>
</div>
</div>
</form>
{% if search or filter_type or filter_status %}
<div class="mt-2">
<small class="text-muted">
Gefiltert: {{ total }} Ergebnisse
{% if search %} | Suche: <strong>{{ search }}</strong>{% endif %}
{% if filter_type %} | Typ: <strong>{{ 'Vollversion' if filter_type == 'full' else 'Testversion' }}</strong>{% endif %}
{% if filter_status %} | Status: <strong>{{ filter_status }}</strong>{% endif %}
</small>
</div>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-container">
<table class="table table-hover table-sticky mb-0">
<thead>
<tr>
<th class="checkbox-cell">
<input type="checkbox" class="form-check-input form-check-input-custom" id="selectAll">
</th>
{{ sortable_header('ID', 'id', sort, order) }}
{{ sortable_header('Lizenzschlüssel', 'license_key', sort, order) }}
{{ sortable_header('Kunde', 'customer', sort, order) }}
{{ sortable_header('E-Mail', 'email', sort, order) }}
{{ sortable_header('Typ', 'type', sort, order) }}
{{ sortable_header('Gültig von', 'valid_from', sort, order) }}
{{ sortable_header('Gültig bis', 'valid_until', sort, order) }}
{{ sortable_header('Status', 'status', sort, order) }}
{{ sortable_header('Aktiv', 'active', sort, order) }}
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for license in licenses %}
<tr>
<td class="checkbox-cell">
<input type="checkbox" class="form-check-input form-check-input-custom license-checkbox" value="{{ license[0] }}">
</td>
<td>{{ license[0] }}</td>
<td>
<div class="d-flex align-items-center">
<code class="me-2">{{ license[1] }}</code>
<button class="btn btn-sm btn-outline-secondary btn-copy" onclick="copyToClipboard('{{ license[1] }}', this)" title="Kopieren">
📋
</button>
</div>
</td>
<td>
{{ license[2] }}
{% if license[8] %}
<span class="badge bg-secondary ms-1" title="Testdaten">🧪</span>
{% endif %}
</td>
<td>{{ license[3] or '-' }}</td>
<td>
{% if license[4] == 'full' %}
<span class="badge bg-success">Vollversion</span>
{% else %}
<span class="badge bg-warning">Testversion</span>
{% endif %}
</td>
<td>{{ license[5].strftime('%d.%m.%Y') }}</td>
<td>{{ license[6].strftime('%d.%m.%Y') }}</td>
<td>
{% if license[9] == 'abgelaufen' %}
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
{% elif license[9] == 'läuft bald ab' %}
<span class="status-ablaufend">⏰ Läuft bald ab</span>
{% elif license[9] == 'deaktiviert' %}
<span class="status-deaktiviert">❌ Deaktiviert</span>
{% else %}
<span class="status-aktiv">✅ Aktiv</span>
{% endif %}
</td>
<td>
<div class="form-check form-switch form-switch-custom">
<input class="form-check-input" type="checkbox"
id="active_{{ license[0] }}"
{{ 'checked' if license[7] else '' }}
onchange="toggleLicenseStatus({{ license[0] }}, this.checked)">
</div>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/license/edit/{{ license[0] }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
<form method="post" action="/license/delete/{{ license[0] }}" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');">
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not licenses %}
<div class="text-center py-5">
{% if search %}
<p class="text-muted">Keine Lizenzen gefunden für: <strong>{{ search }}</strong></p>
<a href="/licenses" class="btn btn-secondary">Alle Lizenzen anzeigen</a>
{% else %}
<p class="text-muted">Noch keine Lizenzen vorhanden.</p>
<a href="/create" class="btn btn-primary">Erste Lizenz erstellen</a>
{% endif %}
</div>
{% endif %}
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav aria-label="Seitennavigation" class="mt-3">
<ul class="pagination justify-content-center">
<!-- Erste Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('licenses', page=1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">Erste</a>
</li>
<!-- Vorherige Seite -->
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('licenses', page=page-1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}"></a>
</li>
<!-- Seitenzahlen -->
{% for p in range(1, total_pages + 1) %}
{% if p >= page - 2 and p <= page + 2 %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link" href="{{ url_for('licenses', page=p, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">{{ p }}</a>
</li>
{% endif %}
{% endfor %}
<!-- Nächste Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('licenses', page=page+1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}"></a>
</li>
<!-- Letzte Seite -->
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('licenses', page=total_pages, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">Letzte</a>
</li>
</ul>
<p class="text-center text-muted">
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Lizenzen
</p>
</nav>
{% endif %}
</div>
</div>
</div>
<!-- Bulk Actions Bar -->
<div class="bulk-actions" id="bulkActionsBar">
<div>
<span id="selectedCount">0</span> Lizenzen ausgewählt
</div>
<div>
<button class="btn btn-success btn-sm me-2" onclick="bulkActivate()">✅ Aktivieren</button>
<button class="btn btn-warning btn-sm me-2" onclick="bulkDeactivate()">⏸️ Deaktivieren</button>
<button class="btn btn-danger btn-sm" onclick="bulkDelete()">🗑️ Löschen</button>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Live Filtering
document.addEventListener('DOMContentLoaded', function() {
const filterForm = document.getElementById('filterForm');
const searchInput = document.getElementById('search');
const typeSelect = document.getElementById('type');
const statusSelect = document.getElementById('status');
// Debounce timer für Suchfeld
let searchTimeout;
// Live-Filter für Suchfeld (mit 300ms Verzögerung)
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
filterForm.submit();
}, 300);
});
// Live-Filter für Dropdowns (sofort)
typeSelect.addEventListener('change', function() {
filterForm.submit();
});
statusSelect.addEventListener('change', function() {
filterForm.submit();
});
});
// Copy to Clipboard
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(function() {
button.classList.add('copied');
button.innerHTML = '✅';
setTimeout(function() {
button.classList.remove('copied');
button.innerHTML = '📋';
}, 2000);
});
}
// Toggle License Status
function toggleLicenseStatus(licenseId, isActive) {
fetch(`/api/license/${licenseId}/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_active: isActive })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Optional: Show success message
} else {
// Revert toggle on error
document.getElementById(`active_${licenseId}`).checked = !isActive;
alert('Fehler beim Ändern des Status');
}
});
}
// Bulk Selection
const selectAll = document.getElementById('selectAll');
const checkboxes = document.querySelectorAll('.license-checkbox');
const bulkActionsBar = document.getElementById('bulkActionsBar');
const selectedCount = document.getElementById('selectedCount');
selectAll.addEventListener('change', function() {
checkboxes.forEach(cb => cb.checked = this.checked);
updateBulkActions();
});
checkboxes.forEach(cb => {
cb.addEventListener('change', updateBulkActions);
});
function updateBulkActions() {
const checkedBoxes = document.querySelectorAll('.license-checkbox:checked');
const count = checkedBoxes.length;
if (count > 0) {
bulkActionsBar.classList.add('show');
selectedCount.textContent = count;
} else {
bulkActionsBar.classList.remove('show');
}
// Update select all checkbox
selectAll.checked = count === checkboxes.length && count > 0;
selectAll.indeterminate = count > 0 && count < checkboxes.length;
}
// Bulk Actions
function getSelectedIds() {
return Array.from(document.querySelectorAll('.license-checkbox:checked'))
.map(cb => cb.value);
}
function bulkActivate() {
const ids = getSelectedIds();
if (confirm(`${ids.length} Lizenzen aktivieren?`)) {
performBulkAction('/api/licenses/bulk-activate', ids);
}
}
function bulkDeactivate() {
const ids = getSelectedIds();
if (confirm(`${ids.length} Lizenzen deaktivieren?`)) {
performBulkAction('/api/licenses/bulk-deactivate', ids);
}
}
function bulkDelete() {
const ids = getSelectedIds();
if (confirm(`${ids.length} Lizenzen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden!`)) {
performBulkAction('/api/licenses/bulk-delete', ids);
}
}
function performBulkAction(url, ids) {
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids: ids })
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Fehler bei der Bulk-Aktion: ' + data.message);
}
});
}
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Lizenzverwaltung</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.error-failed {
background-color: #dc3545 !important;
color: white !important;
font-weight: bold !important;
font-size: 1.5rem !important;
text-align: center !important;
padding: 1rem !important;
border-radius: 0.5rem !important;
text-transform: uppercase !important;
animation: shake 0.5s;
box-shadow: 0 0 20px rgba(220, 53, 69, 0.5);
}
.error-blocked {
background-color: #6f42c1 !important;
color: white !important;
font-weight: bold !important;
font-size: 1.2rem !important;
text-align: center !important;
padding: 1rem !important;
border-radius: 0.5rem !important;
animation: pulse 2s infinite;
}
.error-captcha {
background-color: #fd7e14 !important;
color: white !important;
font-weight: bold !important;
font-size: 1.2rem !important;
text-align: center !important;
padding: 1rem !important;
border-radius: 0.5rem !important;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
20%, 40%, 60%, 80% { transform: translateX(10px); }
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
.attempts-warning {
background-color: #ffc107;
color: #000;
padding: 0.5rem;
border-radius: 0.25rem;
text-align: center;
margin-bottom: 1rem;
font-weight: bold;
}
.security-info {
font-size: 0.875rem;
color: #6c757d;
text-align: center;
margin-top: 1rem;
}
</style>
</head>
<body class="bg-light">
<div class="container">
<div class="row min-vh-100 align-items-center justify-content-center">
<div class="col-md-4">
<div class="card shadow">
<div class="card-body p-5">
<h2 class="text-center mb-4">🔐 Admin Login</h2>
{% if error %}
<div class="error-{{ error_type|default('failed') }}">
{{ error }}
</div>
{% endif %}
{% if attempts_left is defined and attempts_left > 0 and attempts_left < 5 %}
<div class="attempts-warning">
⚠️ Noch {{ attempts_left }} Versuch(e) bis zur IP-Sperre!
</div>
{% endif %}
<form method="post">
<div class="mb-3">
<label for="username" class="form-label">Benutzername</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Passwort</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
{% if show_captcha and recaptcha_site_key %}
<div class="mb-3">
<div class="g-recaptcha" data-sitekey="{{ recaptcha_site_key }}"></div>
</div>
{% endif %}
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
</form>
<div class="security-info">
🛡️ Geschützt durch Rate-Limiting und IP-Sperre
</div>
</div>
</div>
</div>
</div>
</div>
{% if show_captcha and recaptcha_site_key %}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{% endif %}
</body>
</html>

Datei anzeigen

@ -0,0 +1,216 @@
{% extends "base.html" %}
{% block title %}Benutzerprofil{% endblock %}
{% block extra_css %}
<style>
.profile-card {
transition: all 0.3s ease;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.profile-card:hover {
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.profile-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.8;
}
.security-badge {
font-size: 2rem;
margin-right: 1rem;
}
.form-control:focus {
border-color: #6c757d;
box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25);
}
.password-strength {
height: 4px;
margin-top: 5px;
border-radius: 2px;
transition: all 0.3s ease;
}
.strength-very-weak { background-color: #dc3545; width: 20%; }
.strength-weak { background-color: #fd7e14; width: 40%; }
.strength-medium { background-color: #ffc107; width: 60%; }
.strength-strong { background-color: #28a745; width: 80%; }
.strength-very-strong { background-color: #0d6efd; width: 100%; }
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>👤 Benutzerprofil</h1>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- User Info Stats -->
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card profile-card h-100">
<div class="card-body text-center">
<div class="profile-icon text-primary">👤</div>
<h5 class="card-title">{{ user.username }}</h5>
<p class="text-muted mb-0">{{ user.email or 'Keine E-Mail angegeben' }}</p>
<small class="text-muted">Mitglied seit: {{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Unbekannt' }}</small>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card profile-card h-100">
<div class="card-body text-center">
<div class="profile-icon text-info">🔐</div>
<h5 class="card-title">Sicherheitsstatus</h5>
{% if user.totp_enabled %}
<span class="badge bg-success">2FA Aktiv</span>
{% else %}
<span class="badge bg-warning text-dark">2FA Inaktiv</span>
{% endif %}
<p class="text-muted mb-0 mt-2">
<small>Letztes Passwort-Update:<br>{{ user.last_password_change.strftime('%d.%m.%Y') if user.last_password_change else 'Noch nie' }}</small>
</p>
</div>
</div>
</div>
</div>
<!-- Password Change Card -->
<div class="card profile-card mb-4">
<div class="card-body">
<h5 class="card-title d-flex align-items-center">
<span class="security-badge">🔑</span>
Passwort ändern
</h5>
<hr>
<form method="POST" action="{{ url_for('change_password') }}">
<div class="mb-3">
<label for="current_password" class="form-label">Aktuelles Passwort</label>
<input type="password" class="form-control" id="current_password" name="current_password" required>
</div>
<div class="mb-3">
<label for="new_password" class="form-label">Neues Passwort</label>
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
<div class="password-strength" id="password-strength"></div>
<div class="form-text" id="password-help">Mindestens 8 Zeichen</div>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Neues Passwort bestätigen</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
<div class="invalid-feedback">Passwörter stimmen nicht überein</div>
</div>
<button type="submit" class="btn btn-primary">🔄 Passwort ändern</button>
</form>
</div>
</div>
<!-- 2FA Card -->
<div class="card profile-card mb-4">
<div class="card-body">
<h5 class="card-title d-flex align-items-center">
<span class="security-badge">🔐</span>
Zwei-Faktor-Authentifizierung (2FA)
</h5>
<hr>
{% if user.totp_enabled %}
<div class="d-flex align-items-center mb-3">
<div class="flex-grow-1">
<h6 class="mb-1">Status: <span class="badge bg-success">Aktiv</span></h6>
<p class="text-muted mb-0">Ihr Account ist durch 2FA geschützt</p>
</div>
<div class="text-success" style="font-size: 3rem;"></div>
</div>
<form method="POST" action="{{ url_for('disable_2fa') }}" onsubmit="return confirm('Sind Sie sicher, dass Sie 2FA deaktivieren möchten? Dies verringert die Sicherheit Ihres Accounts.');">
<div class="mb-3">
<label for="password" class="form-label">Passwort zur Bestätigung</label>
<input type="password" class="form-control" id="password" name="password" required placeholder="Ihr aktuelles Passwort">
</div>
<button type="submit" class="btn btn-danger">🚫 2FA deaktivieren</button>
</form>
{% else %}
<div class="d-flex align-items-center mb-3">
<div class="flex-grow-1">
<h6 class="mb-1">Status: <span class="badge bg-warning text-dark">Inaktiv</span></h6>
<p class="text-muted mb-0">Aktivieren Sie 2FA für zusätzliche Sicherheit</p>
</div>
<div class="text-warning" style="font-size: 3rem;">⚠️</div>
</div>
<p class="text-muted">
Mit 2FA wird bei jeder Anmeldung zusätzlich ein Code aus Ihrer Authenticator-App benötigt.
Dies schützt Ihren Account auch bei kompromittiertem Passwort.
</p>
<a href="{{ url_for('setup_2fa') }}" class="btn btn-success">✨ 2FA einrichten</a>
{% endif %}
</div>
</div>
</div>
<script>
// Password strength indicator
document.getElementById('new_password').addEventListener('input', function(e) {
const password = e.target.value;
let strength = 0;
if (password.length >= 8) strength++;
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++;
if (password.match(/[0-9]/)) strength++;
if (password.match(/[^a-zA-Z0-9]/)) strength++;
const strengthText = ['Sehr schwach', 'Schwach', 'Mittel', 'Stark', 'Sehr stark'];
const strengthClass = ['strength-very-weak', 'strength-weak', 'strength-medium', 'strength-strong', 'strength-very-strong'];
const textClass = ['text-danger', 'text-warning', 'text-warning', 'text-success', 'text-primary'];
const strengthBar = document.getElementById('password-strength');
const helpText = document.getElementById('password-help');
if (password.length > 0) {
strengthBar.className = `password-strength ${strengthClass[strength]}`;
strengthBar.style.display = 'block';
helpText.textContent = `Stärke: ${strengthText[strength]}`;
helpText.className = `form-text ${textClass[strength]}`;
} else {
strengthBar.style.display = 'none';
helpText.textContent = 'Mindestens 8 Zeichen';
helpText.className = 'form-text';
}
});
// Confirm password validation
document.getElementById('confirm_password').addEventListener('input', function(e) {
const password = document.getElementById('new_password').value;
const confirm = e.target.value;
if (confirm.length > 0) {
if (password !== confirm) {
e.target.classList.add('is-invalid');
e.target.setCustomValidity('Passwörter stimmen nicht überein');
} else {
e.target.classList.remove('is-invalid');
e.target.classList.add('is-valid');
e.target.setCustomValidity('');
}
} else {
e.target.classList.remove('is-invalid', 'is-valid');
}
});
// Also check when password field changes
document.getElementById('new_password').addEventListener('input', function(e) {
const confirm = document.getElementById('confirm_password');
if (confirm.value.length > 0) {
confirm.dispatchEvent(new Event('input'));
}
});
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,365 @@
{% extends "base.html" %}
{% block title %}Resource Historie{% endblock %}
{% block extra_css %}
<style>
/* Resource Info Card */
.resource-info-card {
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* Resource Value Display */
.resource-value {
font-size: 1.5rem;
font-weight: 600;
color: #212529;
background-color: #f8f9fa;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
display: inline-block;
font-family: 'Courier New', monospace;
}
/* Status Badge Large */
.status-badge-large {
padding: 0.5rem 1rem;
font-size: 1rem;
border-radius: 0.5rem;
font-weight: 500;
}
/* Timeline Styling */
.timeline {
position: relative;
padding: 20px 0;
}
.timeline::before {
content: '';
position: absolute;
left: 30px;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(to bottom, #e9ecef 0%, #dee2e6 100%);
}
.timeline-item {
position: relative;
padding-left: 80px;
margin-bottom: 40px;
}
.timeline-marker {
position: absolute;
left: 20px;
top: 0;
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
z-index: 1;
}
.timeline-content {
background: #fff;
border-radius: 10px;
padding: 20px;
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
position: relative;
transition: transform 0.2s ease;
}
.timeline-content:hover {
transform: translateY(-2px);
box-shadow: 0 5px 10px rgba(0,0,0,0.15);
}
.timeline-content::before {
content: '';
position: absolute;
left: -10px;
top: 15px;
width: 0;
height: 0;
border-style: solid;
border-width: 10px 10px 10px 0;
border-color: transparent #fff transparent transparent;
}
/* Action Icons */
.action-icon {
width: 35px;
height: 35px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 1rem;
margin-right: 10px;
}
.action-created { background-color: #d4edda; color: #155724; }
.action-allocated { background-color: #cce5ff; color: #004085; }
.action-deallocated { background-color: #d1ecf1; color: #0c5460; }
.action-quarantined { background-color: #fff3cd; color: #856404; }
.action-released { background-color: #d4edda; color: #155724; }
.action-deleted { background-color: #f8d7da; color: #721c24; }
/* Details Box */
.details-box {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 15px;
margin-top: 15px;
font-family: 'Courier New', monospace;
font-size: 0.875rem;
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.info-item {
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #007bff;
}
.info-label {
font-size: 0.875rem;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.info-value {
font-size: 1.1rem;
font-weight: 500;
color: #212529;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-0">Resource Historie</h1>
<p class="text-muted mb-0">Detaillierte Aktivitätshistorie</p>
</div>
<a href="{{ url_for('resources') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Zurück zur Übersicht
</a>
</div>
<!-- Resource Info Card -->
<div class="card resource-info-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">📋 Resource Details</h5>
</div>
<div class="card-body">
<!-- Main Resource Info -->
<div class="text-center mb-4">
<div class="mb-3">
{% if resource.resource_type == 'domain' %}
<span class="display-1">🌐</span>
{% elif resource.resource_type == 'ipv4' %}
<span class="display-1">🖥️</span>
{% else %}
<span class="display-1">📱</span>
{% endif %}
</div>
<div class="resource-value mb-3">{{ resource.resource_value }}</div>
<div>
{% if resource.status == 'available' %}
<span class="status-badge-large badge bg-success">
✅ Verfügbar
</span>
{% elif resource.status == 'allocated' %}
<span class="status-badge-large badge bg-primary">
🔗 Zugeteilt
</span>
{% else %}
<span class="status-badge-large badge bg-warning text-dark">
⚠️ Quarantäne
</span>
{% endif %}
</div>
</div>
<!-- Detailed Info Grid -->
<div class="info-grid">
<div class="info-item">
<div class="info-label">Ressourcentyp</div>
<div class="info-value">{{ resource.resource_type|upper }}</div>
</div>
<div class="info-item">
<div class="info-label">Erstellt am</div>
<div class="info-value">
{{ resource.created_at.strftime('%d.%m.%Y %H:%M') if resource.created_at else '-' }}
</div>
</div>
<div class="info-item">
<div class="info-label">Status geändert</div>
<div class="info-value">
{{ resource.status_changed_at.strftime('%d.%m.%Y %H:%M') if resource.status_changed_at else '-' }}
</div>
</div>
{% if resource.allocated_to_license %}
<div class="info-item">
<div class="info-label">Zugewiesen an Lizenz</div>
<div class="info-value">
<a href="{{ url_for('edit_license', license_id=resource.allocated_to_license) }}"
class="text-decoration-none">
{{ license_info.license_key if license_info else 'ID: ' + resource.allocated_to_license|string }}
</a>
</div>
</div>
{% endif %}
{% if resource.quarantine_reason %}
<div class="info-item">
<div class="info-label">Quarantäne-Grund</div>
<div class="info-value">
<span class="badge bg-warning text-dark">{{ resource.quarantine_reason }}</span>
</div>
</div>
{% endif %}
{% if resource.quarantine_until %}
<div class="info-item">
<div class="info-label">Quarantäne bis</div>
<div class="info-value">
{{ resource.quarantine_until.strftime('%d.%m.%Y') }}
</div>
</div>
{% endif %}
</div>
{% if resource.notes %}
<div class="mt-4">
<div class="alert alert-info mb-0">
<h6 class="alert-heading">📝 Notizen</h6>
<p class="mb-0">{{ resource.notes }}</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- History Timeline -->
<div class="card">
<div class="card-header bg-white">
<h5 class="mb-0">⏱️ Aktivitäts-Historie</h5>
</div>
<div class="card-body">
{% if history %}
<div class="timeline">
{% for event in history %}
<div class="timeline-item">
<div class="timeline-marker
{% if event.action == 'created' %}bg-success
{% elif event.action == 'allocated' %}bg-primary
{% elif event.action == 'deallocated' %}bg-info
{% elif event.action == 'quarantined' %}bg-warning
{% elif event.action == 'released' %}bg-success
{% elif event.action == 'deleted' %}bg-danger
{% else %}bg-secondary{% endif %}">
</div>
<div class="timeline-content">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-2">
{% if event.action == 'created' %}
<span class="action-icon action-created">
<i class="fas fa-plus"></i>
</span>
<h6 class="mb-0">Ressource erstellt</h6>
{% elif event.action == 'allocated' %}
<span class="action-icon action-allocated">
<i class="fas fa-link"></i>
</span>
<h6 class="mb-0">An Lizenz zugeteilt</h6>
{% elif event.action == 'deallocated' %}
<span class="action-icon action-deallocated">
<i class="fas fa-unlink"></i>
</span>
<h6 class="mb-0">Von Lizenz freigegeben</h6>
{% elif event.action == 'quarantined' %}
<span class="action-icon action-quarantined">
<i class="fas fa-ban"></i>
</span>
<h6 class="mb-0">In Quarantäne gesetzt</h6>
{% elif event.action == 'released' %}
<span class="action-icon action-released">
<i class="fas fa-check"></i>
</span>
<h6 class="mb-0">Aus Quarantäne entlassen</h6>
{% elif event.action == 'deleted' %}
<span class="action-icon action-deleted">
<i class="fas fa-trash"></i>
</span>
<h6 class="mb-0">Ressource gelöscht</h6>
{% else %}
<h6 class="mb-0">{{ event.action }}</h6>
{% endif %}
</div>
<div class="text-muted small">
<i class="fas fa-user"></i> {{ event.action_by }}
{% if event.ip_address %}
&nbsp;&bull;&nbsp; <i class="fas fa-globe"></i> {{ event.ip_address }}
{% endif %}
{% if event.license_id %}
&nbsp;&bull;&nbsp;
<i class="fas fa-key"></i>
<a href="{{ url_for('edit_license', license_id=event.license_id) }}">
Lizenz #{{ event.license_id }}
</a>
{% endif %}
</div>
{% if event.details %}
<div class="details-box">
<strong>Details:</strong>
<pre class="mb-0" style="white-space: pre-wrap;">{{ event.details|tojson(indent=2) }}</pre>
</div>
{% endif %}
</div>
<div class="text-end ms-3">
<div class="badge bg-light text-dark">
<i class="far fa-calendar"></i> {{ event.action_at.strftime('%d.%m.%Y') }}
</div>
<div class="small text-muted mt-1">
<i class="far fa-clock"></i> {{ event.action_at.strftime('%H:%M:%S') }}
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-history text-muted" style="font-size: 3rem; opacity: 0.5;"></i>
<p class="text-muted mt-3">Keine Historie-Einträge vorhanden.</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@ -0,0 +1,559 @@
{% extends "base.html" %}
{% block title %}Resource Metriken{% endblock %}
{% block extra_css %}
<style>
/* Metric Cards */
.metric-card {
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s ease;
height: 100%;
}
.metric-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
}
.metric-card .card-body {
text-align: center;
padding: 2rem;
}
.metric-value {
font-size: 3rem;
font-weight: bold;
line-height: 1;
margin: 0.5rem 0;
}
.metric-label {
font-size: 1rem;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.5rem;
}
.metric-sublabel {
font-size: 0.875rem;
color: #6c757d;
}
/* Chart Cards */
.chart-card {
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
height: 100%;
}
.chart-card .card-header {
background-color: #f8f9fa;
border-bottom: 2px solid #e9ecef;
font-weight: 600;
}
/* Performance Table */
.performance-table {
font-size: 0.875rem;
}
.performance-table td {
vertical-align: middle;
padding: 0.75rem;
}
.performance-table .resource-link {
color: #007bff;
text-decoration: none;
transition: color 0.2s ease;
}
.performance-table .resource-link:hover {
color: #0056b3;
}
/* Progress Bars */
.progress-custom {
height: 22px;
border-radius: 11px;
font-size: 0.75rem;
font-weight: 600;
}
/* Status Badges */
.status-badge {
padding: 0.35rem 0.65rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
/* Icon Badges */
.icon-badge {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.icon-badge.blue { background-color: #e7f3ff; color: #0066cc; }
.icon-badge.green { background-color: #e8f5e9; color: #2e7d32; }
.icon-badge.orange { background-color: #fff3e0; color: #ef6c00; }
.icon-badge.red { background-color: #ffebee; color: #c62828; }
/* Trend Indicator */
.trend-indicator {
display: inline-flex;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.trend-up {
background-color: #e8f5e9;
color: #2e7d32;
}
.trend-down {
background-color: #ffebee;
color: #c62828;
}
.trend-neutral {
background-color: #f5f5f5;
color: #616161;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-0">Performance Dashboard</h1>
<p class="text-muted mb-0">Resource Pool Metriken und Analysen</p>
</div>
<div>
<a href="{{ url_for('resources_report') }}" class="btn btn-info">
📄 Report generieren
</a>
<a href="{{ url_for('resources') }}" class="btn btn-secondary">
← Zurück
</a>
</div>
</div>
<!-- Key Metrics -->
<div class="row g-4 mb-4">
<div class="col-lg-3 col-md-6">
<div class="card metric-card">
<div class="card-body">
<div class="icon-badge blue">
📊
</div>
<div class="metric-label">Ressourcen gesamt</div>
<div class="metric-value text-primary">{{ stats.total_resources or 0 }}</div>
<div class="metric-sublabel">Aktive Ressourcen</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card metric-card">
<div class="card-body">
<div class="icon-badge green">
📈
</div>
<div class="metric-label">Ø Performance</div>
<div class="metric-value text-{{ 'success' if stats.avg_performance > 80 else ('warning' if stats.avg_performance > 60 else 'danger') }}">
{{ "%.1f"|format(stats.avg_performance or 0) }}%
</div>
<div class="metric-sublabel">Letzte 30 Tage</div>
{% if stats.performance_trend %}
<div class="mt-2">
<span class="trend-indicator trend-{{ stats.performance_trend }}">
{% if stats.performance_trend == 'up' %}
<i class="fas fa-arrow-up me-1"></i> Steigend
{% elif stats.performance_trend == 'down' %}
<i class="fas fa-arrow-down me-1"></i> Fallend
{% else %}
<i class="fas fa-minus me-1"></i> Stabil
{% endif %}
</span>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card metric-card">
<div class="card-body">
<div class="icon-badge orange">
💰
</div>
<div class="metric-label">ROI</div>
<div class="metric-value text-{{ 'success' if stats.roi > 1 else 'danger' }}">
{{ "%.2f"|format(stats.roi) }}x
</div>
<div class="metric-sublabel">Revenue / Cost</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card metric-card">
<div class="card-body">
<div class="icon-badge red">
⚠️
</div>
<div class="metric-label">Probleme</div>
<div class="metric-value text-{{ 'danger' if stats.total_issues > 10 else ('warning' if stats.total_issues > 5 else 'success') }}">
{{ stats.total_issues or 0 }}
</div>
<div class="metric-sublabel">Letzte 30 Tage</div>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card chart-card">
<div class="card-header">
<h5 class="mb-0">📊 Performance nach Ressourcentyp</h5>
</div>
<div class="card-body">
<canvas id="performanceByTypeChart"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card chart-card">
<div class="card-header">
<h5 class="mb-0">🎯 Auslastung nach Typ</h5>
</div>
<div class="card-body">
<canvas id="utilizationChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Performance Tables -->
<div class="row g-4">
<div class="col-md-6">
<div class="card chart-card">
<div class="card-header bg-success text-white">
<h5 class="mb-0">🏆 Top Performer</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table performance-table mb-0">
<thead class="table-light">
<tr>
<th>Ressource</th>
<th width="80">Typ</th>
<th width="140">Score</th>
<th width="80" class="text-center">ROI</th>
</tr>
</thead>
<tbody>
{% for resource in top_performers %}
<tr>
<td>
<div class="d-flex align-items-center">
<code class="me-2">{{ resource.resource_value }}</code>
<a href="{{ url_for('resource_history', resource_id=resource.id) }}"
class="resource-link" title="Historie anzeigen">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
</td>
<td>
<span class="badge bg-light text-dark">
{% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %}
{{ resource.resource_type|upper }}
</span>
</td>
<td>
<div class="progress progress-custom">
<div class="progress-bar bg-success"
style="width: {{ resource.avg_score }}%">
{{ "%.1f"|format(resource.avg_score) }}%
</div>
</div>
</td>
<td class="text-center">
<span class="badge bg-success">
{{ "%.2f"|format(resource.roi) }}x
</span>
</td>
</tr>
{% endfor %}
{% if not top_performers %}
<tr>
<td colspan="4" class="text-center text-muted py-4">
Keine Performance-Daten verfügbar
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card chart-card">
<div class="card-header bg-danger text-white">
<h5 class="mb-0">⚠️ Problematische Ressourcen</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table performance-table mb-0">
<thead class="table-light">
<tr>
<th>Ressource</th>
<th width="80">Typ</th>
<th width="100" class="text-center">Probleme</th>
<th width="120">Status</th>
</tr>
</thead>
<tbody>
{% for resource in problem_resources %}
<tr>
<td>
<div class="d-flex align-items-center">
<code class="me-2">{{ resource.resource_value }}</code>
<a href="{{ url_for('resource_history', resource_id=resource.id) }}"
class="resource-link" title="Historie anzeigen">
<i class="fas fa-external-link-alt"></i>
</a>
</div>
</td>
<td>
<span class="badge bg-light text-dark">
{% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %}
{{ resource.resource_type|upper }}
</span>
</td>
<td class="text-center">
<span class="badge bg-danger">
{{ resource.total_issues }}
</span>
</td>
<td>
{% if resource.status == 'quarantine' %}
<span class="status-badge bg-warning text-dark">
⚠️ Quarantäne
</span>
{% elif resource.status == 'allocated' %}
<span class="status-badge bg-primary text-white">
🔗 Zugeteilt
</span>
{% else %}
<span class="status-badge bg-success text-white">
✅ Verfügbar
</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not problem_resources %}
<tr>
<td colspan="4" class="text-center text-muted py-4">
Keine problematischen Ressourcen gefunden
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Trend Chart -->
<div class="card chart-card mt-4">
<div class="card-header">
<h5 class="mb-0">📈 30-Tage Performance Trend</h5>
</div>
<div class="card-body">
<canvas id="trendChart" height="100"></canvas>
</div>
</div>
</div>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
<script>
// Chart defaults
Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
// Performance by Type Chart
const performanceCtx = document.getElementById('performanceByTypeChart').getContext('2d');
new Chart(performanceCtx, {
type: 'bar',
data: {
labels: {{ performance_by_type|map(attribute=0)|list|tojson }},
datasets: [{
label: 'Durchschnittliche Performance',
data: {{ performance_by_type|map(attribute=1)|list|tojson }},
backgroundColor: [
'rgba(33, 150, 243, 0.8)',
'rgba(156, 39, 176, 0.8)',
'rgba(76, 175, 80, 0.8)'
],
borderColor: [
'rgb(33, 150, 243)',
'rgb(156, 39, 176)',
'rgb(76, 175, 80)'
],
borderWidth: 2,
borderRadius: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: function(value) {
return value + '%';
}
}
}
}
}
});
// Utilization Chart
const utilizationCtx = document.getElementById('utilizationChart').getContext('2d');
new Chart(utilizationCtx, {
type: 'doughnut',
data: {
labels: {{ utilization_data|map(attribute='type')|list|tojson }},
datasets: [{
data: {{ utilization_data|map(attribute='allocated_percent')|list|tojson }},
backgroundColor: [
'rgba(33, 150, 243, 0.8)',
'rgba(156, 39, 176, 0.8)',
'rgba(76, 175, 80, 0.8)'
],
borderColor: '#fff',
borderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true
}
},
tooltip: {
callbacks: {
label: function(context) {
return context.label + ': ' + context.parsed + '% ausgelastet';
}
}
}
}
}
});
// Trend Chart
const trendCtx = document.getElementById('trendChart').getContext('2d');
new Chart(trendCtx, {
type: 'line',
data: {
labels: {{ daily_metrics|map(attribute='date')|list|tojson }},
datasets: [{
label: 'Performance Score',
data: {{ daily_metrics|map(attribute='performance')|list|tojson }},
borderColor: 'rgb(76, 175, 80)',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
tension: 0.4,
borderWidth: 3,
pointRadius: 4,
pointHoverRadius: 6,
yAxisID: 'y',
}, {
label: 'Probleme',
data: {{ daily_metrics|map(attribute='issues')|list|tojson }},
borderColor: 'rgb(244, 67, 54)',
backgroundColor: 'rgba(244, 67, 54, 0.1)',
tension: 0.4,
borderWidth: 3,
pointRadius: 4,
pointHoverRadius: 6,
yAxisID: 'y1',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'top',
labels: {
padding: 20,
usePointStyle: true
}
}
},
scales: {
x: {
grid: {
display: false
}
},
y: {
type: 'linear',
display: true,
position: 'left',
title: {
display: true,
text: 'Performance %'
},
beginAtZero: true,
max: 100,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: {
display: true,
text: 'Anzahl Probleme'
},
beginAtZero: true,
grid: {
drawOnChartArea: false,
}
}
}
}
});
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,212 @@
{% extends "base.html" %}
{% block title %}Resource Report Generator{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Resource Report Generator</h1>
<a href="{{ url_for('resources') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Zurück
</a>
</div>
<div class="row">
<div class="col-md-8 mx-auto">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Report-Einstellungen</h5>
</div>
<div class="card-body">
<form method="get" action="{{ url_for('resources_report') }}">
<div class="row g-3">
<div class="col-md-6">
<label for="report_type" class="form-label">Report-Typ</label>
<select name="type" id="report_type" class="form-select" required>
<option value="usage">Auslastungsreport</option>
<option value="performance">Performance-Report</option>
<option value="compliance">Compliance-Report</option>
<option value="inventory">Bestands-Report</option>
</select>
</div>
<div class="col-md-6">
<label for="format" class="form-label">Export-Format</label>
<select name="format" id="format" class="form-select" required>
<option value="excel">Excel (.xlsx)</option>
<option value="csv">CSV (.csv)</option>
<option value="pdf">PDF (Vorschau)</option>
</select>
</div>
<div class="col-md-6">
<label for="date_from" class="form-label">Von</label>
<input type="date" name="from" id="date_from" class="form-control"
value="{{ (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d') }}" required>
</div>
<div class="col-md-6">
<label for="date_to" class="form-label">Bis</label>
<input type="date" name="to" id="date_to" class="form-control"
value="{{ datetime.now().strftime('%Y-%m-%d') }}" required>
</div>
</div>
<div class="mt-4">
<h6>Report-Beschreibungen:</h6>
<div id="report_descriptions">
<div class="alert alert-info report-desc" data-type="usage">
<h6><i class="fas fa-chart-line"></i> Auslastungsreport</h6>
<p class="mb-0">Zeigt die Nutzung aller Ressourcen im gewählten Zeitraum.
Enthält Allokations-Historie, durchschnittliche Auslastung und Trends.</p>
</div>
<div class="alert alert-warning report-desc" data-type="performance" style="display: none;">
<h6><i class="fas fa-tachometer-alt"></i> Performance-Report</h6>
<p class="mb-0">Analysiert die Performance-Metriken aller Ressourcen.
Enthält ROI-Berechnungen, Issue-Tracking und Performance-Scores.</p>
</div>
<div class="alert alert-success report-desc" data-type="compliance" style="display: none;">
<h6><i class="fas fa-shield-alt"></i> Compliance-Report</h6>
<p class="mb-0">Überprüft Compliance-Aspekte wie Quarantäne-Gründe,
Sicherheitsvorfälle und Policy-Verletzungen.</p>
</div>
<div class="alert alert-primary report-desc" data-type="inventory" style="display: none;">
<h6><i class="fas fa-boxes"></i> Bestands-Report</h6>
<p class="mb-0">Aktueller Bestand aller Ressourcen mit Status-Übersicht,
Verfügbarkeit und Zuordnungen.</p>
</div>
</div>
</div>
<div class="d-flex justify-content-between mt-4">
<button type="button" class="btn btn-outline-secondary" onclick="previewReport()">
<i class="fas fa-eye"></i> Vorschau
</button>
<button type="submit" class="btn btn-primary" name="download" value="true">
<i class="fas fa-download"></i> Report generieren
</button>
</div>
</form>
</div>
</div>
<!-- Letzte Reports -->
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">Letzte generierte Reports</h5>
</div>
<div class="card-body">
<div class="list-group">
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">Auslastungsreport_2025-06-01.xlsx</h6>
<small>vor 5 Tagen</small>
</div>
<p class="mb-1">Zeitraum: 01.05.2025 - 01.06.2025</p>
<small>Generiert von: {{ username }}</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Vorschau Modal -->
<div class="modal fade" id="previewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Report-Vorschau</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="previewContent">
<p class="text-center">
<i class="fas fa-spinner fa-spin"></i> Lade Vorschau...
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
<script>
// Report-Typ Beschreibungen
document.getElementById('report_type').addEventListener('change', function() {
const selectedType = this.value;
document.querySelectorAll('.report-desc').forEach(desc => {
desc.style.display = desc.dataset.type === selectedType ? 'block' : 'none';
});
});
// Datum-Validierung
document.getElementById('date_from').addEventListener('change', function() {
document.getElementById('date_to').min = this.value;
});
document.getElementById('date_to').addEventListener('change', function() {
document.getElementById('date_from').max = this.value;
});
// Report-Vorschau
function previewReport() {
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
const content = document.getElementById('previewContent');
// Simuliere Lade-Vorgang
content.innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i> Generiere Vorschau...</p>';
modal.show();
setTimeout(() => {
// Beispiel-Vorschau
content.innerHTML = `
<h5>Report-Vorschau</h5>
<table class="table table-sm">
<thead>
<tr>
<th>Ressourcentyp</th>
<th>Gesamt</th>
<th>Verfügbar</th>
<th>Zugeteilt</th>
<th>Auslastung</th>
</tr>
</thead>
<tbody>
<tr>
<td>Domain</td>
<td>150</td>
<td>45</td>
<td>100</td>
<td>66.7%</td>
</tr>
<tr>
<td>IPv4</td>
<td>100</td>
<td>20</td>
<td>75</td>
<td>75.0%</td>
</tr>
<tr>
<td>Phone</td>
<td>50</td>
<td>15</td>
<td>30</td>
<td>60.0%</td>
</tr>
</tbody>
</table>
<p class="text-muted">Dies ist eine vereinfachte Vorschau. Der vollständige Report enthält weitere Details.</p>
`;
}, 1000);
}
// Initialisierung
document.addEventListener('DOMContentLoaded', function() {
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
document.getElementById('date_from').value = thirtyDaysAgo.toISOString().split('T')[0];
document.getElementById('date_to').value = today.toISOString().split('T')[0];
});
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,898 @@
{% extends "base.html" %}
{% block title %}Resource Pool{% endblock %}
{% block extra_css %}
<style>
/* Statistik-Karten Design wie Dashboard */
.stat-card {
transition: all 0.3s ease;
cursor: pointer;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
height: 100%;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.stat-card .card-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
opacity: 0.8;
}
.stat-card .card-value {
font-size: 2.5rem;
font-weight: bold;
margin: 0.5rem 0;
}
.stat-card .card-label {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
}
/* Resource Type Icons */
.resource-icon {
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 1.2rem;
}
.resource-icon.domain {
background-color: #e3f2fd;
color: #1976d2;
}
.resource-icon.ipv4 {
background-color: #f3e5f5;
color: #7b1fa2;
}
.resource-icon.phone {
background-color: #e8f5e9;
color: #388e3c;
}
/* Status Badges */
.status-badge {
padding: 0.35rem 0.65rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.status-available {
background-color: #d4edda;
color: #155724;
}
.status-allocated {
background-color: #d1ecf1;
color: #0c5460;
}
.status-quarantine {
background-color: #fff3cd;
color: #856404;
}
/* Progress Bar Custom */
.progress-custom {
height: 25px;
background-color: #f0f0f0;
border-radius: 10px;
}
.progress-bar-custom {
font-size: 0.75rem;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
/* Table Styling */
.table-custom {
border: none;
}
.table-custom thead th {
background-color: #f8f9fa;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
color: #495057;
padding: 1rem;
}
.table-custom tbody tr {
transition: all 0.2s ease;
}
.table-custom tbody tr:hover {
background-color: #f8f9fa;
}
.table-custom td {
padding: 1rem;
vertical-align: middle;
}
/* Action Buttons */
.btn-action {
width: 35px;
height: 35px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
margin: 0 2px;
transition: all 0.2s ease;
}
.btn-action:hover {
transform: scale(1.1);
}
/* Copy Button */
.copy-btn {
background: none;
border: none;
color: #6c757d;
padding: 0.25rem 0.5rem;
transition: color 0.2s ease;
}
.copy-btn:hover {
color: #28a745;
}
.copy-btn.copied {
color: #28a745;
}
/* Filter Card */
.filter-card {
background-color: #f8f9fa;
border: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem;
color: #6c757d;
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Dropdown Verbesserungen */
.dropdown-menu {
min-width: 220px;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
border: none;
}
.dropdown-item {
padding: 0.5rem 1rem;
display: flex;
align-items: center;
gap: 0.75rem;
transition: all 0.2s ease;
}
.dropdown-item:hover {
background-color: #f8f9fa;
transform: translateX(2px);
}
.dropdown-item i {
width: 20px;
text-align: center;
}
/* Quick Action Buttons */
.btn-success {
font-weight: 500;
}
/* Sortable Headers */
thead th a {
display: block;
position: relative;
padding-right: 20px;
}
thead th a:hover {
color: #0d6efd !important;
}
thead th a i {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
font-size: 0.8rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Header -->
<div class="mb-4">
<h1 class="mb-0">Resource Pool</h1>
<p class="text-muted mb-0">Verwalten Sie Domains, IPs und Telefonnummern</p>
</div>
<!-- Test/Live Mode Toggle -->
<div class="card mb-3">
<div class="card-body py-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="showTestResources"
{% if show_test %}checked{% endif %}
onchange="toggleTestResources()">
<label class="form-check-label" for="showTestResources">
Testressourcen anzeigen
</label>
</div>
</div>
</div>
<!-- Statistik-Karten -->
<div class="row g-4 mb-4">
{% for type, data in stats.items() %}
<div class="col-md-4">
<div class="card stat-card">
<div class="card-body text-center">
<div class="card-icon">
{% if type == 'domain' %}
🌐
{% elif type == 'ipv4' %}
🖥️
{% else %}
📱
{% endif %}
</div>
<h5 class="text-muted mb-2">{{ type|upper }}</h5>
<div class="card-value text-primary">{{ data.available }}</div>
<div class="card-label text-muted mb-3">von {{ data.total }} verfügbar</div>
<div class="progress progress-custom">
<div class="progress-bar bg-success progress-bar-custom"
style="width: {{ data.available_percent }}%"
data-bs-toggle="tooltip"
title="{{ data.available }} verfügbar">
{{ data.available_percent }}%
</div>
<div class="progress-bar bg-info progress-bar-custom"
style="width: {{ (data.allocated / data.total * 100) if data.total > 0 else 0 }}%"
data-bs-toggle="tooltip"
title="{{ data.allocated }} zugeteilt">
{% if data.allocated > 0 %}{{ data.allocated }}{% endif %}
</div>
<div class="progress-bar bg-warning progress-bar-custom"
style="width: {{ (data.quarantine / data.total * 100) if data.total > 0 else 0 }}%"
data-bs-toggle="tooltip"
title="{{ data.quarantine }} in Quarantäne">
{% if data.quarantine > 0 %}{{ data.quarantine }}{% endif %}
</div>
</div>
<div class="mt-3">
{% if data.available_percent < 20 %}
<span class="badge bg-danger">⚠️ Niedriger Bestand</span>
{% elif data.available_percent < 50 %}
<span class="badge bg-warning text-dark">⚡ Bestand prüfen</span>
{% else %}
<span class="badge bg-success">✅ Gut gefüllt</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Filter -->
<div class="card filter-card mb-4">
<div class="card-body">
<form method="get" action="{{ url_for('resources') }}" id="filterForm">
<input type="hidden" name="show_test" value="{{ 'true' if show_test else 'false' }}">
<div class="row g-3">
<div class="col-md-3">
<label for="type" class="form-label">🏷️ Typ</label>
<select name="type" id="type" class="form-select">
<option value="">Alle Typen</option>
<option value="domain" {% if resource_type == 'domain' %}selected{% endif %}>🌐 Domain</option>
<option value="ipv4" {% if resource_type == 'ipv4' %}selected{% endif %}>🖥️ IPv4</option>
<option value="phone" {% if resource_type == 'phone' %}selected{% endif %}>📱 Telefon</option>
</select>
</div>
<div class="col-md-3">
<label for="status" class="form-label">📊 Status</label>
<select name="status" id="status" class="form-select">
<option value="">Alle Status</option>
<option value="available" {% if status_filter == 'available' %}selected{% endif %}>✅ Verfügbar</option>
<option value="allocated" {% if status_filter == 'allocated' %}selected{% endif %}>🔗 Zugeteilt</option>
<option value="quarantine" {% if status_filter == 'quarantine' %}selected{% endif %}>⚠️ Quarantäne</option>
</select>
</div>
<div class="col-md-4">
<label for="search" class="form-label">🔍 Suche</label>
<input type="text" name="search" id="search" class="form-control"
placeholder="Ressource suchen..." value="{{ search }}">
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<a href="{{ url_for('resources', show_test=show_test) }}" class="btn btn-secondary w-100">
🔄 Zurücksetzen
</a>
</div>
</div>
</form>
</div>
</div>
<!-- Ressourcen-Tabelle -->
<div class="card">
<div class="card-header bg-white">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">📋 Ressourcen-Liste</h5>
<div class="d-flex align-items-center gap-2">
<div class="dropdown">
<button class="btn btn-sm btn-secondary dropdown-toggle"
type="button"
id="exportDropdown"
data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-download"></i> Export
</button>
<ul class="dropdown-menu" aria-labelledby="exportDropdown">
<li><a class="dropdown-item" href="/export/resources?format=excel&type={{ resource_type }}&status={{ status_filter }}&search={{ search }}&show_test={{ show_test }}">
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
<li><a class="dropdown-item" href="/export/resources?format=csv&type={{ resource_type }}&status={{ status_filter }}&search={{ search }}&show_test={{ show_test }}">
<i class="bi bi-file-earmark-text"></i> CSV Export</a></li>
</ul>
</div>
<span class="badge bg-secondary">{{ total }} Einträge</span>
</div>
</div>
</div>
<div class="card-body p-0">
{% if resources %}
<div class="table-responsive">
<table class="table table-custom mb-0">
<thead>
<tr>
<th width="80">
<a href="{{ url_for('resources', sort='id', order='desc' if sort_by == 'id' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
class="text-decoration-none text-dark sort-link">
ID
{% if sort_by == 'id' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th width="120">
<a href="{{ url_for('resources', sort='type', order='desc' if sort_by == 'type' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
class="text-decoration-none text-dark sort-link">
Typ
{% if sort_by == 'type' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th>
<a href="{{ url_for('resources', sort='resource', order='desc' if sort_by == 'resource' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
class="text-decoration-none text-dark sort-link">
Ressource
{% if sort_by == 'resource' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th width="140">
<a href="{{ url_for('resources', sort='status', order='desc' if sort_by == 'status' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
class="text-decoration-none text-dark sort-link">
Status
{% if sort_by == 'status' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th>
<a href="{{ url_for('resources', sort='assigned', order='desc' if sort_by == 'assigned' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
class="text-decoration-none text-dark sort-link">
Zugewiesen an
{% if sort_by == 'assigned' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th width="180">
<a href="{{ url_for('resources', sort='changed', order='desc' if sort_by == 'changed' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_test=show_test) }}"
class="text-decoration-none text-dark sort-link">
Letzte Änderung
{% if sort_by == 'changed' %}
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
{% else %}
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
{% endif %}
</a>
</th>
<th width="200" class="text-center">Aktionen</th>
</tr>
</thead>
<tbody>
{% for resource in resources %}
<tr>
<td>
<span class="text-muted">#{{ resource[0] }}</span>
</td>
<td>
<div class="resource-icon {{ resource[1] }}">
{% if resource[1] == 'domain' %}
🌐
{% elif resource[1] == 'ipv4' %}
🖥️
{% else %}
📱
{% endif %}
</div>
</td>
<td>
<div class="d-flex align-items-center">
<code class="me-2">{{ resource[2] }}</code>
<button class="copy-btn" onclick="copyToClipboard('{{ resource[2] }}', this)"
title="Kopieren">
<i class="bi bi-clipboard"></i>
</button>
</div>
</td>
<td>
{% if resource[3] == 'available' %}
<span class="status-badge status-available">
✅ Verfügbar
</span>
{% elif resource[3] == 'allocated' %}
<span class="status-badge status-allocated">
🔗 Zugeteilt
</span>
{% else %}
<span class="status-badge status-quarantine">
⚠️ Quarantäne
</span>
{% if resource[8] %}
<div class="small text-muted mt-1">{{ resource[8] }}</div>
{% endif %}
{% endif %}
</td>
<td>
{% if resource[5] %}
<div>
<a href="{{ url_for('customers_licenses', customer_id=resource[10] if resource[10] else '', show_test=show_test) }}"
class="text-decoration-none">
<strong>{{ resource[5] }}</strong>
</a>
</div>
<div class="small text-muted">
<a href="{{ url_for('edit_license', license_id=resource[4]) }}?ref=resources{{ '&show_test=true' if show_test else '' }}"
class="text-decoration-none text-muted">
{{ resource[6] }}
</a>
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if resource[7] %}
<div class="small">
<div>{{ resource[7].strftime('%d.%m.%Y') }}</div>
<div class="text-muted">{{ resource[7].strftime('%H:%M Uhr') }}</div>
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="text-center">
{% if resource[3] == 'quarantine' %}
<!-- Quick Action für Quarantäne -->
<form method="post" action="/resources/release?show_test={{ show_test }}&type={{ resource_type }}&status={{ status_filter }}&search={{ search }}"
style="display: inline-block; margin-right: 5px;">
<input type="hidden" name="resource_ids" value="{{ resource[0] }}">
<input type="hidden" name="show_test" value="{{ show_test }}">
<button type="submit"
class="btn btn-sm btn-success">
<i class="bi bi-check-circle"></i> Freigeben
</button>
</form>
{% endif %}
<!-- Dropdown für weitere Aktionen -->
<div class="dropdown" style="display: inline-block;">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle"
type="button"
id="dropdownMenuButton{{ resource[0] }}"
data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-three-dots-vertical"></i> Aktionen
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton{{ resource[0] }}">
<!-- Historie immer verfügbar -->
<li>
<a class="dropdown-item"
href="{{ url_for('resource_history', resource_id=resource[0]) }}">
<i class="bi bi-clock-history text-info"></i> Historie anzeigen
</a>
</li>
<li><hr class="dropdown-divider"></li>
{% if resource[3] == 'available' %}
<!-- Aktionen für verfügbare Ressourcen -->
<li>
<button class="dropdown-item"
onclick="showQuarantineModal({{ resource[0] }})">
<i class="bi bi-exclamation-triangle text-warning"></i> In Quarantäne setzen
</button>
</li>
{% elif resource[3] == 'allocated' %}
<!-- Aktionen für zugeteilte Ressourcen -->
{% if resource[4] %}
<li>
<a class="dropdown-item"
href="{{ url_for('edit_license', license_id=resource[4]) }}?ref=resources{{ '&show_test=true' if show_test else '' }}">
<i class="bi bi-file-text text-primary"></i> Lizenz bearbeiten
</a>
</li>
{% endif %}
{% if resource[10] %}
<li>
<a class="dropdown-item"
href="{{ url_for('customers_licenses', customer_id=resource[10], show_test=show_test) }}">
<i class="bi bi-person text-primary"></i> Kunde anzeigen
</a>
</li>
{% endif %}
{% elif resource[3] == 'quarantine' %}
<!-- Aktionen für Quarantäne-Ressourcen -->
<li>
<form method="post" action="/resources/release?show_test={{ show_test }}&type={{ resource_type }}&status={{ status_filter }}&search={{ search }}"
style="display: contents;">
<input type="hidden" name="resource_ids" value="{{ resource[0] }}">
<input type="hidden" name="show_test" value="{{ show_test }}">
<button type="submit" class="dropdown-item">
<i class="bi bi-check-circle text-success"></i> Ressource freigeben
</button>
</form>
</li>
{% if resource[9] %}
<li>
<button class="dropdown-item"
onclick="extendQuarantine({{ resource[0] }})">
<i class="bi bi-calendar-plus text-warning"></i> Quarantäne verlängern
</button>
</li>
{% endif %}
{% endif %}
<li><hr class="dropdown-divider"></li>
<!-- Kopieren immer verfügbar -->
<li>
<button class="dropdown-item"
onclick="copyToClipboard('{{ resource[2] }}', this)">
<i class="bi bi-clipboard text-secondary"></i> Ressource kopieren
</button>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-inbox"></i>
<h4>Keine Ressourcen gefunden</h4>
<p>Ändern Sie Ihre Filterkriterien oder fügen Sie neue Ressourcen hinzu.</p>
</div>
{% endif %}
</div>
</div>
<!-- Pagination -->
{% if total_pages > 1 %}
<nav class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('resources', page=1, type=resource_type, status=status_filter, search=search, show_test=show_test, sort=sort_by, order=sort_order) }}">
<i class="bi bi-chevron-double-left"></i> Erste
</a>
</li>
<li class="page-item {% if page == 1 %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('resources', page=page-1, type=resource_type, status=status_filter, search=search, show_test=show_test, sort=sort_by, order=sort_order) }}">
<i class="bi bi-chevron-left"></i> Zurück
</a>
</li>
{% for p in range(1, total_pages + 1) %}
{% if p == page or (p >= page - 2 and p <= page + 2) %}
<li class="page-item {% if p == page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('resources', page=p, type=resource_type, status=status_filter, search=search, show_test=show_test, sort=sort_by, order=sort_order) }}">
{{ p }}
</a>
</li>
{% endif %}
{% endfor %}
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('resources', page=page+1, type=resource_type, status=status_filter, search=search, show_test=show_test, sort=sort_by, order=sort_order) }}">
Weiter <i class="bi bi-chevron-right"></i>
</a>
</li>
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('resources', page=total_pages, type=resource_type, status=status_filter, search=search, show_test=show_test, sort=sort_by, order=sort_order) }}">
Letzte <i class="bi bi-chevron-double-right"></i>
</a>
</li>
</ul>
</nav>
{% endif %}
<!-- Kürzliche Aktivitäten -->
{% if recent_activities %}
<div class="card mt-4">
<div class="card-header bg-white">
<h5 class="mb-0">⏰ Kürzliche Aktivitäten</h5>
</div>
<div class="card-body">
<div class="timeline">
{% for activity in recent_activities %}
<div class="d-flex mb-3">
<div class="me-3">
{% if activity[0] == 'created' %}
<span class="badge bg-success rounded-pill"></span>
{% elif activity[0] == 'allocated' %}
<span class="badge bg-info rounded-pill">🔗</span>
{% elif activity[0] == 'deallocated' %}
<span class="badge bg-secondary rounded-pill">🔓</span>
{% elif activity[0] == 'quarantined' %}
<span class="badge bg-warning rounded-pill">⚠️</span>
{% else %}
<span class="badge bg-primary rounded-pill"></span>
{% endif %}
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between">
<div>
<strong>{{ activity[4] }}</strong> ({{ activity[3] }}) - {{ activity[0] }}
{% if activity[1] %}
<span class="text-muted">von {{ activity[1] }}</span>
{% endif %}
</div>
<small class="text-muted">
{{ activity[2].strftime('%d.%m.%Y %H:%M') if activity[2] else '' }}
</small>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
<!-- Quarantäne Modal -->
<div class="modal fade" id="quarantineModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" id="quarantineForm">
<!-- Filter-Parameter als Hidden Fields -->
<input type="hidden" name="show_test" value="{{ show_test }}">
<input type="hidden" name="type" value="{{ resource_type }}">
<input type="hidden" name="status" value="{{ status_filter }}">
<input type="hidden" name="search" value="{{ search }}">
<div class="modal-header">
<h5 class="modal-title">⚠️ Ressource in Quarantäne setzen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="reason" class="form-label">Grund</label>
<select name="reason" id="reason" class="form-select" required>
<option value="">Bitte wählen...</option>
<option value="review">🔍 Überprüfung</option>
<option value="abuse">⚠️ Missbrauch</option>
<option value="defect">❌ Defekt</option>
<option value="maintenance">🔧 Wartung</option>
<option value="blacklisted">🚫 Blacklisted</option>
<option value="expired">⏰ Abgelaufen</option>
</select>
</div>
<div class="mb-3">
<label for="until_date" class="form-label">Bis wann? (optional)</label>
<input type="date" name="until_date" id="until_date" class="form-control"
min="{{ datetime.now().strftime('%Y-%m-%d') }}">
</div>
<div class="mb-3">
<label for="notes" class="form-label">Notizen</label>
<textarea name="notes" id="notes" class="form-control" rows="3"
placeholder="Zusätzliche Informationen..."></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Abbrechen
</button>
<button type="submit" class="btn btn-warning">
⚠️ In Quarantäne setzen
</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Debug Bootstrap
console.log('Bootstrap loaded:', typeof bootstrap !== 'undefined');
if (typeof bootstrap !== 'undefined') {
console.log('Bootstrap version:', bootstrap.Dropdown.VERSION);
}
// Scroll-Position speichern und wiederherstellen
(function() {
// Speichere Scroll-Position vor dem Verlassen der Seite
window.addEventListener('beforeunload', function() {
sessionStorage.setItem('resourcesScrollPos', window.scrollY);
});
// Stelle Scroll-Position wieder her
const scrollPos = sessionStorage.getItem('resourcesScrollPos');
if (scrollPos) {
window.scrollTo(0, parseInt(scrollPos));
sessionStorage.removeItem('resourcesScrollPos');
}
})();
// Live-Filtering
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('filterForm');
const inputs = form.querySelectorAll('select, input[type="text"]');
inputs.forEach(input => {
if (input.type === 'text') {
let timeout;
input.addEventListener('input', function() {
clearTimeout(timeout);
timeout = setTimeout(() => form.submit(), 300);
});
} else {
input.addEventListener('change', () => form.submit());
}
});
// Bootstrap Tooltips initialisieren
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
// Bootstrap Dropdowns explizit initialisieren
var dropdownElementList = [].slice.call(document.querySelectorAll('.dropdown-toggle'))
var dropdownList = dropdownElementList.map(function (dropdownToggleEl) {
return new bootstrap.Dropdown(dropdownToggleEl)
});
// Export Dropdown manuell aktivieren falls nötig
const exportBtn = document.getElementById('exportDropdown');
if (exportBtn) {
exportBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const dropdown = bootstrap.Dropdown.getOrCreateInstance(this);
dropdown.toggle();
});
}
// Sort-Links: Scroll-Position speichern
document.querySelectorAll('.sort-link').forEach(link => {
link.addEventListener('click', function(e) {
sessionStorage.setItem('resourcesScrollPos', window.scrollY);
});
});
});
// Copy to Clipboard mit besserem Feedback
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(() => {
// Original Text speichern
const originalText = button.innerHTML;
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
button.classList.add('copied');
// Nach 2 Sekunden zurücksetzen
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('copied');
}, 2000);
});
}
// Quarantäne Modal
function showQuarantineModal(resourceId) {
const modalElement = document.getElementById('quarantineModal');
const modal = new bootstrap.Modal(modalElement);
const form = modalElement.querySelector('form');
// URL mit aktuellen Filtern
const currentUrl = new URL(window.location);
const params = new URLSearchParams(currentUrl.search);
form.setAttribute('action', `/resources/quarantine/${resourceId}?${params.toString()}`);
modal.show();
}
// Toggle Testressourcen
function toggleTestResources() {
const showTest = document.getElementById('showTestResources').checked;
const currentUrl = new URL(window.location);
currentUrl.searchParams.set('show_test', showTest);
window.location.href = currentUrl.toString();
}
// Quarantäne verlängern
function extendQuarantine(resourceId) {
if (confirm('Möchten Sie die Quarantäne für diese Ressource verlängern?')) {
// TODO: Implementiere Modal für Quarantäne-Verlängerung
alert('Diese Funktion wird noch implementiert.');
}
}
// Fallback für Dropdowns
document.addEventListener('click', function(e) {
// Prüfe ob ein Dropdown-Toggle geklickt wurde
if (e.target.closest('.dropdown-toggle')) {
const button = e.target.closest('.dropdown-toggle');
const dropdown = button.nextElementSibling;
if (dropdown && dropdown.classList.contains('dropdown-menu')) {
// Toggle show class
dropdown.classList.toggle('show');
button.setAttribute('aria-expanded', dropdown.classList.contains('show'));
// Schließe andere Dropdowns
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
if (menu !== dropdown) {
menu.classList.remove('show');
menu.previousElementSibling.setAttribute('aria-expanded', 'false');
}
});
}
} else {
// Schließe alle Dropdowns wenn außerhalb geklickt
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
menu.classList.remove('show');
menu.previousElementSibling.setAttribute('aria-expanded', 'false');
});
}
});
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}Session-Tracking{% endblock %}
{% macro active_sortable_header(label, field, current_sort, current_order) %}
<th>
{% if current_sort == field %}
<a href="{{ url_for('sessions', active_sort=field, active_order='desc' if current_order == 'asc' else 'asc', ended_sort=ended_sort, ended_order=ended_order) }}"
class="server-sortable">
{% else %}
<a href="{{ url_for('sessions', active_sort=field, active_order='asc', ended_sort=ended_sort, ended_order=ended_order) }}"
class="server-sortable">
{% endif %}
{{ label }}
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
{% if current_sort == field %}
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
{% else %}
{% endif %}
</span>
</a>
</th>
{% endmacro %}
{% macro ended_sortable_header(label, field, current_sort, current_order) %}
<th>
{% if current_sort == field %}
<a href="{{ url_for('sessions', active_sort=active_sort, active_order=active_order, ended_sort=field, ended_order='desc' if current_order == 'asc' else 'asc') }}"
class="server-sortable">
{% else %}
<a href="{{ url_for('sessions', active_sort=active_sort, active_order=active_order, ended_sort=field, ended_order='asc') }}"
class="server-sortable">
{% endif %}
{{ label }}
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
{% if current_sort == field %}
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
{% else %}
{% endif %}
</span>
</a>
</th>
{% endmacro %}
{% block extra_css %}
<style>
.session-active { background-color: #d4edda; }
.session-warning { background-color: #fff3cd; }
.session-inactive { background-color: #f8f9fa; }
</style>
{% endblock %}
{% block content %}
<div class="container py-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Session-Tracking</h2>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-download"></i> Export
</button>
<ul class="dropdown-menu">
<li><h6 class="dropdown-header">Aktive Sessions</h6></li>
<li><a class="dropdown-item" href="/export/sessions?type=active&format=excel">
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
<li><a class="dropdown-item" href="/export/sessions?type=active&format=csv">
<i class="bi bi-file-earmark-text"></i> CSV Export</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Beendete Sessions</h6></li>
<li><a class="dropdown-item" href="/export/sessions?type=ended&format=excel">
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
<li><a class="dropdown-item" href="/export/sessions?type=ended&format=csv">
<i class="bi bi-file-earmark-text"></i> CSV Export</a></li>
</ul>
</div>
</div>
<!-- Aktive Sessions -->
<div class="card mb-4">
<div class="card-header bg-success text-white">
<h5 class="mb-0">🟢 Aktive Sessions ({{ active_sessions|length }})</h5>
</div>
<div class="card-body">
{% if active_sessions %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
{{ active_sortable_header('Kunde', 'customer', active_sort, active_order) }}
{{ active_sortable_header('Lizenz', 'license', active_sort, active_order) }}
{{ active_sortable_header('IP-Adresse', 'ip', active_sort, active_order) }}
{{ active_sortable_header('Gestartet', 'started', active_sort, active_order) }}
{{ active_sortable_header('Letzter Heartbeat', 'last_heartbeat', active_sort, active_order) }}
{{ active_sortable_header('Inaktiv seit', 'inactive', active_sort, active_order) }}
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for session in active_sessions %}
<tr class="{% if session[8] > 5 %}session-warning{% else %}session-active{% endif %}">
<td>{{ session[3] }}</td>
<td><small><code>{{ session[2][:12] }}...</code></small></td>
<td>{{ session[4] or '-' }}</td>
<td>{{ session[6].strftime('%d.%m %H:%M') }}</td>
<td>{{ session[7].strftime('%d.%m %H:%M') }}</td>
<td>
{% if session[8] < 1 %}
<span class="badge bg-success">Aktiv</span>
{% elif session[8] < 5 %}
<span class="badge bg-warning">{{ session[8]|round|int }} Min.</span>
{% else %}
<span class="badge bg-danger">{{ session[8]|round|int }} Min.</span>
{% endif %}
</td>
<td>
<form method="post" action="/session/end/{{ session[0] }}" style="display: inline;">
<button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Session wirklich beenden?');">
⏹️ Beenden
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<small class="text-muted">
Sessions gelten als inaktiv nach 5 Minuten ohne Heartbeat
</small>
{% else %}
<p class="text-muted mb-0">Keine aktiven Sessions vorhanden.</p>
{% endif %}
</div>
</div>
<!-- Beendete Sessions -->
<div class="card">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0">⏸️ Beendete Sessions (letzte 24 Stunden)</h5>
</div>
<div class="card-body">
{% if recent_sessions %}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
{{ ended_sortable_header('Kunde', 'customer', ended_sort, ended_order) }}
{{ ended_sortable_header('Lizenz', 'license', ended_sort, ended_order) }}
{{ ended_sortable_header('IP-Adresse', 'ip', ended_sort, ended_order) }}
{{ ended_sortable_header('Gestartet', 'started', ended_sort, ended_order) }}
{{ ended_sortable_header('Beendet', 'ended_at', ended_sort, ended_order) }}
{{ ended_sortable_header('Dauer', 'duration', ended_sort, ended_order) }}
</tr>
</thead>
<tbody>
{% for session in recent_sessions %}
<tr class="session-inactive">
<td>{{ session[3] }}</td>
<td><small><code>{{ session[2][:12] }}...</code></small></td>
<td>{{ session[4] or '-' }}</td>
<td>{{ session[5].strftime('%d.%m %H:%M') }}</td>
<td>{{ session[6].strftime('%d.%m %H:%M') }}</td>
<td>
{% if session[7] < 60 %}
{{ session[7]|round|int }} Min.
{% else %}
{{ (session[7]/60)|round(1) }} Std.
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Keine beendeten Sessions in den letzten 24 Stunden.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

Datei anzeigen

@ -0,0 +1,210 @@
{% extends "base.html" %}
{% block title %}2FA Einrichten{% endblock %}
{% block extra_css %}
<style>
.setup-card {
transition: all 0.3s ease;
border: none;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.setup-card:hover {
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
}
.step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 35px;
height: 35px;
background-color: #0d6efd;
color: white;
border-radius: 50%;
font-weight: bold;
margin-right: 10px;
}
.app-icon {
width: 40px;
height: 40px;
object-fit: contain;
margin-right: 10px;
}
.qr-container {
background: white;
padding: 20px;
border-radius: 10px;
display: inline-block;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.secret-code {
font-family: monospace;
font-size: 1.2rem;
letter-spacing: 2px;
background-color: #f8f9fa;
padding: 10px 15px;
border-radius: 5px;
word-break: break-all;
}
.code-input {
font-size: 2rem;
letter-spacing: 0.5rem;
text-align: center;
font-family: monospace;
font-weight: bold;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>🔐 2FA einrichten</h1>
<a href="{{ url_for('profile') }}" class="btn btn-secondary">← Zurück zum Profil</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Step 1: Install App -->
<div class="card setup-card mb-4">
<div class="card-body">
<h5 class="card-title">
<span class="step-number">1</span>
Authenticator-App installieren
</h5>
<p class="ms-5">Wählen Sie eine der folgenden Apps für Ihr Smartphone:</p>
<div class="row ms-4">
<div class="col-md-4 mb-2">
<div class="d-flex align-items-center">
<span style="font-size: 2rem; margin-right: 10px;">📱</span>
<div>
<strong>Google Authenticator</strong><br>
<small class="text-muted">Android / iOS</small>
</div>
</div>
</div>
<div class="col-md-4 mb-2">
<div class="d-flex align-items-center">
<span style="font-size: 2rem; margin-right: 10px;">🔷</span>
<div>
<strong>Microsoft Authenticator</strong><br>
<small class="text-muted">Android / iOS</small>
</div>
</div>
</div>
<div class="col-md-4 mb-2">
<div class="d-flex align-items-center">
<span style="font-size: 2rem; margin-right: 10px;">🔴</span>
<div>
<strong>Authy</strong><br>
<small class="text-muted">Android / iOS / Desktop</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Step 2: Scan QR Code -->
<div class="card setup-card mb-4">
<div class="card-body">
<h5 class="card-title">
<span class="step-number">2</span>
QR-Code scannen oder Code eingeben
</h5>
<div class="row mt-4">
<div class="col-md-6 text-center mb-4">
<p class="fw-bold">Option A: QR-Code scannen</p>
<div class="qr-container">
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code" style="max-width: 250px;">
</div>
<p class="text-muted mt-2">
<small>Öffnen Sie Ihre Authenticator-App und scannen Sie diesen Code</small>
</p>
</div>
<div class="col-md-6 mb-4">
<p class="fw-bold">Option B: Code manuell eingeben</p>
<div class="mb-3">
<label class="text-muted small">Account-Name:</label>
<div class="alert alert-light py-2">
<strong>V2 Admin Panel</strong>
</div>
</div>
<div class="mb-3">
<label class="text-muted small">Geheimer Schlüssel:</label>
<div class="secret-code">{{ totp_secret }}</div>
<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="copySecret()">
📋 Schlüssel kopieren
</button>
</div>
<div class="alert alert-warning">
<small>
<strong>⚠️ Wichtiger Hinweis:</strong><br>
Speichern Sie diesen Code sicher. Er ist Ihre einzige Möglichkeit,
2FA auf einem neuen Gerät einzurichten.
</small>
</div>
</div>
</div>
</div>
</div>
<!-- Step 3: Verify -->
<div class="card setup-card mb-4">
<div class="card-body">
<h5 class="card-title">
<span class="step-number">3</span>
Code verifizieren
</h5>
<p class="ms-5">Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:</p>
<form method="POST" action="{{ url_for('enable_2fa') }}" class="ms-5 me-5">
<div class="row align-items-center">
<div class="col-md-6 mb-3">
<input type="text"
class="form-control code-input"
id="token"
name="token"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
autocomplete="off"
autofocus
required>
<div class="form-text text-center">Der Code ändert sich alle 30 Sekunden</div>
</div>
<div class="col-md-6 mb-3">
<button type="submit" class="btn btn-success btn-lg w-100">
✅ 2FA aktivieren
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
function copySecret() {
const secret = '{{ totp_secret }}';
navigator.clipboard.writeText(secret).then(function() {
alert('Code wurde in die Zwischenablage kopiert!');
});
}
// Auto-format the code input
document.getElementById('token').addEventListener('input', function(e) {
// Remove non-digits
e.target.value = e.target.value.replace(/\D/g, '');
});
</script>
{% endblock %}

Datei anzeigen

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2FA Verifizierung - Admin Panel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
background: white;
padding: 2rem;
border-radius: 10px;
box-shadow: 0 0 20px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.logo {
text-align: center;
font-size: 3rem;
margin-bottom: 1rem;
}
.code-input {
text-align: center;
font-size: 1.5rem;
letter-spacing: 0.5rem;
font-family: monospace;
}
</style>
</head>
<body>
<div class="login-container">
<div class="logo">🔐</div>
<h2 class="text-center mb-4">Zwei-Faktor-Authentifizierung</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
<div class="mb-3">
<label for="token" class="form-label">Authentifizierungscode eingeben</label>
<input type="text"
class="form-control code-input"
id="token"
name="token"
placeholder="000000"
maxlength="6"
pattern="[0-9]{6}"
autocomplete="off"
autofocus
required>
<div class="form-text text-center mt-2">
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">Verifizieren</button>
</div>
<div class="text-center mt-3">
<details>
<summary class="text-muted" style="cursor: pointer;">Backup-Code verwenden</summary>
<div class="mt-2">
<p class="small text-muted">
Falls Sie keinen Zugriff auf Ihre Authenticator-App haben,
können Sie einen 8-stelligen Backup-Code eingeben.
</p>
<input type="text"
class="form-control code-input mt-2"
id="backup-token"
placeholder="ABCD1234"
maxlength="8"
pattern="[A-Z0-9]{8}"
style="letter-spacing: 0.25rem;">
</div>
</details>
</div>
<hr class="my-4">
<div class="text-center">
<a href="/logout" class="text-muted">Abbrechen und zur Anmeldung zurück</a>
</div>
</form>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Auto-format the code input
document.getElementById('token').addEventListener('input', function(e) {
// Remove non-digits
e.target.value = e.target.value.replace(/\D/g, '');
});
// Handle backup code input
document.getElementById('backup-token').addEventListener('input', function(e) {
// Convert to uppercase and remove non-alphanumeric
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
// If backup code is being used, copy to main token field
if (e.target.value.length > 0) {
document.getElementById('token').value = e.target.value;
document.getElementById('token').removeAttribute('pattern');
document.getElementById('token').setAttribute('maxlength', '8');
}
});
// Reset main field when typing in it
document.getElementById('token').addEventListener('focus', function(e) {
document.getElementById('backup-token').value = '';
e.target.setAttribute('pattern', '[0-9]{6}');
e.target.setAttribute('maxlength', '6');
});
</script>
</body>
</html>