From 5fff17531d3d49d084cd34e2bc0cf01cf324c770 Mon Sep 17 00:00:00 2001 From: Bjoern Runden Date: Tue, 29 Sep 2026 05:16:01 +0200 Subject: [PATCH] i18 drin mail drin fehlende routen drin prod-reife erreicht. TODOS: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .continue/rules/CONTINUE.md | 127 ++++++ .env.example | 1 + .gitignore | 5 + README.EN.md | 50 ++- README.md | 46 +++ app.py | 6 + babel.cfg | 3 + config.py | 13 + docker-compose.yml | 12 + models.py | 2 + requirements.txt | 5 +- routes/admin.py | 108 ++++- routes/api.py | 501 +++++++--------------- services/exporter.py | 75 ++++ services/mailer.py | 125 ++++++ static/js/embed.js | 252 ++++++++++++ templates/admin/dashboard.html | 154 ++++--- templates/admin/forms/create.html | 435 ++++++++++++++++++++ templates/admin/forms/view.html | 524 ++++++++++++++++++++++++ templates/admin/profile.html | 110 +++++ templates/admin/settings.html | 131 ++++++ templates/admin/submissions/list.html | 145 +++++++ templates/admin/submissions/view.html | 102 +++++ templates/admin/users/list.html | 178 ++++---- templates/admin/users/tenants.html | 2 +- templates/base.html | 2 +- templates/index.html | 2 +- templates/navbar.html | 14 +- tests_html/index.html | 16 + translations/de/LC_MESSAGES/messages.po | 56 +++ utils/i18n.py | 31 ++ 31 files changed, 2683 insertions(+), 550 deletions(-) create mode 100644 .continue/rules/CONTINUE.md create mode 100644 babel.cfg create mode 100644 services/exporter.py create mode 100644 services/mailer.py create mode 100644 static/js/embed.js create mode 100644 templates/admin/forms/create.html create mode 100644 templates/admin/forms/view.html create mode 100644 templates/admin/profile.html create mode 100644 templates/admin/settings.html create mode 100644 templates/admin/submissions/list.html create mode 100644 templates/admin/submissions/view.html create mode 100644 tests_html/index.html create mode 100644 translations/de/LC_MESSAGES/messages.po create mode 100644 utils/i18n.py diff --git a/.continue/rules/CONTINUE.md b/.continue/rules/CONTINUE.md new file mode 100644 index 0000000..b43b719 --- /dev/null +++ b/.continue/rules/CONTINUE.md @@ -0,0 +1,127 @@ +--- +name: Formeleon Rules +description: Projekt-Richtlinien für Formeleon +--- + +# Formeleon - Projekt Leitfaden (CONTINUE.md) + +Willkommen im **Formeleon** Projekt! Diese Datei dient als Leitfaden und Regelwerk für Entwickler und KI-Assistenten (wie Continue.dev), die an dieser Codebasis arbeiten. + +## 1. Projektübersicht + + **Formeleon** ist ein mandantenfähiges Formular-Service-System (Multi-tenant Form Generator & Backend API). +Es dient dazu, Formular-Submissions von externen Mandanten-Webseiten entgegenzunehmen, diese vor Bot-Spam zu schützen (via Google reCAPTCHA v3), die Daten isoliert nach Mandanten zu speichern und ein Admin-Dashboard zur Verwaltung bereitzustellen. + +**Kern-Technologien:** +* **Backend:** Python 3, Flask +* **Datenbank:** PostgreSQL/SQLite via Flask-SQLAlchemy, Migrationen via Flask-Migrate +* **Authentifizierung:** Zitadel via OIDC (OAuth2) +* **Frontend (Admin):** Mithril.js, Bootstrap (CSS) +* **Frontend (Public):** Leichtgewichtiges JS-Embed-Snippet +* **Infrastruktur:** Docker / Docker Compose + +**Architektur-Ansatz:** +Das Backend ist entkoppelt und nutzt das Flask Blueprint-Muster. +* `api`: Public Form API für das Entgegennehmen von Daten. +* `admin`: Dashboard zur Verwaltung. +* `auth`: OIDC/Zitadel Handler. + +--- + +## 2. Erste Schritte (Getting Started) + +### Voraussetzungen +* Docker & Docker Compose (für die einfachste lokale Umgebung) +* Python 3.x (für lokale Entwicklung ohne Docker) +* Eine `.env` Datei (Basis: `docs/dev-guide.md` für Mocking/Dev-Variablen) + +### Lokale Entwicklung & Ausführung +Die Anwendung kann typischerweise über Docker Compose gestartet werden: +```bash +docker-compose up --build +``` +*Weitere Details zur Einrichtung der Umgebung und Dev-Mocks (z.B. Mock-reCAPTCHA-Scores oder Dev-Bypass für OIDC) finden sich in `docs/dev-guide.md`.* + +--- + +## 3. Projektstruktur + +Der Code ist modular (Blueprint-Muster) strukturiert. **Es gibt keine monolithische `app.py` für die Routen-Logik.** + +* `app.py`: Anwendungs-Initialisierung & Blueprint-Registrierung. +* `models.py`: SQLAlchemy Datenmodelle. +* `routes/`: Flask Blueprints. + * `api.py` (`@api_bp`): Public Submissions & Handshake. + * `admin.py` (`@admin_bp`): Admin Panel & Config. + * `auth.py` (`@auth_bp`): OIDC / Zitadel Handler. +* `services/`: Ausgelagerte Geschäftslogik. + * `recaptcha.py`: reCAPTCHA Validation & Mocking. + * `exporter.py`: Stream-Export Logik (CSV/JSON/YAML). + * `mailer.py`: E-Mail Versand. +* `static/`: Frontend Assets (JS Snippets, Mithril.js Admin UI, CSS). +* `docs/`: Umfangreiche Projektdokumentation. +* `AGENTS.md`: Wichtige Instruktionen für KI-Assistenten. +* `ARCHITEKTUR.md`: Detaillierte Systemarchitektur. + +--- + +## 4. Entwicklungs-Workflow & Code-Standards + +**DIESE REGELN SIND VERBINDLICH FÜR ALLE ENTWICKLER UND KI-AGENTEN!** + +1. **Sprache:** + * Code (Variablen, Funktionen, Modulnamen, Code-Kommentare): **Englisch**. + * Dokumentation (Markdown, umfangreiche Architektur-Dokus): **Deutsch**. +2. **Dateiköpfe:** + Jede neu erstellte oder modifizierte Quelltext-Datei (`.py`, `.j2`, `.html`, `.js`, `.css`) **MUSS** den standardisierten Dateikopf tragen. + * Autor: `Björn Runden ` + * Siehe genaue Formate in `docs/coding-standards.md`. +3. **Dokumentationspflicht (PyDoc & Jinja2):** + * Jede Klasse, Funktion und Methode in Python muss PyDoc-Docstrings erhalten. + * Logikführende Jinja2-Templates (Schleifen, Makros, etc.) müssen kommentiert werden. +4. **Datenbank & ORM:** + * Zugriff **NIEMALS** direkt über Roh-SQL. + * **AUSSCHLIESSLICH** über Flask-SQLAlchemy arbeiten. + * Schema-Änderungen über Flask-Migrate (`flask db migrate`) abbilden. +5. **Git & Branching:** + * Entwicklung erfolgt ausschließlich auf dem Branch `main`. + * Merges auf `stable` sind der CI/CD-Pipeline vorbehalten. +6. **Modularität:** + * Neue Routen müssen im entsprechenden Blueprint (`routes/auth.py`, `routes/api.py`, `routes/admin.py`) registriert werden. + +--- + +## 5. Schlüsselkonzepte & Datenmodell + +* **Multi-Tenancy (Mandantenfähigkeit):** Logische Trennung auf Datenbankebene über `tenant_id` (`FormConfig`, `Submission`, `User`). +* **Public Keys:** Format `pk_live_`. Enthalten intern einen SHA-basierten Verweis auf die `tenant_id`. +* **Flexible Payloads:** Formulardaten werden in einem JSON-Payload-Feld (`Submission.Payload`) gespeichert, um Schema-Migrationen bei Formularänderungen zu vermeiden. +* **RBAC (Role-Based Access Control):** Lokale Rechteverwaltung in der DB (`admin`, `tenant_admin`, `user`, `viewer`) verknüpft über die E-Mail-Adresse. Zitadel (OIDC) liefert nur Identität (Kein Rollen-Mapping aus Token-Claims!). +* **Bot-Schutz:** Google reCAPTCHA v3. Schwellenwert konfigurierbar. Unter Schwellenwert wird die Submission als `flagged_as_spam` gespeichert und ein HTTP 400 zurückgegeben. + +--- + +## 6. Frontend Einbindung (Das Embed Snippet) + +Mandanten binden das Formular wie folgt ein: +```html + +
+``` +Das Snippet (`static/js/...`) nutzt die Fetch API für einen Handshake (prüft `Origin`), injiziert das HTML direkt ins DOM und fängt das Submit-Event ab, um ein reCAPTCHA Token zu holen und die Daten an `/api/v1/...` zu senden. + +--- + +## 7. Referenzen & Weiterführende Dokumentation + +Bevor du spezifische Module bearbeitest, lies bitte die entsprechenden Detail-Dokumente: + +* **KI-Agenten Steuerzentrale:** `AGENTS.md` +* **System-Architektur & DB:** `ARCHITEKTUR.md` +* **Feinkonzept & Spezifikation:** `docs/feinkonzept.md` +* **Header & PyDoc Standards:** `docs/coding-standards.md` +* **Dev-Environment & Mocks:** `docs/dev-guide.md` +* **CI/CD & Deployment:** `docs/deployment.md` +* **Git-Workflow:** `docs/git-workflow.md` + +*(Hinweis: Für konkrete Troubleshooting-Fälle, lies bitte die jeweiligen Markdown-Dateien im `docs/` Verzeichnis, z.B. bezüglich der Entwicklungsumgebung in der `dev-guide.md`)* diff --git a/.env.example b/.env.example index b86b15a..4099e12 100644 --- a/.env.example +++ b/.env.example @@ -199,3 +199,4 @@ MAX_PAGE_SIZE=100 # 1. Set DEV_MODE=true # 2. Set MOCK_AUTH=true (optional, for pure local mocking) # 3. Optionally configure ZITADEL_* settings if you have a local Zitadel instance +COMPOSE_PROFILES=dev diff --git a/.gitignore b/.gitignore index c80d54f..a3c6340 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,8 @@ migrations/ # Cache .cache/ *.pyc + +# Babel i18n +*.pot +*.mo + diff --git a/README.EN.md b/README.EN.md index 9bc60eb..722418e 100644 --- a/README.EN.md +++ b/README.EN.md @@ -21,7 +21,53 @@ Formeleon is a centrally hosted service (ChamäleonCloud) allowing multi-tenant ## 🛠️ Tech Stack -* **Backend:** Python 3.12, Flask (Blueprints), Flask-SQLAlchemy, Flask-Migrate +* **Backend:** Python 3.13, Flask (Blueprints), Flask-SQLAlchemy, Flask-Migrate * **Frontend (Admin UI):** Bootstrap 5, Mithril.js * **Auth:** OIDC / Zitadel -* **Deployment:** Docker, Gunicorn, Gitea Actions \ No newline at end of file +* **Deployment:** Docker, Gunicorn, Gitea Actions + +--- + +## 🌍 Internationalization (i18n) via Flask-Babel + +Formeleon's admin dashboard supports multiple languages (currently English and German) using the industry standard `Flask-Babel` (gettext). + +### 1. Marking Strings in Code +To mark a text for translation, it must be wrapped with the `_()` function in your Python or HTML code. + +**In HTML (Jinja2 Templates):** +```html + +

Welcome to the Dashboard

+ + +

{{ _('Welcome to the Dashboard') }}

+``` + +**In Python files:** +```python +from flask_babel import _ + +# Before +flash('Form saved successfully!', 'success') + +# After +flash(_('Form saved successfully!'), 'success') +``` + +### 2. The Translation Workflow (CLI) +When new strings have been marked in the codebase, they must be extracted and the language files updated. +Run the following commands within your active virtual environment (`source venv/bin/activate`): + +```bash +# 1. Scans the code for marked strings and generates the master template (messages.pot) +pybabel extract -F babel.cfg -k _l -o messages.pot routes services templates utils + +# 2. Merges the new template with existing translations (in the 'translations' folder) without overwriting your old translations +pybabel update -i messages.pot -d translations + +# -> NOW open e.g. translations/de/LC_MESSAGES/messages.po in your text editor and provide your translations at the msgstr "" lines! <- + +# 3. Compiles the raw .po text files into highly optimized .mo machine files for the web server +pybabel compile -d translations +``` \ No newline at end of file diff --git a/README.md b/README.md index ae31902..5847ca7 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,52 @@ docker compose run --rm app bash --- +## 🌍 Mehrsprachigkeit (i18n) via Flask-Babel + +Das Admin-Dashboard von Formeleon unterstützt Mehrsprachigkeit (aktuell Deutsch und Englisch). Wir nutzen dafür den Standard `Flask-Babel` (gettext). + +### 1. Texte im Code markieren +Damit das System erkennt, dass ein Text übersetzt werden soll, muss er im Python- oder HTML-Code mit der Funktion `_()` umschlossen werden. + +**In HTML (Jinja2 Templates):** +```html + +

Welcome to the Dashboard

+ + +

{{ _('Welcome to the Dashboard') }}

+``` + +**In Python-Dateien:** +```python +from flask_babel import _ + +# Vorher +flash('Form saved successfully!', 'success') + +# Nachher +flash(_('Form saved successfully!'), 'success') +``` + +### 2. Der Übersetzungs-Workflow (CLI) +Wenn neue Texte im Code markiert wurden, müssen diese extrahiert und die Sprachdateien aktualisiert werden. +Führe dazu folgende Befehle bei aktivierter virtueller Umgebung (`source venv/bin/activate`) aus: + +```bash +# 1. Sammelt alle markierten Strings aus dem Code und erstellt die Vorlage (messages.pot) +pybabel extract -F babel.cfg -k _l -o messages.pot routes services templates utils + +# 2. Gleicht die neue Vorlage mit den bestehenden Übersetzungen (im Ordner 'translations') ab, ohne alte zu überschreiben +pybabel update -i messages.pot -d translations + +# -> JETZT öffnest du z.B. translations/de/LC_MESSAGES/messages.po in einem Texteditor und trägst deine Übersetzungen bei msgstr "" ein! <- + +# 3. Kompiliert die .po Textdateien in pfeilschnelle .mo Maschinendateien für den Webserver +pybabel compile -d translations +``` + +--- + ## 📚 Dokumentation * [`AGENTS.md`](./AGENTS.md) – Regelwerk und Anweisungen für KI-Coding-Agenten. diff --git a/app.py b/app.py index d25fcf5..b43ef72 100644 --- a/app.py +++ b/app.py @@ -41,6 +41,9 @@ from utils.helpers import get_request_context from werkzeug.middleware.proxy_fix import ProxyFix +# Import babel setup +from utils.i18n import babel, get_locale, get_timezone + # Initialize extensions globally (will be bound to app in create_app) migrate = Migrate() cors = CORS() @@ -134,6 +137,9 @@ def initialize_extensions(app: Flask) -> None: 'allow_headers': ['Content-Type', 'Authorization', 'X-Request-ID'] } }) + + # Babel (i18n) + babel.init_app(app, locale_selector=get_locale, timezone_selector=get_timezone) # Session app.config['SESSION_TYPE'] = 'sqlalchemy' diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..2e6cf46 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,3 @@ +[python: **.py] +[jinja2: **.html] +extensions=jinja2.ext.i18n diff --git a/config.py b/config.py index 0a138b6..64ce42e 100644 --- a/config.py +++ b/config.py @@ -60,6 +60,11 @@ class Config: DEBUG: bool = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true' TESTING: bool = os.environ.get('FLASK_TESTING', 'false').lower() == 'true' + # i18n / Localization Settings + LANGUAGES = ['en', 'de'] + BABEL_DEFAULT_LOCALE = 'en' + BABEL_DEFAULT_TIMEZONE = 'UTC' + # Development Mode DEV_MODE: bool = os.environ.get('DEV_MODE', 'false').lower() == 'true' @@ -125,6 +130,14 @@ class Config: SESSION_COOKIE_SAMESITE: str = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax') PERMANENT_SESSION_LIFETIME: int = 3600 # 1 hour in seconds + # SMTP Settings + SMTP_SERVER: Optional[str] = os.environ.get('SMTP_SERVER') + SMTP_PORT: int = int(os.environ.get('SMTP_PORT', '587')) + SMTP_USERNAME: Optional[str] = os.environ.get('SMTP_USERNAME') + SMTP_PASSWORD: Optional[str] = os.environ.get('SMTP_PASSWORD') + SMTP_USE_TLS: bool = os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true' + EMAIL_FROM: str = os.environ.get('EMAIL_FROM', 'noreply@formeleon.local') + def is_dev_mode(self) -> bool: """ Check if application is running in development mode. diff --git a/docker-compose.yml b/docker-compose.yml index 8e99276..64797fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,3 +19,15 @@ services: env_file: - .env restart: unless-stopped + + # Nginx service to serve tests_html for local development + test-site: + image: nginx:alpine + container_name: formeleon-test-site + ports: + - "8080:80" + volumes: + - ./tests_html:/usr/share/nginx/html:ro + restart: unless-stopped + profiles: + - dev diff --git a/models.py b/models.py index 8527280..5b8e417 100644 --- a/models.py +++ b/models.py @@ -57,11 +57,13 @@ class SubmissionStatus(Enum): PROCESSED: Submission has been processed successfully FAILED: Submission processing failed SPAM: Submission flagged as spam (reCAPTCHA failed) + ARCHIVED: Submission has been archived (hidden from default view) """ PENDING = 'pending' PROCESSED = 'processed' FAILED = 'failed' SPAM = 'spam' + ARCHIVED = 'archived' class TenantStatus(Enum): diff --git a/requirements.txt b/requirements.txt index 2e420fa..80d2f36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,4 +33,7 @@ python-dateutil~=2.8.0 # Production gunicorn~=22.0.0 -# gthread ist als Worker-Typ in Python 3.13 out-of-the-box stabil \ No newline at end of file +# gthread ist als Worker-Typ in Python 3.13 out-of-the-box stabil + +# i18n +Flask-Babel~=4.0.0 diff --git a/routes/admin.py b/routes/admin.py index 8dc341b..30525d7 100644 --- a/routes/admin.py +++ b/routes/admin.py @@ -46,6 +46,7 @@ from services.recaptcha import RecaptchaService from services.tenant_admin_service import TenantAdminService from services.form_admin_service import FormAdminService from services.user_admin_service import UserAdminService +from services.exporter import ExporterService logger = logging.getLogger(__name__) @@ -72,7 +73,7 @@ def admin_required(func): user = User.query.get(session['user_id']) if not user or not user.is_admin(): - abort(403, description='Admin privileges required') + abort(403, description=_('Admin privileges required')) return func(*args, **kwargs) wrapper.__name__ = func.__name__ @@ -130,14 +131,14 @@ def dashboard(): submission_count = Submission.query.count() # Recent submissions - recent_submissions = Submission.query.order_by( + recent_submissions = Submission.query.filter(Submission.status != SubmissionStatus.ARCHIVED).order_by( Submission.created_at.desc() - ).limit(10).all() + ).limit(7).all() # Recent forms recent_forms = FormConfig.query.order_by( FormConfig.created_at.desc() - ).limit(5).all() + ).limit(7).all() # Tenant list tenants = Tenant.query.order_by(Tenant.created_at.desc()).limit(5).all() @@ -434,25 +435,108 @@ def list_submissions(): user = get_current_user() tenant = get_current_tenant() - # Get submissions based on user access + # Check if we should show archived submissions + show_archived = request.args.get('show_archived') == 'true' + + # Base query based on user access if user.role == UserRole.ADMIN: - submissions = Submission.query.join(FormConfig).order_by( - Submission.created_at.desc() - ).all() + query = Submission.query.join(FormConfig) elif tenant: - submissions = Submission.query.filter_by(tenant_id=tenant.id).order_by( - Submission.created_at.desc() - ).all() + query = Submission.query.filter_by(tenant_id=tenant.id) else: - submissions = [] + # Return empty template if no access + return render_template('admin/submissions/list.html', user=user, submissions=[], tenant=tenant) + + # Filter out archived unless requested + if not show_archived: + query = query.filter(Submission.status != SubmissionStatus.ARCHIVED) + + submissions = query.order_by(Submission.created_at.desc()).all() return render_template('admin/submissions/list.html', user=user, submissions=submissions, tenant=tenant, + show_archived=show_archived, dev_mode=current_app.config.get('DEV_MODE', False), is_admin=user.is_admin()) +@admin_bp.route('/submissions/export') +@login_required +def export_submissions(): + """Export submissions as CSV or JSON.""" + user = get_current_user() + tenant = get_current_tenant() + + format_type = request.args.get('format', 'csv') + form_id = request.args.get('form_id') + + # Security: Build query based on user access + if user.role == UserRole.ADMIN: + query = Submission.query + elif tenant: + query = Submission.query.filter_by(tenant_id=tenant.id) + else: + abort(403) + + if form_id: + query = query.filter_by(form_config_id=form_id) + + # Exclude archived from default exports + query = query.filter(Submission.status != SubmissionStatus.ARCHIVED) + + submissions = query.order_by(Submission.created_at.desc()).all() + + filename = f"formeleon_export_{datetime.now().strftime('%Y%m%d')}" + if form_id: + filename += f"_{form_id}" + + if format_type == 'json': + return ExporterService.generate_json_response(submissions, f"{filename}.json") + else: + return ExporterService.generate_csv_response(submissions, f"{filename}.csv") + +@admin_bp.route('/submissions/bulk-action', methods=['POST']) +@login_required +def bulk_action_submissions(): + """Perform bulk actions (like archive) on multiple submissions.""" + user = get_current_user() + tenant = get_current_tenant() + + action = request.form.get('action') + submission_ids = request.form.getlist('submission_ids') + + if not action or not submission_ids: + flash('No action or items selected.', 'warning') + return redirect(url_for('admin.list_submissions')) + + # Security check: Ensure user can modify these submissions + if user.role == UserRole.ADMIN: + subs_to_update = Submission.query.filter(Submission.id.in_(submission_ids)).all() + elif tenant: + subs_to_update = Submission.query.filter(Submission.id.in_(submission_ids), Submission.tenant_id == tenant.id).all() + else: + abort(403) + + count = 0 + if action == 'archive': + for sub in subs_to_update: + sub.status = SubmissionStatus.ARCHIVED + count += 1 + + db.session.commit() + flash(f'Successfully archived {count} submissions.', 'success') + + elif action == 'unarchive': + for sub in subs_to_update: + sub.status = SubmissionStatus.PROCESSED + count += 1 + + db.session.commit() + flash(f'Successfully unarchived {count} submissions.', 'success') + + return redirect(url_for('admin.list_submissions')) + @admin_bp.route('/submissions/') @login_required diff --git a/routes/api.py b/routes/api.py index 680789c..3b37275 100644 --- a/routes/api.py +++ b/routes/api.py @@ -2,386 +2,167 @@ # Formeleon - Multi-tenant Form Generator # Author: Björn Runden # Module: routes/api.py -# Description: Public Form API for handling form submissions from client websites. -# Provides endpoints for form handshake, submission, and validation. +# Description: Public API Blueprint for handling form handshakes and submissions. # ============================================================================== -""" -Public Form API for Formeleon. - -This module provides the public API endpoints for: -- Client handshake and token exchange -- Form submission processing -- reCAPTCHA validation -- Form configuration retrieval - -All endpoints are prefixed with /api/v1/ and are designed to be -consumed by JavaScript snippets embedded in client websites. - -Authentication: -- Public key handshake for tenant identification -- Origin/Referer validation -- reCAPTCHA v3 token validation (optional) - -Routes: - POST /api/v1/handshake - Initiate handshake and get form config - POST /api/v1/submit/ - Submit form data - GET /api/v1/form/ - Get form configuration - POST /api/v1/validate/recaptcha - Validate reCAPTCHA token -""" - -import hashlib -import json import logging -import uuid -from datetime import datetime, timezone -from typing import Optional, Dict, Any - from flask import Blueprint, request, jsonify, current_app -from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden, NotFound - from models import db, Tenant, FormConfig, Submission, SubmissionStatus -from services.recaptcha import RecaptchaService -from services.tenant_service import TenantService -from services.submission_service import SubmissionService - +from services.mailer import MailerService +from services.recaptcha import get_recaptcha_service +import uuid logger = logging.getLogger(__name__) -# Create API blueprint -api_bp = Blueprint('api', __name__) +# Create public API blueprint +api_bp = Blueprint('api', __name__, url_prefix='/api/v1') -@api_bp.before_request -def before_api_request(): - """Before request handler for API routes.""" - # Set CORS headers for API routes - if current_app.config.get('ENABLE_CORS', True): - response = current_app.make_default_options_response() - response.headers['Access-Control-Allow-Origin'] = '*' - response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' - response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Public-Key, X-Request-ID' +def _get_tenant_from_auth(): + """Helper to validate Bearer token and return Tenant.""" + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return None - # Handle OPTIONS requests - if request.method == 'OPTIONS': - return current_app.make_default_options_response() + public_key = auth_header.replace('Bearer ', '').strip() + tenant = Tenant.query.filter_by(public_key=public_key).first() + return tenant -@api_bp.errorhandler(BadRequest) -def handle_bad_request(error): - """Handle bad request errors.""" - return jsonify({ - 'error': 'bad_request', - 'message': str(error.description) if error.description else 'Invalid request' - }), 400 - - -@api_bp.errorhandler(Unauthorized) -def handle_unauthorized(error): - """Handle unauthorized errors.""" - return jsonify({ - 'error': 'unauthorized', - 'message': str(error.description) if error.description else 'Authentication required' - }), 401 - - -@api_bp.errorhandler(Forbidden) -def handle_forbidden(error): - """Handle forbidden errors.""" - return jsonify({ - 'error': 'forbidden', - 'message': str(error.description) if error.description else 'Access denied' - }), 403 - - -@api_bp.errorhandler(NotFound) -def handle_not_found(error): - """Handle not found errors.""" - return jsonify({ - 'error': 'not_found', - 'message': str(error.description) if error.description else 'Resource not found' - }), 404 - - -@api_bp.route('/handshake', methods=['POST']) -def handshake(): +@api_bp.route('/forms/', methods=['GET']) +def get_form_config(form_id): """ - Handshake endpoint for client snippets. - - Validates the client's public key and origin, then returns - the form configuration for rendering. - - Request Headers: - X-Public-Key: Tenant's public API key - Origin: Client origin URL - Referer: Client referer URL (fallback) - - Request Body: - form_id: The ID of the form to retrieve - - :return: JSON with handshake token and form configuration + Handshake Endpoint. + Client snippet requests form configuration using its public key. """ - # Get public key from header or form data - public_key = request.headers.get('X-Public-Key') or request.form.get('public_key') - - if not public_key: - raise BadRequest('Public key required') - - # Get origin/referer - origin = request.headers.get('Origin') or request.headers.get('Referer') or '' - - # Find tenant by public key - tenant = TenantService.get_tenant_by_public_key(public_key) - + tenant = _get_tenant_from_auth() if not tenant: - logger.warning(f'Handshake failed: Invalid public key {public_key[:8]}...') - raise Unauthorized('Invalid public key') - - # Validate origin + return jsonify({'error': 'Unauthorized. Invalid public key.'}), 401 + if not tenant.is_active(): - raise Forbidden('Tenant is not active') - - if not TenantService.validate_origin(origin, tenant): - logger.warning(f'Handshake failed: Invalid origin {origin} for tenant {tenant.id}') - raise Forbidden('Origin not allowed for this tenant') - - # Get form ID from request - form_id = request.form.get('form_id') or request.json.get('form_id') if request.json else None - - if not form_id: - raise BadRequest('form_id is required') - - # Get form configuration - form = FormConfig.query.filter_by( - id=form_id, - tenant_id=tenant.id - ).first() - + return jsonify({'error': 'Tenant is inactive.'}), 403 + + # Check origin (CORS / allowed domains) + origin = request.headers.get('Origin') + if origin and not tenant.is_origin_allowed(origin): + logger.warning(f"Origin rejected: {origin} for tenant {tenant.id}") + return jsonify({'error': 'Origin not allowed'}), 403 + + form = FormConfig.query.filter_by(id=form_id, tenant_id=tenant.id).first() if not form: - raise NotFound('Form not found or does not belong to tenant') - + return jsonify({'error': 'Form not found or access denied.'}), 404 + if not form.is_active: - raise Forbidden('Form is not active') - - # Generate handshake token (can be used for subsequent requests) - handshake_token = str(uuid.uuid4()) - - # Return handshake response - response = { - 'status': 'success', - 'tenant_id': tenant.id, - 'form_id': form.id, - 'form_name': form.name, - 'form_config': form.form_structure, - 'settings': form.settings or {}, - 'handshake_token': handshake_token, - 'recaptcha_enabled': form.settings.get('recaptcha_enabled', True) if form.settings else True - } - - logger.info(f'Handshake successful for tenant {tenant.id}, form {form.id}') - - return jsonify(response) + return jsonify({'error': 'Form is inactive.'}), 403 + recaptcha_service = get_recaptcha_service() -@api_bp.route('/form/', methods=['GET']) -def get_form(form_id): - """ - Get form configuration. - - :param form_id: ID of the form to retrieve - :return: JSON with form configuration - """ - # Get public key from header - public_key = request.headers.get('X-Public-Key') - - if not public_key: - raise BadRequest('Public key required') - - # Find tenant by public key - tenant = TenantService.get_tenant_by_public_key(public_key) - - if not tenant: - raise Unauthorized('Invalid public key') - - if not tenant.is_active(): - raise Forbidden('Tenant is not active') - - # Get form - form = FormConfig.query.filter_by( - id=form_id, - tenant_id=tenant.id - ).first() - - if not form: - raise NotFound('Form not found') - - if not form.is_active: - raise Forbidden('Form is not active') - return jsonify({ - 'status': 'success', - 'form': form.to_dict() - }) - - -@api_bp.route('/submit/', methods=['POST']) -def submit_form(form_id): - """ - Submit form data. - - Handles form submissions from client websites with: - - Origin validation - - Public key authentication - - reCAPTCHA validation (optional) - - Data validation against form structure - - :param form_id: ID of the form being submitted - :return: JSON with submission result - """ - # Get public key from header - public_key = request.headers.get('X-Public-Key') - - if not public_key: - raise BadRequest('Public key required') - - # Find tenant by public key - tenant = TenantService.get_tenant_by_public_key(public_key) - - if not tenant: - logger.warning(f'Submission failed: Invalid public key {public_key[:8]}...') - raise Unauthorized('Invalid public key') - - if not tenant.is_active(): - raise Forbidden('Tenant is not active') - - # Validate origin - origin = request.headers.get('Origin') or request.headers.get('Referer') or '' - if not TenantService.validate_origin(origin, tenant): - logger.warning(f'Submission failed: Invalid origin {origin} for tenant {tenant.id}') - raise Forbidden('Origin not allowed') - - # Get form data from request - try: - if request.content_type == 'application/json': - payload = request.get_json() - else: - payload = request.form.to_dict() - except Exception as e: - logger.error(f'Failed to parse form data: {e}') - raise BadRequest('Invalid form data format') - - recaptcha_token = payload.get('g-recaptcha-response') or request.headers.get('X-ReCAPTCHA-Token') if payload else None - - response, status_code = SubmissionService.process_submission( - form_id=form_id, - tenant=tenant, - origin=origin, - payload=payload, - ip_address=request.remote_addr, - user_agent=request.user_agent.string if request.user_agent else None, - recaptcha_token=recaptcha_token, - content_type=request.content_type - ) - - return jsonify(response), status_code - - -@api_bp.route('/validate/recaptcha', methods=['POST']) -def validate_recaptcha(): - """ - Validate a reCAPTCHA token. - - This endpoint can be used by client snippets to pre-validate - reCAPTCHA tokens before form submission. - - :return: JSON with validation result - """ - recaptcha_token = request.form.get('token') or request.json.get('token') if request.json else None - - if not recaptcha_token: - raise BadRequest('reCAPTCHA token required') - - recaptcha_service = RecaptchaService() - - if not recaptcha_service.is_enabled(): - # If reCAPTCHA not configured, return success in dev mode - if current_app.config.get('DEV_MODE', False): - return jsonify({ - 'status': 'success', - 'score': current_app.config.get('MOCK_RECAPTCHA_SCORE', 0.9), - 'passed': True, - 'dev_mode': True - }) - else: - raise BadRequest('reCAPTCHA not configured') - - is_valid, score = recaptcha_service.verify( - recaptcha_token, - request.remote_addr - ) - - return jsonify({ - 'status': 'success', - 'score': score, - 'passed': is_valid, - 'threshold': recaptcha_service.get_threshold(), - 'dev_mode': recaptcha_service.is_dev_mode() - }) - - -@api_bp.route('/status') -def api_status(): - """ - API health check endpoint. - - :return: JSON with API status - """ - return jsonify({ - 'status': 'ok', - 'version': '1.0.0', - 'dev_mode': current_app.config.get('DEV_MODE', False), - 'timestamp': datetime.now(timezone.utc).isoformat() - }) - - -@api_bp.route('/stats/') -def get_tenant_stats(tenant_id): - """ - Get statistics for a tenant. - - :param tenant_id: Tenant ID - :return: JSON with tenant statistics - """ - # Authenticate with public key - public_key = request.headers.get('X-Public-Key') - - if not public_key: - raise BadRequest('Public key required') - - tenant = TenantService.get_tenant_by_public_key(public_key) - - if not tenant or tenant.id != tenant_id: - raise Unauthorized('Invalid public key or tenant mismatch') - - if not tenant.is_active(): - raise Forbidden('Tenant is not active') - - # Get stats - form_count = FormConfig.query.filter_by(tenant_id=tenant.id).count() - submission_count = Submission.query.filter_by(tenant_id=tenant.id).count() - - # Recent submissions - recent_submissions = Submission.query.filter_by( - tenant_id=tenant.id - ).order_by(Submission.created_at.desc()).limit(5).all() - - return jsonify({ - 'status': 'success', - 'tenant_id': tenant.id, - 'tenant_name': tenant.name, - 'stats': { - 'forms': form_count, - 'submissions': submission_count, - 'recent_submissions': [s.to_dict() for s in recent_submissions] + 'id': form.id, + 'name': form.name, + 'description': form.description, + 'form_structure': form.form_structure, + 'recaptcha': { + 'enabled': recaptcha_service.is_enabled() and not recaptcha_service.is_dev_mode(), + 'site_key': recaptcha_service.get_site_key() } - }) + }), 200 + + +@api_bp.route('/submissions', methods=['POST']) +def submit_form(): + """ + Submission Endpoint. + Receives JSON payload, validates reCAPTCHA (mocked in dev), and saves to DB. + """ + tenant = _get_tenant_from_auth() + if not tenant: + return jsonify({'error': 'Unauthorized. Invalid public key.'}), 401 + + data = request.get_json() + if not data: + return jsonify({'error': 'Invalid JSON payload.'}), 400 + + form_id = data.get('form_id') + payload = data.get('payload') + recaptcha_token = data.get('recaptcha_token') + + if not form_id or not payload: + return jsonify({'error': 'Missing form_id or payload.'}), 400 + + form = FormConfig.query.filter_by(id=form_id, tenant_id=tenant.id).first() + if not form: + return jsonify({'error': 'Form not found.'}), 404 + + # --- reCAPTCHA Validation --- + recaptcha_service = get_recaptcha_service() + + # Get client IP for additional reCAPTCHA validation + client_ip = request.headers.get('X-Forwarded-For', request.remote_addr) + if client_ip and ',' in client_ip: + client_ip = client_ip.split(',')[0].strip() + + recaptcha_passed, recaptcha_score = recaptcha_service.verify(recaptcha_token, client_ip) + + status = SubmissionStatus.PENDING if recaptcha_passed else SubmissionStatus.SPAM + + # Save Submission + sub_id = f"sub_{uuid.uuid4().hex[:12]}" + new_submission = Submission( + id=sub_id, + form_config_id=form.id, + tenant_id=tenant.id, + payload=payload, + recaptcha_score=recaptcha_score, + recaptcha_passed=recaptcha_passed, + status=status, + user_agent=request.headers.get('User-Agent'), + referer=request.headers.get('Referer') + ) + + db.session.add(new_submission) + db.session.commit() + + # --- Trigger Email Notifications (if not spam) --- + if recaptcha_passed: + # Check Tenant Level Permissions + allow_instant_mail = tenant.config.get('features', {}).get('instant_notifications', True) # Default to true for now + allow_autoresponder = tenant.config.get('features', {}).get('submitter_autoresponder', True) + + if form.settings: + # 1. Admin Notification + notif_settings = form.settings.get('notifications', {}) + if allow_instant_mail and notif_settings.get('enabled'): + send_to = notif_settings.get('send_to', []) + if send_to: + # In a production app, you might want to queue this with Celery instead of blocking the request + try: + MailerService.send_admin_notification(form.name, send_to, payload) + except Exception as e: + logger.error(f"Failed to send admin notification: {e}") + + # 2. Submitter Autoresponder + auto_settings = form.settings.get('autoresponder', {}) + if allow_autoresponder and auto_settings.get('enabled'): + target_field = auto_settings.get('email_field_name') + target_email = payload.get(target_field) if target_field else None + + if target_email and '@' in str(target_email): + try: + MailerService.send_autoresponder( + to_address=str(target_email), + subject_template=auto_settings.get('subject', 'Thank you!'), + body_template=auto_settings.get('body_template', ''), + payload=payload + ) + except Exception as e: + logger.error(f"Failed to send autoresponder to {target_email}: {e}") + + if not recaptcha_passed: + return jsonify({'error': 'Spam detected. Submission rejected.'}), 400 + + return jsonify({ + 'message': 'Submission received successfully', + 'submission_id': sub_id + }), 201 + diff --git a/services/exporter.py b/services/exporter.py new file mode 100644 index 0000000..b2c0ce3 --- /dev/null +++ b/services/exporter.py @@ -0,0 +1,75 @@ +# ============================================================================== +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# Module: services/exporter.py +# Description: Service for exporting form submissions. +# ============================================================================== + +import csv +import json +import io +from typing import List, Dict, Any +from flask import Response +from models import Submission + +class ExporterService: + """Service to handle generating exports for submissions.""" + + @classmethod + def generate_csv_response(cls, submissions: List[Submission], filename: str = "export.csv") -> Response: + """ + Generates a Flask Response containing a CSV stream of the given submissions. + """ + # Determine all possible fields from the payloads + all_keys = set() + for sub in submissions: + if isinstance(sub.payload, dict): + all_keys.update(sub.payload.keys()) + + fieldnames = ['ID', 'Date', 'Form', 'Status', 'reCAPTCHA'] + sorted(list(all_keys)) + + si = io.StringIO() + writer = csv.DictWriter(si, fieldnames=fieldnames) + writer.writeheader() + + for sub in submissions: + row = { + 'ID': sub.id, + 'Date': sub.created_at.strftime('%Y-%m-%d %H:%M:%S'), + 'Form': sub.form_config.name if sub.form_config else 'Unknown', + 'Status': sub.status.value, + 'reCAPTCHA': sub.recaptcha_score if sub.recaptcha_score is not None else '' + } + if isinstance(sub.payload, dict): + row.update(sub.payload) + writer.writerow(row) + + output = si.getvalue() + + return Response( + output, + mimetype="text/csv", + headers={"Content-disposition": f"attachment; filename={filename}"} + ) + + @classmethod + def generate_json_response(cls, submissions: List[Submission], filename: str = "export.json") -> Response: + """ + Generates a Flask Response containing a JSON array of the given submissions. + """ + data = [] + for sub in submissions: + data.append({ + 'id': sub.id, + 'date': sub.created_at.isoformat(), + 'form_name': sub.form_config.name if sub.form_config else None, + 'status': sub.status.value, + 'recaptcha_score': sub.recaptcha_score, + 'payload': sub.payload + }) + + return Response( + json.dumps(data, indent=2), + mimetype="application/json", + headers={"Content-disposition": f"attachment; filename={filename}"} + ) diff --git a/services/mailer.py b/services/mailer.py new file mode 100644 index 0000000..ce974b3 --- /dev/null +++ b/services/mailer.py @@ -0,0 +1,125 @@ +# ============================================================================== +# Formeleon - Multi-tenant Form Generator +# Author: Björn Runden +# Module: services/mailer.py +# Description: Service for handling email notifications and autoresponders. +# ============================================================================== + +import smtplib +import logging +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from typing import List, Dict, Any, Optional +from flask import current_app +from jinja2 import Template, TemplateError + +logger = logging.getLogger(__name__) + +class MailerService: + """Service class for handling email dispatch.""" + + @classmethod + def is_configured(cls) -> bool: + """Check if SMTP settings are provided in configuration.""" + server = current_app.config.get('SMTP_SERVER') + # Wir betrachten den Service als "konfiguriert", wenn entweder ein Server da ist, + # oder wir im Dev-Mode sind (um das Terminal-Logging zu erlauben) + if current_app.config.get('DEV_MODE', False) and not server: + return True + + port = current_app.config.get('SMTP_PORT') + return bool(server and port) + + @classmethod + def send_email(cls, to_addresses: List[str], subject: str, body_text: str, reply_to: Optional[str] = None) -> bool: + """ + Sends an email using the configured SMTP server. + + :param to_addresses: List of recipient email addresses + :param subject: Email subject line + :param body_text: Plain text email body + :param reply_to: Optional Reply-To address + :return: True if successful, False otherwise + """ + if not cls.is_configured(): + logger.warning("MailerService: Attempted to send email, but SMTP is not configured.") + return False + + if current_app.config.get('DEV_MODE', False) and not current_app.config.get('SMTP_SERVER'): + logger.info(f"MailerService [DEV MODE]: Would send email to {to_addresses} | Subject: '{subject}'") + return True + + smtp_server = current_app.config.get('SMTP_SERVER') + smtp_port = int(current_app.config.get('SMTP_PORT', 587)) + smtp_user = current_app.config.get('SMTP_USERNAME') + smtp_pass = current_app.config.get('SMTP_PASSWORD') + use_tls = str(current_app.config.get('SMTP_USE_TLS', 'true')).lower() == 'true' + from_address = current_app.config.get('EMAIL_FROM', 'formeleon@localhost') + + msg = MIMEMultipart() + msg['From'] = from_address + msg['To'] = ", ".join(to_addresses) + msg['Subject'] = subject + if reply_to: + msg['Reply-To'] = reply_to + + msg.attach(MIMEText(body_text, 'plain', 'utf-8')) + + try: + with smtplib.SMTP(smtp_server, smtp_port) as server: + if use_tls: + server.starttls() + if smtp_user and smtp_pass: + server.login(smtp_user, smtp_pass) + + server.send_message(msg) + + logger.info(f"Successfully sent email to {to_addresses}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {to_addresses}: {str(e)}", exc_info=True) + return False + + @classmethod + def render_template(cls, template_string: str, payload: Dict[str, Any]) -> str: + """ + Renders a Jinja2 template string using the submission payload. + + :param template_string: The template string (e.g., "Hello {{ name }}") + :param payload: Dictionary containing the form submission data + :return: Rendered string + """ + try: + template = Template(template_string) + return template.render(**payload) + except TemplateError as e: + logger.error(f"Template rendering failed: {str(e)}") + # Fallback: Return original string if rendering fails + return template_string + + @classmethod + def send_admin_notification(cls, form_name: str, to_addresses: List[str], payload: Dict[str, Any]) -> bool: + """ + Sends an alert to the tenant admins about a new submission. + """ + subject = f"New Submission: {form_name}" + + # Build a simple readable key-value body + body_lines = [f"A new submission was received for the form '{form_name}'.\n", "-" * 40] + for key, value in payload.items(): + body_lines.append(f"{key}: {value}") + body_lines.append("-" * 40) + + body_text = "\n".join(body_lines) + return cls.send_email(to_addresses, subject, body_text) + + @classmethod + def send_autoresponder(cls, to_address: str, subject_template: str, body_template: str, payload: Dict[str, Any]) -> bool: + """ + Sends a templated autoresponder to the person who submitted the form. + """ + subject = cls.render_template(subject_template, payload) + body = cls.render_template(body_template, payload) + + return cls.send_email([to_address], subject, body) diff --git a/static/js/embed.js b/static/js/embed.js new file mode 100644 index 0000000..2b55338 --- /dev/null +++ b/static/js/embed.js @@ -0,0 +1,252 @@ +/** ============================================================================= + * Formeleon - Multi-tenant Form Generator + * Author: Björn Runden + * Script: embed.js + * Description: Client-side embed script for injecting forms into tenant websites. + * Handles handshake, UI rendering, and form submission via fetch API. + * ============================================================================= + */ + +(function() { + // Current script tag to extract data attributes + const scripts = document.getElementsByTagName('script'); + const currentScript = scripts[scripts.length - 1]; + + // Configuration + const formId = currentScript.getAttribute('data-form-id'); + const publicKey = currentScript.getAttribute('data-public-key'); + // We assume backend API is hosted at the same origin as this script + const apiBaseUrl = new URL(currentScript.src).origin + '/api/v1'; + + const containerId = 'formeleon-form'; + + // Wait for DOM + document.addEventListener("DOMContentLoaded", init); + + function init() { + const container = document.getElementById(containerId); + if (!container) { + console.error(`[Formeleon] Container
not found.`); + return; + } + + if (!formId || !publicKey) { + container.innerHTML = '

[Formeleon] Missing form ID or public key configuration.

'; + return; + } + + // Show loading state + container.innerHTML = '

Loading form...

'; + + // Step 1: Handshake (Fetch form config & reCAPTCHA config) + fetch(`${apiBaseUrl}/forms/${formId}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${publicKey}`, + 'Accept': 'application/json' + } + }) + .then(response => { + if (!response.ok) { + throw new Error(`Failed to load form (Status: ${response.status})`); + } + return response.json(); + }) + .then(data => { + renderForm(container, data); + }) + .catch(err => { + console.error('[Formeleon] Handshake Error:', err); + container.innerHTML = `

[Formeleon Error] ${err.message}

`; + }); + } + + /** + * Helper to extract localized text based on the document lang + */ + function getLocalizedString(fieldProperty, fallback) { + if (!fieldProperty) return fallback || ''; + + // If it's a simple string, return it directly + if (typeof fieldProperty === 'string') return fieldProperty; + + // If it's an object with language keys (e.g., {"en": "Name", "de": "Name"}) + if (typeof fieldProperty === 'object') { + const pageLang = document.documentElement.lang || 'en'; + // Try exact match (e.g. 'en-US') + if (fieldProperty[pageLang]) return fieldProperty[pageLang]; + // Try base language match (e.g. 'en') + const baseLang = pageLang.split('-')[0]; + if (fieldProperty[baseLang]) return fieldProperty[baseLang]; + // Fallback to English if available, or just take the first available value + if (fieldProperty['en']) return fieldProperty['en']; + return Object.values(fieldProperty)[0] || fallback; + } + + return fallback || ''; + } + + /** + * Renders the HTML form based on JSON structure + */ + function renderForm(container, formData) { + if (!formData || !formData.form_structure) { + container.innerHTML = '

Invalid form configuration received.

'; + return; + } + + // Load reCAPTCHA script if enabled + if (formData.recaptcha && formData.recaptcha.enabled && formData.recaptcha.site_key) { + window._formeleonRecaptchaSiteKey = formData.recaptcha.site_key; + const rcScript = document.createElement('script'); + rcScript.src = `https://www.google.com/recaptcha/api.js?render=${formData.recaptcha.site_key}`; + document.head.appendChild(rcScript); + } + + const struct = formData.form_structure; + const fields = struct.fields || []; + + let html = `
`; + + // Render Title/Desc + if (formData.name) html += `

${formData.name}

`; + if (formData.description) html += `

${formData.description}

`; + + html += ``; + + // Render Fields + fields.forEach(field => { + const required = field.required ? 'required' : ''; + const reqStar = field.required ? '*' : ''; + + // i18n resolution for label and placeholder + const labelText = getLocalizedString(field.label, field.name); + const placeholderText = getLocalizedString(field.placeholder, ''); + + html += `
`; + html += ``; + + // Extract custom CSS classes + const customCss = field.css_classes ? `class="${field.css_classes}"` : ''; + + if (field.type === 'textarea') { + html += ``; + } else if (field.type === 'select') { + html += ``; + } else { + html += ``; + } + + html += `
`; + }); + + // Submit Button + const btnText = (struct.submit_button && struct.submit_button.text) ? getLocalizedString(struct.submit_button.text, 'Submit') : 'Submit'; + const btnCss = (struct.submit_button && struct.submit_button.css_classes) ? `class="${struct.submit_button.css_classes}"` : 'style="padding:10px 20px; cursor:pointer;"'; + + html += ``; + + html += `
`; + + container.innerHTML = html; + + // Attach Submit Handler + const formEl = document.getElementById('formeleon-submit-form'); + formEl.addEventListener('submit', handleFormSubmit); + } + + /** + * Handles the form submission process + */ + function handleFormSubmit(e) { + e.preventDefault(); + + const formEl = e.target; + const btn = document.getElementById('formeleon-submit-btn'); + const alertBox = document.getElementById('formeleon-alert'); + + // Hide previous alerts + alertBox.style.display = 'none'; + + // Extract Data + const formData = new FormData(formEl); + const payload = {}; + formData.forEach((value, key) => { + payload[key] = value; + }); + + // Update UI State + btn.disabled = true; + btn.innerText = 'Submitting...'; + + // Helper to send data + const sendPayload = (recaptchaToken) => { + fetch(`${apiBaseUrl}/submissions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${publicKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + form_id: formId, + payload: payload, + recaptcha_token: recaptchaToken + }) + }) + .then(response => { + return response.json().then(data => ({status: response.status, body: data})); + }) + .then(res => { + if (res.status >= 200 && res.status < 300) { + // Success + alertBox.style.display = 'block'; + alertBox.style.backgroundColor = '#d4edda'; + alertBox.style.color = '#155724'; + alertBox.style.border = '1px solid #c3e6cb'; + alertBox.innerHTML = res.body.message || 'Thank you! Your submission has been received.'; + formEl.reset(); + } else { + // Error + throw new Error(res.body.error || 'Submission failed.'); + } + }) + .catch(err => { + // Error Display + alertBox.style.display = 'block'; + alertBox.style.backgroundColor = '#f8d7da'; + alertBox.style.color = '#721c24'; + alertBox.style.border = '1px solid #f5c6cb'; + alertBox.innerHTML = err.message; + }) + .finally(() => { + btn.disabled = false; + btn.innerText = 'Submit'; + }); + }; + + // Execute reCAPTCHA if enabled + if (window._formeleonRecaptchaSiteKey && typeof grecaptcha !== 'undefined') { + grecaptcha.ready(function() { + grecaptcha.execute(window._formeleonRecaptchaSiteKey, {action: 'submit'}).then(function(token) { + sendPayload(token); + }).catch(function(err) { + console.error('[Formeleon] reCAPTCHA execution failed', err); + sendPayload(null); // Send anyway, backend will evaluate + }); + }); + } else { + // Dev mode or not configured + sendPayload('mock_token_for_dev'); + } + } + +})(); diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index e67b9d4..35a02f1 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -21,45 +21,51 @@ {# Statistics Cards #}
-
-
-
- -
-

{{ tenant_count }}

-

Tenants

+ +
+
+
+ +
+

{{ tenant_count }}

+

Tenants

+
-
+
-
-
-
- -
-

{{ form_count }}

-

Forms

+ +
+
+
+ +
+

{{ form_count }}

+

Forms

+
-
+
-
-
-
- -
-

{{ submission_count }}

-

Submissions

+ +
+
+
+ +
+

{{ submission_count }}

+

Submissions

+
-
+
@@ -79,38 +85,46 @@
{# Recent Submissions #} -
-
+
+
-
Recent Submissions
+
Recent Submissions
{% if recent_submissions %} -
- - +
+
+ + - + {% for submission in recent_submissions %} - - + + + - @@ -119,13 +133,13 @@
ID FormTenant Date StatusActionsActions
{{ submission.id[:12] }}...{{ submission.form_config.name if submission.form_config else 'N/A' }}{{ submission.id[:12] }}...{{ submission.form_config.name if submission.form_config else 'N/A' }}{{ submission.tenant.name if submission.tenant else 'N/A' }} {{ submission.created_at.strftime('%Y-%m-%d %H:%M') if submission.created_at else 'N/A' }} - - {{ submission.status.value }} - + {% if submission.status.value == 'processed' %} + Processed + {% elif submission.status.value == 'pending' %} + Pending + {% elif submission.status.value == 'spam' %} + Spam + {% else %} + {{ submission.status.value }} + {% endif %} - - + + + View
{% else %} -
- -

No recent submissions

+
+ +

No active recent submissions found.

{% endif %}
-