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

86
.claude/settings.local.json Normale Datei
Datei anzeigen

@ -0,0 +1,86 @@
{
"permissions": {
"allow": [
"Bash(ls:*)",
"Bash(docker-compose ps:*)",
"Bash(docker-compose logs:*)",
"Bash(docker-compose up:*)",
"Bash(mkdir:*)",
"Bash(docker-compose down:*)",
"Bash(docker logs:*)",
"Bash(docker exec:*)",
"Bash(python3:*)",
"Bash(docker-compose restart:*)",
"Bash(docker-compose build:*)",
"Bash(docker restart:*)",
"Bash(docker network inspect:*)",
"Bash(mkdir:*)",
"Bash(sudo touch:*)",
"Bash(docker volume rm:*)",
"Bash(rm:*)",
"Bash(docker-compose stop:*)",
"Bash(docker-compose rm:*)",
"Bash(docker-compose down:*)",
"Bash(docker stop:*)",
"Bash(docker rm:*)",
"Bash(docker-compose build:*)",
"Bash(docker-compose up:*)",
"Bash(docker-compose ps:*)",
"Bash(docker logs:*)",
"Bash(nslookup:*)",
"Bash(getent:*)",
"Bash(ipconfig:*)",
"Bash(ss:*)",
"Bash(curl:*)",
"Bash(powershell.exe:*)",
"Bash(cp:*)",
"Bash(chmod:*)",
"Bash(unzip:*)",
"Bash(python3:*)",
"Bash(grep:*)",
"Bash(docker exec:*)",
"Bash(rm:*)",
"Bash(mv:*)",
"Bash(docker-compose restart:*)",
"Bash(find:*)",
"Bash(docker network:*)",
"Bash(curl:*)",
"Bash(find:*)",
"Bash(openssl x509:*)",
"Bash(cat:*)",
"Bash(openssl dhparam:*)",
"Bash(rg:*)",
"Bash(docker cp:*)",
"Bash(docker-compose:*)",
"Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"href=[''\"\"]/?[''\"\"].*📊 Dashboard\" --type html)",
"Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n 'href=[\"\"\\']/?[\"\\''].*Dashboard'' --type html)",
"Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" --type html)",
"Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/base.html)",
"Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -A5 -B5 \"navbar|nav\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/base.html)",
"Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"href=[''\"\"][/]?(dashboard)?[''\"\"]\" --type html)",
"Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/resources.html)",
"Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"Dashboard\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/profile.html /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/resource_metrics.html)",
"Bash(/home/rac00n/.npm-global/lib/node_modules/@anthropic-ai/claude-code/vendor/ripgrep/x64-linux/rg -n \"BACKUP|LOGIN_2FA_SUCCESS\" /mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/app.py)",
"Bash(sed:*)",
"Bash(python:*)",
"Bash(awk:*)",
"Bash(./backup_before_cleanup.sh:*)",
"Bash(for template in add_resource.html batch_create.html batch_import.html batch_update.html session_history.html session_statistics.html)",
"Bash(do if [ ! -f \"/mnt/c/Users/Administrator/Documents/GitHub/v2-Docker/v2_adminpanel/templates/$template\" ])",
"Bash(then echo \"- $template\")",
"Bash(fi)",
"Bash(done)",
"Bash(docker compose:*)",
"Bash(true)",
"Bash(git checkout:*)",
"Bash(touch:*)",
"Bash(wget:*)",
"Bash(docker inspect:*)",
"Bash(docker run:*)",
"Bash(ping:*)",
"Bash(timeout:*)",
"Bash(nc:*)"
],
"deny": []
}
}

714
API_REFERENCE.md Normale Datei
Datei anzeigen

@ -0,0 +1,714 @@
⎿ # V2-Docker API Reference
## Authentication
### API Key Authentication
All License Server API endpoints require authentication using an API key. The API key must be included in the
request headers.
**Header Format:**
```
X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
**API Key Management:**
- API keys can be managed through the Admin Panel under "Lizenzserver Administration" → "System-API-Key
generieren"
- Keys follow the format: `AF-YYYY-[32 random characters]`
- Only one system API key is active at a time
- Regenerating the key will immediately invalidate the old key
- The initial API key is automatically generated on first startup
- To retrieve the initial API key from database: `SELECT api_key FROM system_api_key WHERE id = 1;`
**Error Response (401 Unauthorized):**
```json
{
"error": "Invalid or missing API key",
"code": "INVALID_API_KEY",
"status": 401
}
```
## License Server API
**Base URL:** `https://api-software-undso.intelsight.de`
### Public Endpoints
#### GET /
Root endpoint - Service status.
**Response:**
```json
{
"status": "ok",
"service": "V2 License Server",
"timestamp": "2025-06-19T10:30:00Z"
}
```
#### GET /health
Health check endpoint.
**Response:**
```json
{
"status": "healthy",
"timestamp": "2025-06-19T10:30:00Z"
}
```
#### GET /metrics
Prometheus metrics endpoint.
**Response:**
Prometheus metrics in CONTENT_TYPE_LATEST format.
### License API Endpoints
All license endpoints require API key authentication via `X-API-Key` header.
#### POST /api/license/activate
Activate a license on a new system.
**Headers:**
```
X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json
```
**Request:**
```json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"hardware_hash": "unique-hardware-identifier",
"machine_name": "DESKTOP-ABC123",
"app_version": "1.0.0"
}
```
**Response:**
```json
{
"message": "License activated successfully",
"activation": {
"id": 123,
"license_key": "XXXX-XXXX-XXXX-XXXX",
"hardware_hash": "unique-hardware-identifier",
"machine_name": "DESKTOP-ABC123",
"activated_at": "2025-06-19T10:30:00Z",
"last_heartbeat": "2025-06-19T10:30:00Z",
"is_active": true
}
}
```
#### POST /api/license/verify
Verify an active license.
**Headers:**
```
X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json
```
**Request:**
```json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"hardware_hash": "unique-hardware-identifier",
"app_version": "1.0.0"
}
```
**Response:**
```json
{
"valid": true,
"message": "License is valid",
"license": {
"key": "XXXX-XXXX-XXXX-XXXX",
"valid_until": "2026-01-01",
"max_users": 10
},
"update_available": false,
"latest_version": "1.0.0"
}
```
#### GET /api/license/info/{license_key}
Get license information.
**Headers:**
```
X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
**Response:**
```json
{
"license": {
"id": 123,
"key": "XXXX-XXXX-XXXX-XXXX",
"customer_name": "ACME Corp",
"type": "perpetual",
"valid_from": "2025-01-01",
"valid_until": "2026-01-01",
"max_activations": 5,
"max_users": 10,
"is_active": true
},
"activations": [
{
"id": 456,
"hardware_hash": "unique-hardware-identifier",
"machine_name": "DESKTOP-ABC123",
"activated_at": "2025-06-19T10:00:00Z",
"last_heartbeat": "2025-06-19T14:30:00Z",
"is_active": true
}
]
}
```
### Session Management API Endpoints
**Note:** Session endpoints require that the client application is configured in the `client_configs` table.
The default client "Account Forger" is pre-configured.
#### POST /api/license/session/start
Start a new session for a license.
**Headers:**
```
X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json
```
**Request:**
```json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"machine_id": "DESKTOP-ABC123",
"hardware_hash": "unique-hardware-identifier",
"version": "1.0.0"
}
```
**Response:**
- 200 OK: Returns session_token and optional update info
- 409 Conflict: "Es ist nur eine Sitzung erlaubt..." (single session enforcement)
#### POST /api/license/session/heartbeat
Keep session alive with heartbeat.
**Headers:**
```
X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json
```
**Request:**
```json
{
"session_token": "550e8400-e29b-41d4-a716-446655440000",
"license_key": "XXXX-XXXX-XXXX-XXXX"
}
```
**Response:** 200 OK with last_heartbeat timestamp
#### POST /api/license/session/end
End an active session.
**Headers:**
```
X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Content-Type: application/json
```
**Request:**
```json
{
"session_token": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Response:** 200 OK with session duration and end reason
### Version API Endpoints
#### POST /api/version/check
Check for available updates.
**Headers:**
```
X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
**Request:**
```json
{
"current_version": "1.0.0",
"license_key": "XXXX-XXXX-XXXX-XXXX"
}
```
**Response:**
```json
{
"update_available": true,
"latest_version": "1.1.0",
"download_url": "https://example.com/download/v1.1.0",
"release_notes": "Bug fixes and performance improvements"
}
```
#### GET /api/version/latest
Get latest version information.
**Headers:**
```
X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
**Response:**
```json
{
"version": "1.1.0",
"release_date": "2025-06-20",
"download_url": "https://example.com/download/v1.1.0",
"release_notes": "Bug fixes and performance improvements"
}
```
## Admin Panel API
**Base URL:** `https://admin-panel-undso.intelsight.de`
### Customer API Endpoints
#### GET /api/customers
Search customers for Select2 dropdown.
**Query Parameters:**
- `q`: Search query
- `page`: Page number (default: 1)
**Response:**
```json
{
"results": [
{
"id": 123,
"text": "ACME Corp - admin@acme.com"
}
],
"pagination": {
"more": false
}
}
```
### License Management API
- `POST /api/license/{id}/toggle` - Toggle active status
- `POST /api/licenses/bulk-activate` - Activate multiple (license_ids array)
- `POST /api/licenses/bulk-deactivate` - Deactivate multiple
- `POST /api/licenses/bulk-delete` - Delete multiple
- `POST /api/license/{id}/quick-edit` - Update validity/limits
- `GET /api/license/{id}/devices` - List registered devices
#### POST /api/license/{license_id}/quick-edit
Quick edit license properties.
**Request:**
```json
{
"valid_until": "2027-01-01",
"max_activations": 10,
"max_users": 50
}
```
**Response:**
```json
{
"success": true,
"message": "License updated successfully"
}
```
#### POST /api/generate-license-key
Generate a new license key.
**Response:**
```json
{
"license_key": "NEW1-NEW2-NEW3-NEW4"
}
```
### Device Management API
#### GET /api/license/{license_id}/devices
Get devices for a license.
**Response:**
```json
{
"devices": [
{
"id": 123,
"hardware_hash": "unique-hardware-identifier",
"machine_name": "DESKTOP-ABC123",
"activated_at": "2025-01-01T10:00:00Z",
"last_heartbeat": "2025-06-19T14:30:00Z",
"is_active": true,
"app_version": "1.0.0"
}
]
}
```
#### POST /api/license/{license_id}/register-device
Register a new device.
**Request:**
```json
{
"hardware_hash": "unique-hardware-identifier",
"machine_name": "DESKTOP-XYZ789",
"app_version": "1.0.0"
}
```
**Response:**
```json
{
"success": true,
"device_id": 456,
"message": "Device registered successfully"
}
```
#### POST /api/license/{license_id}/deactivate-device/{device_id}
Deactivate a device.
**Response:**
```json
{
"success": true,
"message": "Device deactivated successfully"
}
```
### Resource Management API
#### GET /api/license/{license_id}/resources
Get resources for a license.
**Response:**
```json
{
"resources": [
{
"id": 789,
"type": "server",
"identifier": "SRV-001",
"status": "allocated",
"allocated_at": "2025-06-01T10:00:00Z"
}
]
}
```
#### POST /api/resources/allocate
Allocate resources to a license.
**Request:**
```json
{
"license_id": 123,
"resource_ids": [789, 790]
}
```
**Response:**
```json
{
"success": true,
"allocated": 2,
"message": "2 resources allocated successfully"
}
```
#### GET /api/resources/check-availability
Check resource availability.
**Query Parameters:**
- `type`: Resource type
- `count`: Number of resources needed
**Response:**
```json
{
"available": true,
"count": 5,
"resources": [
{
"id": 791,
"type": "server",
"identifier": "SRV-002"
}
]
}
```
### Search API
#### GET /api/global-search
Global search across all entities.
**Query Parameters:**
- `q`: Search query
- `type`: Entity type filter (customer, license, device)
- `limit`: Maximum results (default: 20)
**Response:**
```json
{
"results": [
{
"type": "customer",
"id": 123,
"title": "ACME Corp",
"subtitle": "admin@acme.com",
"url": "/customer/edit/123"
},
{
"type": "license",
"id": 456,
"title": "XXXX-XXXX-XXXX-XXXX",
"subtitle": "ACME Corp - Active",
"url": "/license/edit/456"
}
],
"total": 15
}
```
### Lead Management API
#### GET /leads/api/institutions
Get all institutions with pagination.
**Query Parameters:**
- `page`: Page number (default: 1)
- `per_page`: Items per page (default: 20)
- `search`: Search query
**Response:**
```json
{
"institutions": [
{
"id": 1,
"name": "Tech University",
"contact_count": 5,
"created_at": "2025-06-19T10:00:00Z"
}
],
"total": 100,
"page": 1,
"per_page": 20
}
```
#### POST /leads/api/institutions
Create a new institution.
**Request:**
```json
{
"name": "New University"
}
```
**Response:**
```json
{
"id": 101,
"name": "New University",
"created_at": "2025-06-19T15:00:00Z"
}
```
#### GET /leads/api/contacts/{contact_id}
Get contact details.
**Response:**
```json
{
"id": 1,
"first_name": "John",
"last_name": "Doe",
"position": "IT Manager",
"institution_id": 1,
"details": [
{
"id": 1,
"type": "email",
"value": "john.doe@example.com",
"label": "Work"
},
{
"id": 2,
"type": "phone",
"value": "+49 123 456789",
"label": "Mobile"
}
],
"notes": [
{
"id": 1,
"content": "Initial contact",
"version": 1,
"created_at": "2025-06-19T10:00:00Z",
"created_by": "admin"
}
]
}
```
#### POST /leads/api/contacts/{contact_id}/details
Add contact detail (phone/email).
**Request:**
```json
{
"type": "email",
"value": "secondary@example.com",
"label": "Secondary"
}
```
**Response:**
```json
{
"id": 3,
"type": "email",
"value": "secondary@example.com",
"label": "Secondary"
}
```
### Resource Management API
#### POST /api/resources/allocate
Allocate resources to a license.
**Request:**
```json
{
"license_id": 123,
"resource_type": "domain",
"resource_ids": [45, 46, 47]
}
```
**Response:**
```json
{
"success": true,
"allocated": 3,
"message": "3 resources allocated successfully"
}
```
## Lead Management API
### GET /leads/api/stats
Get lead statistics.
**Response:**
```json
{
"total_institutions": 150,
"total_contacts": 450,
"recent_activities": 25,
"conversion_rate": 12.5,
"by_type": {
"university": 50,
"company": 75,
"government": 25
}
}
```
### Lead Routes (HTML Pages)
- `GET /leads/` - Lead overview page
- `GET /leads/create` - Create lead form
- `POST /leads/create` - Save new lead
- `GET /leads/edit/{lead_id}` - Edit lead form
- `POST /leads/update/{lead_id}` - Update lead
- `POST /leads/delete/{lead_id}` - Delete lead
- `GET /leads/export` - Export leads
- `POST /leads/import` - Import leads
## Common Response Codes
- `200 OK`: Successful request
- `201 Created`: Resource created
- `400 Bad Request`: Invalid request data
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Resource not found
- `409 Conflict`: Resource conflict (e.g., duplicate)
- `429 Too Many Requests`: Rate limit exceeded
- `500 Internal Server Error`: Server error
## Rate Limiting
- API endpoints: 100 requests/minute
- Login attempts: 5 per minute
- Configurable via Admin Panel
## Error Response Format
All errors return JSON with `error`, `code`, and `status` fields.
## Client Integration
Example request with required headers:
```bash
curl -X POST https://api-software-undso.intelsight.de/api/license/activate \
-H "X-API-Key: AF-2025-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"hardware_hash": "unique-hardware-id",
"machine_name": "DESKTOP-123",
"app_version": "1.0.0"
}'
```
## Testing
### Test Credentials
- Admin Users:
- Username: `rac00n` / Password: `1248163264`
- Username: `w@rh@mm3r` / Password: `Warhammer123!`
- API Key: Generated in Admin Panel under "Lizenzserver Administration"
### Getting the Initial API Key
If you need to retrieve the API key directly from the database:
```bash
docker exec -it v2_postgres psql -U postgres -d v2_db -c "SELECT api_key FROM system_api_key WHERE id = 1;"
```
### Test Endpoints
- Admin Panel: `https://admin-panel-undso.intelsight.de/`
- License Server API: `https://api-software-undso.intelsight.de/`

154
CLAUDE.md Normale Datei
Datei anzeigen

@ -0,0 +1,154 @@
# CLAUDE.md - AI Coding Assistant Guidelines
## Core Principles
- **Structured Code First**: Write code that is well-organized from the start to avoid future refactoring
- **YAGNI (You Aren't Gonna Need It)**: Only implement what is currently needed, not what might be needed
## Project Structure
```
v2_adminpanel/
├── routes/ # Blueprint route handlers
├── templates/ # Jinja2 templates
├── utils/ # Utilities
├── leads/ # CRM module (service/repository pattern)
├── core/ # Error handling, logging, monitoring
└── middleware/ # Request processing
```
## Database Schema Reference
### Key Database Tables
Refer to `v2_adminpanel/init.sql` for complete schema. Important tables:
- `license_heartbeats` - Partitioned by month, NO response_time column
- `license_sessions` - Active sessions (UNIQUE per license_id)
- `session_history` - Audit trail with end_reason
- `client_configs` - API configuration for Account Forger
- `system_api_key` - Global API key management
Additional tables: customers, licenses, users, audit_log, lead_*, resource_pools, activations, feature_flags, rate_limits
## Template Parameter Contracts
### error.html
```python
render_template('error.html',
error='Error message', # NOT error_message!
details='Optional details', # Optional
error_code=404, # Optional
request_id='uuid' # Optional
)
```
### Common Template Parameters
- All templates expect `current_user` in session context
- Use `error` not `error_message` for error displays
- Flash messages use categories: 'success', 'error', 'warning', 'info'
## Pre-Implementation Checklist
### Pre-Implementation Checklist
- Check existing routes: `grep -r "route_name" .`
- Verify template parameters match expectations
- Confirm table/column exists in init.sql
- Use RealDictCursor and handle cleanup in finally blocks
- Check leads/ for existing repository methods
### Before Modifying Templates
- [ ] Check which routes use this template
- [ ] Verify all passed parameters are used
- [ ] Maintain consistent styling with existing templates
## Common Patterns
### Error Handling
```python
try:
# operation
except Exception as e:
logger.error(f"Error in operation: {str(e)}")
return render_template('error.html',
error='Specific error message',
details=str(e))
```
### Database Connections
```python
conn = get_db_connection()
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
# queries
conn.commit()
finally:
cur.close()
conn.close()
```
### API Authentication
```python
# Check API key
api_key = request.headers.get('X-API-Key')
if not api_key or not verify_api_key(api_key):
return jsonify({'error': 'Invalid API key'}), 401
```
### Session Management
```python
# For user sessions
if 'user_id' not in session:
return redirect(url_for('auth.login'))
# For 2FA
if session.get('requires_2fa'):
return redirect(url_for('auth.verify_2fa'))
```
## Testing & Verification
### Check Logs
```bash
docker-compose logs admin-panel | tail -50
```
### Verify Container Status
```bash
docker-compose ps
```
### Common Issues to Avoid
1. **Parameter Mismatches**: Verify template expectations (use `error` not `error_message`)
2. **Missing Columns**: Check schema before queries
3. **Creating Unnecessary Files**: Check if functionality exists first
4. **Missing Audit Logs**: Add audit_log entries for important actions
5. **Hardcoded Values**: Use config.py or environment variables
## Docker Environment
Container names: v2_admin_panel, v2_license_server, v2_postgres, v2_redis, v2_rabbitmq, v2_nginx
Public access: Port 80 via Nginx
## Code Style Rules
- NO comments unless explicitly requested
- Follow existing patterns in the codebase
- Use existing utilities before creating new ones
- Maintain consistent error handling
- Always use absolute paths for file operations
## YAGNI Reminders
- Don't add features "for the future"
- Don't create generic solutions for single use cases
- Don't add configuration options that aren't needed now
- Don't abstract code that's only used once
- Implement exactly what's requested, nothing more
## Recent Updates
### June 22, 2025 - 20:26
- Added Lead Management to main navigation (above Ressourcen Pool)
- Created Lead Management dashboard with:
- Overview statistics (institutions, contacts, user attribution)
- Recent activity feed showing who added/edited what
- Quick actions (add institution, view all, export)
- Shared information view between users rac00n and w@rh@mm3r
- Route: `/leads/management` accessible via navbar "Lead Management"
## Last Updated: June 22, 2025

405
CLAUDE_PROJECT_README.md Normale Datei
Datei anzeigen

@ -0,0 +1,405 @@
# v2-Docker
*This README was automatically generated by Claude Project Manager*
## Project Overview
- **Path**: `A:/GiTea/v2-Docker`
- **Files**: 1571 files
- **Size**: 54.8 MB
- **Last Modified**: 2025-07-01 16:22
## Technology Stack
### Languages
- Batch
- C#
- PowerShell
- Python
- Shell
## Project Structure
```
API_REFERENCE.md
backup_before_cleanup.sh
CLAUDE.md
cloud-init.yaml
generate-secrets.py
JOURNAL.md
OPERATIONS_GUIDE.md
PRODUCTION_DEPLOYMENT.md
Start.bat
SYSTEM_DOCUMENTATION.md
backups/
│ ├── backup_v2docker_20250607_174645_encrypted.sql.gz.enc
│ ├── backup_v2docker_20250607_232845_encrypted.sql.gz.enc
│ ├── backup_v2docker_20250608_075834_encrypted.sql.gz.enc
│ ├── backup_v2docker_20250608_174930_encrypted.sql.gz.enc
│ ├── backup_v2docker_20250608_200224_encrypted.sql.gz.enc
│ ├── backup_v2docker_20250616_211330_encrypted.sql.gz.enc
│ ├── backup_v2docker_20250618_020559_encrypted.sql.gz.enc
│ ├── backup_v2docker_20250618_021107_encrypted.sql.gz.enc
│ ├── backup_v2docker_20250618_024414_encrypted.sql.gz.enc
│ └── refactoring_20250616_223724/
│ ├── app.py.backup_20250616_223724
│ ├── blueprint_overview.txt
│ ├── commented_routes.txt
│ ├── git_diff.txt
│ ├── git_log.txt
│ ├── git_status.txt
│ └── v2_adminpanel_backup/
│ ├── app.py
│ ├── app.py.backup
│ ├── app.py.backup_before_blueprint_migration
│ ├── app.py.old
│ ├── app_before_blueprint.py
│ ├── app_new.py
│ ├── app_with_duplicates.py
│ ├── config.py
│ ├── cookies.txt
│ └── create_users_table.sql
lizenzserver/
│ ├── API_DOCUMENTATION.md
│ ├── config.py
│ ├── docker-compose.yaml
│ ├── docker-compose.yml
│ ├── Dockerfile.admin
│ ├── Dockerfile.analytics
│ ├── Dockerfile.auth
│ ├── Dockerfile.license
│ ├── init.sql
│ ├── api/
│ │ └── v1
│ ├── events/
│ │ ├── event_bus.py
│ │ └── __init__.py
│ ├── middleware/
│ │ ├── rate_limiter.py
│ │ └── __init__.py
│ ├── models/
│ │ └── __init__.py
│ ├── repositories/
│ │ ├── base.py
│ │ ├── cache_repo.py
│ │ └── license_repo.py
│ ├── services/
│ │ ├── admin_api/
│ │ │ ├── app.py
│ │ │ └── __init__.py
│ │ ├── analytics/
│ │ │ ├── app.py
│ │ │ └── __init__.py
│ │ ├── auth/
│ │ │ ├── app.py
│ │ │ ├── config.py
│ │ │ ├── Dockerfile
│ │ │ └── requirements.txt
│ │ └── license_api/
│ │ ├── app.py
│ │ ├── Dockerfile
│ │ └── requirements.txt
│ ├── tests
│ └── utils
scripts/
│ ├── reset-to-dhcp.ps1
│ ├── set-static-ip.ps1
│ └── setup-firewall.ps1
SSL/
│ ├── cert.pem
│ ├── chain.pem
│ ├── fullchain.pem
│ ├── privkey.pem
│ └── SSL_Wichtig.md
v2/
│ ├── backup_before_timezone_change.sql
│ ├── cookies.txt
│ ├── docker-compose.yaml
│ └── postgres_data
v2_adminpanel/
│ ├── app.py
│ ├── apply_lead_migration.py
│ ├── apply_license_heartbeats_migration.py
│ ├── apply_partition_migration.py
│ ├── config.py
│ ├── db.py
│ ├── Dockerfile
│ ├── ERROR_HANDLING_GUIDE.md
│ ├── init.sql
│ ├── models.py
│ ├── auth/
│ │ ├── decorators.py
│ │ ├── password.py
│ │ ├── rate_limiting.py
│ │ ├── two_factor.py
│ │ └── __init__.py
│ ├── core/
│ │ ├── error_handlers.py
│ │ ├── exceptions.py
│ │ ├── logging_config.py
│ │ ├── monitoring.py
│ │ ├── validators.py
│ │ └── __init__.py
│ ├── docs
│ ├── leads/
│ │ ├── models.py
│ │ ├── repositories.py
│ │ ├── routes.py
│ │ ├── services.py
│ │ ├── __init__.py
│ │ └── templates
│ ├── middleware/
│ │ ├── error_middleware.py
│ │ └── __init__.py
│ ├── migrations/
│ │ ├── add_device_type.sql
│ │ ├── add_fake_constraint.sql
│ │ ├── add_june_2025_partition.sql
│ │ ├── cleanup_orphaned_api_tables.sql
│ │ ├── create_lead_tables.sql
│ │ ├── create_license_heartbeats_table.sql
│ │ ├── remove_duplicate_api_key.sql
│ │ └── rename_test_to_fake.sql
│ ├── routes/
│ │ ├── admin_routes.py
│ │ ├── api_routes.py
│ │ ├── auth_routes.py
│ │ ├── batch_routes.py
│ │ ├── customer_routes.py
│ │ ├── export_routes.py
│ │ ├── license_routes.py
│ │ ├── monitoring_routes.py
│ │ ├── resource_routes.py
│ │ └── session_routes.py
│ ├── services
│ ├── templates/
│ │ ├── 404.html
│ │ ├── 500.html
│ │ ├── add_resources.html
│ │ ├── audit_log.html
│ │ ├── backups.html
│ │ ├── backup_codes.html
│ │ ├── base.html
│ │ ├── batch_form.html
│ │ ├── batch_result.html
│ │ ├── blocked_ips.html
│ │ ├── api_keys
│ │ ├── devices
│ │ ├── macros
│ │ └── monitoring/
│ │ ├── alerts.html
│ │ ├── analytics.html
│ │ ├── live_dashboard.html
│ │ └── unified_monitoring.html
│ ├── tests/
│ │ ├── test_error_handling.py
│ │ └── __init__.py
│ └── utils/
│ ├── audit.py
│ ├── backup.py
│ ├── export.py
│ ├── license.py
│ ├── network.py
│ ├── partition_helper.py
│ ├── recaptcha.py
│ └── __init__.py
v2_lizenzserver/
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── init_db.py
│ ├── requirements.txt
│ ├── test_api.py
│ ├── app/
│ │ ├── main.py
│ │ ├── api/
│ │ │ ├── license.py
│ │ │ ├── version.py
│ │ │ └── __init__.py
│ │ ├── core/
│ │ │ ├── api_key_auth.py
│ │ │ ├── config.py
│ │ │ ├── metrics.py
│ │ │ └── security.py
│ │ ├── db/
│ │ │ └── database.py
│ │ ├── models/
│ │ │ ├── models.py
│ │ │ └── __init__.py
│ │ ├── schemas/
│ │ │ ├── license.py
│ │ │ └── __init__.py
│ │ └── services
│ ├── client_examples/
│ │ ├── csharp_client.cs
│ │ └── python_client.py
│ └── services/
│ ├── admin/
│ │ ├── app.py
│ │ ├── Dockerfile
│ │ ├── requirements.txt
│ │ └── __init__.py
│ └── analytics/
│ ├── app.py
│ ├── Dockerfile
│ ├── requirements.txt
│ └── __init__.py
v2_nginx/
│ ├── Dockerfile
│ ├── Dockerfile.letsencrypt
│ ├── entrypoint-letsencrypt.sh
│ ├── entrypoint.sh
│ ├── nginx.conf
│ └── ssl/
│ ├── dhparam.pem
│ ├── fullchain.pem
│ ├── privkey.pem
│ └── README.md
v2_postgres/
│ └── Dockerfile
v2_postgreSQL/
│ ├── pg_hba.conf
│ ├── pg_ident.conf
│ ├── PG_VERSION
│ ├── postgresql.auto.conf
│ ├── postgresql.conf
│ ├── postmaster.opts
│ ├── postmaster.pid
│ ├── base/
│ │ ├── 1/
│ │ │ ├── 112
│ │ │ ├── 113
│ │ │ ├── 1247
│ │ │ ├── 1247_fsm
│ │ │ ├── 1247_vm
│ │ │ ├── 1249
│ │ │ ├── 1249_fsm
│ │ │ ├── 1249_vm
│ │ │ ├── 1255
│ │ │ └── 1255_fsm
│ │ ├── 13779/
│ │ │ ├── 112
│ │ │ ├── 113
│ │ │ ├── 1247
│ │ │ ├── 1247_fsm
│ │ │ ├── 1247_vm
│ │ │ ├── 1249
│ │ │ ├── 1249_fsm
│ │ │ ├── 1249_vm
│ │ │ ├── 1255
│ │ │ └── 1255_fsm
│ │ ├── 13780/
│ │ │ ├── 112
│ │ │ ├── 113
│ │ │ ├── 1247
│ │ │ ├── 1247_fsm
│ │ │ ├── 1247_vm
│ │ │ ├── 1249
│ │ │ ├── 1249_fsm
│ │ │ ├── 1249_vm
│ │ │ ├── 1255
│ │ │ └── 1255_fsm
│ │ └── 16384/
│ │ ├── 112
│ │ ├── 113
│ │ ├── 1247
│ │ ├── 1247_fsm
│ │ ├── 1247_vm
│ │ ├── 1249
│ │ ├── 1249_fsm
│ │ ├── 1249_vm
│ │ ├── 1255
│ │ └── 1255_fsm
│ ├── global/
│ │ ├── 1213
│ │ ├── 1213_fsm
│ │ ├── 1213_vm
│ │ ├── 1214
│ │ ├── 1214_fsm
│ │ ├── 1214_vm
│ │ ├── 1232
│ │ ├── 1233
│ │ ├── 1260
│ │ └── 1260_fsm
│ ├── pg_commit_ts
│ ├── pg_dynshmem
│ ├── pg_logical/
│ │ ├── replorigin_checkpoint
│ │ ├── mappings
│ │ └── snapshots
│ ├── pg_multixact/
│ │ ├── members/
│ │ │ └── 0000
│ │ └── offsets/
│ │ └── 0000
│ ├── pg_notify
│ ├── pg_replslot
│ ├── pg_serial
│ ├── pg_snapshots
│ ├── pg_stat
│ ├── pg_stat_tmp/
│ │ ├── db_0.stat
│ │ ├── db_13780.stat
│ │ ├── db_16384.stat
│ │ └── global.stat
│ ├── pg_subtrans/
│ │ └── 0000
│ ├── pg_tblspc
│ ├── pg_twophase
│ ├── pg_wal/
│ │ ├── 000000010000000000000001
│ │ └── archive_status
│ └── pg_xact/
│ └── 0000
v2_testing/
├── test_admin_login.py
├── test_audit_json.py
├── test_audit_log.py
├── test_audit_raw.py
├── test_audit_simple.py
├── test_audit_timezone.py
├── test_customer_management.py
├── test_dashboard.py
├── test_dashboard_detail.py
└── test_export.py
```
## Key Files
- `Dockerfile`
- `requirements.txt`
- `Makefile`
- `README.md`
- `requirements.txt`
- `Dockerfile`
- `requirements.txt`
- `Dockerfile`
- `requirements.txt`
- `Dockerfile`
- `requirements.txt`
- `Dockerfile`
- `requirements.txt`
- `Dockerfile`
- `requirements.txt`
- `Dockerfile`
- `requirements.txt`
- `Dockerfile`
- `README.md`
- `Dockerfile`
## Claude Integration
This project is managed with Claude Project Manager. To work with this project:
1. Open Claude Project Manager
2. Click on this project's tile
3. Claude will open in the project directory
## Notes
*Add your project-specific notes here*
---
## Development Log
- README generated on 2025-07-05 17:50:23

3217
JOURNAL.md Normale Datei

Datei-Diff unterdrückt, da er zu groß ist Diff laden

376
OPERATIONS_GUIDE.md Normale Datei
Datei anzeigen

@ -0,0 +1,376 @@
# V2-Docker Operations Guide
## WICHTIGER HINWEIS
**NICHT VERWENDEN (für <100 Kunden nicht benötigt):**
- ❌ Redis - System verwendet direkte DB-Verbindungen
- ❌ RabbitMQ - System verwendet synchrone Verarbeitung
- ❌ Prometheus/Grafana/Alertmanager - Integrierte Überwachung ist ausreichend
- ❌ Externe Monitoring-Tools - Admin Panel hat alle benötigten Metriken
**NUR DIESE SERVICES VERWENDEN:**
- ✅ PostgreSQL (db)
- ✅ License Server (license-server)
- ✅ Admin Panel (admin-panel)
- ✅ Nginx Proxy (nginx-proxy)
## Deployment
### Prerequisites
- Docker and Docker Compose
- 4GB RAM, 20GB disk
### Initial Setup
```bash
cd v2-Docker
docker-compose up -d
```
Database initializes automatically via init.sql.
### Standard-Zugangsdaten
#### Admin Panel
- URL: https://admin-panel-undso.z5m7q9dk3ah2v1plx6ju.com/
- User 1: `rac00n` / `1248163264`
- User 2: `w@rh@mm3r` / `Warhammer123!`
#### License Server API
- URL: https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com/
- API Key: Wird im Admin Panel unter "Lizenzserver Administration" verwaltet
- Header: `X-API-Key: <api-key>`
### Service Configuration
#### License Server
```yaml
license-server:
build: ./v2_lizenzserver
container_name: license-server
environment:
- DATABASE_URL=postgresql://adminuser:supergeheimespasswort@db:5432/meinedatenbank
- JWT_SECRET=your-secret-jwt-key-here-minimum-32-chars
# NICHT VERWENDEN:
# - REDIS_HOST=redis # NICHT BENÖTIGT
# - RABBITMQ_HOST=rabbitmq # NICHT BENÖTIGT
expose:
- "8443"
networks:
- backend
depends_on:
- db # Nur PostgreSQL wird benötigt
```
#### Admin Panel
```yaml
admin-panel:
build: ./v2_adminpanel
container_name: admin-panel
environment:
- DATABASE_URL=postgresql://adminuser:supergeheimespasswort@db:5432/meinedatenbank
- SECRET_KEY=supersecretkey
- JWT_SECRET=your-secret-jwt-key-here-minimum-32-chars
# NICHT VERWENDEN:
# - REDIS_HOST=redis # NICHT BENÖTIGT
expose:
- "5000"
networks:
- backend
depends_on:
- db # Nur PostgreSQL wird benötigt
volumes:
- ./backups:/app/backups
```
#### Nginx Reverse Proxy
```yaml
nginx:
build: ./v2_nginx
container_name: nginx-proxy
ports:
- "80:80"
networks:
- backend
depends_on:
- admin-panel
- license-server
volumes:
- ./v2_nginx/nginx.conf:/etc/nginx/nginx.conf:ro
# Routing:
# / → admin-panel:5000 (Admin Panel)
# /api → license-server:8000 (API Endpoints)
```
## Monitoring
**WICHTIG**: Externe Monitoring-Tools werden NICHT verwendet! Die folgenden Konfigurationen sind VERALTET und sollten IGNORIERT werden.
### Integrierte Überwachung (Admin Panel)
**HINWEIS**: Externe Monitoring-Tools (Grafana, Prometheus, etc.) werden NICHT verwendet!
Das Admin Panel bietet alle benötigten Überwachungsfunktionen:
1. **Dashboard** (Startseite)
- Aktive Lizenzen
- Aktive Sessions
- Heartbeat-Statistiken
- System-Metriken
2. **Log-Seite**
- Vollständiges Audit-Log aller Aktionen
- Filterbar nach Benutzer, Aktion, Entität
- Export in Excel/CSV
3. **Lizenz-Übersicht**
- Aktive/Inaktive Lizenzen
- Session-Status in Echtzeit
- Letzte Heartbeats
4. **Metriken-Endpoint**
- `/metrics` im License Server für basic monitoring
- Zeigt aktuelle Anfragen, Fehler, etc.
## Features Overview
### Lead Management System
- **UPDATE 22.06.2025**: Jetzt direkt über Navbar "Lead Management" erreichbar
- Lead Management Dashboard unter `/leads/management`
- Gemeinsame Kontaktdatenbank zwischen rac00n und w@rh@mm3r
- Features:
- Dashboard mit Statistiken und Aktivitätsfeed
- Institution management
- Contact persons with multiple phones/emails
- Versioned notes system
- Full audit trail
- Benutzer-Attribution (wer hat was hinzugefügt)
### Resource Pool Management
- Domain allocation system
- IPv4 address management
- Phone number allocation
- Features:
- Resource assignment to licenses
- Quarantine management
- Resource history tracking
- Availability monitoring
### Batch Operations
- Bulk license creation
- Mass updates
- Accessible from Customers & Licenses page
### Monitoring Integration
- Unified monitoring dashboard at `/monitoring`
- Live analytics and metrics
- Alert management interface
- Integrated with Prometheus/Grafana stack
### API Key Management
- Single system-wide API key
- Managed in "Lizenzserver Administration"
- Used for all API authentication
### Session Management
- Single-session enforcement per license
- 30-second heartbeat system
- Automatic session cleanup after 60 seconds
- Session history tracking
## Maintenance
### Database Maintenance
#### Partition Management
```sql
-- Check existing partitions
SELECT tablename FROM pg_tables
WHERE tablename LIKE 'license_heartbeats_%'
ORDER BY tablename;
-- Create future partitions manually
CALL create_monthly_partitions('license_heartbeats', 3);
-- Drop old partitions
DROP TABLE IF EXISTS license_heartbeats_2024_01;
```
#### Backup Procedures
```bash
# Backup
docker exec db pg_dump -U adminuser meinedatenbank | gzip > backup_$(date +%Y%m%d).sql.gz
# Restore
gunzip -c backup_20250619.sql.gz | docker exec -i db psql -U adminuser meinedatenbank
```
##### Integriertes Backup-System
Das Admin Panel bietet ein eingebautes Backup-System:
1. Login ins Admin Panel
2. Navigiere zu "Backups"
3. Klicke "Create Backup"
4. Backups werden verschlüsselt im Verzeichnis `/backups` gespeichert
5. Download oder Restore direkt über die UI
### Log Management
#### Log Locations
##### Logs
- Container logs: `docker logs <container_name>`
- Nginx logs: `./v2_nginx/logs/`
- Audit logs: Database table `audit_log`
#### Log Rotation
```bash
# Configure logrotate
/var/log/license-server/*.log {
daily
rotate 7
compress
delaycompress
notifempty
create 0640 www-data www-data
}
```
### Performance Optimization
#### Database Tuning
- Run `ANALYZE` periodically
- `VACUUM ANALYZE` on large tables
- Maintain partitions: `CALL create_monthly_partitions('license_heartbeats', 3)`
#### Resource Limits
Alle Services haben konfigurierte Resource Limits:
```yaml
# License Server
license-server:
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
# Admin Panel
admin-panel:
deploy:
resources:
limits:
cpus: '1.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
# PostgreSQL
db:
deploy:
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
cpus: '1.0'
memory: 1G
```
## Troubleshooting
### Common Issues
#### License Server Not Responding
- Check status: `docker ps | grep license`
- View logs: `docker logs license-server --tail 100`
- Test health: `docker exec nginx-proxy curl http://license-server:8443/health`
#### Database Connection Issues
- Check status: `docker exec db pg_isready`
- Test connection: Use psql from admin panel container
- Check logs: `docker logs db --tail 50`
#### High Memory Usage
1. Check container stats: `docker stats`
2. Review memory limits in docker-compose.yml
3. Analyze database queries for optimization
4. Consider scaling horizontally
### Health Checks
Quick health check script:
```bash
# All services
docker ps --format "table {{.Names}}\t{{.Status}}"
# Key endpoints
curl -s https://api-software-undso.z5m7q9dk3ah2v1plx6ju.com/health
curl -s http://localhost:9090/-/healthy
```
## Security Considerations
- Strong JWT_SECRET (32+ chars)
- Rotate API keys regularly
- Rate limiting enabled
- Use HTTPS in production
- Strong database passwords
- Keep Docker and images updated
## Scaling Strategies
### Horizontal Scaling
#### Scaling License Server
```bash
# Scale license server instances
docker-compose -f v2/docker-compose.yaml up -d --scale license-server=3
```
#### Nginx Load Balancing Configuration
```nginx
# In nginx.conf
upstream license_servers {
least_conn;
server license-server_1:8443 max_fails=3 fail_timeout=30s;
server license-server_2:8443 max_fails=3 fail_timeout=30s;
server license-server_3:8443 max_fails=3 fail_timeout=30s;
# Health checks
keepalive 32;
}
server {
server_name api-software-undso.z5m7q9dk3ah2v1plx6ju.com;
location / {
proxy_pass http://license_servers;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
```
#### Scaling Considerations (für >100 Kunden)
**HINWEIS**: Für <100 Kunden ist keine Skalierung notwendig!
- Direkter DB-Zugriff ist ausreichend (kein Redis benötigt)
- Synchrone Verarbeitung ist schnell genug (kein RabbitMQ benötigt)
- Single Instance ist völlig ausreichend
### Database Scaling
- Read replicas for reporting
- Connection pooling
- Query optimization
- Partitioning for large tables
## Disaster Recovery
- Daily automated backups via Admin Panel
- Test restore procedures regularly
- Consider database replication for HA
## Monitoring Best Practices
- Configure alerts in Alertmanager
- Review Grafana dashboards regularly
- Monitor resource trends for capacity planning

121
PRODUCTION_DEPLOYMENT.md Normale Datei
Datei anzeigen

@ -0,0 +1,121 @@
# Production Deployment Guide for intelsight.de
## Pre-Deployment Checklist
### 1. Generate Secure Secrets
```bash
python3 generate-secrets.py
```
Save the output securely - you'll need these passwords!
**Note**: The admin panel users (rac00n and w@rh@mm3r) keep their existing passwords as configured in the .env file.
### 2. Configure Environment Files
#### v2/.env
1. Copy the template:
```bash
cp v2/.env.production.template v2/.env
```
2. Replace all `CHANGE_THIS_` placeholders with generated secrets
3. Ensure `PRODUCTION=true` is set
#### v2_lizenzserver/.env
1. Copy the template:
```bash
cp v2_lizenzserver/.env.production.template v2_lizenzserver/.env
```
2. Use the same database password as in v2/.env
3. Set a unique SECRET_KEY from generate-secrets.py
### 3. SSL Certificates
```bash
# Copy your SSL certificates
cp /SSL/fullchain.pem v2_nginx/ssl/
cp /SSL/privkey.pem v2_nginx/ssl/
chmod 644 v2_nginx/ssl/fullchain.pem
chmod 600 v2_nginx/ssl/privkey.pem
# Generate dhparam.pem (this takes a few minutes)
openssl dhparam -out v2_nginx/ssl/dhparam.pem 2048
```
### 4. Verify Configuration
```bash
./verify-deployment.sh
```
## Deployment on Hetzner Server
### 1. Update Deploy Script
On your Hetzner server:
```bash
nano /root/deploy.sh
```
Replace `YOUR_GITHUB_TOKEN` with your actual GitHub token.
### 2. Run Deployment
```bash
cd /root
./deploy.sh
```
### 3. Start Services
```bash
cd /opt/v2-Docker/v2
docker compose up -d
```
### 4. Check Status
```bash
docker compose ps
docker compose logs -f
```
## Post-Deployment
### 1. Create Admin Panel API Key
1. Access https://admin-panel-undso.intelsight.de
2. Login with your admin credentials
3. Go to "Lizenzserver Administration"
4. Generate a new API key for production use
### 2. Test Endpoints
- Admin Panel: https://admin-panel-undso.intelsight.de
- API Server: https://api-software-undso.intelsight.de
### 3. Monitor Logs
```bash
docker compose logs -f admin-panel
docker compose logs -f license-server
```
## Security Notes
1. **Never commit .env files** with real passwords to git
2. **Backup your passwords** securely
3. **Rotate API keys** regularly
4. **Monitor access logs** for suspicious activity
5. **Keep SSL certificates** up to date (expires every 90 days)
## Troubleshooting
### Services won't start
```bash
docker compose down
docker compose up -d
docker compose logs
```
### Database connection issues
- Verify POSTGRES_PASSWORD matches in both .env files
- Check if postgres container is running: `docker compose ps db`
### SSL issues
- Ensure certificates are in v2_nginx/ssl/
- Check nginx logs: `docker compose logs nginx-proxy`
### Cannot access website
- Verify DNS points to your server IP
- Check if ports 80/443 are open: `ss -tlnp | grep -E '(:80|:443)'`
- Check nginx is running: `docker compose ps nginx-proxy`

Datei anzeigen

@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(sudo apt:*)",
"Bash(sudo apt install:*)",
"Bash(apt list:*)",
"Bash(pip install:*)",
"Bash(pip3 install:*)",
"Bash(chmod:*)",
"Bash(sudo cp:*)"
],
"deny": []
}
}

130
SSL/SSL_Wichtig.md Normale Datei
Datei anzeigen

@ -0,0 +1,130 @@
# SSL Zertifikat für intelsight.de - Wichtige Informationen
## Erfolgreich erstelltes Zertifikat
**Erstellungsdatum**: 26. Juni 2025
**Ablaufdatum**: 24. September 2025 (90 Tage)
**E-Mail für Benachrichtigungen**: momohomma@googlemail.com
**Abgedeckte Domains**:
- intelsight.de
- www.intelsight.de
- admin-panel-undso.intelsight.de
- api-software-undso.intelsight.de
## Zertifikatsdateien (in WSL)
- **Zertifikat (Full Chain)**: `/etc/letsencrypt/live/intelsight.de/fullchain.pem`
- **Privater Schlüssel**: `/etc/letsencrypt/live/intelsight.de/privkey.pem`
- **Nur Zertifikat**: `/etc/letsencrypt/live/intelsight.de/cert.pem`
- **Zwischenzertifikat**: `/etc/letsencrypt/live/intelsight.de/chain.pem`
## Komplette Anleitung - So wurde es gemacht
### 1. WSL Installation und Setup
```bash
# In Windows PowerShell WSL starten
wsl
# System aktualisieren
sudo apt update
# Certbot installieren
sudo apt install certbot
# Version prüfen
certbot --version
# Ausgabe: certbot 2.9.0
```
### 2. Certbot DNS Challenge starten
```bash
sudo certbot certonly --manual --preferred-challenges dns --email momohomma@googlemail.com --agree-tos --no-eff-email -d intelsight.de -d www.intelsight.de -d admin-panel-undso.intelsight.de -d api-software-undso.intelsight.de
```
### 3. DNS Challenge Werte sammeln
Certbot zeigt nacheinander 4 DNS Challenges an. **Nach jedem Wert Enter drücken** um den nächsten zu sehen:
1. Enter → Erster Wert erscheint
2. Enter → Zweiter Wert erscheint
3. Enter → Dritter Wert erscheint
4. Enter → Vierter Wert erscheint
5. **STOPP! Noch nicht Enter drücken!**
### 4. DNS Einträge bei IONOS hinzufügen
Bei IONOS anmelden und unter DNS-Einstellungen diese TXT-Einträge hinzufügen:
| Typ | Hostname | Wert | TTL |
|-----|----------|------|-----|
| TXT | `_acme-challenge.admin-panel-undso` | [Wert von Certbot] | 5 Min |
| TXT | `_acme-challenge.api-software-undso` | [Wert von Certbot] | 5 Min |
| TXT | `_acme-challenge` | [Wert von Certbot] | 5 Min |
| TXT | `_acme-challenge.www` | [Wert von Certbot] | 5 Min |
### 5. DNS Einträge verifizieren
**In einem neuen WSL Terminal** prüfen ob die Einträge aktiv sind:
```bash
nslookup -type=TXT _acme-challenge.admin-panel-undso.intelsight.de
nslookup -type=TXT _acme-challenge.api-software-undso.intelsight.de
nslookup -type=TXT _acme-challenge.intelsight.de
nslookup -type=TXT _acme-challenge.www.intelsight.de
```
Wenn alle 4 Einträge die richtigen Werte zeigen, fortfahren.
### 6. Zertifikat generieren
Im Certbot Terminal (wo es wartet) **Enter drücken** zur Verifizierung.
Erfolgreiche Ausgabe:
```
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/intelsight.de/fullchain.pem
Key is saved at: /etc/letsencrypt/live/intelsight.de/privkey.pem
This certificate expires on 2025-09-24.
```
## Zertifikate für späteren Server-Upload kopieren
```bash
# Zertifikate ins Home-Verzeichnis kopieren
sudo cp /etc/letsencrypt/live/intelsight.de/fullchain.pem ~/
sudo cp /etc/letsencrypt/live/intelsight.de/privkey.pem ~/
# Berechtigungen setzen
sudo chmod 644 ~/*.pem
# Dateien anzeigen
ls -la ~/*.pem
```
Die Dateien sind dann unter:
- Windows Pfad: `\\wsl$\Ubuntu\home\[dein-username]\fullchain.pem`
- Windows Pfad: `\\wsl$\Ubuntu\home\[dein-username]\privkey.pem`
## Wichtige Hinweise
1. **Erneuerung**: Das Zertifikat muss alle 90 Tage erneuert werden
2. **Manuelle Erneuerung**: Gleicher Prozess mit DNS Challenge wiederholen
3. **Automatische Erneuerung**: Erst möglich wenn Server läuft
4. **DNS Einträge**: Nach erfolgreicher Zertifikatserstellung können die `_acme-challenge` TXT-Einträge bei IONOS gelöscht werden
## Für den zukünftigen Server
Wenn der Server bereit ist, diese Dateien verwenden:
- `fullchain.pem` - Komplette Zertifikatskette
- `privkey.pem` - Privater Schlüssel (GEHEIM HALTEN!)
### Beispiel Nginx Konfiguration:
```nginx
ssl_certificate /etc/ssl/certs/fullchain.pem;
ssl_certificate_key /etc/ssl/private/privkey.pem;
```
### Beispiel Apache Konfiguration:
```apache
SSLCertificateFile /etc/ssl/certs/fullchain.pem
SSLCertificateKeyFile /etc/ssl/private/privkey.pem
```

23
SSL/cert.pem Normale Datei
Datei anzeigen

@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIID3TCCA2OgAwIBAgISBimcX2wwj3Z1U/Qlfu5y5keoMAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
NjAeFw0yNTA2MjYxNjAwMjBaFw0yNTA5MjQxNjAwMTlaMBgxFjAUBgNVBAMTDWlu
dGVsc2lnaHQuZGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATEQD6vfDoXM7Yz
iT75OmB/kvxoEebMFRBCzpTOdUZpThlFmLijjCsYnxc8DeWDn8/eLltrBWhuM4Yx
gX8tseO0o4ICcTCCAm0wDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUF
BwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBSM5CYyn//CSmLp
JADwjccRtsnZFDAfBgNVHSMEGDAWgBSTJ0aYA6lRaI6Y1sRCSNsjv1iU0jAyBggr
BgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lNi5pLmxlbmNyLm9yZy8w
bgYDVR0RBGcwZYIfYWRtaW4tcGFuZWwtdW5kc28uaW50ZWxzaWdodC5kZYIgYXBp
LXNvZnR3YXJlLXVuZHNvLmludGVsc2lnaHQuZGWCDWludGVsc2lnaHQuZGWCEXd3
dy5pbnRlbHNpZ2h0LmRlMBMGA1UdIAQMMAowCAYGZ4EMAQIBMC0GA1UdHwQmMCQw
IqAgoB6GHGh0dHA6Ly9lNi5jLmxlbmNyLm9yZy80MS5jcmwwggEEBgorBgEEAdZ5
AgQCBIH1BIHyAPAAdgDM+w9qhXEJZf6Vm1PO6bJ8IumFXA2XjbapflTA/kwNsAAA
AZetLYOmAAAEAwBHMEUCIB8bQYn7h64sSmHZavNbIM6ScHDBxmMWN6WqjyaTz75I
AiEArz5mC+TaVMsofIIFkEj+dOMD1/oj6w10zgVunTPb01wAdgCkQsUGSWBhVI8P
1Oqc+3otJkVNh6l/L99FWfYnTzqEVAAAAZetLYRWAAAEAwBHMEUCIFVulS2bEmSQ
HYcE2UbsHhn7WJl8MeWZJSKGG1LbtnvyAiEAsLHL/VyIfXVhOmcMf1gmPL/eu7xj
W/2JuPHVWgjUDhQwCgYIKoZIzj0EAwMDaAAwZQIxANaSy/SOYXq9+oQJNhpXIlMJ
i0HBvwebvhNVkNGJN2QodV5gE2yi4s4q19XkpFO+fQIwCCqLSQvaC+AcOTFT9XL5
6hk8bFapLf/b2EFv3DE06qKIrDVPWhtYwyEYBRT4Ii4p
-----END CERTIFICATE-----

26
SSL/chain.pem Normale Datei
Datei anzeigen

@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEVzCCAj+gAwIBAgIRALBXPpFzlydw27SHyzpFKzgwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCRTYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATZ8Z5G
h/ghcWCoJuuj+rnq2h25EqfUJtlRFLFhfHWWvyILOR/VvtEKRqotPEoJhC6+QJVV
6RlAN2Z17TJOdwRJ+HB7wxjnzvdxEP6sdNgA1O1tHHMWMxCcOrLqbGL0vbijgfgw
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSTJ0aYA6lRaI6Y1sRCSNsj
v1iU0jAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAfYt7SiA1sgWGCIpunk46r4AExIRc
MxkKgUhNlrrv1B21hOaXN/5miE+LOTbrcmU/M9yvC6MVY730GNFoL8IhJ8j8vrOL
pMY22OP6baS1k9YMrtDTlwJHoGby04ThTUeBDksS9RiuHvicZqBedQdIF65pZuhp
eDcGBcLiYasQr/EO5gxxtLyTmgsHSOVSBcFOn9lgv7LECPq9i7mfH3mpxgrRKSxH
pOoZ0KXMcB+hHuvlklHntvcI0mMMQ0mhYj6qtMFStkF1RpCG3IPdIwpVCQqu8GV7
s8ubknRzs+3C/Bm19RFOoiPpDkwvyNfvmQ14XkyqqKK5oZ8zhD32kFRQkxa8uZSu
h4aTImFxknu39waBxIRXE4jKxlAmQc4QjFZoq1KmQqQg0J/1JF8RlFvJas1VcjLv
YlvUB2t6npO6oQjB3l+PNf0DpQH7iUx3Wz5AjQCi6L25FjyE06q6BZ/QlmtYdl/8
ZYao4SRqPEs/6cAiF+Qf5zg2UkaWtDphl1LKMuTNLotvsX99HP69V2faNyegodQ0
LyTApr/vT01YPE46vNsDLgK+4cL6TrzC/a4WcmF5SRJ938zrv/duJHLXQIku5v0+
EwOy59Hdm0PT/Er/84dDV0CSjdR/2XuZM3kpysSKLgD1cKiDA+IRguODCxfO9cyY
Ig46v9mFmBvyH04=
-----END CERTIFICATE-----

49
SSL/fullchain.pem Normale Datei
Datei anzeigen

@ -0,0 +1,49 @@
-----BEGIN CERTIFICATE-----
MIID3TCCA2OgAwIBAgISBimcX2wwj3Z1U/Qlfu5y5keoMAoGCCqGSM49BAMDMDIx
CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJF
NjAeFw0yNTA2MjYxNjAwMjBaFw0yNTA5MjQxNjAwMTlaMBgxFjAUBgNVBAMTDWlu
dGVsc2lnaHQuZGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATEQD6vfDoXM7Yz
iT75OmB/kvxoEebMFRBCzpTOdUZpThlFmLijjCsYnxc8DeWDn8/eLltrBWhuM4Yx
gX8tseO0o4ICcTCCAm0wDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQGCCsGAQUF
BwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBSM5CYyn//CSmLp
JADwjccRtsnZFDAfBgNVHSMEGDAWgBSTJ0aYA6lRaI6Y1sRCSNsjv1iU0jAyBggr
BgEFBQcBAQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly9lNi5pLmxlbmNyLm9yZy8w
bgYDVR0RBGcwZYIfYWRtaW4tcGFuZWwtdW5kc28uaW50ZWxzaWdodC5kZYIgYXBp
LXNvZnR3YXJlLXVuZHNvLmludGVsc2lnaHQuZGWCDWludGVsc2lnaHQuZGWCEXd3
dy5pbnRlbHNpZ2h0LmRlMBMGA1UdIAQMMAowCAYGZ4EMAQIBMC0GA1UdHwQmMCQw
IqAgoB6GHGh0dHA6Ly9lNi5jLmxlbmNyLm9yZy80MS5jcmwwggEEBgorBgEEAdZ5
AgQCBIH1BIHyAPAAdgDM+w9qhXEJZf6Vm1PO6bJ8IumFXA2XjbapflTA/kwNsAAA
AZetLYOmAAAEAwBHMEUCIB8bQYn7h64sSmHZavNbIM6ScHDBxmMWN6WqjyaTz75I
AiEArz5mC+TaVMsofIIFkEj+dOMD1/oj6w10zgVunTPb01wAdgCkQsUGSWBhVI8P
1Oqc+3otJkVNh6l/L99FWfYnTzqEVAAAAZetLYRWAAAEAwBHMEUCIFVulS2bEmSQ
HYcE2UbsHhn7WJl8MeWZJSKGG1LbtnvyAiEAsLHL/VyIfXVhOmcMf1gmPL/eu7xj
W/2JuPHVWgjUDhQwCgYIKoZIzj0EAwMDaAAwZQIxANaSy/SOYXq9+oQJNhpXIlMJ
i0HBvwebvhNVkNGJN2QodV5gE2yi4s4q19XkpFO+fQIwCCqLSQvaC+AcOTFT9XL5
6hk8bFapLf/b2EFv3DE06qKIrDVPWhtYwyEYBRT4Ii4p
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEVzCCAj+gAwIBAgIRALBXPpFzlydw27SHyzpFKzgwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCRTYwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATZ8Z5G
h/ghcWCoJuuj+rnq2h25EqfUJtlRFLFhfHWWvyILOR/VvtEKRqotPEoJhC6+QJVV
6RlAN2Z17TJOdwRJ+HB7wxjnzvdxEP6sdNgA1O1tHHMWMxCcOrLqbGL0vbijgfgw
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSTJ0aYA6lRaI6Y1sRCSNsj
v1iU0jAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAfYt7SiA1sgWGCIpunk46r4AExIRc
MxkKgUhNlrrv1B21hOaXN/5miE+LOTbrcmU/M9yvC6MVY730GNFoL8IhJ8j8vrOL
pMY22OP6baS1k9YMrtDTlwJHoGby04ThTUeBDksS9RiuHvicZqBedQdIF65pZuhp
eDcGBcLiYasQr/EO5gxxtLyTmgsHSOVSBcFOn9lgv7LECPq9i7mfH3mpxgrRKSxH
pOoZ0KXMcB+hHuvlklHntvcI0mMMQ0mhYj6qtMFStkF1RpCG3IPdIwpVCQqu8GV7
s8ubknRzs+3C/Bm19RFOoiPpDkwvyNfvmQ14XkyqqKK5oZ8zhD32kFRQkxa8uZSu
h4aTImFxknu39waBxIRXE4jKxlAmQc4QjFZoq1KmQqQg0J/1JF8RlFvJas1VcjLv
YlvUB2t6npO6oQjB3l+PNf0DpQH7iUx3Wz5AjQCi6L25FjyE06q6BZ/QlmtYdl/8
ZYao4SRqPEs/6cAiF+Qf5zg2UkaWtDphl1LKMuTNLotvsX99HP69V2faNyegodQ0
LyTApr/vT01YPE46vNsDLgK+4cL6TrzC/a4WcmF5SRJ938zrv/duJHLXQIku5v0+
EwOy59Hdm0PT/Er/84dDV0CSjdR/2XuZM3kpysSKLgD1cKiDA+IRguODCxfO9cyY
Ig46v9mFmBvyH04=
-----END CERTIFICATE-----

5
SSL/privkey.pem Normale Datei
Datei anzeigen

@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgi8/a6iwFCHSbBe/I
2Zo6exFpcLL4icRgotOF605ZrY6hRANCAATEQD6vfDoXM7YziT75OmB/kvxoEebM
FRBCzpTOdUZpThlFmLijjCsYnxc8DeWDn8/eLltrBWhuM4YxgX8tseO0
-----END PRIVATE KEY-----

263
SYSTEM_DOCUMENTATION.md Normale Datei
Datei anzeigen

@ -0,0 +1,263 @@
# V2-Docker System Documentation
## WICHTIGER HINWEIS FÜR ZUKÜNFTIGE ENTWICKLUNG
**DIESE SERVICES WERDEN NICHT VERWENDET:**
- ❌ Redis - NICHT BENÖTIGT für <100 Kunden
- RabbitMQ - NICHT BENÖTIGT für <100 Kunden
- Prometheus - NICHT BENÖTIGT
- Grafana - NICHT BENÖTIGT
- Alertmanager - NICHT BENÖTIGT
- Externe Monitoring-Tools - NICHT BENÖTIGT
**Das System verwendet NUR:**
- PostgreSQL für alle Datenspeicherung
- Integrierte Überwachung im Admin Panel
- Direkte Datenbankverbindungen ohne Cache
- Synchrone Verarbeitung ohne Message Queue
## Overview
V2-Docker is a streamlined system featuring a License Server, Admin Panel, and Lead Management with integrated monitoring. This document consolidates all architecture and implementation details.
## License Server Architecture
### Core Principles
- Designed to avoid refactoring
- Microservices architecture
- Hardware-based license binding
- Offline grace period support (7 days)
- Version control with update enforcement
### Core Functionalities
#### 1. License Validation
- Real-time license verification
- Hardware binding (MAC address, CPU ID, system UUID)
- Version compatibility checks
- Usage limit enforcement
#### 2. Activation Management
- Initial activation with hardware fingerprint
- Multi-activation support
- Deactivation capabilities
- Transfer between systems
#### 3. Usage Monitoring
- Active user tracking
- Feature usage statistics
- Heartbeat monitoring (15-minute intervals)
- Historical data analysis
### Microservices Architecture
#### Aktive Services
1. **License Server** (`v2_lizenzserver`) - Core license validation
- Vollständig implementiert
- API-Endpunkte für Aktivierung, Verifizierung, Info
- Läuft auf internem Port über Nginx
2. **Admin Panel** (`v2_adminpanel`) - Web-basierte Verwaltung
- Vollständig implementiert auf Port 80
- Customer, License, Resource Management
- Integrierte Backup-Funktionalität
- Lead Management System
#### Infrastructure Services
- **PostgreSQL** - Main database
- **Redis** - Caching
- **RabbitMQ** - Message queue
- **Nginx** - Reverse proxy
*Note: Analytics, Admin API, and Auth services exist in code but are currently inactive.*
#### Communication
- REST APIs für externe Kommunikation
- Redis für Caching
- RabbitMQ für asynchrone Verarbeitung (vorbereitet)
### Database Schema
See `v2_adminpanel/init.sql` for complete schema.
Key feature: Monthly partitioned `license_heartbeats` table.
### Security Concepts
- JWT-based authentication
- API key management
- Rate limiting (100 requests/minute)
- Hardware fingerprint validation
- Encrypted communication
### Implementation Status (June 22, 2025)
#### Completed
- License Server mit vollständigen API-Endpunkten
- POST /api/license/activate
- POST /api/license/verify
- GET /api/license/info/{license_key}
- POST /api/license/session/start - Session-Initialisierung
- POST /api/license/session/heartbeat - Keep-alive
- POST /api/license/session/end - Session-Beendigung
- POST /api/version/check
- GET /api/version/latest
- Admin Panel mit voller Funktionalität
- Customer Management mit erweiterten Features
- License Management mit Resource Allocation
- Resource Pool Management (Domains, IPs, Telefonnummern)
- Session Management mit Live-Monitor
- Lead Management System (vollständiges CRM)
- Batch Operations für Bulk-Aktionen
- Export/Import Funktionalität
- Device Registration und Management
- API Key Management (System-wide)
- Monitoring Stack (Prometheus, Grafana, Alertmanager)
- Integriertes Monitoring Dashboard
- Vorkonfigurierte Dashboards
- Alert Rules für kritische Metriken
- Docker Services Konfiguration
- JWT/API Key Management
- Backup-System (integriert im Admin Panel)
- 2FA-Authentifizierung
- Audit Logging mit Request IDs
- Rate Limiting (konfigurierbar)
- Single-Session Enforcement (Account Forger)
- Partitionierte Datenbank für Heartbeats
#### Code vorhanden aber nicht aktiviert
- Analytics Service (auskommentiert)
- Admin API Service (auskommentiert)
- Auth Service (auskommentiert)
#### Geplant
- 📋 Notification Service
- 📋 Erweiterte Analytics
- 📋 Machine Learning Integration
## Lead Management System
### Status
**Vollständig implementiert** als Teil des Admin Panels unter `/leads/`
### Update June 22, 2025 - 20:26
- **Neuer Navbar-Eintrag**: "Lead Management" über "Ressourcen Pool"
- **Lead Management Dashboard** unter `/leads/management` mit:
- Übersicht Statistiken (Institutionen, Kontakte, Benutzer-Attribution)
- Aktivitätsfeed zeigt wer was hinzugefügt/bearbeitet hat
- Schnellaktionen (Institution hinzufügen, alle anzeigen, exportieren)
- Geteilte Informationsansicht zwischen rac00n und w@rh@mm3r
### Architecture
- **Modular Architecture**: Clean separation of concerns
- **Service Layer Pattern**: Business logic in `leads/services.py`
- **Repository Pattern**: Data access in `leads/repositories.py`
- **Blueprint Integration**: Routes in `leads/routes.py`
### Data Model (implementiert)
```
lead_institutions
├── lead_contacts (1:n)
│ └── lead_contact_details (1:n) - Telefon/E-Mail
└── lead_notes (1:n) - Versionierte Notizen
```
### Implementierte Features
1. Institution Management (CRUD)
2. Contact Person Management mit mehreren Telefon/E-Mail
3. Notes mit vollständiger Versionierung
4. Flexible Kontaktdetails (beliebig viele pro Person)
5. Audit Trail Integration
6. Service/Repository Pattern für Clean Code
7. JSONB Felder für zukünftige Erweiterungen
### API Endpoints
- GET /leads/ - Institutionen-Übersicht
- GET /leads/institutions - Institutionen-Liste
- POST /leads/institutions - Neue Institution
- GET /leads/institutions/{id} - Institution Details
- PUT /leads/institutions/{id} - Institution bearbeiten
- DELETE /leads/institutions/{id} - Institution löschen
- GET /leads/contacts/{id} - Kontakt Details
- POST /leads/contacts/{id}/details - Kontaktdetail hinzufügen
- PUT /leads/contacts/{id}/details/{detail_id} - Detail bearbeiten
- POST /leads/contacts/{id}/notes - Notiz hinzufügen
## Admin Panel
### Implementierte Features
1. **Authentication & Security**
- Login mit 2FA-Unterstützung
- Session Management
- Rate Limiting
- IP-Blocking bei fehlgeschlagenen Logins
- Audit Logging aller Aktionen
2. **Customer Management**
- CRUD-Operationen für Kunden
- Kundensuche mit Autocomplete
- Kunden-Lizenz-Übersicht
- Quick Stats pro Kunde
3. **License Management**
- Lizenzerstellung (Einzel und Batch)
- Lizenzbearbeitung und -löschung
- Bulk-Operationen (Aktivieren/Deaktivieren)
- Device Management mit Hardware IDs
- Resource Allocation (Domains, IPs, Telefonnummern)
- Quick Edit Funktionalität
- Session Management und Monitoring
- Lizenz-Konfiguration für Account Forger
4. **Monitoring & Analytics**
- Dashboard mit Live-Statistiken
- Lizenzserver-Monitoring
- Session-Überwachung mit Live-Updates
- Resource Pool Monitoring
- Integriertes Monitoring Dashboard (/monitoring)
- Prometheus/Grafana Integration
- Alert Management
5. **System Administration**
- Backup & Restore (manuell und geplant)
- Export-Funktionen (CSV, JSON)
- Audit Log Viewer mit Filterung
- Blocked IPs Management
- Feature Flags Konfiguration
- API Key Generation und Management
- Lizenzserver Administration
- Session-Terminierung durch Admins
### Technical Stack
- Backend: Flask 3.0.3, PostgreSQL
- Frontend: Bootstrap 5.3, jQuery
- Security: bcrypt, pyotp (2FA), JWT
## Deployment Configuration
### Docker Services
#### Aktive Services
- `db`: PostgreSQL database (Port 5432)
- `admin-panel`: Admin interface (interner Port 5000)
- `nginx-proxy`: Reverse proxy (Ports 80, 443)
- `license-server`: License server (interner Port 8443)
#### NICHT VERWENDETE Services (DO NOT USE)
- `redis`: Redis cache - NICHT BENÖTIGT für <100 Kunden
- `rabbitmq`: Message queue - NICHT BENÖTIGT für <100 Kunden
- External monitoring (Prometheus, Grafana, Alertmanager) - NICHT BENÖTIGT
- `monitoring/docker-compose.monitoring.yml` - NICHT VERWENDEN
**WICHTIG**: Das System verwendet KEINE externen Monitoring-Tools, Redis oder RabbitMQ. Die eingebaute Überwachung im Admin Panel ist ausreichend für <100 Kunden.
### Environment Configuration
Required: DATABASE_URL, SECRET_KEY, JWT_SECRET
NOT Required: REDIS_HOST, RABBITMQ_HOST (diese NICHT konfigurieren)
See docker-compose.yaml for all environment variables.
## Current Status
System is production-ready with all core features implemented:
- License management with session enforcement
- Lead management CRM
- Resource pool management
- Integrierte Überwachung (Admin Panel)
- Backup and audit systems

36
Start.bat Normale Datei
Datei anzeigen

@ -0,0 +1,36 @@
@echo off
echo Starting v2-Docker System...
echo.
cd /d "%~dp0"
echo Checking Docker status...
docker version >nul 2>&1
if %errorlevel% neq 0 (
echo ERROR: Docker is not running or not installed!
echo Please start Docker Desktop first.
pause
exit /b 1
)
echo Starting services...
docker-compose -f v2/docker-compose.yaml up -d
echo.
echo Waiting for services to start...
timeout /t 10 /nobreak >nul
echo.
echo Checking service status...
docker-compose -f v2/docker-compose.yaml ps
echo.
echo ========================================
echo Services started successfully!
echo.
echo Admin Panel: http://localhost
echo License API: http://localhost/api
echo ========================================
echo.
echo Press any key to exit...
pause >nul

69
backup_before_cleanup.sh Normale Datei
Datei anzeigen

@ -0,0 +1,69 @@
#!/bin/bash
# Backup-Skript vor dem Cleanup der auskommentierten Routes
# Erstellt ein vollständiges Backup des aktuellen Zustands
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="./backups/refactoring_${TIMESTAMP}"
echo "🔒 Erstelle Backup vor Refactoring-Cleanup..."
echo " Timestamp: ${TIMESTAMP}"
# Backup-Verzeichnis erstellen
mkdir -p "${BACKUP_DIR}"
# 1. Code-Backup
echo "📁 Sichere Code..."
cp -r v2_adminpanel "${BACKUP_DIR}/v2_adminpanel_backup"
# Speziell app.py sichern
cp v2_adminpanel/app.py "${BACKUP_DIR}/app.py.backup_${TIMESTAMP}"
# 2. Git-Status dokumentieren
echo "📝 Dokumentiere Git-Status..."
git status > "${BACKUP_DIR}/git_status.txt"
git log --oneline -10 > "${BACKUP_DIR}/git_log.txt"
git diff > "${BACKUP_DIR}/git_diff.txt"
# 3. Blueprint-Übersicht erstellen
echo "📊 Erstelle Blueprint-Übersicht..."
cat > "${BACKUP_DIR}/blueprint_overview.txt" << EOF
Blueprint Migration Status - ${TIMESTAMP}
==========================================
Blueprints erstellt und registriert:
- auth_bp (9 routes) - Authentication
- admin_bp (10 routes) - Admin Dashboard
- license_bp (4 routes) - License Management
- customer_bp (7 routes) - Customer Management
- resource_bp (7 routes) - Resource Pool
- session_bp (6 routes) - Session Management
- batch_bp (4 routes) - Batch Operations
- api_bp (14 routes) - API Endpoints
- export_bp (5 routes) - Export Functions
Gesamt: 66 Routes in Blueprints
Status:
- Alle Routes aus app.py sind auskommentiert
- Blueprints sind aktiv und funktionsfähig
- Keine aktiven @app.route mehr in app.py
Nächste Schritte:
1. Auskommentierte Routes entfernen
2. Redundante Funktionen bereinigen
3. URL-Präfixe implementieren
EOF
# 4. Route-Mapping erstellen
echo "🗺️ Erstelle Route-Mapping..."
grep -n "# @app.route" v2_adminpanel/app.py > "${BACKUP_DIR}/commented_routes.txt"
# 5. Zusammenfassung
echo ""
echo "✅ Backup erstellt in: ${BACKUP_DIR}"
echo ""
echo "Inhalt:"
ls -la "${BACKUP_DIR}/"
echo ""
echo "🎯 Nächster Schritt: Auskommentierte Routes können jetzt sicher entfernt werden"
echo " Rollback möglich mit: cp ${BACKUP_DIR}/app.py.backup_${TIMESTAMP} v2_adminpanel/app.py"

1
backups/.backup_key Normale Datei
Datei anzeigen

@ -0,0 +1 @@
vJgDckVjr3cSictLNFLGl8QIfqSXVD5skPU7kVhkyfc=

Datei anzeigen

@ -0,0 +1 @@
gAAAAABoRHsFDJi5AsC1qRqcqnIM8eqtRWIwuHF7n2IL5DTz2myp3zVWmN3KmHNHO3pxV4Zf3DSWalPWCT45Ie-KapLGXdApCjDKFIBsTGlEStAxLx5UQPCTknCy0tqcw_osXjdCU1tE3YfLi6MRmJHFOClmipW0RVSDIoN8BBV8uex4rc10LZ79V1_UZ1pUjatSqjQW-WMOTdN3KcECW8MstAhp0JJG_AoKTZU8Px_kn-1wrQCyf0NIgcMFg4raEBsJ3290jRoHYdVs_89uei3xZAoyCfK1l2cvp0AQUKIC3RionZWqYxt420vmMYbninosyIHYKDmDj1xsPRWVZ4PLs6LPGrYY_AhHHj4011HSJmqG0kCfXmYqjTXlZQ9FHiPYze5mOayMJaCOQWDhkDghzKpW9Z3PHgfiEPKz-95soSeYdbICO_B7I4BTzjlNejBbV0iRZPYzkgX11QOQE1p268hRRLjl6PFOOPzBV1ectqpLuYdMuidaa243UmN-PjIfGOiAZrRCKsKbXF8wUmnCPlfLIT74PZo5YVLJiPPKn63qlLvRyZPn96WHdJF6sW4xOn5pxKn0wAtyg-Qp2RKwjg-W8a3RqhXfamNQvkR6w5cRZSgrbPjIuaPBE7Im-IWn2A-WjnPY8KqzoJDRmFpaeeKLMBGoQ6U6uYXjSXbmS2wmwSR3rmROcbsuIubTWpNakM8QbT3egfuFShWs68he4Gr8wM-mtddxci9HSlDTufqRRLgg74_1-0So94qRn6fR47zgMXF7sS0dQVUe_X7o73xirwECI_BQQe415OjeDI086PyNmGD9DBO9oARvIXcamT0Mxv5lJhCLFjT6vtASTGlNSxdKmwKdu5yEesMLPzatx0-tNf8YSFaYLFlczNIpEkuqKo04qlNbSYEunHIc8AFzK3WRurNrBvTbrYZ5cZHM2sh-nzNCxWlfhITodck0qt-aMr0XgRLkJ-Q7uzUfmsS2TKUsE5cJE2V1ibpHCHpzAAiWckUrH1LBirSGUSJGlgOP6hZqztUlYv46wNVHLs924HUTtBsUrHLCIBslgsUXR8SM_xBVXXhoFu_QZLMSPV63_HsOAhJr0U8Pwg0cu7S5OY03ZO4Ehpevqo8O-DLtKgrm3TOC_S44objadaBLnJUbP5KoZIRZNxqu_MO2kQKFT4_fcaTgFgz3nf6ztqxkBXMqQ8FeEm7IcfgQEcY52JI0jomkN_KFqp0aMa9P9pkcN-ZCi7ipzgjoJnJGt1mKxWM5uHn-eksD-zyhmw8LNyoOxPQv0b0MMq--5WGD1I0ylw7HuG-ZLt1G3KV3PqruZFsn3_as71yB1011Kr4iBNeDZfsd0IfC-VMUjPE1KBipy-zgtWN1244gGM8rfz8Fp_3_FXduE6ckYSXKbvCaZmcbUa537H_n0Eq0u6iG6-ZuhSV-ll0dv68T5LF8mH71HO_4fXMyjL5bf8CsqgL16F_EJHW25ljGM84XsXcJtVVTTPFfjwvDliQU07Sd1yDFhKUKD61DE0D74V4JJaTebg6HvtqFJ2cShAXDeI9WUMzdTmnS1vRSnED_5-ag-O3vgQYYbinJugGmj5W8J1r1b6OXD3Lok6DuqRKzPCYM5GJBVTUKZjCRzxkXTsbVogvNWLV8rRS5iC-hQgzwImuDi4tzMKX2k7Q5jGIuiCwNCu7t5QFe6zpDeR1hvrNfjHIwahQvUIIzInv2LwjRkG5S8eWpNBImLCYkyobxBRZsd24OSKxD8KIdMJqg9P3GlSgskopwsWiLEoLbWC9CuaS303aAgYjo3czq8Bx2QKrSZtI0uXyouNAc5P9t-Y2RSfloQh_TyVJij6LwCLgIHdoX4N-_OQjVCtxOqvem6PPMYoyjvh89bcrcIzBNke-z3nn8OK_4uTzzm9z3_OHMHK6TxbxaR4XNMogWWkQPZD2pePw73iDY4H7b8iXrD3zE0QT9F_yQWnqAfotudcdXDFe3c4-U6WHNJZ4XhkCcok2Kebi-pvQEkkv5YSi8sPC7jBx2O8qJp-VYCqQhSncTzZRyXt-ZCtvfHtZe4g9wEKmtf4jySJyW87YLe-fkmyP3kGaYCqAgCt6ieeQxG-n7Q_THwRTcHTxDn08YLukYoa5hFwurkn0LZmfcF7T0FRQqs-n_Y3IDqEK-32lBFwbajfvsPpZs_JQe7LFOOin4JhwCuZRKoStQNUBKtwEaxvXxayEKx5c2nJogshl284EyTdJesDujb1PMW1w-TyQDj0y0Bts41-fpIkCUxPl4gXXEP9J38LP-rg8Qh2HbSDHo-qSHO-PyHtSV6BQIVHQE9JUvEEa2OO35QY3cjMP3tyY_z7I2dOYGB4K9_LmmYQkn6Q1J3YDB646B4k9nQBnE8CJVAzTD5p2b6CrJm-r5GS3qC8e4EoJLg9-Gec3m-pjVo9E3ZToSDQ1Upf4Ej82YmIyy8r6aWrH_ztJ9uAwv63_osh3QAaWFcesBzIy9TnN_n2VzzIcP13RgAqwQZBhVGaXyfCZvUQNWo6vFOwgzvcDnt_ECYH-quSrS-g_Tdo1X9mv7SllMPmDZ1YEv0Szyr82tkP9NLBLdq8hZJ92kkrCahGAVgPEhlCIfJap3HOa22ezPVafmvxv8tc8qp9pUIySn5pmKK2YVL31lnCBa0IOTED0dlfyANKWJCregAf0ZpR0z57rqaG3nYDzN1f1sgCHdcTsyNR1revwlRr5XNd66PDu5_sA7-iqH2XGiqvoVfUIPz9mi7Zf4CbQW_gb1yyN4LrBQUq-fjn0xJMlkgJUHzVOcZo30IOSN_61sU1dLiJJNIvp_utwxD72zpOyxrpOzeytWBgKGpWmFhc21vUQar24m_dG9E4FmEULM6Nzep7y0dxgA4baQ5zoaYUTdLO8grxUlJ0PDoV9vldsXQ0sgwh4ioB7JcYh1WlKUTcQ-EPrhvh8lRc1WWjNnOnkjoRy1QFRnLk3peqxIkR7iMZRfwCAD46906g-TAiJWX7MAIOHyFEug7lh1jYMaHb_XfS5Uxzqj_YCfPW3KiPjyw_Bt5_NJucYwgEprD70VnvmC-39v3-hIxOCYQMMJdSGeY0omKH4PytVCkXAlM6HQqhY-Xt54-ZO6iVwrotHsH5oDgZ30Q9TT699V8XqoaKAP6ZgAfuiPzUubzJm6UhX9W6sRqZfgwxkb8rWgdMede80_ggxJkxV5fYsBw9hhRGwx6nCkuXL9hdTN_TFPilndna-AlSfwendm8Fh7YvIU7A-o4UXPwAVHDMwrlMfFsR-brEgZOvNZJgDd_LDYDLiWly5dvDUQ80L5erSFtsLCXTZB2TKrpROk1gEVyYR_B6Wf08A40MoKRLUpBAYW7B_Io4IBukAzTdkZp37rHJkpZB3gdmPpliLUpq5mh4lzQGZT-LyFAI-sbEWWNRtt-y5hs0S4VqIo9OO7JqmWuNScOPucmZnApfc1NzPA_2gxzd8kYDbtgjw0QD_WF0UTzgiRQK7Y80gzF-pJdeJGr6tfkSN0zTfxcLRBHhRM0rJe0933-V5V33pvMj1l66pt_5pHV4ZByXCNZJq4hd-TN8Al95VPox8qWK_2THyVuzRzX8BT0acEmsjDHWdZPNIlFdfMj7effacQSacxXTabiGrpB5-3sLXiQZoV94i8PNRG6ru9MFTxij42skadY-d3B5TVxLxifoyz2BJaWiLnKYzw405qCZdFIkzUQxUvrv6HO2ppGcZ7przRwk9wuxhBtT87G8vQR7y2ZZz2uqezrPPEs1nVlWXb-V9plqJjTmDVMgvRzkrVwGTLn0iBLrdjuJGoBUV8UV_S7HUkz6QQc9Apkqye7QIm1LUlEJ47s6jVkV3qQTrLu6b9OSJlUvK7SsFikNcCnKiqtHm1W679aOEaNKWZj6XhDDEaGDZqM6tq1mrTX8oUQdsXp31D8YbcftMzFBhyVLsXacL_1bkEPcwmfMoERuMPevCXcNk2KktJCu4t3z_ivC_bl-6jj47oMl0nau3Ug4bGe4jTD4TpUDlz6aLHEGJwaHaNuz9WbP0NUPaaEeWhCsrRyocOAeQYqDYI-jr0rTBGUPLrm74Tn18Hf3tD5KW5OdWOyeBF

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@ -0,0 +1,25 @@
Blueprint Migration Status - 20250616_223724
==========================================
Blueprints erstellt und registriert:
- auth_bp (9 routes) - Authentication
- admin_bp (10 routes) - Admin Dashboard
- license_bp (4 routes) - License Management
- customer_bp (7 routes) - Customer Management
- resource_bp (7 routes) - Resource Pool
- session_bp (6 routes) - Session Management
- batch_bp (4 routes) - Batch Operations
- api_bp (14 routes) - API Endpoints
- export_bp (5 routes) - Export Functions
Gesamt: 66 Routes in Blueprints
Status:
- Alle Routes aus app.py sind auskommentiert
- Blueprints sind aktiv und funktionsfähig
- Keine aktiven @app.route mehr in app.py
Nächste Schritte:
1. Auskommentierte Routes entfernen
2. Redundante Funktionen bereinigen
3. URL-Präfixe implementieren

Datei anzeigen

@ -0,0 +1,60 @@
153:# @app.route("/login", methods=["GET", "POST"])
267:# @app.route("/logout")
279:# @app.route("/verify-2fa", methods=["GET", "POST"])
358:# @app.route("/profile")
368:# @app.route("/profile/change-password", methods=["POST"])
406:# @app.route("/profile/setup-2fa")
426:# @app.route("/profile/enable-2fa", methods=["POST"])
464:# @app.route("/profile/disable-2fa", methods=["POST"])
491:# @app.route("/heartbeat", methods=['POST'])
506:# @app.route("/api/generate-license-key", methods=['POST'])
551:# @app.route("/api/customers", methods=['GET'])
662:# @app.route("/")
892:# @app.route("/create", methods=["GET", "POST"])
1123:# @app.route("/batch", methods=["GET", "POST"])
1378:# @app.route("/batch/export")
1417:# @app.route("/licenses")
1423:# @app.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
1515:# @app.route("/license/delete/<int:license_id>", methods=["POST"])
1548:# @app.route("/customers")
1554:# @app.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
1638:# @app.route("/customer/create", methods=["GET", "POST"])
1693:# @app.route("/customer/delete/<int:customer_id>", methods=["POST"])
1731:# @app.route("/customers-licenses")
1824:# @app.route("/api/customer/<int:customer_id>/licenses")
1927:# @app.route("/api/customer/<int:customer_id>/quick-stats")
1960:# @app.route("/api/license/<int:license_id>/quick-edit", methods=['POST'])
2030:# @app.route("/api/license/<int:license_id>/resources")
2080:# @app.route("/sessions")
2162:# @app.route("/session/end/<int:session_id>", methods=["POST"])
2181:# @app.route("/export/licenses")
2291:# @app.route("/export/audit")
2415:# @app.route("/export/customers")
2519:# @app.route("/export/sessions")
2658:# @app.route("/export/resources")
2787:# @app.route("/audit")
2881:# @app.route("/backups")
2916:# @app.route("/backup/create", methods=["POST"])
2934:# @app.route("/backup/restore/<int:backup_id>", methods=["POST"])
2953:# @app.route("/backup/download/<int:backup_id>")
2985:# @app.route("/backup/delete/<int:backup_id>", methods=["DELETE"])
3041:# @app.route("/security/blocked-ips")
3082:# @app.route("/security/unblock-ip", methods=["POST"])
3108:# @app.route("/security/clear-attempts", methods=["POST"])
3124:# @app.route("/api/license/<int:license_id>/toggle", methods=["POST"])
3156:# @app.route("/api/licenses/bulk-activate", methods=["POST"])
3192:# @app.route("/api/licenses/bulk-deactivate", methods=["POST"])
3228:# @app.route("/api/license/<int:license_id>/devices")
3283:# @app.route("/api/license/<int:license_id>/register-device", methods=["POST"])
3398:# @app.route("/api/license/<int:license_id>/deactivate-device/<int:device_id>", methods=["POST"])
3440:# @app.route("/api/licenses/bulk-delete", methods=["POST"])
3485:# @app.route('/resources')
3625:# @app.route('/resources/add', methods=['GET', 'POST'])
3689:# @app.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
3747:# @app.route('/resources/release', methods=['POST'])
3798:# @app.route('/api/resources/allocate', methods=['POST'])
3946:# @app.route('/api/resources/check-availability', methods=['GET'])
4005:# @app.route('/api/global-search', methods=['GET'])
4068:# @app.route('/resources/history/<int:resource_id>')
4155:# @app.route('/resources/metrics')
4319:# @app.route('/resources/report', methods=['GET'])

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@ -0,0 +1,10 @@
4915513 Refactoring - Part 1
29b302a Refactoring - Part1
262de28 lizenzserver
ff93520 Zuweisung über Kunden & Lizenzen geht
13e1386 Ressource Sort gefixt
b18fb49 Testressource Checkbox Fix
d65e5d3 Export und Aktion gefixt
df60ce6 Ressourcen bei Kunden&Lizenzen ist richtig
a878d9b Gerätelimit drin
4b66d8b Zurück zur Übersicht Button

Datei anzeigen

@ -0,0 +1,38 @@
On branch main
Your branch is up to date with 'origin/main'.
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: .claude/settings.local.json
modified: JOURNAL.md
modified: v2/.env
modified: v2/docker-compose.yaml
modified: v2_adminpanel/Dockerfile
modified: v2_adminpanel/__pycache__/app.cpython-312.pyc
modified: v2_adminpanel/app.py
modified: v2_adminpanel/app.py.backup
modified: v2_adminpanel/app.py.old
deleted: v2_adminpanel/comment_routes.py
modified: v2_adminpanel/init.sql
modified: v2_adminpanel/requirements.txt
modified: v2_adminpanel/templates/create_customer.html
modified: v2_adminpanel/templates/index.html
modified: v2_nginx/nginx.conf
Untracked files:
(use "git add <file>..." to include in what will be committed)
backup_before_cleanup.sh
backups/refactoring_20250616_223724/
refactoring.md
v2_adminpanel/app.py.backup_before_blueprint_migration
v2_adminpanel/routes/api_routes.py
v2_adminpanel/routes/batch_routes.py
v2_adminpanel/routes/customer_routes.py
v2_adminpanel/routes/export_routes.py
v2_adminpanel/routes/license_routes.py
v2_adminpanel/routes/resource_routes.py
v2_adminpanel/routes/session_routes.py
v2_adminpanel/test_blueprint_routes.py
no changes added to commit (use "git add" and/or "git commit -a")

Datei anzeigen

@ -0,0 +1,33 @@
FROM python:3.11-slim
# Locale für deutsche Sprache und UTF-8 setzen
ENV LANG=de_DE.UTF-8
ENV LC_ALL=de_DE.UTF-8
ENV PYTHONIOENCODING=utf-8
# Zeitzone auf Europe/Berlin setzen
ENV TZ=Europe/Berlin
WORKDIR /app
# System-Dependencies inkl. PostgreSQL-Tools installieren
RUN apt-get update && apt-get install -y \
locales \
postgresql-client \
tzdata \
&& sed -i '/de_DE.UTF-8/s/^# //g' /etc/locale.gen \
&& locale-gen \
&& update-locale LANG=de_DE.UTF-8 \
&& ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \
&& echo "Europe/Berlin" > /etc/timezone \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei-Diff unterdrückt, da er zu groß ist Diff laden

Datei anzeigen

@ -0,0 +1,124 @@
import os
import time
import json
import logging
import requests
from io import BytesIO
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from pathlib import Path
from flask import Flask, render_template, request, redirect, session, url_for, send_file, jsonify, flash
from flask_session import Session
from werkzeug.middleware.proxy_fix import ProxyFix
from apscheduler.schedulers.background import BackgroundScheduler
import pandas as pd
from psycopg2.extras import Json
# Import our new modules
import config
from db import get_connection, get_db_connection, get_db_cursor, execute_query
from auth.decorators import login_required
from auth.password import hash_password, verify_password
from auth.two_factor import (
generate_totp_secret, generate_qr_code, verify_totp,
generate_backup_codes, hash_backup_code, verify_backup_code
)
from auth.rate_limiting import (
get_client_ip, check_ip_blocked, record_failed_attempt,
reset_login_attempts, get_login_attempts
)
from utils.audit import log_audit
from utils.license import generate_license_key, validate_license_key
from utils.backup import create_backup, restore_backup, get_or_create_encryption_key
from utils.export import (
create_excel_export, format_datetime_for_export,
prepare_license_export_data, prepare_customer_export_data,
prepare_session_export_data, prepare_audit_export_data
)
from models import get_user_by_username
app = Flask(__name__)
# Load configuration from config module
app.config['SECRET_KEY'] = config.SECRET_KEY
app.config['SESSION_TYPE'] = config.SESSION_TYPE
app.config['JSON_AS_ASCII'] = config.JSON_AS_ASCII
app.config['JSONIFY_MIMETYPE'] = config.JSONIFY_MIMETYPE
app.config['PERMANENT_SESSION_LIFETIME'] = config.PERMANENT_SESSION_LIFETIME
app.config['SESSION_COOKIE_HTTPONLY'] = config.SESSION_COOKIE_HTTPONLY
app.config['SESSION_COOKIE_SECURE'] = config.SESSION_COOKIE_SECURE
app.config['SESSION_COOKIE_SAMESITE'] = config.SESSION_COOKIE_SAMESITE
app.config['SESSION_COOKIE_NAME'] = config.SESSION_COOKIE_NAME
app.config['SESSION_REFRESH_EACH_REQUEST'] = config.SESSION_REFRESH_EACH_REQUEST
Session(app)
# ProxyFix für korrekte IP-Adressen hinter Nginx
app.wsgi_app = ProxyFix(
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1
)
# Configuration is now loaded from config module
# Scheduler für automatische Backups
scheduler = BackgroundScheduler()
scheduler.start()
# Logging konfigurieren
logging.basicConfig(level=logging.INFO)
# Scheduled Backup Job
def scheduled_backup():
"""Führt ein geplantes Backup aus"""
logging.info("Starte geplantes Backup...")
create_backup(backup_type="scheduled", created_by="scheduler")
# Scheduler konfigurieren - täglich um 3:00 Uhr
scheduler.add_job(
scheduled_backup,
'cron',
hour=config.SCHEDULER_CONFIG['backup_hour'],
minute=config.SCHEDULER_CONFIG['backup_minute'],
id='daily_backup',
replace_existing=True
)
def verify_recaptcha(response):
"""Verifiziert die reCAPTCHA v2 Response mit Google"""
secret_key = config.RECAPTCHA_SECRET_KEY
# Wenn kein Secret Key konfiguriert ist, CAPTCHA als bestanden werten (für PoC)
if not secret_key:
logging.warning("RECAPTCHA_SECRET_KEY nicht konfiguriert - CAPTCHA wird übersprungen")
return True
# Verifizierung bei Google
try:
verify_url = 'https://www.google.com/recaptcha/api/siteverify'
data = {
'secret': secret_key,
'response': response
}
# Timeout für Request setzen
r = requests.post(verify_url, data=data, timeout=5)
result = r.json()
# Log für Debugging
if not result.get('success'):
logging.warning(f"reCAPTCHA Validierung fehlgeschlagen: {result.get('error-codes', [])}")
return result.get('success', False)
except requests.exceptions.RequestException as e:
logging.error(f"reCAPTCHA Verifizierung fehlgeschlagen: {str(e)}")
# Bei Netzwerkfehlern CAPTCHA als bestanden werten
return True
except Exception as e:
logging.error(f"Unerwarteter Fehler bei reCAPTCHA: {str(e)}")
return False
# Now copy all the route handlers from the original file
# Starting from line 693...

Datei anzeigen

@ -0,0 +1 @@
# Auth module initialization

Datei anzeigen

@ -0,0 +1,44 @@
from functools import wraps
from flask import session, redirect, url_for, flash, request
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import logging
from utils.audit import log_audit
logger = logging.getLogger(__name__)
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'logged_in' not in session:
return redirect(url_for('login'))
# Check if session has expired
if 'last_activity' in session:
last_activity = datetime.fromisoformat(session['last_activity'])
time_since_activity = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) - last_activity
# Debug logging
logger.info(f"Session check for {session.get('username', 'unknown')}: "
f"Last activity: {last_activity}, "
f"Time since: {time_since_activity.total_seconds()} seconds")
if time_since_activity > timedelta(minutes=5):
# Session expired - Logout
username = session.get('username', 'unbekannt')
logger.info(f"Session timeout for user {username} - auto logout")
# Audit log for automatic logout (before session.clear()!)
try:
log_audit('AUTO_LOGOUT', 'session',
additional_info={'reason': 'Session timeout (5 minutes)', 'username': username})
except:
pass
session.clear()
flash('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning')
return redirect(url_for('login'))
# Activity is NOT automatically updated
# Only on explicit user actions (done by heartbeat)
return f(*args, **kwargs)
return decorated_function

Datei anzeigen

@ -0,0 +1,11 @@
import bcrypt
def hash_password(password):
"""Hash a password using bcrypt"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verify_password(password, hashed):
"""Verify a password against its hash"""
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))

Datei anzeigen

@ -0,0 +1,124 @@
import random
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import request
from db import execute_query, get_db_connection, get_db_cursor
from config import FAIL_MESSAGES, MAX_LOGIN_ATTEMPTS, BLOCK_DURATION_HOURS, EMAIL_ENABLED
from utils.audit import log_audit
from utils.network import get_client_ip
logger = logging.getLogger(__name__)
def check_ip_blocked(ip_address):
"""Check if an IP address is blocked"""
result = execute_query(
"""
SELECT blocked_until FROM login_attempts
WHERE ip_address = %s AND blocked_until IS NOT NULL
""",
(ip_address,),
fetch_one=True
)
if result and result[0]:
if result[0] > datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None):
return True, result[0]
return False, None
def record_failed_attempt(ip_address, username):
"""Record a failed login attempt"""
# Random error message
error_message = random.choice(FAIL_MESSAGES)
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
try:
# Check if IP already exists
cur.execute("""
SELECT attempt_count FROM login_attempts
WHERE ip_address = %s
""", (ip_address,))
result = cur.fetchone()
if result:
# Update existing entry
new_count = result[0] + 1
blocked_until = None
if new_count >= MAX_LOGIN_ATTEMPTS:
blocked_until = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None) + timedelta(hours=BLOCK_DURATION_HOURS)
# Email notification (if enabled)
if EMAIL_ENABLED:
send_security_alert_email(ip_address, username, new_count)
cur.execute("""
UPDATE login_attempts
SET attempt_count = %s,
last_attempt = CURRENT_TIMESTAMP,
blocked_until = %s,
last_username_tried = %s,
last_error_message = %s
WHERE ip_address = %s
""", (new_count, blocked_until, username, error_message, ip_address))
else:
# Create new entry
cur.execute("""
INSERT INTO login_attempts
(ip_address, attempt_count, last_username_tried, last_error_message)
VALUES (%s, 1, %s, %s)
""", (ip_address, username, error_message))
conn.commit()
# Audit log
log_audit('LOGIN_FAILED', 'user',
additional_info=f"IP: {ip_address}, User: {username}, Message: {error_message}")
except Exception as e:
logger.error(f"Rate limiting error: {e}")
conn.rollback()
return error_message
def reset_login_attempts(ip_address):
"""Reset login attempts for an IP"""
execute_query(
"DELETE FROM login_attempts WHERE ip_address = %s",
(ip_address,)
)
def get_login_attempts(ip_address):
"""Get the number of login attempts for an IP"""
result = execute_query(
"SELECT attempt_count FROM login_attempts WHERE ip_address = %s",
(ip_address,),
fetch_one=True
)
return result[0] if result else 0
def send_security_alert_email(ip_address, username, attempt_count):
"""Send a security alert email"""
subject = f"⚠️ SICHERHEITSWARNUNG: {attempt_count} fehlgeschlagene Login-Versuche"
body = f"""
WARNUNG: Mehrere fehlgeschlagene Login-Versuche erkannt!
IP-Adresse: {ip_address}
Versuchter Benutzername: {username}
Anzahl Versuche: {attempt_count}
Zeit: {datetime.now(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%d %H:%M:%S')}
Die IP-Adresse wurde für 24 Stunden gesperrt.
Dies ist eine automatische Nachricht vom v2-Docker Admin Panel.
"""
# TODO: Email sending implementation when SMTP is configured
logger.warning(f"Sicherheitswarnung: {attempt_count} fehlgeschlagene Versuche von IP {ip_address}")
print(f"E-Mail würde gesendet: {subject}")

Datei anzeigen

@ -0,0 +1,57 @@
import pyotp
import qrcode
import random
import string
import hashlib
from io import BytesIO
import base64
def generate_totp_secret():
"""Generate a new TOTP secret"""
return pyotp.random_base32()
def generate_qr_code(username, totp_secret):
"""Generate QR code for TOTP setup"""
totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
name=username,
issuer_name='V2 Admin Panel'
)
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(totp_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return base64.b64encode(buf.getvalue()).decode()
def verify_totp(totp_secret, token):
"""Verify a TOTP token"""
totp = pyotp.TOTP(totp_secret)
return totp.verify(token, valid_window=1)
def generate_backup_codes(count=8):
"""Generate backup codes for 2FA recovery"""
codes = []
for _ in range(count):
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
codes.append(code)
return codes
def hash_backup_code(code):
"""Hash a backup code for storage"""
return hashlib.sha256(code.encode()).hexdigest()
def verify_backup_code(code, hashed_codes):
"""Verify a backup code against stored hashes"""
code_hash = hashlib.sha256(code.encode()).hexdigest()
return code_hash in hashed_codes

Datei anzeigen

@ -0,0 +1,64 @@
import os
from datetime import timedelta
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# Flask Configuration
SECRET_KEY = os.urandom(24)
SESSION_TYPE = 'filesystem'
JSON_AS_ASCII = False
JSONIFY_MIMETYPE = 'application/json; charset=utf-8'
PERMANENT_SESSION_LIFETIME = timedelta(minutes=5)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = False # Set to True when HTTPS (internal runs HTTP)
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_NAME = 'admin_session'
SESSION_REFRESH_EACH_REQUEST = False
# Database Configuration
DATABASE_CONFIG = {
'host': os.getenv("POSTGRES_HOST", "postgres"),
'port': os.getenv("POSTGRES_PORT", "5432"),
'dbname': os.getenv("POSTGRES_DB"),
'user': os.getenv("POSTGRES_USER"),
'password': os.getenv("POSTGRES_PASSWORD"),
'options': '-c client_encoding=UTF8'
}
# Backup Configuration
BACKUP_DIR = Path("/app/backups")
BACKUP_DIR.mkdir(exist_ok=True)
BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY")
# Rate Limiting Configuration
FAIL_MESSAGES = [
"NOPE!",
"ACCESS DENIED, TRY HARDER",
"WRONG! 🚫",
"COMPUTER SAYS NO",
"YOU FAILED"
]
MAX_LOGIN_ATTEMPTS = 5
BLOCK_DURATION_HOURS = 24
CAPTCHA_AFTER_ATTEMPTS = 2
# reCAPTCHA Configuration
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY')
RECAPTCHA_SECRET_KEY = os.getenv('RECAPTCHA_SECRET_KEY')
# Email Configuration
EMAIL_ENABLED = os.getenv("EMAIL_ENABLED", "false").lower() == "true"
# Admin Users (for backward compatibility)
ADMIN_USERS = {
os.getenv("ADMIN1_USERNAME"): os.getenv("ADMIN1_PASSWORD"),
os.getenv("ADMIN2_USERNAME"): os.getenv("ADMIN2_PASSWORD")
}
# Scheduler Configuration
SCHEDULER_CONFIG = {
'backup_hour': 3,
'backup_minute': 0
}

Datei anzeigen

@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1749329847 admin_session aojqyq4GcSt5oT7NJPeg7UHPoEZUVkn-s1Kr-EAnJWM

Datei anzeigen

@ -0,0 +1,20 @@
-- Create users table if it doesn't exist
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100),
totp_secret VARCHAR(32),
totp_enabled BOOLEAN DEFAULT FALSE,
backup_codes TEXT, -- JSON array of hashed backup codes
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
password_reset_token VARCHAR(64),
password_reset_expires TIMESTAMP WITH TIME ZONE,
failed_2fa_attempts INTEGER DEFAULT 0,
last_failed_2fa TIMESTAMP WITH TIME ZONE
);
-- Index for faster login lookups
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;

Datei anzeigen

@ -0,0 +1,84 @@
import psycopg2
from psycopg2.extras import Json, RealDictCursor
from contextlib import contextmanager
from config import DATABASE_CONFIG
def get_connection():
"""Create and return a new database connection"""
conn = psycopg2.connect(**DATABASE_CONFIG)
conn.set_client_encoding('UTF8')
return conn
@contextmanager
def get_db_connection():
"""Context manager for database connections"""
conn = get_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
@contextmanager
def get_db_cursor(conn=None):
"""Context manager for database cursors"""
if conn is None:
with get_db_connection() as connection:
cur = connection.cursor()
try:
yield cur
finally:
cur.close()
else:
cur = conn.cursor()
try:
yield cur
finally:
cur.close()
@contextmanager
def get_dict_cursor(conn=None):
"""Context manager for dictionary cursors"""
if conn is None:
with get_db_connection() as connection:
cur = connection.cursor(cursor_factory=RealDictCursor)
try:
yield cur
finally:
cur.close()
else:
cur = conn.cursor(cursor_factory=RealDictCursor)
try:
yield cur
finally:
cur.close()
def execute_query(query, params=None, fetch_one=False, fetch_all=False, as_dict=False):
"""Execute a query and optionally fetch results"""
with get_db_connection() as conn:
cursor_func = get_dict_cursor if as_dict else get_db_cursor
with cursor_func(conn) as cur:
cur.execute(query, params)
if fetch_one:
return cur.fetchone()
elif fetch_all:
return cur.fetchall()
else:
return cur.rowcount
def execute_many(query, params_list):
"""Execute a query multiple times with different parameters"""
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.executemany(query, params_list)
return cur.rowcount

Datei anzeigen

@ -0,0 +1,13 @@
-- Fix für die fehlerhafte Migration - entfernt doppelte Bindestriche
UPDATE licenses
SET license_key = REPLACE(license_key, 'AF--', 'AF-')
WHERE license_key LIKE 'AF--%';
UPDATE licenses
SET license_key = REPLACE(license_key, '6--', '6-')
WHERE license_key LIKE '%6--%';
-- Zeige die korrigierten Keys
SELECT id, license_key, license_type
FROM licenses
ORDER BY id;

Datei anzeigen

@ -0,0 +1,282 @@
-- UTF-8 Encoding für deutsche Sonderzeichen sicherstellen
SET client_encoding = 'UTF8';
-- Zeitzone auf Europe/Berlin setzen
SET timezone = 'Europe/Berlin';
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
is_test BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_email UNIQUE (email)
);
CREATE TABLE IF NOT EXISTS licenses (
id SERIAL PRIMARY KEY,
license_key TEXT UNIQUE NOT NULL,
customer_id INTEGER REFERENCES customers(id),
license_type TEXT NOT NULL,
valid_from DATE NOT NULL,
valid_until DATE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
is_test BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id),
session_id TEXT UNIQUE NOT NULL,
ip_address TEXT,
user_agent TEXT,
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_heartbeat TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMP WITH TIME ZONE,
is_active BOOLEAN DEFAULT TRUE
);
-- Audit-Log-Tabelle für Änderungsprotokolle
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
username TEXT NOT NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
old_values JSONB,
new_values JSONB,
ip_address TEXT,
user_agent TEXT,
additional_info TEXT
);
-- Index für bessere Performance bei Abfragen
CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC);
CREATE INDEX idx_audit_log_username ON audit_log(username);
CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id);
-- Backup-Historie-Tabelle
CREATE TABLE IF NOT EXISTS backup_history (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL,
filepath TEXT NOT NULL,
filesize BIGINT,
backup_type TEXT NOT NULL, -- 'manual' oder 'scheduled'
status TEXT NOT NULL, -- 'success', 'failed', 'in_progress'
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL,
tables_count INTEGER,
records_count INTEGER,
duration_seconds NUMERIC,
is_encrypted BOOLEAN DEFAULT TRUE
);
-- Index für bessere Performance
CREATE INDEX idx_backup_history_created_at ON backup_history(created_at DESC);
CREATE INDEX idx_backup_history_status ON backup_history(status);
-- Login-Attempts-Tabelle für Rate-Limiting
CREATE TABLE IF NOT EXISTS login_attempts (
ip_address VARCHAR(45) PRIMARY KEY,
attempt_count INTEGER DEFAULT 0,
first_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_attempt TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
blocked_until TIMESTAMP WITH TIME ZONE NULL,
last_username_tried TEXT,
last_error_message TEXT
);
-- Index für schnelle Abfragen
CREATE INDEX idx_login_attempts_blocked_until ON login_attempts(blocked_until);
CREATE INDEX idx_login_attempts_last_attempt ON login_attempts(last_attempt DESC);
-- Migration: Füge created_at zu licenses hinzu, falls noch nicht vorhanden
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'created_at') THEN
ALTER TABLE licenses ADD COLUMN created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
-- Setze created_at für bestehende Einträge auf das valid_from Datum
UPDATE licenses SET created_at = valid_from WHERE created_at IS NULL;
END IF;
END $$;
-- ===================== RESOURCE POOL SYSTEM =====================
-- Haupttabelle für den Resource Pool
CREATE TABLE IF NOT EXISTS resource_pools (
id SERIAL PRIMARY KEY,
resource_type VARCHAR(20) NOT NULL CHECK (resource_type IN ('domain', 'ipv4', 'phone')),
resource_value VARCHAR(255) NOT NULL,
status VARCHAR(20) DEFAULT 'available' CHECK (status IN ('available', 'allocated', 'quarantine')),
allocated_to_license INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
status_changed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
status_changed_by VARCHAR(50),
quarantine_reason VARCHAR(100) CHECK (quarantine_reason IN ('abuse', 'defect', 'maintenance', 'blacklisted', 'expired', 'review', NULL)),
quarantine_until TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
notes TEXT,
is_test BOOLEAN DEFAULT FALSE,
UNIQUE(resource_type, resource_value)
);
-- Resource History für vollständige Nachverfolgbarkeit
CREATE TABLE IF NOT EXISTS resource_history (
id SERIAL PRIMARY KEY,
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
license_id INTEGER REFERENCES licenses(id) ON DELETE SET NULL,
action VARCHAR(50) NOT NULL CHECK (action IN ('allocated', 'deallocated', 'quarantined', 'released', 'created', 'deleted')),
action_by VARCHAR(50) NOT NULL,
action_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
details JSONB,
ip_address TEXT
);
-- Resource Metrics für Performance-Tracking und ROI
CREATE TABLE IF NOT EXISTS resource_metrics (
id SERIAL PRIMARY KEY,
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
metric_date DATE NOT NULL,
usage_count INTEGER DEFAULT 0,
performance_score DECIMAL(5,2) DEFAULT 0.00,
cost DECIMAL(10,2) DEFAULT 0.00,
revenue DECIMAL(10,2) DEFAULT 0.00,
issues_count INTEGER DEFAULT 0,
availability_percent DECIMAL(5,2) DEFAULT 100.00,
UNIQUE(resource_id, metric_date)
);
-- Zuordnungstabelle zwischen Lizenzen und Ressourcen
CREATE TABLE IF NOT EXISTS license_resources (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
resource_id INTEGER REFERENCES resource_pools(id) ON DELETE CASCADE,
assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
assigned_by VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
UNIQUE(license_id, resource_id)
);
-- Erweiterung der licenses Tabelle um Resource-Counts
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'domain_count') THEN
ALTER TABLE licenses
ADD COLUMN domain_count INTEGER DEFAULT 1 CHECK (domain_count >= 0 AND domain_count <= 10),
ADD COLUMN ipv4_count INTEGER DEFAULT 1 CHECK (ipv4_count >= 0 AND ipv4_count <= 10),
ADD COLUMN phone_count INTEGER DEFAULT 1 CHECK (phone_count >= 0 AND phone_count <= 10);
END IF;
END $$;
-- Erweiterung der licenses Tabelle um device_limit
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'device_limit') THEN
ALTER TABLE licenses
ADD COLUMN device_limit INTEGER DEFAULT 3 CHECK (device_limit >= 1 AND device_limit <= 10);
END IF;
END $$;
-- Tabelle für Geräte-Registrierungen
CREATE TABLE IF NOT EXISTS device_registrations (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
hardware_id TEXT NOT NULL,
device_name TEXT,
operating_system TEXT,
first_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
deactivated_at TIMESTAMP WITH TIME ZONE,
deactivated_by TEXT,
ip_address TEXT,
user_agent TEXT,
UNIQUE(license_id, hardware_id)
);
-- Indizes für device_registrations
CREATE INDEX IF NOT EXISTS idx_device_license ON device_registrations(license_id);
CREATE INDEX IF NOT EXISTS idx_device_hardware ON device_registrations(hardware_id);
CREATE INDEX IF NOT EXISTS idx_device_active ON device_registrations(license_id, is_active) WHERE is_active = TRUE;
-- Indizes für Performance
CREATE INDEX IF NOT EXISTS idx_resource_status ON resource_pools(status);
CREATE INDEX IF NOT EXISTS idx_resource_type_status ON resource_pools(resource_type, status);
CREATE INDEX IF NOT EXISTS idx_resource_allocated ON resource_pools(allocated_to_license) WHERE allocated_to_license IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_resource_quarantine ON resource_pools(quarantine_until) WHERE status = 'quarantine';
CREATE INDEX IF NOT EXISTS idx_resource_history_date ON resource_history(action_at DESC);
CREATE INDEX IF NOT EXISTS idx_resource_history_resource ON resource_history(resource_id);
CREATE INDEX IF NOT EXISTS idx_resource_metrics_date ON resource_metrics(metric_date DESC);
CREATE INDEX IF NOT EXISTS idx_license_resources_active ON license_resources(license_id) WHERE is_active = TRUE;
-- Users table for authentication with password and 2FA support
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100),
totp_secret VARCHAR(32),
totp_enabled BOOLEAN DEFAULT FALSE,
backup_codes TEXT, -- JSON array of hashed backup codes
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_password_change TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
password_reset_token VARCHAR(64),
password_reset_expires TIMESTAMP WITH TIME ZONE,
failed_2fa_attempts INTEGER DEFAULT 0,
last_failed_2fa TIMESTAMP WITH TIME ZONE
);
-- Index for faster login lookups
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_reset_token ON users(password_reset_token) WHERE password_reset_token IS NOT NULL;
-- Migration: Add is_test column to licenses if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'licenses' AND column_name = 'is_test') THEN
ALTER TABLE licenses ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
-- Mark all existing licenses as test data
UPDATE licenses SET is_test = TRUE;
-- Add index for better performance when filtering test data
CREATE INDEX idx_licenses_is_test ON licenses(is_test);
END IF;
END $$;
-- Migration: Add is_test column to customers if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'customers' AND column_name = 'is_test') THEN
ALTER TABLE customers ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
-- Mark all existing customers as test data
UPDATE customers SET is_test = TRUE;
-- Add index for better performance
CREATE INDEX idx_customers_is_test ON customers(is_test);
END IF;
END $$;
-- Migration: Add is_test column to resource_pools if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'resource_pools' AND column_name = 'is_test') THEN
ALTER TABLE resource_pools ADD COLUMN is_test BOOLEAN DEFAULT FALSE;
-- Mark all existing resources as test data
UPDATE resource_pools SET is_test = TRUE;
-- Add index for better performance
CREATE INDEX idx_resource_pools_is_test ON resource_pools(is_test);
END IF;
END $$;

Datei anzeigen

@ -0,0 +1,5 @@
-- Markiere alle existierenden Ressourcen als Testdaten
UPDATE resource_pools SET is_test = TRUE WHERE is_test = FALSE OR is_test IS NULL;
-- Zeige Anzahl der aktualisierten Ressourcen
SELECT COUNT(*) as updated_resources FROM resource_pools WHERE is_test = TRUE;

Datei anzeigen

@ -0,0 +1,13 @@
-- Migration: Setze device_limit für bestehende Test-Lizenzen auf 3
-- Dieses Script wird nur einmal ausgeführt, um bestehende Lizenzen zu aktualisieren
-- Setze device_limit = 3 für alle bestehenden Lizenzen, die noch keinen Wert haben
UPDATE licenses
SET device_limit = 3
WHERE device_limit IS NULL;
-- Bestätige die Änderung
SELECT COUNT(*) as updated_licenses,
COUNT(CASE WHEN is_test = TRUE THEN 1 END) as test_licenses_updated
FROM licenses
WHERE device_limit = 3;

Datei anzeigen

@ -0,0 +1,54 @@
-- Migration der Lizenzschlüssel vom alten Format zum neuen Format
-- Alt: AF-YYYYMMFT-XXXX-YYYY-ZZZZ
-- Neu: AF-F-YYYYMM-XXXX-YYYY-ZZZZ
-- Backup der aktuellen Schlüssel erstellen (für Sicherheit)
CREATE TEMP TABLE license_backup AS
SELECT id, license_key FROM licenses;
-- Update für Fullversion Keys (F)
UPDATE licenses
SET license_key =
CONCAT(
SUBSTRING(license_key, 1, 3), -- 'AF-'
'-F-',
SUBSTRING(license_key, 4, 6), -- 'YYYYMM'
'-',
SUBSTRING(license_key, 11) -- Rest des Keys
)
WHERE license_key LIKE 'AF-%F-%'
AND license_type = 'full'
AND license_key NOT LIKE 'AF-F-%'; -- Nicht bereits migriert
-- Update für Testversion Keys (T)
UPDATE licenses
SET license_key =
CONCAT(
SUBSTRING(license_key, 1, 3), -- 'AF-'
'-T-',
SUBSTRING(license_key, 4, 6), -- 'YYYYMM'
'-',
SUBSTRING(license_key, 11) -- Rest des Keys
)
WHERE license_key LIKE 'AF-%T-%'
AND license_type = 'test'
AND license_key NOT LIKE 'AF-T-%'; -- Nicht bereits migriert
-- Zeige die Änderungen
SELECT
b.license_key as old_key,
l.license_key as new_key,
l.license_type
FROM licenses l
JOIN license_backup b ON l.id = b.id
WHERE b.license_key != l.license_key
ORDER BY l.id;
-- Anzahl der migrierten Keys
SELECT
COUNT(*) as total_migrated,
SUM(CASE WHEN license_type = 'full' THEN 1 ELSE 0 END) as full_licenses,
SUM(CASE WHEN license_type = 'test' THEN 1 ELSE 0 END) as test_licenses
FROM licenses l
JOIN license_backup b ON l.id = b.id
WHERE b.license_key != l.license_key;

Datei anzeigen

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""
Migration script to create initial users in the database from environment variables
Run this once after creating the users table
"""
import os
import psycopg2
import bcrypt
from dotenv import load_dotenv
from datetime import datetime
load_dotenv()
def get_connection():
return psycopg2.connect(
host=os.getenv("POSTGRES_HOST", "postgres"),
port=os.getenv("POSTGRES_PORT", "5432"),
dbname=os.getenv("POSTGRES_DB"),
user=os.getenv("POSTGRES_USER"),
password=os.getenv("POSTGRES_PASSWORD"),
options='-c client_encoding=UTF8'
)
def hash_password(password):
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def migrate_users():
conn = get_connection()
cur = conn.cursor()
try:
# Check if users already exist
cur.execute("SELECT COUNT(*) FROM users")
user_count = cur.fetchone()[0]
if user_count > 0:
print(f"Users table already contains {user_count} users. Skipping migration.")
return
# Get admin users from environment
admin1_user = os.getenv("ADMIN1_USERNAME")
admin1_pass = os.getenv("ADMIN1_PASSWORD")
admin2_user = os.getenv("ADMIN2_USERNAME")
admin2_pass = os.getenv("ADMIN2_PASSWORD")
if not all([admin1_user, admin1_pass, admin2_user, admin2_pass]):
print("ERROR: Admin credentials not found in environment variables!")
return
# Insert admin users
users = [
(admin1_user, hash_password(admin1_pass), f"{admin1_user}@v2-admin.local"),
(admin2_user, hash_password(admin2_pass), f"{admin2_user}@v2-admin.local")
]
for username, password_hash, email in users:
cur.execute("""
INSERT INTO users (username, password_hash, email, totp_enabled, created_at)
VALUES (%s, %s, %s, %s, %s)
""", (username, password_hash, email, False, datetime.now()))
print(f"Created user: {username}")
conn.commit()
print("\nMigration completed successfully!")
print("Users can now log in with their existing credentials.")
print("They can enable 2FA from their profile page.")
except Exception as e:
conn.rollback()
print(f"ERROR during migration: {e}")
finally:
cur.close()
conn.close()
if __name__ == "__main__":
print("Starting user migration...")
migrate_users()

Datei anzeigen

@ -0,0 +1,29 @@
# Temporary models file - will be expanded in Phase 3
from db import execute_query
def get_user_by_username(username):
"""Get user from database by username"""
result = execute_query(
"""
SELECT id, username, password_hash, email, totp_secret, totp_enabled,
backup_codes, last_password_change, failed_2fa_attempts
FROM users WHERE username = %s
""",
(username,),
fetch_one=True
)
if result:
return {
'id': result[0],
'username': result[1],
'password_hash': result[2],
'email': result[3],
'totp_secret': result[4],
'totp_enabled': result[5],
'backup_codes': result[6],
'last_password_change': result[7],
'failed_2fa_attempts': result[8]
}
return None

Datei anzeigen

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""
Remove duplicate routes that have been moved to blueprints
"""
import re
# Read the current app.py
with open('app.py', 'r') as f:
content = f.read()
# List of function names that have been moved to blueprints
moved_functions = [
# Auth routes
'login',
'logout',
'verify_2fa',
'profile',
'change_password',
'setup_2fa',
'enable_2fa',
'disable_2fa',
'heartbeat',
# Admin routes
'dashboard',
'audit_log',
'backups',
'create_backup_route',
'restore_backup_route',
'download_backup',
'delete_backup',
'blocked_ips',
'unblock_ip',
'clear_attempts'
]
# Create a pattern to match route decorators and their functions
for func_name in moved_functions:
# Pattern to match from @app.route to the end of the function
pattern = rf'@app\.route\([^)]+\)\s*(?:@login_required\s*)?def {func_name}\([^)]*\):.*?(?=\n@app\.route|\n@[a-zA-Z]|\nif __name__|$)'
# Replace with a comment
replacement = f'# Function {func_name} moved to blueprint'
content = re.sub(pattern, replacement, content, flags=re.DOTALL)
# Write the modified content
with open('app_no_duplicates.py', 'w') as f:
f.write(content)
print("Created app_no_duplicates.py with duplicate routes removed")
print("Please review the file before using it")

Datei anzeigen

@ -0,0 +1,14 @@
flask
flask-session
psycopg2-binary
python-dotenv
pyopenssl
pandas
openpyxl
cryptography
apscheduler
requests
python-dateutil
bcrypt
pyotp
qrcode[pil]

Datei anzeigen

@ -0,0 +1,2 @@
# Routes module initialization
# This module contains all Flask blueprints organized by functionality

Datei anzeigen

@ -0,0 +1,540 @@
import os
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from pathlib import Path
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file, jsonify
import config
from auth.decorators import login_required
from utils.audit import log_audit
from utils.backup import create_backup, restore_backup
from utils.network import get_client_ip
from db import get_connection, get_db_connection, get_db_cursor, execute_query
from utils.export import create_excel_export, prepare_audit_export_data
# Create Blueprint
admin_bp = Blueprint('admin', __name__)
@admin_bp.route("/")
@login_required
def dashboard():
conn = get_connection()
cur = conn.cursor()
try:
# Hole Statistiken
# Anzahl aktiver Lizenzen
cur.execute("SELECT COUNT(*) FROM licenses WHERE active = true")
active_licenses = cur.fetchone()[0]
# Anzahl Kunden
cur.execute("SELECT COUNT(*) FROM customers")
total_customers = cur.fetchone()[0]
# Anzahl aktiver Sessions
cur.execute("SELECT COUNT(*) FROM sessions WHERE active = true")
active_sessions = cur.fetchone()[0]
# Top 10 Lizenzen nach Nutzung (letzte 30 Tage)
cur.execute("""
SELECT
l.license_key,
c.name as customer_name,
COUNT(DISTINCT s.id) as session_count,
COUNT(DISTINCT s.username) as unique_users,
MAX(s.last_activity) as last_activity
FROM licenses l
LEFT JOIN customers c ON l.customer_id = c.id
LEFT JOIN sessions s ON l.license_key = s.license_key
AND s.login_time >= CURRENT_TIMESTAMP - INTERVAL '30 days'
GROUP BY l.license_key, c.name
ORDER BY session_count DESC
LIMIT 10
""")
top_licenses = cur.fetchall()
# Letzte 10 Aktivitäten aus dem Audit Log
cur.execute("""
SELECT
id,
timestamp AT TIME ZONE 'Europe/Berlin' as timestamp,
username,
action,
entity_type,
entity_id,
additional_info
FROM audit_log
ORDER BY timestamp DESC
LIMIT 10
""")
recent_activities = cur.fetchall()
# Lizenztyp-Verteilung
cur.execute("""
SELECT
CASE
WHEN is_test_license THEN 'Test'
ELSE 'Full'
END as license_type,
COUNT(*) as count
FROM licenses
GROUP BY is_test_license
""")
license_distribution = cur.fetchall()
# Sessions nach Stunden (letzte 24h)
cur.execute("""
WITH hours AS (
SELECT generate_series(
CURRENT_TIMESTAMP - INTERVAL '23 hours',
CURRENT_TIMESTAMP,
INTERVAL '1 hour'
) AS hour
)
SELECT
TO_CHAR(hours.hour AT TIME ZONE 'Europe/Berlin', 'HH24:00') as hour_label,
COUNT(DISTINCT s.id) as session_count
FROM hours
LEFT JOIN sessions s ON
s.login_time >= hours.hour AND
s.login_time < hours.hour + INTERVAL '1 hour'
GROUP BY hours.hour
ORDER BY hours.hour
""")
hourly_sessions = cur.fetchall()
# System-Status
cur.execute("SELECT pg_database_size(current_database())")
db_size = cur.fetchone()[0]
# Letzte Backup-Info
cur.execute("""
SELECT filename, created_at, filesize, status
FROM backup_history
WHERE status = 'success'
ORDER BY created_at DESC
LIMIT 1
""")
last_backup = cur.fetchone()
# Resource Statistiken
cur.execute("""
SELECT
COUNT(*) FILTER (WHERE status = 'available') as available,
COUNT(*) FILTER (WHERE status = 'in_use') as in_use,
COUNT(*) FILTER (WHERE status = 'quarantine') as quarantine,
COUNT(*) as total
FROM resources
""")
resource_stats = cur.fetchone()
return render_template('dashboard.html',
active_licenses=active_licenses,
total_customers=total_customers,
active_sessions=active_sessions,
top_licenses=top_licenses,
recent_activities=recent_activities,
license_distribution=license_distribution,
hourly_sessions=hourly_sessions,
db_size=db_size,
last_backup=last_backup,
resource_stats=resource_stats,
username=session.get('username'))
finally:
cur.close()
conn.close()
@admin_bp.route("/audit")
@login_required
def audit_log():
page = request.args.get('page', 1, type=int)
per_page = 50
search = request.args.get('search', '')
action_filter = request.args.get('action', '')
entity_filter = request.args.get('entity', '')
conn = get_connection()
cur = conn.cursor()
try:
# Base query
query = """
SELECT
id,
timestamp AT TIME ZONE 'Europe/Berlin' as timestamp,
username,
action,
entity_type,
entity_id,
old_values::text,
new_values::text,
ip_address,
user_agent,
additional_info
FROM audit_log
WHERE 1=1
"""
params = []
# Suchfilter
if search:
query += """ AND (
username ILIKE %s OR
action ILIKE %s OR
entity_type ILIKE %s OR
additional_info ILIKE %s OR
ip_address ILIKE %s
)"""
search_param = f"%{search}%"
params.extend([search_param] * 5)
# Action Filter
if action_filter:
query += " AND action = %s"
params.append(action_filter)
# Entity Filter
if entity_filter:
query += " AND entity_type = %s"
params.append(entity_filter)
# Count total
count_query = f"SELECT COUNT(*) FROM ({query}) as filtered"
cur.execute(count_query, params)
total_count = cur.fetchone()[0]
# Add pagination
query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s"
params.extend([per_page, (page - 1) * per_page])
cur.execute(query, params)
logs = cur.fetchall()
# Get unique actions and entities for filters
cur.execute("SELECT DISTINCT action FROM audit_log ORDER BY action")
actions = [row[0] for row in cur.fetchall()]
cur.execute("SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type")
entities = [row[0] for row in cur.fetchall()]
# Pagination info
total_pages = (total_count + per_page - 1) // per_page
# Convert to dictionaries for easier template access
audit_logs = []
for log in logs:
audit_logs.append({
'id': log[0],
'timestamp': log[1],
'username': log[2],
'action': log[3],
'entity_type': log[4],
'entity_id': log[5],
'old_values': log[6],
'new_values': log[7],
'ip_address': log[8],
'user_agent': log[9],
'additional_info': log[10]
})
return render_template('audit_log.html',
logs=audit_logs,
page=page,
total_pages=total_pages,
total_count=total_count,
search=search,
action_filter=action_filter,
entity_filter=entity_filter,
actions=actions,
entities=entities,
username=session.get('username'))
finally:
cur.close()
conn.close()
@admin_bp.route("/backups")
@login_required
def backups():
conn = get_connection()
cur = conn.cursor()
try:
# Hole alle Backups
cur.execute("""
SELECT
id,
filename,
created_at AT TIME ZONE 'Europe/Berlin' as created_at,
filesize,
backup_type,
status,
created_by,
duration_seconds,
tables_count,
records_count,
error_message,
is_encrypted
FROM backup_history
ORDER BY created_at DESC
""")
backups = cur.fetchall()
# Prüfe ob Dateien noch existieren
backups_with_status = []
for backup in backups:
backup_dict = {
'id': backup[0],
'filename': backup[1],
'created_at': backup[2],
'filesize': backup[3],
'backup_type': backup[4],
'status': backup[5],
'created_by': backup[6],
'duration_seconds': backup[7],
'tables_count': backup[8],
'records_count': backup[9],
'error_message': backup[10],
'is_encrypted': backup[11],
'file_exists': False
}
# Prüfe ob Datei existiert
if backup[1]: # filename
filepath = config.BACKUP_DIR / backup[1]
backup_dict['file_exists'] = filepath.exists()
backups_with_status.append(backup_dict)
return render_template('backups.html',
backups=backups_with_status,
username=session.get('username'))
finally:
cur.close()
conn.close()
@admin_bp.route("/backup/create", methods=["POST"])
@login_required
def create_backup_route():
"""Manuelles Backup erstellen"""
success, result = create_backup(backup_type="manual", created_by=session.get('username'))
if success:
flash(f'Backup erfolgreich erstellt: {result}', 'success')
else:
flash(f'Backup fehlgeschlagen: {result}', 'error')
return redirect(url_for('admin.backups'))
@admin_bp.route("/backup/restore/<int:backup_id>", methods=["POST"])
@login_required
def restore_backup_route(backup_id):
"""Backup wiederherstellen"""
encryption_key = request.form.get('encryption_key')
success, message = restore_backup(backup_id, encryption_key)
if success:
flash(message, 'success')
else:
flash(f'Wiederherstellung fehlgeschlagen: {message}', 'error')
return redirect(url_for('admin.backups'))
@admin_bp.route("/backup/download/<int:backup_id>")
@login_required
def download_backup(backup_id):
"""Backup herunterladen"""
conn = get_connection()
cur = conn.cursor()
try:
# Hole Backup-Info
cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,))
result = cur.fetchone()
if not result:
flash('Backup nicht gefunden', 'error')
return redirect(url_for('admin.backups'))
filename, filepath = result
filepath = Path(filepath)
if not filepath.exists():
flash('Backup-Datei nicht gefunden', 'error')
return redirect(url_for('admin.backups'))
# Audit-Log
log_audit('BACKUP_DOWNLOAD', 'backup', backup_id,
additional_info=f"Backup heruntergeladen: {filename}")
return send_file(filepath, as_attachment=True, download_name=filename)
finally:
cur.close()
conn.close()
@admin_bp.route("/backup/delete/<int:backup_id>", methods=["DELETE"])
@login_required
def delete_backup(backup_id):
"""Backup löschen"""
conn = get_connection()
cur = conn.cursor()
try:
# Hole Backup-Info
cur.execute("SELECT filename, filepath FROM backup_history WHERE id = %s", (backup_id,))
result = cur.fetchone()
if not result:
return jsonify({'success': False, 'message': 'Backup nicht gefunden'}), 404
filename, filepath = result
filepath = Path(filepath)
# Lösche Datei wenn vorhanden
if filepath.exists():
try:
filepath.unlink()
except Exception as e:
return jsonify({'success': False, 'message': f'Fehler beim Löschen der Datei: {str(e)}'}), 500
# Lösche Datenbank-Eintrag
cur.execute("DELETE FROM backup_history WHERE id = %s", (backup_id,))
conn.commit()
# Audit-Log
log_audit('BACKUP_DELETE', 'backup', backup_id,
additional_info=f"Backup gelöscht: {filename}")
return jsonify({'success': True, 'message': 'Backup erfolgreich gelöscht'})
except Exception as e:
conn.rollback()
return jsonify({'success': False, 'message': str(e)}), 500
finally:
cur.close()
conn.close()
@admin_bp.route("/security/blocked-ips")
@login_required
def blocked_ips():
"""Zeigt gesperrte IP-Adressen"""
conn = get_connection()
cur = conn.cursor()
try:
cur.execute("""
SELECT
ip_address,
attempt_count,
last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt,
blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until,
last_username_tried,
last_error_message
FROM login_attempts
WHERE blocked_until IS NOT NULL AND blocked_until > CURRENT_TIMESTAMP
ORDER BY blocked_until DESC
""")
blocked = cur.fetchall()
# Alle Login-Versuche (auch nicht gesperrte)
cur.execute("""
SELECT
ip_address,
attempt_count,
last_attempt AT TIME ZONE 'Europe/Berlin' as last_attempt,
blocked_until AT TIME ZONE 'Europe/Berlin' as blocked_until,
last_username_tried,
last_error_message
FROM login_attempts
ORDER BY last_attempt DESC
LIMIT 100
""")
all_attempts = cur.fetchall()
return render_template('blocked_ips.html',
blocked_ips=blocked,
all_attempts=all_attempts,
username=session.get('username'))
finally:
cur.close()
conn.close()
@admin_bp.route("/security/unblock-ip", methods=["POST"])
@login_required
def unblock_ip():
"""Entsperrt eine IP-Adresse"""
ip_address = request.form.get('ip_address')
if not ip_address:
flash('Keine IP-Adresse angegeben', 'error')
return redirect(url_for('admin.blocked_ips'))
conn = get_connection()
cur = conn.cursor()
try:
cur.execute("""
UPDATE login_attempts
SET blocked_until = NULL
WHERE ip_address = %s
""", (ip_address,))
if cur.rowcount > 0:
conn.commit()
flash(f'IP-Adresse {ip_address} wurde entsperrt', 'success')
log_audit('UNBLOCK_IP', 'security',
additional_info=f"IP-Adresse entsperrt: {ip_address}")
else:
flash(f'IP-Adresse {ip_address} nicht gefunden', 'warning')
except Exception as e:
conn.rollback()
flash(f'Fehler beim Entsperren: {str(e)}', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('admin.blocked_ips'))
@admin_bp.route("/security/clear-attempts", methods=["POST"])
@login_required
def clear_attempts():
"""Löscht alle Login-Versuche"""
conn = get_connection()
cur = conn.cursor()
try:
cur.execute("DELETE FROM login_attempts")
count = cur.rowcount
conn.commit()
flash(f'{count} Login-Versuche wurden gelöscht', 'success')
log_audit('CLEAR_LOGIN_ATTEMPTS', 'security',
additional_info=f"{count} Login-Versuche gelöscht")
except Exception as e:
conn.rollback()
flash(f'Fehler beim Löschen: {str(e)}', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('admin.blocked_ips'))

Datei anzeigen

@ -0,0 +1,906 @@
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from flask import Blueprint, request, jsonify, session
import config
from auth.decorators import login_required
from utils.audit import log_audit
from utils.network import get_client_ip
from utils.license import generate_license_key
from db import get_connection, get_db_connection, get_db_cursor
from models import get_license_by_id
# Create Blueprint
api_bp = Blueprint('api', __name__, url_prefix='/api')
@api_bp.route("/license/<int:license_id>/toggle", methods=["POST"])
@login_required
def toggle_license(license_id):
"""Toggle license active status"""
conn = get_connection()
cur = conn.cursor()
try:
# Get current status
license_data = get_license_by_id(license_id)
if not license_data:
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
new_status = not license_data['active']
# Update status
cur.execute("UPDATE licenses SET active = %s WHERE id = %s", (new_status, license_id))
conn.commit()
# Log change
log_audit('TOGGLE', 'license', license_id,
old_values={'active': license_data['active']},
new_values={'active': new_status})
return jsonify({'success': True, 'active': new_status})
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Umschalten der Lizenz: {str(e)}")
return jsonify({'error': 'Fehler beim Umschalten der Lizenz'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/licenses/bulk-activate", methods=["POST"])
@login_required
def bulk_activate_licenses():
"""Aktiviere mehrere Lizenzen gleichzeitig"""
data = request.get_json()
license_ids = data.get('license_ids', [])
if not license_ids:
return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400
conn = get_connection()
cur = conn.cursor()
try:
# Update all selected licenses
cur.execute("""
UPDATE licenses
SET active = true
WHERE id = ANY(%s) AND active = false
RETURNING id
""", (license_ids,))
updated_ids = [row[0] for row in cur.fetchall()]
conn.commit()
# Log changes
for license_id in updated_ids:
log_audit('BULK_ACTIVATE', 'license', license_id,
new_values={'active': True})
return jsonify({
'success': True,
'updated_count': len(updated_ids)
})
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Bulk-Aktivieren: {str(e)}")
return jsonify({'error': 'Fehler beim Aktivieren der Lizenzen'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/licenses/bulk-deactivate", methods=["POST"])
@login_required
def bulk_deactivate_licenses():
"""Deaktiviere mehrere Lizenzen gleichzeitig"""
data = request.get_json()
license_ids = data.get('license_ids', [])
if not license_ids:
return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400
conn = get_connection()
cur = conn.cursor()
try:
# Update all selected licenses
cur.execute("""
UPDATE licenses
SET active = false
WHERE id = ANY(%s) AND active = true
RETURNING id
""", (license_ids,))
updated_ids = [row[0] for row in cur.fetchall()]
conn.commit()
# Log changes
for license_id in updated_ids:
log_audit('BULK_DEACTIVATE', 'license', license_id,
new_values={'active': False})
return jsonify({
'success': True,
'updated_count': len(updated_ids)
})
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Bulk-Deaktivieren: {str(e)}")
return jsonify({'error': 'Fehler beim Deaktivieren der Lizenzen'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/license/<int:license_id>/devices")
@login_required
def get_license_devices(license_id):
"""Hole alle Geräte einer Lizenz"""
conn = get_connection()
cur = conn.cursor()
try:
# Hole Lizenz-Info
license_data = get_license_by_id(license_id)
if not license_data:
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
# Hole registrierte Geräte
cur.execute("""
SELECT
dr.id,
dr.device_id,
dr.device_name,
dr.device_type,
dr.registration_date,
dr.last_seen,
dr.is_active,
(SELECT COUNT(*) FROM sessions s
WHERE s.license_key = dr.license_key
AND s.device_id = dr.device_id
AND s.active = true) as active_sessions
FROM device_registrations dr
WHERE dr.license_key = %s
ORDER BY dr.registration_date DESC
""", (license_data['license_key'],))
devices = []
for row in cur.fetchall():
devices.append({
'id': row[0],
'device_id': row[1],
'device_name': row[2],
'device_type': row[3],
'registration_date': row[4].isoformat() if row[4] else None,
'last_seen': row[5].isoformat() if row[5] else None,
'is_active': row[6],
'active_sessions': row[7]
})
return jsonify({
'license_key': license_data['license_key'],
'device_limit': license_data['device_limit'],
'devices': devices,
'device_count': len(devices)
})
except Exception as e:
logging.error(f"Fehler beim Abrufen der Geräte: {str(e)}")
return jsonify({'error': 'Fehler beim Abrufen der Geräte'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/license/<int:license_id>/register-device", methods=["POST"])
@login_required
def register_device(license_id):
"""Registriere ein neues Gerät für eine Lizenz"""
data = request.get_json()
device_id = data.get('device_id')
device_name = data.get('device_name')
device_type = data.get('device_type', 'unknown')
if not device_id or not device_name:
return jsonify({'error': 'Geräte-ID und Name erforderlich'}), 400
conn = get_connection()
cur = conn.cursor()
try:
# Hole Lizenz-Info
license_data = get_license_by_id(license_id)
if not license_data:
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
# Prüfe Gerätelimit
cur.execute("""
SELECT COUNT(*) FROM device_registrations
WHERE license_key = %s AND is_active = true
""", (license_data['license_key'],))
active_device_count = cur.fetchone()[0]
if active_device_count >= license_data['device_limit']:
return jsonify({'error': 'Gerätelimit erreicht'}), 400
# Prüfe ob Gerät bereits registriert
cur.execute("""
SELECT id, is_active FROM device_registrations
WHERE license_key = %s AND device_id = %s
""", (license_data['license_key'], device_id))
existing = cur.fetchone()
if existing:
if existing[1]: # is_active
return jsonify({'error': 'Gerät bereits registriert'}), 400
else:
# Reaktiviere Gerät
cur.execute("""
UPDATE device_registrations
SET is_active = true, last_seen = CURRENT_TIMESTAMP
WHERE id = %s
""", (existing[0],))
else:
# Registriere neues Gerät
cur.execute("""
INSERT INTO device_registrations
(license_key, device_id, device_name, device_type, is_active)
VALUES (%s, %s, %s, %s, true)
""", (license_data['license_key'], device_id, device_name, device_type))
conn.commit()
# Audit-Log
log_audit('DEVICE_REGISTER', 'license', license_id,
additional_info=f"Gerät {device_name} ({device_id}) registriert")
return jsonify({'success': True})
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Registrieren des Geräts: {str(e)}")
return jsonify({'error': 'Fehler beim Registrieren des Geräts'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/license/<int:license_id>/deactivate-device/<int:device_id>", methods=["POST"])
@login_required
def deactivate_device(license_id, device_id):
"""Deaktiviere ein Gerät einer Lizenz"""
conn = get_connection()
cur = conn.cursor()
try:
# Prüfe ob Gerät zur Lizenz gehört
cur.execute("""
SELECT dr.device_name, dr.device_id, l.license_key
FROM device_registrations dr
JOIN licenses l ON dr.license_key = l.license_key
WHERE dr.id = %s AND l.id = %s
""", (device_id, license_id))
device = cur.fetchone()
if not device:
return jsonify({'error': 'Gerät nicht gefunden'}), 404
# Deaktiviere Gerät
cur.execute("""
UPDATE device_registrations
SET is_active = false
WHERE id = %s
""", (device_id,))
# Beende aktive Sessions
cur.execute("""
UPDATE sessions
SET active = false, logout_time = CURRENT_TIMESTAMP
WHERE license_key = %s AND device_id = %s AND active = true
""", (device[2], device[1]))
conn.commit()
# Audit-Log
log_audit('DEVICE_DEACTIVATE', 'license', license_id,
additional_info=f"Gerät {device[0]} ({device[1]}) deaktiviert")
return jsonify({'success': True})
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Deaktivieren des Geräts: {str(e)}")
return jsonify({'error': 'Fehler beim Deaktivieren des Geräts'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/licenses/bulk-delete", methods=["POST"])
@login_required
def bulk_delete_licenses():
"""Lösche mehrere Lizenzen gleichzeitig"""
data = request.get_json()
license_ids = data.get('license_ids', [])
if not license_ids:
return jsonify({'error': 'Keine Lizenzen ausgewählt'}), 400
conn = get_connection()
cur = conn.cursor()
try:
deleted_count = 0
for license_id in license_ids:
# Hole Lizenz-Info für Audit
cur.execute("SELECT license_key FROM licenses WHERE id = %s", (license_id,))
result = cur.fetchone()
if result:
license_key = result[0]
# Lösche Sessions
cur.execute("DELETE FROM sessions WHERE license_key = %s", (license_key,))
# Lösche Geräte-Registrierungen
cur.execute("DELETE FROM device_registrations WHERE license_key = %s", (license_key,))
# Lösche Lizenz
cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,))
# Audit-Log
log_audit('BULK_DELETE', 'license', license_id,
old_values={'license_key': license_key})
deleted_count += 1
conn.commit()
return jsonify({
'success': True,
'deleted_count': deleted_count
})
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Bulk-Löschen: {str(e)}")
return jsonify({'error': 'Fehler beim Löschen der Lizenzen'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/license/<int:license_id>/quick-edit", methods=['POST'])
@login_required
def quick_edit_license(license_id):
"""Schnellbearbeitung einer Lizenz"""
data = request.get_json()
conn = get_connection()
cur = conn.cursor()
try:
# Hole aktuelle Lizenz für Vergleich
current_license = get_license_by_id(license_id)
if not current_license:
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
# Update nur die übergebenen Felder
updates = []
params = []
old_values = {}
new_values = {}
if 'device_limit' in data:
updates.append("device_limit = %s")
params.append(int(data['device_limit']))
old_values['device_limit'] = current_license['device_limit']
new_values['device_limit'] = int(data['device_limit'])
if 'valid_until' in data:
updates.append("valid_until = %s")
params.append(data['valid_until'])
old_values['valid_until'] = str(current_license['valid_until'])
new_values['valid_until'] = data['valid_until']
if 'active' in data:
updates.append("active = %s")
params.append(bool(data['active']))
old_values['active'] = current_license['active']
new_values['active'] = bool(data['active'])
if not updates:
return jsonify({'error': 'Keine Änderungen angegeben'}), 400
# Führe Update aus
params.append(license_id)
cur.execute(f"""
UPDATE licenses
SET {', '.join(updates)}
WHERE id = %s
""", params)
conn.commit()
# Audit-Log
log_audit('QUICK_EDIT', 'license', license_id,
old_values=old_values,
new_values=new_values)
return jsonify({'success': True})
except Exception as e:
conn.rollback()
logging.error(f"Fehler bei Schnellbearbeitung: {str(e)}")
return jsonify({'error': 'Fehler bei der Bearbeitung'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/license/<int:license_id>/resources")
@login_required
def get_license_resources(license_id):
"""Hole alle Ressourcen einer Lizenz"""
conn = get_connection()
cur = conn.cursor()
try:
# Hole Lizenz-Info
license_data = get_license_by_id(license_id)
if not license_data:
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
# Hole zugewiesene Ressourcen
cur.execute("""
SELECT
rp.id,
rp.resource_type,
rp.resource_value,
rp.is_test,
rp.status_changed_at,
lr.assigned_at,
lr.assigned_by
FROM resource_pools rp
JOIN license_resources lr ON rp.id = lr.resource_id
WHERE lr.license_id = %s
ORDER BY rp.resource_type, rp.resource_value
""", (license_id,))
resources = []
for row in cur.fetchall():
resources.append({
'id': row[0],
'type': row[1],
'value': row[2],
'is_test': row[3],
'status_changed_at': row[4].isoformat() if row[4] else None,
'assigned_at': row[5].isoformat() if row[5] else None,
'assigned_by': row[6]
})
# Gruppiere nach Typ
grouped = {}
for resource in resources:
res_type = resource['type']
if res_type not in grouped:
grouped[res_type] = []
grouped[res_type].append(resource)
return jsonify({
'license_key': license_data['license_key'],
'resources': resources,
'grouped': grouped,
'total_count': len(resources)
})
except Exception as e:
logging.error(f"Fehler beim Abrufen der Ressourcen: {str(e)}")
return jsonify({'error': 'Fehler beim Abrufen der Ressourcen'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/resources/allocate", methods=['POST'])
@login_required
def allocate_resources():
"""Weise Ressourcen einer Lizenz zu"""
data = request.get_json()
license_id = data.get('license_id')
resource_ids = data.get('resource_ids', [])
if not license_id or not resource_ids:
return jsonify({'error': 'Lizenz-ID und Ressourcen erforderlich'}), 400
conn = get_connection()
cur = conn.cursor()
try:
# Prüfe Lizenz
license_data = get_license_by_id(license_id)
if not license_data:
return jsonify({'error': 'Lizenz nicht gefunden'}), 404
allocated_count = 0
errors = []
for resource_id in resource_ids:
try:
# Prüfe ob Ressource verfügbar ist
cur.execute("""
SELECT resource_value, status, is_test
FROM resource_pools
WHERE id = %s
""", (resource_id,))
resource = cur.fetchone()
if not resource:
errors.append(f"Ressource {resource_id} nicht gefunden")
continue
if resource[1] != 'available':
errors.append(f"Ressource {resource[0]} ist nicht verfügbar")
continue
# Prüfe Test/Produktion Kompatibilität
if resource[2] != license_data['is_test']:
errors.append(f"Ressource {resource[0]} ist {'Test' if resource[2] else 'Produktion'}, Lizenz ist {'Test' if license_data['is_test'] else 'Produktion'}")
continue
# Weise Ressource zu
cur.execute("""
UPDATE resource_pools
SET status = 'allocated',
allocated_to_license = %s,
status_changed_at = CURRENT_TIMESTAMP,
status_changed_by = %s
WHERE id = %s
""", (license_id, session['username'], resource_id))
# Erstelle Verknüpfung
cur.execute("""
INSERT INTO license_resources (license_id, resource_id, assigned_by)
VALUES (%s, %s, %s)
""", (license_id, resource_id, session['username']))
# History-Eintrag
cur.execute("""
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
VALUES (%s, %s, 'allocated', %s, %s)
""", (resource_id, license_id, session['username'], get_client_ip()))
allocated_count += 1
except Exception as e:
errors.append(f"Fehler bei Ressource {resource_id}: {str(e)}")
conn.commit()
# Audit-Log
if allocated_count > 0:
log_audit('RESOURCE_ALLOCATE', 'license', license_id,
additional_info=f"{allocated_count} Ressourcen zugewiesen")
return jsonify({
'success': True,
'allocated_count': allocated_count,
'errors': errors
})
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Zuweisen der Ressourcen: {str(e)}")
return jsonify({'error': 'Fehler beim Zuweisen der Ressourcen'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/resources/check-availability", methods=['GET'])
@login_required
def check_resource_availability():
"""Prüfe Verfügbarkeit von Ressourcen"""
resource_type = request.args.get('type')
count = int(request.args.get('count', 1))
is_test = request.args.get('is_test', 'false') == 'true'
if not resource_type:
return jsonify({'error': 'Ressourcen-Typ erforderlich'}), 400
conn = get_connection()
cur = conn.cursor()
try:
# Zähle verfügbare Ressourcen
cur.execute("""
SELECT COUNT(*)
FROM resource_pools
WHERE resource_type = %s
AND status = 'available'
AND is_test = %s
""", (resource_type, is_test))
available_count = cur.fetchone()[0]
return jsonify({
'resource_type': resource_type,
'requested': count,
'available': available_count,
'sufficient': available_count >= count,
'is_test': is_test
})
except Exception as e:
logging.error(f"Fehler beim Prüfen der Verfügbarkeit: {str(e)}")
return jsonify({'error': 'Fehler beim Prüfen der Verfügbarkeit'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/global-search", methods=['GET'])
@login_required
def global_search():
"""Globale Suche über alle Entitäten"""
query = request.args.get('q', '').strip()
if not query or len(query) < 3:
return jsonify({'error': 'Suchbegriff muss mindestens 3 Zeichen haben'}), 400
conn = get_connection()
cur = conn.cursor()
results = {
'licenses': [],
'customers': [],
'resources': [],
'sessions': []
}
try:
# Suche in Lizenzen
cur.execute("""
SELECT id, license_key, customer_name, active
FROM licenses
WHERE license_key ILIKE %s
OR customer_name ILIKE %s
OR customer_email ILIKE %s
LIMIT 10
""", (f'%{query}%', f'%{query}%', f'%{query}%'))
for row in cur.fetchall():
results['licenses'].append({
'id': row[0],
'license_key': row[1],
'customer_name': row[2],
'active': row[3]
})
# Suche in Kunden
cur.execute("""
SELECT id, name, email
FROM customers
WHERE name ILIKE %s OR email ILIKE %s
LIMIT 10
""", (f'%{query}%', f'%{query}%'))
for row in cur.fetchall():
results['customers'].append({
'id': row[0],
'name': row[1],
'email': row[2]
})
# Suche in Ressourcen
cur.execute("""
SELECT id, resource_type, resource_value, status
FROM resource_pools
WHERE resource_value ILIKE %s
LIMIT 10
""", (f'%{query}%',))
for row in cur.fetchall():
results['resources'].append({
'id': row[0],
'type': row[1],
'value': row[2],
'status': row[3]
})
# Suche in Sessions
cur.execute("""
SELECT id, license_key, username, device_id, active
FROM sessions
WHERE username ILIKE %s OR device_id ILIKE %s
ORDER BY login_time DESC
LIMIT 10
""", (f'%{query}%', f'%{query}%'))
for row in cur.fetchall():
results['sessions'].append({
'id': row[0],
'license_key': row[1],
'username': row[2],
'device_id': row[3],
'active': row[4]
})
return jsonify(results)
except Exception as e:
logging.error(f"Fehler bei der globalen Suche: {str(e)}")
return jsonify({'error': 'Fehler bei der Suche'}), 500
finally:
cur.close()
conn.close()
@api_bp.route("/generate-license-key", methods=['POST'])
@login_required
def api_generate_key():
"""API Endpoint zur Generierung eines neuen Lizenzschlüssels"""
try:
# Lizenztyp aus Request holen (default: full)
data = request.get_json() or {}
license_type = data.get('type', 'full')
# Key generieren
key = generate_license_key(license_type)
# Prüfen ob Key bereits existiert (sehr unwahrscheinlich aber sicher ist sicher)
conn = get_connection()
cur = conn.cursor()
# Wiederhole bis eindeutiger Key gefunden
attempts = 0
while attempts < 10: # Max 10 Versuche
cur.execute("SELECT 1 FROM licenses WHERE license_key = %s", (key,))
if not cur.fetchone():
break # Key ist eindeutig
key = generate_license_key(license_type)
attempts += 1
cur.close()
conn.close()
# Log für Audit
log_audit('GENERATE_KEY', 'license',
additional_info={'type': license_type, 'key': key})
return jsonify({
'success': True,
'key': key,
'type': license_type
})
except Exception as e:
logging.error(f"Fehler bei Key-Generierung: {str(e)}")
return jsonify({
'success': False,
'error': 'Fehler bei der Key-Generierung'
}), 500
@api_bp.route("/customers", methods=['GET'])
@login_required
def api_customers():
"""API Endpoint für die Kundensuche mit Select2"""
try:
# Suchparameter
search = request.args.get('q', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 20
customer_id = request.args.get('id', type=int)
conn = get_connection()
cur = conn.cursor()
# Einzelnen Kunden per ID abrufen
if customer_id:
cur.execute("""
SELECT c.id, c.name, c.email,
COUNT(l.id) as license_count
FROM customers c
LEFT JOIN licenses l ON c.id = l.customer_id
WHERE c.id = %s
GROUP BY c.id, c.name, c.email
""", (customer_id,))
customer = cur.fetchone()
results = []
if customer:
results.append({
'id': customer[0],
'text': f"{customer[1]} ({customer[2]})",
'name': customer[1],
'email': customer[2],
'license_count': customer[3]
})
cur.close()
conn.close()
return jsonify({
'results': results,
'pagination': {'more': False}
})
# SQL Query mit optionaler Suche
elif search:
cur.execute("""
SELECT c.id, c.name, c.email,
COUNT(l.id) as license_count
FROM customers c
LEFT JOIN licenses l ON c.id = l.customer_id
WHERE LOWER(c.name) LIKE LOWER(%s)
OR LOWER(c.email) LIKE LOWER(%s)
GROUP BY c.id, c.name, c.email
ORDER BY c.name
LIMIT %s OFFSET %s
""", (f'%{search}%', f'%{search}%', per_page, (page - 1) * per_page))
else:
cur.execute("""
SELECT c.id, c.name, c.email,
COUNT(l.id) as license_count
FROM customers c
LEFT JOIN licenses l ON c.id = l.customer_id
GROUP BY c.id, c.name, c.email
ORDER BY c.name
LIMIT %s OFFSET %s
""", (per_page, (page - 1) * per_page))
customers = cur.fetchall()
# Format für Select2
results = []
for customer in customers:
results.append({
'id': customer[0],
'text': f"{customer[1]} - {customer[2]} ({customer[3]} Lizenzen)",
'name': customer[1],
'email': customer[2],
'license_count': customer[3]
})
# Gesamtanzahl für Pagination
if search:
cur.execute("""
SELECT COUNT(*) FROM customers
WHERE LOWER(name) LIKE LOWER(%s)
OR LOWER(email) LIKE LOWER(%s)
""", (f'%{search}%', f'%{search}%'))
else:
cur.execute("SELECT COUNT(*) FROM customers")
total_count = cur.fetchone()[0]
cur.close()
conn.close()
# Select2 Response Format
return jsonify({
'results': results,
'pagination': {
'more': (page * per_page) < total_count
}
})
except Exception as e:
logging.error(f"Fehler bei Kundensuche: {str(e)}")
return jsonify({
'results': [],
'pagination': {'more': False},
'error': str(e)
}), 500

Datei anzeigen

@ -0,0 +1,377 @@
import time
import json
from datetime import datetime
from zoneinfo import ZoneInfo
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
import config
from auth.decorators import login_required
from auth.password import hash_password, verify_password
from auth.two_factor import (
generate_totp_secret, generate_qr_code, verify_totp,
generate_backup_codes, hash_backup_code, verify_backup_code
)
from auth.rate_limiting import (
check_ip_blocked, record_failed_attempt,
reset_login_attempts, get_login_attempts
)
from utils.network import get_client_ip
from utils.audit import log_audit
from models import get_user_by_username
from db import get_db_connection, get_db_cursor
from utils.recaptcha import verify_recaptcha
# Create Blueprint
auth_bp = Blueprint('auth', __name__)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
# Timing-Attack Schutz - Start Zeit merken
start_time = time.time()
# IP-Adresse ermitteln
ip_address = get_client_ip()
# Prüfen ob IP gesperrt ist
is_blocked, blocked_until = check_ip_blocked(ip_address)
if is_blocked:
time_remaining = (blocked_until - datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None)).total_seconds() / 3600
error_msg = f"IP GESPERRT! Noch {time_remaining:.1f} Stunden warten."
return render_template("login.html", error=error_msg, error_type="blocked")
# Anzahl bisheriger Versuche
attempt_count = get_login_attempts(ip_address)
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
captcha_response = request.form.get("g-recaptcha-response")
# CAPTCHA-Prüfung nur wenn Keys konfiguriert sind
recaptcha_site_key = config.RECAPTCHA_SITE_KEY
if attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and recaptcha_site_key:
if not captcha_response:
# Timing-Attack Schutz
elapsed = time.time() - start_time
if elapsed < 1.0:
time.sleep(1.0 - elapsed)
return render_template("login.html",
error="CAPTCHA ERFORDERLICH!",
show_captcha=True,
error_type="captcha",
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
recaptcha_site_key=recaptcha_site_key)
# CAPTCHA validieren
if not verify_recaptcha(captcha_response):
# Timing-Attack Schutz
elapsed = time.time() - start_time
if elapsed < 1.0:
time.sleep(1.0 - elapsed)
return render_template("login.html",
error="CAPTCHA UNGÜLTIG! Bitte erneut versuchen.",
show_captcha=True,
error_type="captcha",
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
recaptcha_site_key=recaptcha_site_key)
# Check user in database first, fallback to env vars
user = get_user_by_username(username)
login_success = False
needs_2fa = False
if user:
# Database user authentication
if verify_password(password, user['password_hash']):
login_success = True
needs_2fa = user['totp_enabled']
else:
# Fallback to environment variables for backward compatibility
if username in config.ADMIN_USERS and password == config.ADMIN_USERS[username]:
login_success = True
# Timing-Attack Schutz - Mindestens 1 Sekunde warten
elapsed = time.time() - start_time
if elapsed < 1.0:
time.sleep(1.0 - elapsed)
if login_success:
# Erfolgreicher Login
if needs_2fa:
# Store temporary session for 2FA verification
session['temp_username'] = username
session['temp_user_id'] = user['id']
session['awaiting_2fa'] = True
return redirect(url_for('auth.verify_2fa'))
else:
# Complete login without 2FA
session.permanent = True # Aktiviert das Timeout
session['logged_in'] = True
session['username'] = username
session['user_id'] = user['id'] if user else None
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
reset_login_attempts(ip_address)
log_audit('LOGIN_SUCCESS', 'user',
additional_info=f"Erfolgreiche Anmeldung von IP: {ip_address}")
return redirect(url_for('admin.dashboard'))
else:
# Fehlgeschlagener Login
error_message = record_failed_attempt(ip_address, username)
new_attempt_count = get_login_attempts(ip_address)
# Prüfen ob jetzt gesperrt
is_now_blocked, _ = check_ip_blocked(ip_address)
if is_now_blocked:
log_audit('LOGIN_BLOCKED', 'security',
additional_info=f"IP {ip_address} wurde nach {config.MAX_LOGIN_ATTEMPTS} Versuchen gesperrt")
return render_template("login.html",
error=error_message,
show_captcha=(new_attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY),
error_type="failed",
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - new_attempt_count),
recaptcha_site_key=config.RECAPTCHA_SITE_KEY)
# GET Request
return render_template("login.html",
show_captcha=(attempt_count >= config.CAPTCHA_AFTER_ATTEMPTS and config.RECAPTCHA_SITE_KEY),
attempts_left=max(0, config.MAX_LOGIN_ATTEMPTS - attempt_count),
recaptcha_site_key=config.RECAPTCHA_SITE_KEY)
@auth_bp.route("/logout")
def logout():
username = session.get('username', 'unknown')
log_audit('LOGOUT', 'user', additional_info=f"Abmeldung")
session.pop('logged_in', None)
session.pop('username', None)
session.pop('user_id', None)
session.pop('temp_username', None)
session.pop('temp_user_id', None)
session.pop('awaiting_2fa', None)
return redirect(url_for('auth.login'))
@auth_bp.route("/verify-2fa", methods=["GET", "POST"])
def verify_2fa():
if not session.get('awaiting_2fa'):
return redirect(url_for('auth.login'))
if request.method == "POST":
token = request.form.get('token', '').replace(' ', '')
username = session.get('temp_username')
user_id = session.get('temp_user_id')
if not username or not user_id:
flash('Session expired. Please login again.', 'error')
return redirect(url_for('auth.login'))
user = get_user_by_username(username)
if not user:
flash('User not found.', 'error')
return redirect(url_for('auth.login'))
# Check if it's a backup code
if len(token) == 8 and token.isupper():
# Try backup code
backup_codes = json.loads(user['backup_codes']) if user['backup_codes'] else []
if verify_backup_code(token, backup_codes):
# Remove used backup code
code_hash = hash_backup_code(token)
backup_codes.remove(code_hash)
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("UPDATE users SET backup_codes = %s WHERE id = %s",
(json.dumps(backup_codes), user_id))
# Complete login
session.permanent = True
session['logged_in'] = True
session['username'] = username
session['user_id'] = user_id
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
session.pop('temp_username', None)
session.pop('temp_user_id', None)
session.pop('awaiting_2fa', None)
flash('Login successful using backup code. Please generate new backup codes.', 'warning')
log_audit('LOGIN_2FA_BACKUP', 'user', additional_info=f"2FA login with backup code")
return redirect(url_for('admin.dashboard'))
else:
# Try TOTP token
if verify_totp(user['totp_secret'], token):
# Complete login
session.permanent = True
session['logged_in'] = True
session['username'] = username
session['user_id'] = user_id
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
session.pop('temp_username', None)
session.pop('temp_user_id', None)
session.pop('awaiting_2fa', None)
log_audit('LOGIN_2FA_SUCCESS', 'user', additional_info=f"2FA login successful")
return redirect(url_for('admin.dashboard'))
# Failed verification
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("UPDATE users SET failed_2fa_attempts = failed_2fa_attempts + 1, last_failed_2fa = %s WHERE id = %s",
(datetime.now(), user_id))
flash('Invalid authentication code. Please try again.', 'error')
log_audit('LOGIN_2FA_FAILED', 'user', additional_info=f"Failed 2FA attempt")
return render_template('verify_2fa.html')
@auth_bp.route("/profile")
@login_required
def profile():
user = get_user_by_username(session['username'])
if not user:
# For environment-based users, redirect with message
flash('Bitte führen Sie das Migrations-Script aus, um Passwort-Änderung und 2FA zu aktivieren.', 'info')
return redirect(url_for('admin.dashboard'))
return render_template('profile.html', user=user)
@auth_bp.route("/profile/change-password", methods=["POST"])
@login_required
def change_password():
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
user = get_user_by_username(session['username'])
# Verify current password
if not verify_password(current_password, user['password_hash']):
flash('Current password is incorrect.', 'error')
return redirect(url_for('auth.profile'))
# Check new password
if new_password != confirm_password:
flash('New passwords do not match.', 'error')
return redirect(url_for('auth.profile'))
if len(new_password) < 8:
flash('Password must be at least 8 characters long.', 'error')
return redirect(url_for('auth.profile'))
# Update password
new_hash = hash_password(new_password)
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("UPDATE users SET password_hash = %s, last_password_change = %s WHERE id = %s",
(new_hash, datetime.now(), user['id']))
log_audit('PASSWORD_CHANGE', 'user', entity_id=user['id'],
additional_info="Password changed successfully")
flash('Password changed successfully.', 'success')
return redirect(url_for('auth.profile'))
@auth_bp.route("/profile/setup-2fa")
@login_required
def setup_2fa():
user = get_user_by_username(session['username'])
if user['totp_enabled']:
flash('2FA is already enabled for your account.', 'info')
return redirect(url_for('auth.profile'))
# Generate new TOTP secret
totp_secret = generate_totp_secret()
session['temp_totp_secret'] = totp_secret
# Generate QR code
qr_code = generate_qr_code(user['username'], totp_secret)
return render_template('setup_2fa.html',
totp_secret=totp_secret,
qr_code=qr_code)
@auth_bp.route("/profile/enable-2fa", methods=["POST"])
@login_required
def enable_2fa():
token = request.form.get('token', '').replace(' ', '')
totp_secret = session.get('temp_totp_secret')
if not totp_secret:
flash('2FA setup session expired. Please try again.', 'error')
return redirect(url_for('auth.setup_2fa'))
# Verify the token
if not verify_totp(totp_secret, token):
flash('Invalid authentication code. Please try again.', 'error')
return redirect(url_for('auth.setup_2fa'))
# Generate backup codes
backup_codes = generate_backup_codes()
backup_codes_hashed = [hash_backup_code(code) for code in backup_codes]
# Enable 2FA for user
user = get_user_by_username(session['username'])
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
UPDATE users
SET totp_secret = %s, totp_enabled = true, backup_codes = %s
WHERE id = %s
""", (totp_secret, json.dumps(backup_codes_hashed), user['id']))
# Clear temp secret
session.pop('temp_totp_secret', None)
log_audit('2FA_ENABLED', 'user', entity_id=user['id'],
additional_info="2FA successfully enabled")
# Show backup codes
return render_template('backup_codes.html', backup_codes=backup_codes)
@auth_bp.route("/profile/disable-2fa", methods=["POST"])
@login_required
def disable_2fa():
password = request.form.get('password')
user = get_user_by_username(session['username'])
# Verify password
if not verify_password(password, user['password_hash']):
flash('Incorrect password. 2FA was not disabled.', 'error')
return redirect(url_for('auth.profile'))
# Disable 2FA
with get_db_connection() as conn:
with get_db_cursor(conn) as cur:
cur.execute("""
UPDATE users
SET totp_enabled = false, totp_secret = NULL, backup_codes = NULL
WHERE id = %s
""", (user['id'],))
log_audit('2FA_DISABLED', 'user', entity_id=user['id'],
additional_info="2FA disabled by user")
flash('2FA has been disabled for your account.', 'success')
return redirect(url_for('auth.profile'))
@auth_bp.route("/heartbeat", methods=['POST'])
@login_required
def heartbeat():
"""Endpoint für Session Keep-Alive - aktualisiert last_activity"""
# Aktualisiere last_activity nur wenn explizit angefordert
session['last_activity'] = datetime.now(ZoneInfo("Europe/Berlin")).replace(tzinfo=None).isoformat()
# Force session save
session.modified = True
return jsonify({
'status': 'ok',
'last_activity': session['last_activity'],
'username': session.get('username')
})

Datei anzeigen

@ -0,0 +1,377 @@
import os
import logging
import secrets
import string
from datetime import datetime, timedelta
from pathlib import Path
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, send_file
import config
from auth.decorators import login_required
from utils.audit import log_audit
from utils.network import get_client_ip
from utils.export import create_batch_export
from db import get_connection, get_db_connection, get_db_cursor
from models import get_customers
# Create Blueprint
batch_bp = Blueprint('batch', __name__)
def generate_license_key():
"""Generiert einen zufälligen Lizenzschlüssel"""
chars = string.ascii_uppercase + string.digits
return '-'.join([''.join(secrets.choice(chars) for _ in range(4)) for _ in range(4)])
@batch_bp.route("/batch", methods=["GET", "POST"])
@login_required
def batch_create():
"""Batch-Erstellung von Lizenzen"""
customers = get_customers()
if request.method == "POST":
conn = get_connection()
cur = conn.cursor()
try:
# Form data
customer_id = int(request.form['customer_id'])
license_type = request.form['license_type']
count = int(request.form['count'])
valid_from = request.form['valid_from']
valid_until = request.form['valid_until']
device_limit = int(request.form['device_limit'])
is_test = 'is_test' in request.form
# Validierung
if count < 1 or count > 100:
flash('Anzahl muss zwischen 1 und 100 liegen!', 'error')
return redirect(url_for('batch.batch_create'))
# Hole Kundendaten
cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,))
customer = cur.fetchone()
if not customer:
flash('Kunde nicht gefunden!', 'error')
return redirect(url_for('batch.batch_create'))
created_licenses = []
# Erstelle Lizenzen
for i in range(count):
license_key = generate_license_key()
# Prüfe ob Schlüssel bereits existiert
while True:
cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,))
if not cur.fetchone():
break
license_key = generate_license_key()
# Erstelle Lizenz
cur.execute("""
INSERT INTO licenses (
license_key, customer_id, customer_name, customer_email,
license_type, valid_from, valid_until, device_limit,
is_test, created_at, created_by
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
license_key, customer_id, customer[0], customer[1],
license_type, valid_from, valid_until, device_limit,
is_test, datetime.now(), session['username']
))
license_id = cur.fetchone()[0]
created_licenses.append({
'id': license_id,
'license_key': license_key
})
# Audit-Log
log_audit('CREATE', 'license', license_id,
new_values={
'license_key': license_key,
'customer_name': customer[0],
'batch_creation': True
})
conn.commit()
# Speichere erstellte Lizenzen in Session für Export
session['batch_created_licenses'] = created_licenses
flash(f'{count} Lizenzen erfolgreich erstellt!', 'success')
# Weiterleitung zum Export
return redirect(url_for('batch.batch_export'))
except Exception as e:
conn.rollback()
logging.error(f"Fehler bei Batch-Erstellung: {str(e)}")
flash('Fehler bei der Batch-Erstellung!', 'error')
finally:
cur.close()
conn.close()
return render_template("batch_create.html", customers=customers)
@batch_bp.route("/batch/export")
@login_required
def batch_export():
"""Exportiert die zuletzt erstellten Batch-Lizenzen"""
created_licenses = session.get('batch_created_licenses', [])
if not created_licenses:
flash('Keine Lizenzen zum Exportieren gefunden!', 'error')
return redirect(url_for('batch.batch_create'))
conn = get_connection()
cur = conn.cursor()
try:
# Hole vollständige Lizenzdaten
license_ids = [l['id'] for l in created_licenses]
cur.execute("""
SELECT
l.license_key, l.customer_name, l.customer_email,
l.license_type, l.valid_from, l.valid_until,
l.device_limit, l.is_test, l.created_at
FROM licenses l
WHERE l.id = ANY(%s)
ORDER BY l.id
""", (license_ids,))
licenses = []
for row in cur.fetchall():
licenses.append({
'license_key': row[0],
'customer_name': row[1],
'customer_email': row[2],
'license_type': row[3],
'valid_from': row[4],
'valid_until': row[5],
'device_limit': row[6],
'is_test': row[7],
'created_at': row[8]
})
# Erstelle Excel-Export
excel_file = create_batch_export(licenses)
# Lösche aus Session
session.pop('batch_created_licenses', None)
# Sende Datei
filename = f"batch_licenses_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
flash('Fehler beim Exportieren der Lizenzen!', 'error')
return redirect(url_for('batch.batch_create'))
finally:
cur.close()
conn.close()
@batch_bp.route("/batch/update", methods=["GET", "POST"])
@login_required
def batch_update():
"""Batch-Update von Lizenzen"""
if request.method == "POST":
conn = get_connection()
cur = conn.cursor()
try:
# Form data
license_keys = request.form.get('license_keys', '').strip().split('\n')
license_keys = [key.strip() for key in license_keys if key.strip()]
if not license_keys:
flash('Keine Lizenzschlüssel angegeben!', 'error')
return redirect(url_for('batch.batch_update'))
# Update-Parameter
updates = []
params = []
if 'update_valid_until' in request.form and request.form['valid_until']:
updates.append("valid_until = %s")
params.append(request.form['valid_until'])
if 'update_device_limit' in request.form and request.form['device_limit']:
updates.append("device_limit = %s")
params.append(int(request.form['device_limit']))
if 'update_active' in request.form:
updates.append("active = %s")
params.append('active' in request.form)
if not updates:
flash('Keine Änderungen angegeben!', 'error')
return redirect(url_for('batch.batch_update'))
# Führe Updates aus
updated_count = 0
not_found = []
for license_key in license_keys:
# Prüfe ob Lizenz existiert
cur.execute("SELECT id FROM licenses WHERE license_key = %s", (license_key,))
result = cur.fetchone()
if not result:
not_found.append(license_key)
continue
license_id = result[0]
# Update ausführen
update_params = params + [license_id]
cur.execute(f"""
UPDATE licenses
SET {', '.join(updates)}
WHERE id = %s
""", update_params)
# Audit-Log
log_audit('BATCH_UPDATE', 'license', license_id,
additional_info=f"Batch-Update: {', '.join(updates)}")
updated_count += 1
conn.commit()
# Feedback
flash(f'{updated_count} Lizenzen erfolgreich aktualisiert!', 'success')
if not_found:
flash(f'{len(not_found)} Lizenzen nicht gefunden: {", ".join(not_found[:5])}{"..." if len(not_found) > 5 else ""}', 'warning')
except Exception as e:
conn.rollback()
logging.error(f"Fehler bei Batch-Update: {str(e)}")
flash('Fehler beim Batch-Update!', 'error')
finally:
cur.close()
conn.close()
return render_template("batch_update.html")
@batch_bp.route("/batch/import", methods=["GET", "POST"])
@login_required
def batch_import():
"""Import von Lizenzen aus CSV/Excel"""
if request.method == "POST":
if 'file' not in request.files:
flash('Keine Datei ausgewählt!', 'error')
return redirect(url_for('batch.batch_import'))
file = request.files['file']
if file.filename == '':
flash('Keine Datei ausgewählt!', 'error')
return redirect(url_for('batch.batch_import'))
# Verarbeite Datei
try:
import pandas as pd
# Lese Datei
if file.filename.endswith('.csv'):
df = pd.read_csv(file)
elif file.filename.endswith(('.xlsx', '.xls')):
df = pd.read_excel(file)
else:
flash('Ungültiges Dateiformat! Nur CSV und Excel erlaubt.', 'error')
return redirect(url_for('batch.batch_import'))
# Validiere Spalten
required_columns = ['customer_email', 'license_type', 'valid_from', 'valid_until', 'device_limit']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
flash(f'Fehlende Spalten: {", ".join(missing_columns)}', 'error')
return redirect(url_for('batch.batch_import'))
conn = get_connection()
cur = conn.cursor()
imported_count = 0
errors = []
for index, row in df.iterrows():
try:
# Finde oder erstelle Kunde
email = row['customer_email']
cur.execute("SELECT id, name FROM customers WHERE email = %s", (email,))
customer = cur.fetchone()
if not customer:
# Erstelle neuen Kunden
name = row.get('customer_name', email.split('@')[0])
cur.execute("""
INSERT INTO customers (name, email, created_at)
VALUES (%s, %s, %s)
RETURNING id
""", (name, email, datetime.now()))
customer_id = cur.fetchone()[0]
customer_name = name
else:
customer_id = customer[0]
customer_name = customer[1]
# Generiere Lizenzschlüssel
license_key = row.get('license_key', generate_license_key())
# Erstelle Lizenz
cur.execute("""
INSERT INTO licenses (
license_key, customer_id, customer_name, customer_email,
license_type, valid_from, valid_until, device_limit,
is_test, created_at, created_by
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
license_key, customer_id, customer_name, email,
row['license_type'], row['valid_from'], row['valid_until'],
int(row['device_limit']), row.get('is_test', False),
datetime.now(), session['username']
))
license_id = cur.fetchone()[0]
imported_count += 1
# Audit-Log
log_audit('IMPORT', 'license', license_id,
additional_info=f"Importiert aus {file.filename}")
except Exception as e:
errors.append(f"Zeile {index + 2}: {str(e)}")
conn.commit()
# Feedback
flash(f'{imported_count} Lizenzen erfolgreich importiert!', 'success')
if errors:
flash(f'{len(errors)} Fehler aufgetreten. Erste Fehler: {"; ".join(errors[:3])}', 'warning')
except Exception as e:
logging.error(f"Fehler beim Import: {str(e)}")
flash(f'Fehler beim Import: {str(e)}', 'error')
finally:
if 'conn' in locals():
cur.close()
conn.close()
return render_template("batch_import.html")

Datei anzeigen

@ -0,0 +1,338 @@
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
import config
from auth.decorators import login_required
from utils.audit import log_audit
from db import get_connection, get_db_connection, get_db_cursor
from models import get_customers, get_customer_by_id
# Create Blueprint
customer_bp = Blueprint('customers', __name__)
@customer_bp.route("/customers")
@login_required
def customers():
customers_list = get_customers()
return render_template("customers.html", customers=customers_list)
@customer_bp.route("/customer/edit/<int:customer_id>", methods=["GET", "POST"])
@login_required
def edit_customer(customer_id):
conn = get_connection()
cur = conn.cursor()
if request.method == "POST":
try:
# Get current customer data for comparison
current_customer = get_customer_by_id(customer_id)
if not current_customer:
flash('Kunde nicht gefunden!', 'error')
return redirect(url_for('customers.customers'))
# Update customer data
new_values = {
'name': request.form['name'],
'email': request.form['email'],
'phone': request.form.get('phone', ''),
'address': request.form.get('address', ''),
'notes': request.form.get('notes', '')
}
cur.execute("""
UPDATE customers
SET name = %s, email = %s, phone = %s, address = %s, notes = %s
WHERE id = %s
""", (
new_values['name'],
new_values['email'],
new_values['phone'],
new_values['address'],
new_values['notes'],
customer_id
))
conn.commit()
# Log changes
log_audit('UPDATE', 'customer', customer_id,
old_values={
'name': current_customer['name'],
'email': current_customer['email'],
'phone': current_customer.get('phone', ''),
'address': current_customer.get('address', ''),
'notes': current_customer.get('notes', '')
},
new_values=new_values)
flash('Kunde erfolgreich aktualisiert!', 'success')
return redirect(url_for('customers.customers'))
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Aktualisieren des Kunden: {str(e)}")
flash('Fehler beim Aktualisieren des Kunden!', 'error')
finally:
cur.close()
conn.close()
# GET request
customer_data = get_customer_by_id(customer_id)
if not customer_data:
flash('Kunde nicht gefunden!', 'error')
return redirect(url_for('customers.customers'))
return render_template("edit_customer.html", customer=customer_data)
@customer_bp.route("/customer/create", methods=["GET", "POST"])
@login_required
def create_customer():
if request.method == "POST":
conn = get_connection()
cur = conn.cursor()
try:
# Insert new customer
name = request.form['name']
email = request.form['email']
phone = request.form.get('phone', '')
address = request.form.get('address', '')
notes = request.form.get('notes', '')
cur.execute("""
INSERT INTO customers (name, email, phone, address, notes, created_at)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (name, email, phone, address, notes, datetime.now()))
customer_id = cur.fetchone()[0]
conn.commit()
# Log creation
log_audit('CREATE', 'customer', customer_id,
new_values={
'name': name,
'email': email,
'phone': phone,
'address': address,
'notes': notes
})
flash(f'Kunde {name} erfolgreich erstellt!', 'success')
return redirect(url_for('customers.customers'))
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Erstellen des Kunden: {str(e)}")
flash('Fehler beim Erstellen des Kunden!', 'error')
finally:
cur.close()
conn.close()
return render_template("create_customer.html")
@customer_bp.route("/customer/delete/<int:customer_id>", methods=["POST"])
@login_required
def delete_customer(customer_id):
conn = get_connection()
cur = conn.cursor()
try:
# Get customer data before deletion
customer_data = get_customer_by_id(customer_id)
if not customer_data:
flash('Kunde nicht gefunden!', 'error')
return redirect(url_for('customers.customers'))
# Check if customer has licenses
cur.execute("SELECT COUNT(*) FROM licenses WHERE customer_id = %s", (customer_id,))
license_count = cur.fetchone()[0]
if license_count > 0:
flash(f'Kunde kann nicht gelöscht werden - hat noch {license_count} Lizenz(en)!', 'error')
return redirect(url_for('customers.customers'))
# Delete the customer
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
conn.commit()
# Log deletion
log_audit('DELETE', 'customer', customer_id,
old_values={
'name': customer_data['name'],
'email': customer_data['email']
})
flash(f'Kunde {customer_data["name"]} erfolgreich gelöscht!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Löschen des Kunden: {str(e)}")
flash('Fehler beim Löschen des Kunden!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('customers.customers'))
@customer_bp.route("/customers-licenses")
@login_required
def customers_licenses():
"""Zeigt die Übersicht von Kunden und deren Lizenzen"""
conn = get_connection()
cur = conn.cursor()
try:
# Hole alle Kunden mit ihren Lizenzen
cur.execute("""
SELECT
c.id as customer_id,
c.name as customer_name,
c.email as customer_email,
c.created_at as customer_created,
COUNT(l.id) as license_count,
COUNT(CASE WHEN l.active = true THEN 1 END) as active_licenses,
COUNT(CASE WHEN l.is_test = true THEN 1 END) as test_licenses,
MAX(l.created_at) as last_license_created
FROM customers c
LEFT JOIN licenses l ON c.id = l.customer_id
GROUP BY c.id, c.name, c.email, c.created_at
ORDER BY c.name
""")
customers = []
for row in cur.fetchall():
customers.append({
'id': row[0],
'name': row[1],
'email': row[2],
'created_at': row[3],
'license_count': row[4],
'active_licenses': row[5],
'test_licenses': row[6],
'last_license_created': row[7]
})
return render_template("customers_licenses.html", customers=customers)
except Exception as e:
logging.error(f"Fehler beim Laden der Kunden-Lizenz-Übersicht: {str(e)}")
flash('Fehler beim Laden der Daten!', 'error')
return redirect(url_for('admin.dashboard'))
finally:
cur.close()
conn.close()
@customer_bp.route("/api/customer/<int:customer_id>/licenses")
@login_required
def api_customer_licenses(customer_id):
"""API-Endpunkt für die Lizenzen eines Kunden"""
conn = get_connection()
cur = conn.cursor()
try:
# Hole Kundeninformationen
customer = get_customer_by_id(customer_id)
if not customer:
return jsonify({'error': 'Kunde nicht gefunden'}), 404
# Hole alle Lizenzen des Kunden
cur.execute("""
SELECT
l.id,
l.license_key,
l.license_type,
l.active,
l.is_test,
l.valid_from,
l.valid_until,
l.device_limit,
l.created_at,
(SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.active = true) as active_sessions,
(SELECT COUNT(DISTINCT device_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices,
CASE
WHEN l.valid_until < CURRENT_DATE THEN 'expired'
WHEN l.active = false THEN 'inactive'
ELSE 'active'
END as status
FROM licenses l
WHERE l.customer_id = %s
ORDER BY l.created_at DESC
""", (customer_id,))
licenses = []
for row in cur.fetchall():
licenses.append({
'id': row[0],
'license_key': row[1],
'license_type': row[2],
'active': row[3],
'is_test': row[4],
'valid_from': row[5].strftime('%Y-%m-%d') if row[5] else None,
'valid_until': row[6].strftime('%Y-%m-%d') if row[6] else None,
'device_limit': row[7],
'created_at': row[8].strftime('%Y-%m-%d %H:%M:%S') if row[8] else None,
'active_sessions': row[9],
'registered_devices': row[10],
'status': row[11]
})
return jsonify({
'customer': {
'id': customer['id'],
'name': customer['name'],
'email': customer['email']
},
'licenses': licenses
})
except Exception as e:
logging.error(f"Fehler beim Laden der Kundenlizenzen: {str(e)}")
return jsonify({'error': 'Fehler beim Laden der Daten'}), 500
finally:
cur.close()
conn.close()
@customer_bp.route("/api/customer/<int:customer_id>/quick-stats")
@login_required
def api_customer_quick_stats(customer_id):
"""Schnelle Statistiken für einen Kunden"""
conn = get_connection()
cur = conn.cursor()
try:
cur.execute("""
SELECT
COUNT(l.id) as total_licenses,
COUNT(CASE WHEN l.active = true THEN 1 END) as active_licenses,
COUNT(CASE WHEN l.is_test = true THEN 1 END) as test_licenses,
SUM(l.device_limit) as total_device_limit
FROM licenses l
WHERE l.customer_id = %s
""", (customer_id,))
row = cur.fetchone()
return jsonify({
'total_licenses': row[0] or 0,
'active_licenses': row[1] or 0,
'test_licenses': row[2] or 0,
'total_device_limit': row[3] or 0
})
except Exception as e:
logging.error(f"Fehler beim Laden der Kundenstatistiken: {str(e)}")
return jsonify({'error': 'Fehler beim Laden der Daten'}), 500
finally:
cur.close()
conn.close()

Datei anzeigen

@ -0,0 +1,364 @@
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Blueprint, request, send_file
import config
from auth.decorators import login_required
from utils.export import create_excel_export, prepare_audit_export_data
from db import get_connection
# Create Blueprint
export_bp = Blueprint('export', __name__, url_prefix='/export')
@export_bp.route("/licenses")
@login_required
def export_licenses():
"""Exportiert Lizenzen als Excel-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# Filter aus Request
show_test = request.args.get('show_test', 'false') == 'true'
# SQL Query mit optionalem Test-Filter
if show_test:
query = """
SELECT
l.id,
l.license_key,
c.name as customer_name,
c.email as customer_email,
l.license_type,
l.valid_from,
l.valid_until,
l.active,
l.device_limit,
l.created_at,
l.is_test,
CASE
WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen'
WHEN l.active = false THEN 'Deaktiviert'
ELSE 'Aktiv'
END as status,
(SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.active = true) as active_sessions,
(SELECT COUNT(DISTINCT device_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices
FROM licenses l
LEFT JOIN customers c ON l.customer_id = c.id
ORDER BY l.created_at DESC
"""
else:
query = """
SELECT
l.id,
l.license_key,
c.name as customer_name,
c.email as customer_email,
l.license_type,
l.valid_from,
l.valid_until,
l.active,
l.device_limit,
l.created_at,
l.is_test,
CASE
WHEN l.valid_until < CURRENT_DATE THEN 'Abgelaufen'
WHEN l.active = false THEN 'Deaktiviert'
ELSE 'Aktiv'
END as status,
(SELECT COUNT(*) FROM sessions s WHERE s.license_key = l.license_key AND s.active = true) as active_sessions,
(SELECT COUNT(DISTINCT device_id) FROM sessions s WHERE s.license_key = l.license_key) as registered_devices
FROM licenses l
LEFT JOIN customers c ON l.customer_id = c.id
WHERE l.is_test = false
ORDER BY l.created_at DESC
"""
cur.execute(query)
# Daten für Export vorbereiten
data = []
columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'E-Mail', 'Typ', 'Gültig von',
'Gültig bis', 'Aktiv', 'Gerätelimit', 'Erstellt am', 'Test-Lizenz',
'Status', 'Aktive Sessions', 'Registrierte Geräte']
for row in cur.fetchall():
data.append(list(row))
# Excel-Datei erstellen
excel_file = create_excel_export(data, columns, 'Lizenzen')
# Datei senden
filename = f"lizenzen_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Lizenzen", 500
finally:
cur.close()
conn.close()
@export_bp.route("/audit")
@login_required
def export_audit():
"""Exportiert Audit-Logs als Excel-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# Filter aus Request
days = int(request.args.get('days', 30))
action_filter = request.args.get('action', '')
entity_type_filter = request.args.get('entity_type', '')
# Daten für Export vorbereiten
data = prepare_audit_export_data(days, action_filter, entity_type_filter)
# Excel-Datei erstellen
columns = ['Zeitstempel', 'Benutzer', 'Aktion', 'Entität', 'Entität ID',
'IP-Adresse', 'Alte Werte', 'Neue Werte', 'Zusatzinfo']
excel_file = create_excel_export(data, columns, 'Audit-Log')
# Datei senden
filename = f"audit_log_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Audit-Logs", 500
finally:
cur.close()
conn.close()
@export_bp.route("/customers")
@login_required
def export_customers():
"""Exportiert Kunden als Excel-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# SQL Query
cur.execute("""
SELECT
c.id,
c.name,
c.email,
c.phone,
c.address,
c.created_at,
c.is_test,
COUNT(l.id) as license_count,
COUNT(CASE WHEN l.active = true THEN 1 END) as active_licenses,
COUNT(CASE WHEN l.valid_until < CURRENT_DATE THEN 1 END) as expired_licenses
FROM customers c
LEFT JOIN licenses l ON c.id = l.customer_id
GROUP BY c.id, c.name, c.email, c.phone, c.address, c.created_at, c.is_test
ORDER BY c.name
""")
# Daten für Export vorbereiten
data = []
columns = ['ID', 'Name', 'E-Mail', 'Telefon', 'Adresse', 'Erstellt am',
'Test-Kunde', 'Anzahl Lizenzen', 'Aktive Lizenzen', 'Abgelaufene Lizenzen']
for row in cur.fetchall():
data.append(list(row))
# Excel-Datei erstellen
excel_file = create_excel_export(data, columns, 'Kunden')
# Datei senden
filename = f"kunden_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Kunden", 500
finally:
cur.close()
conn.close()
@export_bp.route("/sessions")
@login_required
def export_sessions():
"""Exportiert Sessions als Excel-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# Filter aus Request
days = int(request.args.get('days', 7))
active_only = request.args.get('active_only', 'false') == 'true'
# SQL Query
if active_only:
query = """
SELECT
s.id,
s.license_key,
l.customer_name,
s.username,
s.device_id,
s.login_time,
s.logout_time,
s.last_activity,
s.active,
l.license_type,
l.is_test
FROM sessions s
LEFT JOIN licenses l ON s.license_key = l.license_key
WHERE s.active = true
ORDER BY s.login_time DESC
"""
cur.execute(query)
else:
query = """
SELECT
s.id,
s.license_key,
l.customer_name,
s.username,
s.device_id,
s.login_time,
s.logout_time,
s.last_activity,
s.active,
l.license_type,
l.is_test
FROM sessions s
LEFT JOIN licenses l ON s.license_key = l.license_key
WHERE s.login_time >= CURRENT_TIMESTAMP - INTERVAL '%s days'
ORDER BY s.login_time DESC
"""
cur.execute(query, (days,))
# Daten für Export vorbereiten
data = []
columns = ['ID', 'Lizenzschlüssel', 'Kunde', 'Benutzer', 'Geräte-ID',
'Login-Zeit', 'Logout-Zeit', 'Letzte Aktivität', 'Aktiv',
'Lizenztyp', 'Test-Lizenz']
for row in cur.fetchall():
data.append(list(row))
# Excel-Datei erstellen
excel_file = create_excel_export(data, columns, 'Sessions')
# Datei senden
filename = f"sessions_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Sessions", 500
finally:
cur.close()
conn.close()
@export_bp.route("/resources")
@login_required
def export_resources():
"""Exportiert Ressourcen als Excel-Datei"""
conn = get_connection()
cur = conn.cursor()
try:
# Filter aus Request
resource_type = request.args.get('type', 'all')
status_filter = request.args.get('status', 'all')
show_test = request.args.get('show_test', 'false') == 'true'
# SQL Query aufbauen
query = """
SELECT
rp.id,
rp.resource_type,
rp.resource_value,
rp.status,
rp.is_test,
l.license_key,
c.name as customer_name,
rp.created_at,
rp.created_by,
rp.status_changed_at,
rp.status_changed_by,
rp.quarantine_reason
FROM resource_pools rp
LEFT JOIN licenses l ON rp.allocated_to_license = l.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE 1=1
"""
params = []
if resource_type != 'all':
query += " AND rp.resource_type = %s"
params.append(resource_type)
if status_filter != 'all':
query += " AND rp.status = %s"
params.append(status_filter)
if not show_test:
query += " AND rp.is_test = false"
query += " ORDER BY rp.resource_type, rp.resource_value"
cur.execute(query, params)
# Daten für Export vorbereiten
data = []
columns = ['ID', 'Typ', 'Wert', 'Status', 'Test-Ressource', 'Lizenzschlüssel',
'Kunde', 'Erstellt am', 'Erstellt von', 'Status geändert am',
'Status geändert von', 'Quarantäne-Grund']
for row in cur.fetchall():
data.append(list(row))
# Excel-Datei erstellen
excel_file = create_excel_export(data, columns, 'Ressourcen')
# Datei senden
filename = f"ressourcen_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return send_file(
excel_file,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
logging.error(f"Fehler beim Export: {str(e)}")
return "Fehler beim Exportieren der Ressourcen", 500
finally:
cur.close()
conn.close()

Datei anzeigen

@ -0,0 +1,374 @@
import os
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from dateutil.relativedelta import relativedelta
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
import config
from auth.decorators import login_required
from utils.audit import log_audit
from utils.network import get_client_ip
from utils.license import validate_license_key
from db import get_connection, get_db_connection, get_db_cursor
from models import get_licenses, get_license_by_id
# Create Blueprint
license_bp = Blueprint('licenses', __name__)
@license_bp.route("/licenses")
@login_required
def licenses():
show_test = request.args.get('show_test', 'false') == 'true'
licenses_list = get_licenses(show_test=show_test)
return render_template("licenses.html", licenses=licenses_list, show_test=show_test)
@license_bp.route("/license/edit/<int:license_id>", methods=["GET", "POST"])
@login_required
def edit_license(license_id):
conn = get_connection()
cur = conn.cursor()
if request.method == "POST":
try:
# Get current license data for comparison
current_license = get_license_by_id(license_id)
if not current_license:
flash('Lizenz nicht gefunden!', 'error')
return redirect(url_for('licenses.licenses'))
# Update license data
new_values = {
'customer_name': request.form['customer_name'],
'customer_email': request.form['customer_email'],
'valid_from': request.form['valid_from'],
'valid_until': request.form['valid_until'],
'device_limit': int(request.form['device_limit']),
'active': 'active' in request.form
}
cur.execute("""
UPDATE licenses
SET customer_name = %s, customer_email = %s, valid_from = %s,
valid_until = %s, device_limit = %s, active = %s
WHERE id = %s
""", (
new_values['customer_name'],
new_values['customer_email'],
new_values['valid_from'],
new_values['valid_until'],
new_values['device_limit'],
new_values['active'],
license_id
))
conn.commit()
# Log changes
log_audit('UPDATE', 'license', license_id,
old_values={
'customer_name': current_license['customer_name'],
'customer_email': current_license['customer_email'],
'valid_from': str(current_license['valid_from']),
'valid_until': str(current_license['valid_until']),
'device_limit': current_license['device_limit'],
'active': current_license['active']
},
new_values=new_values)
flash('Lizenz erfolgreich aktualisiert!', 'success')
# Preserve show_test parameter if present
show_test = request.args.get('show_test', 'false')
return redirect(url_for('licenses.licenses', show_test=show_test))
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Aktualisieren der Lizenz: {str(e)}")
flash('Fehler beim Aktualisieren der Lizenz!', 'error')
finally:
cur.close()
conn.close()
# GET request
license_data = get_license_by_id(license_id)
if not license_data:
flash('Lizenz nicht gefunden!', 'error')
return redirect(url_for('licenses.licenses'))
return render_template("edit_license.html", license=license_data)
@license_bp.route("/license/delete/<int:license_id>", methods=["POST"])
@login_required
def delete_license(license_id):
conn = get_connection()
cur = conn.cursor()
try:
# Get license data before deletion
license_data = get_license_by_id(license_id)
if not license_data:
flash('Lizenz nicht gefunden!', 'error')
return redirect(url_for('licenses.licenses'))
# Delete from sessions first
cur.execute("DELETE FROM sessions WHERE license_key = %s", (license_data['license_key'],))
# Delete the license
cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,))
conn.commit()
# Log deletion
log_audit('DELETE', 'license', license_id,
old_values={
'license_key': license_data['license_key'],
'customer_name': license_data['customer_name'],
'customer_email': license_data['customer_email']
})
flash(f'Lizenz {license_data["license_key"]} erfolgreich gelöscht!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Löschen der Lizenz: {str(e)}")
flash('Fehler beim Löschen der Lizenz!', 'error')
finally:
cur.close()
conn.close()
# Preserve show_test parameter if present
show_test = request.args.get('show_test', 'false')
return redirect(url_for('licenses.licenses', show_test=show_test))
@license_bp.route("/create", methods=["GET", "POST"])
@login_required
def create_license():
if request.method == "POST":
customer_id = request.form.get("customer_id")
license_key = request.form["license_key"].upper() # Immer Großbuchstaben
license_type = request.form["license_type"]
valid_from = request.form["valid_from"]
is_test = request.form.get("is_test") == "on" # Checkbox value
# Berechne valid_until basierend auf Laufzeit
duration = int(request.form.get("duration", 1))
duration_type = request.form.get("duration_type", "years")
start_date = datetime.strptime(valid_from, "%Y-%m-%d")
if duration_type == "days":
end_date = start_date + timedelta(days=duration)
elif duration_type == "months":
end_date = start_date + relativedelta(months=duration)
else: # years
end_date = start_date + relativedelta(years=duration)
# Ein Tag abziehen, da der Starttag mitgezählt wird
end_date = end_date - timedelta(days=1)
valid_until = end_date.strftime("%Y-%m-%d")
# Validiere License Key Format
if not validate_license_key(license_key):
flash('Ungültiges License Key Format! Erwartet: AF-YYYYMMFT-XXXX-YYYY-ZZZZ', 'error')
return redirect(url_for('licenses.create_license'))
# Resource counts
domain_count = int(request.form.get("domain_count", 1))
ipv4_count = int(request.form.get("ipv4_count", 1))
phone_count = int(request.form.get("phone_count", 1))
device_limit = int(request.form.get("device_limit", 3))
conn = get_connection()
cur = conn.cursor()
try:
# Prüfe ob neuer Kunde oder bestehender
if customer_id == "new":
# Neuer Kunde
name = request.form.get("customer_name")
email = request.form.get("email")
if not name:
flash('Kundenname ist erforderlich!', 'error')
return redirect(url_for('licenses.create_license'))
# Prüfe ob E-Mail bereits existiert
if email:
cur.execute("SELECT id, name FROM customers WHERE LOWER(email) = LOWER(%s)", (email,))
existing = cur.fetchone()
if existing:
flash(f'E-Mail bereits vergeben für Kunde: {existing[1]}', 'error')
return redirect(url_for('licenses.create_license'))
# Kunde einfügen (erbt Test-Status von Lizenz)
cur.execute("""
INSERT INTO customers (name, email, is_test, created_at)
VALUES (%s, %s, %s, NOW())
RETURNING id
""", (name, email, is_test))
customer_id = cur.fetchone()[0]
customer_info = {'name': name, 'email': email, 'is_test': is_test}
# Audit-Log für neuen Kunden
log_audit('CREATE', 'customer', customer_id,
new_values={'name': name, 'email': email, 'is_test': is_test})
else:
# Bestehender Kunde - hole Infos für Audit-Log
cur.execute("SELECT name, email, is_test FROM customers WHERE id = %s", (customer_id,))
customer_data = cur.fetchone()
if not customer_data:
flash('Kunde nicht gefunden!', 'error')
return redirect(url_for('licenses.create_license'))
customer_info = {'name': customer_data[0], 'email': customer_data[1]}
# Wenn Kunde Test-Kunde ist, Lizenz auch als Test markieren
if customer_data[2]: # is_test des Kunden
is_test = True
# Lizenz hinzufügen
cur.execute("""
INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active,
domain_count, ipv4_count, phone_count, device_limit, is_test)
VALUES (%s, %s, %s, %s, %s, TRUE, %s, %s, %s, %s, %s)
RETURNING id
""", (license_key, customer_id, license_type, valid_from, valid_until,
domain_count, ipv4_count, phone_count, device_limit, is_test))
license_id = cur.fetchone()[0]
# Ressourcen zuweisen
try:
# Prüfe Verfügbarkeit
cur.execute("""
SELECT
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s) as domains,
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s) as ipv4s,
(SELECT COUNT(*) FROM resource_pools WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s) as phones
""", (is_test, is_test, is_test))
available = cur.fetchone()
if available[0] < domain_count:
raise ValueError(f"Nicht genügend Domains verfügbar (benötigt: {domain_count}, verfügbar: {available[0]})")
if available[1] < ipv4_count:
raise ValueError(f"Nicht genügend IPv4-Adressen verfügbar (benötigt: {ipv4_count}, verfügbar: {available[1]})")
if available[2] < phone_count:
raise ValueError(f"Nicht genügend Telefonnummern verfügbar (benötigt: {phone_count}, verfügbar: {available[2]})")
# Domains zuweisen
if domain_count > 0:
cur.execute("""
SELECT id FROM resource_pools
WHERE resource_type = 'domain' AND status = 'available' AND is_test = %s
LIMIT %s FOR UPDATE
""", (is_test, domain_count))
for (resource_id,) in cur.fetchall():
cur.execute("""
UPDATE resource_pools
SET status = 'allocated', allocated_to_license = %s,
status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
WHERE id = %s
""", (license_id, session['username'], resource_id))
cur.execute("""
INSERT INTO license_resources (license_id, resource_id, assigned_by)
VALUES (%s, %s, %s)
""", (license_id, resource_id, session['username']))
cur.execute("""
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
VALUES (%s, %s, 'allocated', %s, %s)
""", (resource_id, license_id, session['username'], get_client_ip()))
# IPv4s zuweisen
if ipv4_count > 0:
cur.execute("""
SELECT id FROM resource_pools
WHERE resource_type = 'ipv4' AND status = 'available' AND is_test = %s
LIMIT %s FOR UPDATE
""", (is_test, ipv4_count))
for (resource_id,) in cur.fetchall():
cur.execute("""
UPDATE resource_pools
SET status = 'allocated', allocated_to_license = %s,
status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
WHERE id = %s
""", (license_id, session['username'], resource_id))
cur.execute("""
INSERT INTO license_resources (license_id, resource_id, assigned_by)
VALUES (%s, %s, %s)
""", (license_id, resource_id, session['username']))
cur.execute("""
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
VALUES (%s, %s, 'allocated', %s, %s)
""", (resource_id, license_id, session['username'], get_client_ip()))
# Telefonnummern zuweisen
if phone_count > 0:
cur.execute("""
SELECT id FROM resource_pools
WHERE resource_type = 'phone' AND status = 'available' AND is_test = %s
LIMIT %s FOR UPDATE
""", (is_test, phone_count))
for (resource_id,) in cur.fetchall():
cur.execute("""
UPDATE resource_pools
SET status = 'allocated', allocated_to_license = %s,
status_changed_at = CURRENT_TIMESTAMP, status_changed_by = %s
WHERE id = %s
""", (license_id, session['username'], resource_id))
cur.execute("""
INSERT INTO license_resources (license_id, resource_id, assigned_by)
VALUES (%s, %s, %s)
""", (license_id, resource_id, session['username']))
cur.execute("""
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
VALUES (%s, %s, 'allocated', %s, %s)
""", (resource_id, license_id, session['username'], get_client_ip()))
except ValueError as e:
conn.rollback()
flash(str(e), 'error')
return redirect(url_for('licenses.create_license'))
conn.commit()
# Audit-Log
log_audit('CREATE', 'license', license_id,
new_values={
'license_key': license_key,
'customer_name': customer_info['name'],
'customer_email': customer_info['email'],
'license_type': license_type,
'valid_from': valid_from,
'valid_until': valid_until,
'device_limit': device_limit,
'is_test': is_test
})
flash(f'Lizenz {license_key} erfolgreich erstellt!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Erstellen der Lizenz: {str(e)}")
flash('Fehler beim Erstellen der Lizenz!', 'error')
finally:
cur.close()
conn.close()
# Preserve show_test parameter if present
redirect_url = url_for('licenses.create_license')
if request.args.get('show_test') == 'true':
redirect_url += "?show_test=true"
return redirect(redirect_url)
# Unterstützung für vorausgewählten Kunden
preselected_customer_id = request.args.get('customer_id', type=int)
return render_template("index.html", username=session.get('username'), preselected_customer_id=preselected_customer_id)

Datei anzeigen

@ -0,0 +1,617 @@
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
from flask import Blueprint, render_template, request, redirect, session, url_for, flash, jsonify
import config
from auth.decorators import login_required
from utils.audit import log_audit
from utils.network import get_client_ip
from db import get_connection, get_db_connection, get_db_cursor
# Create Blueprint
resource_bp = Blueprint('resources', __name__)
@resource_bp.route('/resources')
@login_required
def resources():
"""Zeigt die Ressourcenpool-Übersicht"""
conn = get_connection()
cur = conn.cursor()
try:
# Filter aus Query-Parametern
resource_type = request.args.get('type', 'all')
status_filter = request.args.get('status', 'all')
search_query = request.args.get('search', '')
show_test = request.args.get('show_test', 'false') == 'true'
# Basis-Query
query = """
SELECT
rp.id,
rp.resource_type,
rp.resource_value,
rp.status,
rp.is_test,
rp.allocated_to_license,
rp.created_at,
rp.status_changed_at,
rp.status_changed_by,
l.customer_name,
l.license_type
FROM resource_pools rp
LEFT JOIN licenses l ON rp.allocated_to_license = l.id
WHERE 1=1
"""
params = []
# Filter anwenden
if resource_type != 'all':
query += " AND rp.resource_type = %s"
params.append(resource_type)
if status_filter != 'all':
query += " AND rp.status = %s"
params.append(status_filter)
if search_query:
query += " AND (rp.resource_value ILIKE %s OR l.customer_name ILIKE %s)"
params.extend([f'%{search_query}%', f'%{search_query}%'])
if not show_test:
query += " AND rp.is_test = false"
query += " ORDER BY rp.resource_type, rp.resource_value"
cur.execute(query, params)
resources_list = []
for row in cur.fetchall():
resources_list.append({
'id': row[0],
'resource_type': row[1],
'resource_value': row[2],
'status': row[3],
'is_test': row[4],
'allocated_to_license': row[5],
'created_at': row[6],
'status_changed_at': row[7],
'status_changed_by': row[8],
'customer_name': row[9],
'license_type': row[10]
})
# Statistiken
cur.execute("""
SELECT
resource_type,
status,
is_test,
COUNT(*) as count
FROM resource_pools
GROUP BY resource_type, status, is_test
""")
stats = {}
for row in cur.fetchall():
res_type = row[0]
status = row[1]
is_test = row[2]
count = row[3]
if res_type not in stats:
stats[res_type] = {'available': 0, 'allocated': 0, 'quarantined': 0, 'test': 0, 'prod': 0}
stats[res_type][status] = stats[res_type].get(status, 0) + count
if is_test:
stats[res_type]['test'] += count
else:
stats[res_type]['prod'] += count
return render_template('resources.html',
resources=resources_list,
stats=stats,
resource_type=resource_type,
status_filter=status_filter,
search_query=search_query,
show_test=show_test)
except Exception as e:
logging.error(f"Fehler beim Laden der Ressourcen: {str(e)}")
flash('Fehler beim Laden der Ressourcen!', 'error')
return redirect(url_for('admin.dashboard'))
finally:
cur.close()
conn.close()
@resource_bp.route('/resources/add', methods=['GET', 'POST'])
@login_required
def add_resource():
"""Neue Ressource hinzufügen"""
if request.method == 'POST':
conn = get_connection()
cur = conn.cursor()
try:
resource_type = request.form['resource_type']
resource_value = request.form['resource_value'].strip()
is_test = 'is_test' in request.form
# Prüfe ob Ressource bereits existiert
cur.execute("""
SELECT id FROM resource_pools
WHERE resource_type = %s AND resource_value = %s
""", (resource_type, resource_value))
if cur.fetchone():
flash(f'Ressource {resource_value} existiert bereits!', 'error')
return redirect(url_for('resources.add_resource'))
# Füge neue Ressource hinzu
cur.execute("""
INSERT INTO resource_pools (resource_type, resource_value, status, is_test, created_by)
VALUES (%s, %s, 'available', %s, %s)
RETURNING id
""", (resource_type, resource_value, is_test, session['username']))
resource_id = cur.fetchone()[0]
conn.commit()
# Audit-Log
log_audit('CREATE', 'resource', resource_id,
new_values={
'resource_type': resource_type,
'resource_value': resource_value,
'is_test': is_test
})
flash(f'Ressource {resource_value} erfolgreich hinzugefügt!', 'success')
return redirect(url_for('resources.resources'))
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Hinzufügen der Ressource: {str(e)}")
flash('Fehler beim Hinzufügen der Ressource!', 'error')
finally:
cur.close()
conn.close()
return render_template('add_resource.html')
@resource_bp.route('/resources/quarantine/<int:resource_id>', methods=['POST'])
@login_required
def quarantine_resource(resource_id):
"""Ressource in Quarantäne versetzen"""
conn = get_connection()
cur = conn.cursor()
try:
reason = request.form.get('reason', '')
# Hole aktuelle Ressourcen-Info
cur.execute("""
SELECT resource_value, status, allocated_to_license
FROM resource_pools WHERE id = %s
""", (resource_id,))
resource = cur.fetchone()
if not resource:
flash('Ressource nicht gefunden!', 'error')
return redirect(url_for('resources.resources'))
# Setze Status auf quarantined
cur.execute("""
UPDATE resource_pools
SET status = 'quarantined',
allocated_to_license = NULL,
status_changed_at = CURRENT_TIMESTAMP,
status_changed_by = %s,
quarantine_reason = %s
WHERE id = %s
""", (session['username'], reason, resource_id))
# Wenn die Ressource zugewiesen war, entferne die Zuweisung
if resource[2]: # allocated_to_license
cur.execute("""
DELETE FROM license_resources
WHERE license_id = %s AND resource_id = %s
""", (resource[2], resource_id))
# History-Eintrag
cur.execute("""
INSERT INTO resource_history (resource_id, license_id, action, action_by, notes, ip_address)
VALUES (%s, %s, 'quarantined', %s, %s, %s)
""", (resource_id, resource[2], session['username'], reason, get_client_ip()))
conn.commit()
# Audit-Log
log_audit('QUARANTINE', 'resource', resource_id,
old_values={'status': resource[1]},
new_values={'status': 'quarantined', 'reason': reason})
flash(f'Ressource {resource[0]} in Quarantäne versetzt!', 'warning')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Quarantänisieren der Ressource: {str(e)}")
flash('Fehler beim Quarantänisieren der Ressource!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('resources.resources'))
@resource_bp.route('/resources/release', methods=['POST'])
@login_required
def release_resources():
"""Ressourcen aus Quarantäne freigeben oder von Lizenz entfernen"""
conn = get_connection()
cur = conn.cursor()
try:
resource_ids = request.form.getlist('resource_ids[]')
action = request.form.get('action', 'release')
if not resource_ids:
flash('Keine Ressourcen ausgewählt!', 'error')
return redirect(url_for('resources.resources'))
for resource_id in resource_ids:
# Hole aktuelle Ressourcen-Info
cur.execute("""
SELECT resource_value, status, allocated_to_license
FROM resource_pools WHERE id = %s
""", (resource_id,))
resource = cur.fetchone()
if resource:
# Setze Status auf available
cur.execute("""
UPDATE resource_pools
SET status = 'available',
allocated_to_license = NULL,
status_changed_at = CURRENT_TIMESTAMP,
status_changed_by = %s,
quarantine_reason = NULL
WHERE id = %s
""", (session['username'], resource_id))
# Entferne Lizenz-Zuweisung wenn vorhanden
if resource[2]: # allocated_to_license
cur.execute("""
DELETE FROM license_resources
WHERE license_id = %s AND resource_id = %s
""", (resource[2], resource_id))
# History-Eintrag
cur.execute("""
INSERT INTO resource_history (resource_id, license_id, action, action_by, ip_address)
VALUES (%s, %s, 'released', %s, %s)
""", (resource_id, resource[2], session['username'], get_client_ip()))
# Audit-Log
log_audit('RELEASE', 'resource', resource_id,
old_values={'status': resource[1]},
new_values={'status': 'available'})
conn.commit()
flash(f'{len(resource_ids)} Ressource(n) freigegeben!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Freigeben der Ressourcen: {str(e)}")
flash('Fehler beim Freigeben der Ressourcen!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('resources.resources'))
@resource_bp.route('/resources/history/<int:resource_id>')
@login_required
def resource_history(resource_id):
"""Zeigt die Historie einer Ressource"""
conn = get_connection()
cur = conn.cursor()
try:
# Hole Ressourcen-Info
cur.execute("""
SELECT resource_type, resource_value, status, is_test
FROM resource_pools WHERE id = %s
""", (resource_id,))
resource = cur.fetchone()
if not resource:
flash('Ressource nicht gefunden!', 'error')
return redirect(url_for('resources.resources'))
# Hole Historie
cur.execute("""
SELECT
rh.action,
rh.action_timestamp,
rh.action_by,
rh.notes,
rh.ip_address,
l.license_key,
c.name as customer_name
FROM resource_history rh
LEFT JOIN licenses l ON rh.license_id = l.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE rh.resource_id = %s
ORDER BY rh.action_timestamp DESC
""", (resource_id,))
history = []
for row in cur.fetchall():
history.append({
'action': row[0],
'timestamp': row[1],
'by': row[2],
'notes': row[3],
'ip_address': row[4],
'license_key': row[5],
'customer_name': row[6]
})
return render_template('resource_history.html',
resource={
'id': resource_id,
'type': resource[0],
'value': resource[1],
'status': resource[2],
'is_test': resource[3]
},
history=history)
except Exception as e:
logging.error(f"Fehler beim Laden der Ressourcen-Historie: {str(e)}")
flash('Fehler beim Laden der Historie!', 'error')
return redirect(url_for('resources.resources'))
finally:
cur.close()
conn.close()
@resource_bp.route('/resources/metrics')
@login_required
def resource_metrics():
"""Zeigt Metriken und Statistiken zu Ressourcen"""
conn = get_connection()
cur = conn.cursor()
try:
# Allgemeine Statistiken
cur.execute("""
SELECT
resource_type,
status,
is_test,
COUNT(*) as count
FROM resource_pools
GROUP BY resource_type, status, is_test
ORDER BY resource_type, status
""")
general_stats = {}
for row in cur.fetchall():
res_type = row[0]
if res_type not in general_stats:
general_stats[res_type] = {
'total': 0,
'available': 0,
'allocated': 0,
'quarantined': 0,
'test': 0,
'production': 0
}
general_stats[res_type]['total'] += row[3]
general_stats[res_type][row[1]] += row[3]
if row[2]:
general_stats[res_type]['test'] += row[3]
else:
general_stats[res_type]['production'] += row[3]
# Zuweisungs-Statistiken
cur.execute("""
SELECT
rp.resource_type,
COUNT(DISTINCT l.customer_id) as unique_customers,
COUNT(DISTINCT rp.allocated_to_license) as unique_licenses
FROM resource_pools rp
JOIN licenses l ON rp.allocated_to_license = l.id
WHERE rp.status = 'allocated'
GROUP BY rp.resource_type
""")
allocation_stats = {}
for row in cur.fetchall():
allocation_stats[row[0]] = {
'unique_customers': row[1],
'unique_licenses': row[2]
}
# Historische Daten (letzte 30 Tage)
cur.execute("""
SELECT
DATE(action_timestamp) as date,
action,
COUNT(*) as count
FROM resource_history
WHERE action_timestamp >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY DATE(action_timestamp), action
ORDER BY date, action
""")
historical_data = {}
for row in cur.fetchall():
date_str = row[0].strftime('%Y-%m-%d')
if date_str not in historical_data:
historical_data[date_str] = {}
historical_data[date_str][row[1]] = row[2]
# Top-Kunden nach Ressourcennutzung
cur.execute("""
SELECT
c.name,
rp.resource_type,
COUNT(*) as count
FROM resource_pools rp
JOIN licenses l ON rp.allocated_to_license = l.id
JOIN customers c ON l.customer_id = c.id
WHERE rp.status = 'allocated'
GROUP BY c.name, rp.resource_type
ORDER BY count DESC
LIMIT 20
""")
top_customers = []
for row in cur.fetchall():
top_customers.append({
'customer': row[0],
'resource_type': row[1],
'count': row[2]
})
return render_template('resource_metrics.html',
general_stats=general_stats,
allocation_stats=allocation_stats,
historical_data=historical_data,
top_customers=top_customers)
except Exception as e:
logging.error(f"Fehler beim Laden der Ressourcen-Metriken: {str(e)}")
flash('Fehler beim Laden der Metriken!', 'error')
return redirect(url_for('resources.resources'))
finally:
cur.close()
conn.close()
@resource_bp.route('/resources/report', methods=['GET'])
@login_required
def resource_report():
"""Generiert einen Ressourcen-Report"""
from io import BytesIO
import xlsxwriter
conn = get_connection()
cur = conn.cursor()
try:
# Erstelle Excel-Datei im Speicher
output = BytesIO()
workbook = xlsxwriter.Workbook(output)
# Formatierungen
header_format = workbook.add_format({
'bold': True,
'bg_color': '#4CAF50',
'font_color': 'white',
'border': 1
})
date_format = workbook.add_format({'num_format': 'dd.mm.yyyy hh:mm'})
# Sheet 1: Übersicht
overview_sheet = workbook.add_worksheet('Übersicht')
# Header
headers = ['Ressourcen-Typ', 'Gesamt', 'Verfügbar', 'Zugewiesen', 'Quarantäne', 'Test', 'Produktion']
for col, header in enumerate(headers):
overview_sheet.write(0, col, header, header_format)
# Daten
cur.execute("""
SELECT
resource_type,
COUNT(*) as total,
COUNT(CASE WHEN status = 'available' THEN 1 END) as available,
COUNT(CASE WHEN status = 'allocated' THEN 1 END) as allocated,
COUNT(CASE WHEN status = 'quarantined' THEN 1 END) as quarantined,
COUNT(CASE WHEN is_test = true THEN 1 END) as test,
COUNT(CASE WHEN is_test = false THEN 1 END) as production
FROM resource_pools
GROUP BY resource_type
ORDER BY resource_type
""")
row = 1
for data in cur.fetchall():
for col, value in enumerate(data):
overview_sheet.write(row, col, value)
row += 1
# Sheet 2: Detailliste
detail_sheet = workbook.add_worksheet('Detailliste')
# Header
headers = ['Typ', 'Wert', 'Status', 'Test', 'Kunde', 'Lizenz', 'Zugewiesen am', 'Zugewiesen von']
for col, header in enumerate(headers):
detail_sheet.write(0, col, header, header_format)
# Daten
cur.execute("""
SELECT
rp.resource_type,
rp.resource_value,
rp.status,
rp.is_test,
c.name as customer_name,
l.license_key,
rp.status_changed_at,
rp.status_changed_by
FROM resource_pools rp
LEFT JOIN licenses l ON rp.allocated_to_license = l.id
LEFT JOIN customers c ON l.customer_id = c.id
ORDER BY rp.resource_type, rp.resource_value
""")
row = 1
for data in cur.fetchall():
for col, value in enumerate(data):
if col == 6 and value: # Datum
detail_sheet.write_datetime(row, col, value, date_format)
else:
detail_sheet.write(row, col, value if value is not None else '')
row += 1
# Spaltenbreiten anpassen
overview_sheet.set_column('A:A', 20)
overview_sheet.set_column('B:G', 12)
detail_sheet.set_column('A:A', 15)
detail_sheet.set_column('B:B', 30)
detail_sheet.set_column('C:D', 12)
detail_sheet.set_column('E:F', 25)
detail_sheet.set_column('G:H', 20)
workbook.close()
output.seek(0)
# Sende Datei
filename = f"ressourcen_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Exception as e:
logging.error(f"Fehler beim Generieren des Reports: {str(e)}")
flash('Fehler beim Generieren des Reports!', 'error')
return redirect(url_for('resources.resources'))
finally:
cur.close()
conn.close()

Datei anzeigen

@ -0,0 +1,388 @@
import logging
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Blueprint, render_template, request, redirect, session, url_for, flash
import config
from auth.decorators import login_required
from utils.audit import log_audit
from utils.network import get_client_ip
from db import get_connection, get_db_connection, get_db_cursor
from models import get_active_sessions
# Create Blueprint
session_bp = Blueprint('sessions', __name__)
@session_bp.route("/sessions")
@login_required
def sessions():
active_sessions = get_active_sessions()
return render_template("sessions.html", sessions=active_sessions)
@session_bp.route("/sessions/history")
@login_required
def session_history():
"""Zeigt die Session-Historie"""
conn = get_connection()
cur = conn.cursor()
try:
# Query parameters
license_key = request.args.get('license_key', '')
username = request.args.get('username', '')
days = int(request.args.get('days', 7))
# Base query
query = """
SELECT
s.id,
s.license_key,
s.username,
s.device_id,
s.login_time,
s.logout_time,
s.last_activity,
s.active,
l.customer_name,
l.license_type,
l.is_test
FROM sessions s
LEFT JOIN licenses l ON s.license_key = l.license_key
WHERE 1=1
"""
params = []
# Apply filters
if license_key:
query += " AND s.license_key = %s"
params.append(license_key)
if username:
query += " AND s.username ILIKE %s"
params.append(f'%{username}%')
# Time filter
query += " AND s.login_time >= CURRENT_TIMESTAMP - INTERVAL '%s days'"
params.append(days)
query += " ORDER BY s.login_time DESC LIMIT 1000"
cur.execute(query, params)
sessions_list = []
for row in cur.fetchall():
session_duration = None
if row[4] and row[5]: # login_time and logout_time
duration = row[5] - row[4]
hours = int(duration.total_seconds() // 3600)
minutes = int((duration.total_seconds() % 3600) // 60)
session_duration = f"{hours}h {minutes}m"
elif row[4] and row[7]: # login_time and active
duration = datetime.now(ZoneInfo("UTC")) - row[4]
hours = int(duration.total_seconds() // 3600)
minutes = int((duration.total_seconds() % 3600) // 60)
session_duration = f"{hours}h {minutes}m (aktiv)"
sessions_list.append({
'id': row[0],
'license_key': row[1],
'username': row[2],
'device_id': row[3],
'login_time': row[4],
'logout_time': row[5],
'last_activity': row[6],
'active': row[7],
'customer_name': row[8],
'license_type': row[9],
'is_test': row[10],
'duration': session_duration
})
# Get unique license keys for filter dropdown
cur.execute("""
SELECT DISTINCT s.license_key, l.customer_name
FROM sessions s
LEFT JOIN licenses l ON s.license_key = l.license_key
WHERE s.login_time >= CURRENT_TIMESTAMP - INTERVAL '30 days'
ORDER BY l.customer_name, s.license_key
""")
available_licenses = []
for row in cur.fetchall():
available_licenses.append({
'license_key': row[0],
'customer_name': row[1] or 'Unbekannt'
})
return render_template("session_history.html",
sessions=sessions_list,
available_licenses=available_licenses,
filters={
'license_key': license_key,
'username': username,
'days': days
})
except Exception as e:
logging.error(f"Fehler beim Laden der Session-Historie: {str(e)}")
flash('Fehler beim Laden der Session-Historie!', 'error')
return redirect(url_for('sessions.sessions'))
finally:
cur.close()
conn.close()
@session_bp.route("/session/terminate/<int:session_id>", methods=["POST"])
@login_required
def terminate_session(session_id):
"""Beendet eine aktive Session"""
conn = get_connection()
cur = conn.cursor()
try:
# Get session info
cur.execute("""
SELECT license_key, username, device_id
FROM sessions
WHERE id = %s AND active = true
""", (session_id,))
session_info = cur.fetchone()
if not session_info:
flash('Session nicht gefunden oder bereits beendet!', 'error')
return redirect(url_for('sessions.sessions'))
# Terminate session
cur.execute("""
UPDATE sessions
SET active = false, logout_time = CURRENT_TIMESTAMP
WHERE id = %s
""", (session_id,))
conn.commit()
# Audit log
log_audit('SESSION_TERMINATE', 'session', session_id,
additional_info=f"Session beendet für {session_info[1]} auf Lizenz {session_info[0]}")
flash('Session erfolgreich beendet!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Beenden der Session: {str(e)}")
flash('Fehler beim Beenden der Session!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('sessions.sessions'))
@session_bp.route("/sessions/terminate-all/<license_key>", methods=["POST"])
@login_required
def terminate_all_sessions(license_key):
"""Beendet alle aktiven Sessions einer Lizenz"""
conn = get_connection()
cur = conn.cursor()
try:
# Count active sessions
cur.execute("""
SELECT COUNT(*) FROM sessions
WHERE license_key = %s AND active = true
""", (license_key,))
active_count = cur.fetchone()[0]
if active_count == 0:
flash('Keine aktiven Sessions gefunden!', 'info')
return redirect(url_for('sessions.sessions'))
# Terminate all sessions
cur.execute("""
UPDATE sessions
SET active = false, logout_time = CURRENT_TIMESTAMP
WHERE license_key = %s AND active = true
""", (license_key,))
conn.commit()
# Audit log
log_audit('SESSION_TERMINATE_ALL', 'license', None,
additional_info=f"{active_count} Sessions beendet für Lizenz {license_key}")
flash(f'{active_count} Sessions erfolgreich beendet!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Beenden der Sessions: {str(e)}")
flash('Fehler beim Beenden der Sessions!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('sessions.sessions'))
@session_bp.route("/sessions/cleanup", methods=["POST"])
@login_required
def cleanup_sessions():
"""Bereinigt alte inaktive Sessions"""
conn = get_connection()
cur = conn.cursor()
try:
days = int(request.form.get('days', 30))
# Delete old inactive sessions
cur.execute("""
DELETE FROM sessions
WHERE active = false
AND logout_time < CURRENT_TIMESTAMP - INTERVAL '%s days'
RETURNING id
""", (days,))
deleted_ids = [row[0] for row in cur.fetchall()]
deleted_count = len(deleted_ids)
conn.commit()
# Audit log
if deleted_count > 0:
log_audit('SESSION_CLEANUP', 'system', None,
additional_info=f"{deleted_count} Sessions älter als {days} Tage gelöscht")
flash(f'{deleted_count} alte Sessions bereinigt!', 'success')
except Exception as e:
conn.rollback()
logging.error(f"Fehler beim Bereinigen der Sessions: {str(e)}")
flash('Fehler beim Bereinigen der Sessions!', 'error')
finally:
cur.close()
conn.close()
return redirect(url_for('sessions.session_history'))
@session_bp.route("/sessions/statistics")
@login_required
def session_statistics():
"""Zeigt Session-Statistiken"""
conn = get_connection()
cur = conn.cursor()
try:
# Aktuelle Statistiken
cur.execute("""
SELECT
COUNT(DISTINCT s.license_key) as active_licenses,
COUNT(DISTINCT s.username) as unique_users,
COUNT(DISTINCT s.device_id) as unique_devices,
COUNT(*) as total_active_sessions
FROM sessions s
WHERE s.active = true
""")
current_stats = cur.fetchone()
# Sessions nach Lizenztyp
cur.execute("""
SELECT
l.license_type,
COUNT(*) as session_count
FROM sessions s
JOIN licenses l ON s.license_key = l.license_key
WHERE s.active = true
GROUP BY l.license_type
ORDER BY session_count DESC
""")
sessions_by_type = []
for row in cur.fetchall():
sessions_by_type.append({
'license_type': row[0],
'count': row[1]
})
# Top 10 Lizenzen nach aktiven Sessions
cur.execute("""
SELECT
s.license_key,
l.customer_name,
COUNT(*) as session_count,
l.device_limit
FROM sessions s
JOIN licenses l ON s.license_key = l.license_key
WHERE s.active = true
GROUP BY s.license_key, l.customer_name, l.device_limit
ORDER BY session_count DESC
LIMIT 10
""")
top_licenses = []
for row in cur.fetchall():
top_licenses.append({
'license_key': row[0],
'customer_name': row[1],
'session_count': row[2],
'device_limit': row[3]
})
# Session-Verlauf (letzte 7 Tage)
cur.execute("""
SELECT
DATE(login_time) as date,
COUNT(*) as login_count,
COUNT(DISTINCT license_key) as unique_licenses,
COUNT(DISTINCT username) as unique_users
FROM sessions
WHERE login_time >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY DATE(login_time)
ORDER BY date
""")
session_history = []
for row in cur.fetchall():
session_history.append({
'date': row[0].strftime('%Y-%m-%d'),
'login_count': row[1],
'unique_licenses': row[2],
'unique_users': row[3]
})
# Durchschnittliche Session-Dauer
cur.execute("""
SELECT
AVG(EXTRACT(EPOCH FROM (logout_time - login_time))/3600) as avg_duration_hours
FROM sessions
WHERE active = false
AND logout_time IS NOT NULL
AND logout_time - login_time < INTERVAL '24 hours'
AND login_time >= CURRENT_DATE - INTERVAL '30 days'
""")
avg_duration = cur.fetchone()[0] or 0
return render_template("session_statistics.html",
current_stats={
'active_licenses': current_stats[0],
'unique_users': current_stats[1],
'unique_devices': current_stats[2],
'total_sessions': current_stats[3]
},
sessions_by_type=sessions_by_type,
top_licenses=top_licenses,
session_history=session_history,
avg_duration=round(avg_duration, 1))
except Exception as e:
logging.error(f"Fehler beim Laden der Session-Statistiken: {str(e)}")
flash('Fehler beim Laden der Statistiken!', 'error')
return redirect(url_for('sessions.sessions'))
finally:
cur.close()
conn.close()

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 %}

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden Mehr anzeigen