mail drin
fehlende routen drin
prod-reife erreicht.
TODOS:
Deine aktuellen offenen TODOs
1. Rate-Limiting (Spam-Schutz VOR dem reCAPTCHA)
Das reCAPTCHA hält zwar Bots ab, aber wenn jemand böswillig ein Script schreibt, das 1.000 POST-Requests pro Sekunde an /api/v1/submissions sendet, muss dein Backend jedes Mal Google kontaktieren und die Datenbank befragen. Das frisst Server-Ressourcen.
Todo: Einbinden von Flask-Limiter und Absichern der API-Route (z.B. max. 10 Submissions pro Minute pro IP-Adresse oder Tenant).
2. Billing & Feature-Enforcement (Limits pro Tenant durchsetzen)
Die Mandanten-Architektur und das config-Feld im JSON existieren bereits. Aber wir erzwingen diese Limits aktuell nicht im Backend.
Todo: Im Dashboard (templates/admin/tenants/view.html) UI-Controls einbauen, um Feature-Flags für einen Tenant festzulegen:
max_forms (z.B. 1 für Free-User, 10 für Pro-User).
allow_instant_mail (Dürfen E-Mails an den Tenant gesendet werden?).
allow_autoresponder (Darf der Autoresponder für Endkunden genutzt werden?).
Todo: Diese Limits hart im Backend (beim Erstellen von Forms und beim Mailversand) kontrollieren. (Der Mailer prüft das zwar schon halbwegs, das UI zum Pflegen fehlt aber noch komplett).
3. E-Mail Templates aufwerten (HTML statt Plain-Text)
Bisher versendet der Service E-Mails als reinen Text (plain). Für eine professionelle SaaS-Lösung sollten diese Mails ansprechend aussehen.
Todo: Im services/mailer.py den MIMEMultipart so erweitern, dass er saubere HTML-Mails aus einem Jinja2-Template (templates/emails/...) generiert, inklusive Branding/Logo deines Services.
4. Frontend-Snippet "Graceful Degradation"
Das embed.js arbeitet aktuell sehr gut. Aber was ist, wenn das Formeleon-Backend mal für 5 Minuten down ist?
Todo: Einen sauberen Timeout im fetch einbauen und dem Endkunden auf der Webseite eine schöne, fallback HTML-Meldung anzeigen ("Entschuldigung, das Formular ist momentan nicht erreichbar"), anstatt dass sich das Script lautlos in der Console mit einem CORS/Network-Error verabschiedet.
5. CI/CD Pipeline (Deployment)
Wenn der Code fertig ist, willst du ihn sicher nicht immer händisch per git pull auf deinen Produktionsserver schieben und dort docker compose up tippen.
Todo: Eine simple Pipeline (Github Actions / Gitea Actions) anlegen, die den Code testet und automatisch auf dem Zielserver ausrollt.
309 lines
14 KiB
HTML
309 lines
14 KiB
HTML
{# ==============================================================================
|
|
Formeleon - Multi-tenant Form Generator
|
|
Author: Björn Runden <br@runden.net>
|
|
Template: admin/dashboard.html
|
|
Description: Admin dashboard overview page with statistics and quick access.
|
|
============================================================================== #}
|
|
|
|
{% extends "base.html" %}
|
|
|
|
{% block title %}Dashboard - Formeleon{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container">
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<h1><i class="bi bi-speedometer2"></i> Dashboard</h1>
|
|
<p class="text-muted">Welcome, <strong>{{ user.name }}</strong>! | Role: <span class="badge bg-{{ 'primary' if user.role.value == 'admin' else 'secondary' }}">{{ user.role.value }}</span></p>
|
|
</div>
|
|
</div>
|
|
|
|
{# Statistics Cards #}
|
|
<div class="row mb-4">
|
|
<div class="col-md-3 col-6 mb-3">
|
|
<a href="{{ url_for('admin.list_tenants') }}" class="text-decoration-none">
|
|
<div class="card bg-primary text-white h-100 hover-shadow">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-center">
|
|
<i class="bi bi-buildings display-6"></i>
|
|
<div class="ms-3">
|
|
<h3 class="mb-0">{{ tenant_count }}</h3>
|
|
<p class="mb-0">Tenants</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
|
|
<div class="col-md-3 col-6 mb-3">
|
|
<a href="{{ url_for('admin.list_forms') }}" class="text-decoration-none">
|
|
<div class="card bg-success text-white h-100 hover-shadow">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-center">
|
|
<i class="bi bi-file-earmark-text display-6"></i>
|
|
<div class="ms-3">
|
|
<h3 class="mb-0">{{ form_count }}</h3>
|
|
<p class="mb-0">Forms</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
|
|
<div class="col-md-3 col-6 mb-3">
|
|
<a href="{{ url_for('admin.list_submissions') }}" class="text-decoration-none">
|
|
<div class="card bg-info text-white h-100 hover-shadow">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-center">
|
|
<i class="bi bi-inbox display-6"></i>
|
|
<div class="ms-3">
|
|
<h3 class="mb-0">{{ submission_count }}</h3>
|
|
<p class="mb-0">Submissions</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
|
|
<div class="col-md-3 col-6 mb-3">
|
|
<div class="card bg-warning text-dark">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-center">
|
|
<i class="bi bi-activity display-6"></i>
|
|
<div class="ms-3">
|
|
<h3 class="mb-0">100%</h3>
|
|
<p class="mb-0">Uptime</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
{# Recent Submissions #}
|
|
<div class="col-12 mb-4">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header bg-light">
|
|
<h5 class="mb-0"><i class="bi bi-inbox me-2"></i> Recent Submissions</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
{% if recent_submissions %}
|
|
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
|
<table class="table table-hover mb-0 align-middle">
|
|
<thead class="table-light sticky-top">
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Form</th>
|
|
<th>Tenant</th>
|
|
<th>Date</th>
|
|
<th>Status</th>
|
|
<th class="text-end">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for submission in recent_submissions %}
|
|
<tr>
|
|
<td><small class="font-monospace text-muted">{{ submission.id[:12] }}...</small></td>
|
|
<td><strong>{{ submission.form_config.name if submission.form_config else 'N/A' }}</strong></td>
|
|
<td>{{ submission.tenant.name if submission.tenant else 'N/A' }}</td>
|
|
<td>{{ submission.created_at.strftime('%Y-%m-%d %H:%M') if submission.created_at else 'N/A' }}</td>
|
|
<td>
|
|
{% if submission.status.value == 'processed' %}
|
|
<span class="badge bg-success">Processed</span>
|
|
{% elif submission.status.value == 'pending' %}
|
|
<span class="badge bg-warning text-dark">Pending</span>
|
|
{% elif submission.status.value == 'spam' %}
|
|
<span class="badge bg-danger">Spam</span>
|
|
{% else %}
|
|
<span class="badge bg-secondary">{{ submission.status.value }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td class="text-end">
|
|
<a href="{{ url_for('admin.view_submission', submission_id=submission.id) }}" class="btn btn-sm btn-outline-primary">
|
|
<i class="bi bi-eye"></i> View
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="p-5 text-center text-muted">
|
|
<i class="bi bi-inbox display-4 d-block mb-3"></i>
|
|
<p>No active recent submissions found.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="card-footer bg-light text-end">
|
|
<a href="{{ url_for('admin.list_submissions') }}" class="btn btn-sm btn-outline-primary">
|
|
<i class="bi bi-list"></i> View All Submissions
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# Recent Forms #}
|
|
<div class="col-12 mb-4">
|
|
<div class="card shadow-sm">
|
|
<div class="card-header bg-light">
|
|
<h5 class="mb-0"><i class="bi bi-file-earmark-text me-2"></i> Recent Forms</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
{% if recent_forms %}
|
|
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
|
<table class="table table-hover mb-0 align-middle">
|
|
<thead class="table-light sticky-top">
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Tenant</th>
|
|
<th>Fields</th>
|
|
<th>Status</th>
|
|
<th>Created</th>
|
|
<th class="text-end">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for form in recent_forms %}
|
|
<tr>
|
|
<td><strong>{{ form.name }}</strong></td>
|
|
<td>{{ form.tenant.name if form.tenant else 'N/A' }}</td>
|
|
<td><span class="badge bg-info text-dark">{{ form.get_field_definitions()|length }}</span></td>
|
|
<td>
|
|
{% if form.is_active %}
|
|
<span class="badge bg-success">Active</span>
|
|
{% else %}
|
|
<span class="badge bg-secondary">Inactive</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>{{ form.created_at.strftime('%Y-%m-%d') if form.created_at else 'N/A' }}</td>
|
|
<td class="text-end">
|
|
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-sm btn-outline-primary">
|
|
<i class="bi bi-pencil"></i> Edit
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="p-5 text-center text-muted">
|
|
<i class="bi bi-file-earmark-text display-4 d-block mb-3"></i>
|
|
<p>No forms have been created yet.</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="card-footer bg-light d-flex justify-content-between">
|
|
<a href="{{ url_for('admin.create_form') }}" class="btn btn-sm btn-primary">
|
|
<i class="bi bi-plus"></i> Create New Form
|
|
</a>
|
|
<a href="{{ url_for('admin.list_forms') }}" class="btn btn-sm btn-outline-primary">
|
|
<i class="bi bi-list"></i> View All Forms
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if user.is_admin() %}
|
|
{# Tenants Overview #}
|
|
<div class="row">
|
|
<div class="col-12 mb-4">
|
|
<div class="card">
|
|
<div class="card-header bg-light">
|
|
<h5><i class="bi bi-buildings"></i> Tenants Overview</h5>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
{% if tenants %}
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>ID</th>
|
|
<th>Status</th>
|
|
<th>Forms</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for tenant in tenants %}
|
|
<tr>
|
|
<td>{{ tenant.name }}</td>
|
|
<td><code>{{ tenant.id }}</code></td>
|
|
<td>
|
|
<span class="badge bg-{{ 'success' if tenant.status.value == 'active' else 'warning' if tenant.status.value == 'suspended' else 'danger' }}">
|
|
{{ tenant.status.value }}
|
|
</span>
|
|
</td>
|
|
<td>{{ tenant.forms|length }}</td>
|
|
<td>
|
|
<a href="{{ url_for('admin.view_tenant', tenant_id=tenant.id) }}" class="btn btn-sm btn-outline-secondary">
|
|
<i class="bi bi-eye"></i>
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="p-4 text-center text-muted">
|
|
<i class="bi bi-buildings display-4"></i>
|
|
<p>No tenants yet</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="card-footer bg-light">
|
|
<a href="{{ url_for('admin.list_tenants') }}" class="btn btn-sm btn-outline-primary">
|
|
<i class="bi bi-list"></i> View All Tenants
|
|
</a>
|
|
<a href="{{ url_for('admin.create_tenant') }}" class="btn btn-sm btn-primary">
|
|
<i class="bi bi-plus"></i> Create Tenant
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.dev-mode-banner {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
background: #ffc107;
|
|
color: #000;
|
|
padding: 10px 20px;
|
|
font-size: 0.9rem;
|
|
z-index: 1000;
|
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.card {
|
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
}
|
|
|
|
.display-6 {
|
|
font-size: calc(1.2525rem + 0.975vw);
|
|
}
|
|
|
|
.hover-shadow {
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
.hover-shadow:hover {
|
|
transform: translateY(-3px);
|
|
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
|
|
}
|
|
</style>
|
|
{% endblock %}
|