i18 drin
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Successful in 1m1s
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Skipped

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.
This commit is contained in:
br committed 2026-09-29 05:16:01 +02:00
1 parent 7f81932fa5
commit 5fff17531d
31 files changed
+2600 -467

No files matched your search

+127
View File
@@ -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 <br@runden.net>`
* 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_<hash>`. 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
<script src="https://formeleon.domain/embed.js" data-form-id="123" data-public-key="pk_live_xyz"></script>
<div id="formeleon-form"></div>
```
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`)*
+1
View File
@@ -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
+5
View File
@@ -88,3 +88,8 @@ migrations/
# Cache
.cache/
*.pyc
# Babel i18n
*.pot
*.mo
+47 -1
View File
@@ -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
---
## 🌍 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
<!-- Before -->
<h1>Welcome to the Dashboard</h1>
<!-- After -->
<h1>{{ _('Welcome to the Dashboard') }}</h1>
```
**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
```
+46
View File
@@ -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
<!-- Vorher -->
<h1>Welcome to the Dashboard</h1>
<!-- Nachher -->
<h1>{{ _('Welcome to the Dashboard') }}</h1>
```
**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.
+6
View File
@@ -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()
@@ -135,6 +138,9 @@ def initialize_extensions(app: Flask) -> None:
}
})
# Babel (i18n)
babel.init_app(app, locale_selector=get_locale, timezone_selector=get_timezone)
# Session
app.config['SESSION_TYPE'] = 'sqlalchemy'
app.config['SESSION_SQLALCHEMY'] = db
+3
View File
@@ -0,0 +1,3 @@
[python: **.py]
[jinja2: **.html]
extensions=jinja2.ext.i18n
+13
View File
@@ -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.
+12
View File
@@ -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
+2
View File
@@ -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):
+3
View File
@@ -34,3 +34,6 @@ python-dateutil~=2.8.0
# Production
gunicorn~=22.0.0
# gthread ist als Worker-Typ in Python 3.13 out-of-the-box stabil
# i18n
Flask-Babel~=4.0.0
+96 -12
View File
@@ -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/<submission_id>')
@login_required
+113 -332
View File
@@ -2,386 +2,167 @@
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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/<form_id> - Submit form data
GET /api/v1/form/<form_id> - 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/<form_id>', 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')
return jsonify({'error': 'Unauthorized. Invalid public key.'}), 401
# Validate origin
if not tenant.is_active():
raise Forbidden('Tenant is not active')
return jsonify({'error': 'Tenant is inactive.'}), 403
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()
# 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')
return jsonify({'error': 'Form is inactive.'}), 403
# Generate handshake token (can be used for subsequent requests)
handshake_token = str(uuid.uuid4())
recaptcha_service = get_recaptcha_service()
# 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
return jsonify({
'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()
}
logger.info(f'Handshake successful for tenant {tenant.id}, form {form.id}')
return jsonify(response)
}), 200
@api_bp.route('/form/<form_id>', methods=['GET'])
def get_form(form_id):
@api_bp.route('/submissions', methods=['POST'])
def submit_form():
"""
Get form configuration.
:param form_id: ID of the form to retrieve
:return: JSON with form configuration
Submission Endpoint.
Receives JSON payload, validates reCAPTCHA (mocked in dev), and saves to DB.
"""
# 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)
tenant = _get_tenant_from_auth()
if not tenant:
raise Unauthorized('Invalid public key')
return jsonify({'error': 'Unauthorized. Invalid public key.'}), 401
if not tenant.is_active():
raise Forbidden('Tenant is not active')
data = request.get_json()
if not data:
return jsonify({'error': 'Invalid JSON payload.'}), 400
# Get form
form = FormConfig.query.filter_by(
id=form_id,
tenant_id=tenant.id
).first()
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:
raise NotFound('Form not found')
return jsonify({'error': 'Form not found.'}), 404
if not form.is_active:
raise Forbidden('Form is not active')
# --- reCAPTCHA Validation ---
recaptcha_service = get_recaptcha_service()
return jsonify({
'status': 'success',
'form': form.to_dict()
})
# 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)
@api_bp.route('/submit/<form_id>', methods=['POST'])
def submit_form(form_id):
"""
Submit form data.
status = SubmissionStatus.PENDING if recaptcha_passed else SubmissionStatus.SPAM
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,
# 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,
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
recaptcha_score=recaptcha_score,
recaptcha_passed=recaptcha_passed,
status=status,
user_agent=request.headers.get('User-Agent'),
referer=request.headers.get('Referer')
)
return jsonify(response), status_code
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)
@api_bp.route('/validate/recaptcha', methods=['POST'])
def validate_recaptcha():
"""
Validate a reCAPTCHA token.
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}")
This endpoint can be used by client snippets to pre-validate
reCAPTCHA tokens before form submission.
# 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
: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
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({
'status': 'success',
'score': score,
'passed': is_valid,
'threshold': recaptcha_service.get_threshold(),
'dev_mode': recaptcha_service.is_dev_mode()
})
'message': 'Submission received successfully',
'submission_id': sub_id
}), 201
@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/<tenant_id>')
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]
}
})
+75
View File
@@ -0,0 +1,75 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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}"}
)
+125
View File
@@ -0,0 +1,125 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# 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)
+252
View File
@@ -0,0 +1,252 @@
/** =============================================================================
* Formeleon - Multi-tenant Form Generator
* Author: Björn Runden <br@runden.net>
* 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 <div id="${containerId}"></div> not found.`);
return;
}
if (!formId || !publicKey) {
container.innerHTML = '<p style="color:red;">[Formeleon] Missing form ID or public key configuration.</p>';
return;
}
// Show loading state
container.innerHTML = '<p>Loading form...</p>';
// 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 = `<p style="color:red;">[Formeleon Error] ${err.message}</p>`;
});
}
/**
* 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 = '<p>Invalid form configuration received.</p>';
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 = `<form id="formeleon-submit-form" class="formeleon-container">`;
// Render Title/Desc
if (formData.name) html += `<h3>${formData.name}</h3>`;
if (formData.description) html += `<p>${formData.description}</p>`;
html += `<div id="formeleon-alert" style="display:none; padding:10px; margin-bottom:15px; border-radius:4px;"></div>`;
// Render Fields
fields.forEach(field => {
const required = field.required ? 'required' : '';
const reqStar = field.required ? '<span style="color:red">*</span>' : '';
// i18n resolution for label and placeholder
const labelText = getLocalizedString(field.label, field.name);
const placeholderText = getLocalizedString(field.placeholder, '');
html += `<div class="formeleon-field-group" style="margin-bottom: 15px;">`;
html += `<label for="${field.name}" style="display:block; margin-bottom:5px; font-weight:bold;">${labelText} ${reqStar}</label>`;
// Extract custom CSS classes
const customCss = field.css_classes ? `class="${field.css_classes}"` : '';
if (field.type === 'textarea') {
html += `<textarea id="${field.name}" name="${field.name}" ${customCss} ${required} style="width:100%; padding:8px;" rows="${field.rows || 3}" placeholder="${placeholderText}"></textarea>`;
} else if (field.type === 'select') {
html += `<select id="${field.name}" name="${field.name}" ${customCss} ${required} style="width:100%; padding:8px;">`;
html += `<option value="">-- Please select --</option>`;
if (field.options && Array.isArray(field.options)) {
field.options.forEach(opt => {
const optText = getLocalizedString(opt, opt);
// We use the fallback text as value, but the localized text for display
html += `<option value="${typeof opt === 'string' ? opt : optText}">${optText}</option>`;
});
}
html += `</select>`;
} else {
html += `<input type="${field.type || 'text'}" id="${field.name}" name="${field.name}" ${customCss} ${required} placeholder="${placeholderText}" style="width:100%; padding:8px;">`;
}
html += `</div>`;
});
// 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 += `<button type="submit" id="formeleon-submit-btn" ${btnCss}>${btnText}</button>`;
html += `</form>`;
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');
}
}
})();
+69 -43
View File
@@ -21,7 +21,8 @@
{# Statistics Cards #}
<div class="row mb-4">
<div class="col-md-3 col-6 mb-3">
<div class="card bg-primary text-white">
<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>
@@ -32,10 +33,12 @@
</div>
</div>
</div>
</a>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card bg-success text-white">
<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>
@@ -46,10 +49,12 @@
</div>
</div>
</div>
</a>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card bg-info text-white">
<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>
@@ -60,6 +65,7 @@
</div>
</div>
</div>
</a>
</div>
<div class="col-md-3 col-6 mb-3">
@@ -79,38 +85,46 @@
<div class="row">
{# Recent Submissions #}
<div class="col-lg-6 mb-4">
<div class="card">
<div class="col-12 mb-4">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5><i class="bi bi-inbox"></i> Recent Submissions</h5>
<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">
<table class="table table-hover mb-0">
<thead class="table-light">
<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>Actions</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for submission in recent_submissions %}
<tr>
<td>{{ submission.id[:12] }}...</td>
<td>{{ submission.form_config.name if submission.form_config else 'N/A' }}</td>
<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>
<span class="badge bg-{{ 'success' if submission.status.value == 'processed' else 'warning' if submission.status.value == 'pending' else 'danger' }}">
{{ submission.status.value }}
</span>
{% 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>
<a href="{{ url_for('admin.view_submission', submission_id=submission.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
<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>
@@ -119,13 +133,13 @@
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-inbox display-4"></i>
<p>No recent submissions</p>
<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">
<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>
@@ -134,38 +148,42 @@
</div>
{# Recent Forms #}
<div class="col-lg-6 mb-4">
<div class="card">
<div class="col-12 mb-4">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5><i class="bi bi-file-earmark-text"></i> Recent Forms</h5>
<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">
<table class="table table-hover mb-0">
<thead class="table-light">
<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>Actions</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for form in recent_forms %}
<tr>
<td>{{ form.name }}</td>
<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>
<span class="badge bg-{{ 'success' if form.is_active else 'secondary' }}">
{{ 'Active' if form.is_active else 'Inactive' }}
</span>
{% 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>
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
<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>
@@ -174,19 +192,19 @@
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-file-earmark-text display-4"></i>
<p>No recent forms</p>
<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">
<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>
<a href="{{ url_for('admin.create_form') }}" class="btn btn-sm btn-primary">
<i class="bi bi-plus"></i> Create Form
</a>
</div>
</div>
</div>
@@ -278,5 +296,13 @@
.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 %}
+435
View File
@@ -0,0 +1,435 @@
{% extends "base.html" %}
{% block title %}Create Form - Admin Dashboard{% endblock %}
{% block extra_css %}
<style>
/* Styling for the visual form builder */
.field-card { border-left: 4px solid #0d6efd; transition: all 0.2s; }
.field-card:hover { box-shadow: 0 .125rem .25rem rgba(0,0,0,.075); }
.drag-handle { cursor: grab; color: #adb5bd; }
</style>
<!-- Include SortableJS for drag-and-drop support -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
{% endblock %}
{% block content %}
<div class="mb-4">
<a href="{{ url_for('admin.list_forms') }}" class="btn btn-outline-secondary btn-sm mb-3">
<i class="bi bi-arrow-left"></i> Back to Forms
</a>
<h2><i class="bi bi-plus-circle me-2"></i> Create New Form</h2>
</div>
<div class="row">
<div class="col-12">
<div class="card shadow-sm mb-4">
<div class="card-body">
<form action="{{ url_for('admin.create_form') }}" method="POST" id="create-form-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() if csrf_token else '' }}">
<!-- Hidden input to store the JSON payload we build via JS -->
<input type="hidden" name="form_structure" id="hidden_form_structure">
<div class="row">
<div class="col-md-6 mb-3">
<label for="name" class="form-label">Form Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" required placeholder="e.g. Contact Us">
</div>
<div class="col-md-6 mb-3 d-flex align-items-end">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="is_active" name="is_active" value="true" checked>
<label class="form-check-label" for="is_active">Active (accepting submissions)</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="2" placeholder="Brief description of the form's purpose"></textarea>
</div>
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Form Builder</h5>
<div>
<button type="button" class="btn btn-sm btn-outline-secondary me-2" id="toggle-json-view">
<i class="bi bi-code-slash"></i> Toggle JSON View
</button>
<button type="button" class="btn btn-sm btn-success" id="add-field-btn">
<i class="bi bi-plus"></i> Add Field
</button>
</div>
</div>
<!-- Visual Builder Area -->
<div id="visual-builder" class="mb-4 bg-light p-3 rounded border">
<div id="fields-container">
<!-- Fields will be dynamically injected here by JS -->
</div>
<!-- Submit Button Config -->
<div class="card mt-4 border-success">
<div class="card-header bg-success text-white py-2">
<i class="bi bi-hand-index-thumb me-1"></i> Submit Button Configuration
</div>
<div class="card-body row">
<div class="col-md-6 mb-2">
<label class="form-label small">Button Text</label>
<input type="text" class="form-control form-control-sm" id="submit-btn-text" value="Submit">
</div>
<div class="col-md-6 mb-2">
<label class="form-label small">Custom CSS Classes</label>
<input type="text" class="form-control form-control-sm" id="submit-btn-css" placeholder="e.g. btn btn-primary">
</div>
</div>
</div>
<!-- Notifications & Email Settings -->
<div class="card mt-4 border-info">
<div class="card-header bg-info text-dark py-2">
<i class="bi bi-envelope-paper me-1"></i> Notifications & Autoresponder
</div>
<div class="card-body">
<input type="hidden" name="settings" id="hidden_settings">
<div class="row mb-4">
<div class="col-12 border-bottom pb-3">
<h6><i class="bi bi-bell text-primary me-2"></i>Admin Notification</h6>
<p class="small text-muted mb-2">Send an email alert to specific addresses when a new submission arrives.</p>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="notify_enabled">
<label class="form-check-label small fw-bold" for="notify_enabled">Enable Admin Notification</label>
</div>
<div id="notify_options" style="display:none;" class="ps-4 mt-2 border-start border-2 border-primary">
<label class="form-label small">Recipient Email(s)</label>
<input type="text" class="form-control form-control-sm mb-1" id="notify_emails" placeholder="admin@domain.com, sales@domain.com">
<small class="text-muted d-block" style="font-size: 0.75rem;">Comma separated list of email addresses.</small>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<h6><i class="bi bi-reply-all text-success me-2"></i>Submitter Autoresponder</h6>
<p class="small text-muted mb-2">Send an automated "Thank You" email to the person who submitted the form.</p>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="autorespond_enabled">
<label class="form-check-label small fw-bold" for="autorespond_enabled">Enable Autoresponder</label>
</div>
<div id="autorespond_options" style="display:none;" class="ps-4 mt-2 border-start border-2 border-success">
<div class="mb-2">
<label class="form-label small">Target Email Field</label>
<select class="form-select form-select-sm" id="autorespond_target_field">
<option value="">-- Select an email field from your form --</option>
</select>
<small class="text-muted d-block mt-1" style="font-size: 0.75rem;">Select which form field contains the submitter's email address.</small>
</div>
<div class="mb-2">
<label class="form-label small">Email Subject</label>
<input type="text" class="form-control form-control-sm" id="autorespond_subject" value="Thank you for your submission!">
</div>
<div class="mb-2">
<label class="form-label small">Email Body Template</label>
<textarea class="form-control form-control-sm" id="autorespond_body" rows="4">Hello,
Thank you for contacting us. We have received your submission and will get back to you shortly.
Best regards,
The Team</textarea>
<small class="text-muted d-block mt-1" style="font-size: 0.75rem;">You can use field variables like <code>{{'{{'}} field_name {{'}}'}}</code> in the body.</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- JSON Editor Area (Hidden by default) -->
<div id="json-editor-container" class="mb-4" style="display:none;">
<div class="alert alert-warning py-2 small">
<i class="bi bi-exclamation-triangle"></i> Editing JSON directly will override the visual builder.
</div>
<textarea class="form-control font-monospace" id="json_textarea" rows="15">{{ default_form_structure | tojson(indent=4) }}</textarea>
<div class="form-text text-danger" id="json-error" style="display:none;">Invalid JSON format.</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{{ url_for('admin.list_forms') }}" class="btn btn-light me-md-2">Cancel</a>
<button type="submit" class="btn btn-primary" id="save-btn"><i class="bi bi-save"></i> Save Form</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Template for a new field -->
<template id="field-template">
<div class="card field-card mb-3 shadow-sm">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 text-primary"><i class="bi bi-grip-vertical drag-handle me-2"></i> Form Field</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-field-btn"><i class="bi bi-trash"></i></button>
</div>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small mb-1">Field Type</label>
<select class="form-select form-select-sm field-type">
<option value="text">Text Input</option>
<option value="email">Email</option>
<option value="textarea">Textarea (Multiline)</option>
<option value="select">Dropdown (Select)</option>
<option value="checkbox">Checkbox</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Field Name (ID) *</label>
<input type="text" class="form-control form-control-sm field-name" required placeholder="e.g. first_name">
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Display Label</label>
<input type="text" class="form-control form-control-sm field-label" placeholder="e.g. First Name">
</div>
<div class="col-md-4 mt-2">
<label class="form-label small mb-1">Placeholder</label>
<input type="text" class="form-control form-control-sm field-placeholder" placeholder="Optional text inside field">
</div>
<div class="col-md-4 mt-2">
<label class="form-label small mb-1">Custom CSS Classes</label>
<input type="text" class="form-control form-control-sm field-css" placeholder="e.g. form-control my-custom-input">
</div>
<div class="col-md-4 mt-2 d-flex align-items-end">
<div class="form-check form-switch mb-1">
<input class="form-check-input field-required" type="checkbox">
<label class="form-check-label small">Required Field</label>
</div>
</div>
<!-- Options container (only visible for select/radio/checkbox) -->
<div class="col-12 mt-2 options-container" style="display:none;">
<label class="form-label small mb-1">Options (Comma separated)</label>
<input type="text" class="form-control form-control-sm field-options" placeholder="Option 1, Option 2, Option 3">
</div>
</div>
</div>
</div>
</template>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Core Elements
const fieldsContainer = document.getElementById('fields-container');
const addFieldBtn = document.getElementById('add-field-btn');
const toggleJsonBtn = document.getElementById('toggle-json-view');
const visualBuilder = document.getElementById('visual-builder');
const jsonEditor = document.getElementById('json-editor-container');
const jsonTextarea = document.getElementById('json_textarea');
const hiddenFormStructure = document.getElementById('hidden_form_structure');
const formEl = document.getElementById('create-form-form');
// Initialize Drag & Drop Sortable
if (typeof Sortable !== 'undefined') {
new Sortable(fieldsContainer, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'bg-light',
dragClass: 'shadow-lg',
onEnd: function (evt) {
// Fired when dragging ends
}
});
}
let isJsonView = false;
let fieldCounter = 0;
// Initialize with one default field
addField('text', 'name', 'Name', 'Enter your name');
addField('email', 'email', 'Email Address', 'Enter your email');
// Add Field Button
addFieldBtn.addEventListener('click', () => {
addField();
});
// Toggle JSON / Visual View
toggleJsonBtn.addEventListener('click', () => {
isJsonView = !isJsonView;
if (isJsonView) {
// Switching to JSON: Generate JSON from visual builder
jsonTextarea.value = JSON.stringify(buildJsonFromVisual(), null, 4);
visualBuilder.style.display = 'none';
jsonEditor.style.display = 'block';
toggleJsonBtn.innerHTML = '<i class="bi bi-palette"></i> Back to Builder';
addFieldBtn.style.display = 'none';
} else {
// Switching to Visual: We don't auto-parse complex JSON back to visual (too error prone for edge cases)
// But we hide JSON and show visual again.
visualBuilder.style.display = 'block';
jsonEditor.style.display = 'none';
toggleJsonBtn.innerHTML = '<i class="bi bi-code-slash"></i> Toggle JSON View';
addFieldBtn.style.display = 'inline-block';
}
});
// Toggle Settings UI
const notifyEnabled = document.getElementById('notify_enabled');
const notifyOptions = document.getElementById('notify_options');
notifyEnabled.addEventListener('change', (e) => { notifyOptions.style.display = e.target.checked ? 'block' : 'none'; });
const autorespondEnabled = document.getElementById('autorespond_enabled');
const autorespondOptions = document.getElementById('autorespond_options');
autorespondEnabled.addEventListener('change', (e) => {
autorespondOptions.style.display = e.target.checked ? 'block' : 'none';
if(e.target.checked) updateEmailFieldDropdown();
});
// Helper to extract email fields for the dropdown
function updateEmailFieldDropdown() {
const select = document.getElementById('autorespond_target_field');
const currentVal = select.value;
select.innerHTML = '<option value="">-- Select an email field from your form --</option>';
const cards = fieldsContainer.querySelectorAll('.field-card');
cards.forEach(card => {
const type = card.querySelector('.field-type').value;
if(type === 'email' || type === 'text') {
const name = card.querySelector('.field-name').value;
const label = card.querySelector('.field-label').value || name;
if(name) {
const opt = document.createElement('option');
opt.value = name;
opt.text = `${label} (${name})`;
select.appendChild(opt);
}
}
});
if(currentVal) select.value = currentVal;
}
// Handle Form Submit
formEl.addEventListener('submit', function(e) {
// Build settings JSON
const settingsJson = {
notifications: {
enabled: notifyEnabled.checked,
send_to: document.getElementById('notify_emails').value.split(',').map(s => s.trim()).filter(s => s)
},
autoresponder: {
enabled: autorespondEnabled.checked,
email_field_name: document.getElementById('autorespond_target_field').value,
subject: document.getElementById('autorespond_subject').value,
body_template: document.getElementById('autorespond_body').value
}
};
document.getElementById('hidden_settings').value = JSON.stringify(settingsJson);
if (!isJsonView) {
// If in visual mode, build the JSON right before submit
hiddenFormStructure.value = JSON.stringify(buildJsonFromVisual());
} else {
// If in JSON mode, take text area value directly
try {
JSON.parse(jsonTextarea.value); // Validate
hiddenFormStructure.value = jsonTextarea.value;
} catch (err) {
e.preventDefault();
alert("Invalid JSON configuration. Please fix errors before saving.");
return false;
}
}
});
// Function to add a new visual field
function addField(type = 'text', name = '', label = '', placeholder = '') {
fieldCounter++;
const template = document.getElementById('field-template');
const clone = template.content.cloneNode(true);
const card = clone.querySelector('.field-card');
// Populate default values if provided
card.querySelector('.field-type').value = type;
card.querySelector('.field-name').value = name || `field_${fieldCounter}`;
card.querySelector('.field-label').value = label;
card.querySelector('.field-placeholder').value = placeholder;
// Setup event listeners for this specific card
setupCardEvents(card);
fieldsContainer.appendChild(clone);
}
function setupCardEvents(card) {
// Remove button
card.querySelector('.remove-field-btn').addEventListener('click', function() {
card.remove();
updateEmailFieldDropdown(); // Refresh dropdown
});
// Type change (show/hide options)
const typeSelect = card.querySelector('.field-type');
const optionsContainer = card.querySelector('.options-container');
typeSelect.addEventListener('change', function() {
const val = this.value;
if (val === 'select' || val === 'radio') {
optionsContainer.style.display = 'block';
} else {
optionsContainer.style.display = 'none';
}
updateEmailFieldDropdown(); // Refresh dropdown
});
// Name change (refresh dropdown)
card.querySelector('.field-name').addEventListener('change', updateEmailFieldDropdown);
// Trigger change initially to set correct state
typeSelect.dispatchEvent(new Event('change'));
}
// Builder Logic: Read visual fields and construct JSON
function buildJsonFromVisual() {
const json = {
version: "1.0",
fields: [],
submit_button: {
text: document.getElementById('submit-btn-text').value || "Submit",
css_classes: document.getElementById('submit-btn-css').value || ""
}
};
const cards = fieldsContainer.querySelectorAll('.field-card');
cards.forEach(card => {
const type = card.querySelector('.field-type').value;
const fieldDef = {
name: card.querySelector('.field-name').value,
type: type,
label: card.querySelector('.field-label').value,
placeholder: card.querySelector('.field-placeholder').value,
required: card.querySelector('.field-required').checked,
css_classes: card.querySelector('.field-css').value
};
if (type === 'select' || type === 'radio') {
const optsString = card.querySelector('.field-options').value;
if (optsString) {
fieldDef.options = optsString.split(',').map(s => s.trim()).filter(s => s);
}
}
json.fields.push(fieldDef);
});
return json;
}
});
</script>
{% endblock %}
+524
View File
@@ -0,0 +1,524 @@
{% extends "base.html" %}
{% block title %}Edit Form - Admin Dashboard{% endblock %}
{% block extra_css %}
<style>
/* Styling for the visual form builder */
.field-card { border-left: 4px solid #0d6efd; transition: all 0.2s; }
.field-card:hover { box-shadow: 0 .125rem .25rem rgba(0,0,0,.075); }
.drag-handle { cursor: grab; color: #adb5bd; }
</style>
<!-- Include SortableJS for drag-and-drop support -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
{% endblock %}
{% block content %}
<div class="mb-4">
<a href="{{ url_for('admin.list_forms') }}" class="btn btn-outline-secondary btn-sm mb-3">
<i class="bi bi-arrow-left"></i> Back to Forms
</a>
<div class="d-flex justify-content-between align-items-center">
<h2><i class="bi bi-pencil-square me-2"></i> Edit Form: {{ form.name }}</h2>
<span class="badge {{ 'bg-success' if form.is_active else 'bg-secondary' }}">
{{ 'Active' if form.is_active else 'Inactive' }}
</span>
</div>
</div>
<div class="row">
<div class="col-lg-8 mb-4">
<div class="card shadow-sm h-100">
<div class="card-body">
<form action="{{ url_for('admin.view_form', form_id=form.id) }}" method="POST" id="edit-form-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() if csrf_token else '' }}">
<input type="hidden" name="form_structure" id="hidden_form_structure">
<div class="row">
<div class="col-md-6 mb-3">
<label for="name" class="form-label">Form Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="name" name="name" value="{{ form.name }}" required>
</div>
<div class="col-md-6 mb-3 d-flex align-items-end">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="is_active" name="is_active" value="true" {% if form.is_active %}checked{% endif %}>
<label class="form-check-label" for="is_active">Active (accepting submissions)</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="2">{{ form.description or '' }}</textarea>
</div>
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Form Builder</h5>
<div>
<button type="button" class="btn btn-sm btn-outline-secondary me-2" id="toggle-json-view">
<i class="bi bi-code-slash"></i> Toggle JSON View
</button>
<button type="button" class="btn btn-sm btn-success" id="add-field-btn">
<i class="bi bi-plus"></i> Add Field
</button>
</div>
</div>
<!-- Visual Builder Area -->
<div id="visual-builder" class="mb-4 bg-light p-3 rounded border">
<div id="fields-container">
<!-- Fields will be dynamically injected here by JS -->
</div>
<!-- Submit Button Config -->
<div class="card mt-4 border-success">
<div class="card-header bg-success text-white py-2">
<i class="bi bi-hand-index-thumb me-1"></i> Submit Button Configuration
</div>
<div class="card-body row">
<div class="col-md-6 mb-2">
<label class="form-label small">Button Text</label>
<input type="text" class="form-control form-control-sm" id="submit-btn-text" value="{{ form.form_structure.submit_button.text if form.form_structure.submit_button else 'Submit' }}">
</div>
<div class="col-md-6 mb-2">
<label class="form-label small">Custom CSS Classes</label>
<input type="text" class="form-control form-control-sm" id="submit-btn-css" value="{{ form.form_structure.submit_button.css_classes if form.form_structure.submit_button and form.form_structure.submit_button.css_classes else '' }}" placeholder="e.g. btn btn-primary">
</div>
</div>
</div>
<!-- Notifications & Email Settings -->
<div class="card mt-4 border-info">
<div class="card-header bg-info text-dark py-2">
<i class="bi bi-envelope-paper me-1"></i> Notifications & Autoresponder
</div>
<div class="card-body">
<input type="hidden" name="settings" id="hidden_settings">
<div class="row mb-4">
<div class="col-12 border-bottom pb-3">
<h6><i class="bi bi-bell text-primary me-2"></i>Admin Notification</h6>
<p class="small text-muted mb-2">Send an email alert to specific addresses when a new submission arrives.</p>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="notify_enabled" {% if form.settings and form.settings.notifications and form.settings.notifications.enabled %}checked{% endif %}>
<label class="form-check-label small fw-bold" for="notify_enabled">Enable Admin Notification</label>
</div>
<div id="notify_options" style="display:{% if form.settings and form.settings.notifications and form.settings.notifications.enabled %}block{% else %}none{% endif %};" class="ps-4 mt-2 border-start border-2 border-primary">
<label class="form-label small">Recipient Email(s)</label>
<input type="text" class="form-control form-control-sm mb-1" id="notify_emails" placeholder="admin@domain.com, sales@domain.com" value="{{ form.settings.notifications.send_to|join(', ') if form.settings and form.settings.notifications and form.settings.notifications.send_to else '' }}">
<small class="text-muted d-block" style="font-size: 0.75rem;">Comma separated list of email addresses.</small>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<h6><i class="bi bi-reply-all text-success me-2"></i>Submitter Autoresponder</h6>
<p class="small text-muted mb-2">Send an automated "Thank You" email to the person who submitted the form.</p>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="autorespond_enabled" {% if form.settings and form.settings.autoresponder and form.settings.autoresponder.enabled %}checked{% endif %}>
<label class="form-check-label small fw-bold" for="autorespond_enabled">Enable Autoresponder</label>
</div>
<div id="autorespond_options" style="display:{% if form.settings and form.settings.autoresponder and form.settings.autoresponder.enabled %}block{% else %}none{% endif %};" class="ps-4 mt-2 border-start border-2 border-success">
<div class="mb-2">
<label class="form-label small">Target Email Field</label>
<select class="form-select form-select-sm" id="autorespond_target_field" data-selected="{{ form.settings.autoresponder.email_field_name if form.settings and form.settings.autoresponder else '' }}">
<option value="">-- Select an email field from your form --</option>
</select>
<small class="text-muted d-block mt-1" style="font-size: 0.75rem;">Select which form field contains the submitter's email address.</small>
</div>
<div class="mb-2">
<label class="form-label small">Email Subject</label>
<input type="text" class="form-control form-control-sm" id="autorespond_subject" value="{{ form.settings.autoresponder.subject if form.settings and form.settings.autoresponder and form.settings.autoresponder.subject else 'Thank you for your submission!' }}">
</div>
<div class="mb-2">
<label class="form-label small">Email Body Template</label>
<textarea class="form-control form-control-sm" id="autorespond_body" rows="4">{{ form.settings.autoresponder.body_template if form.settings and form.settings.autoresponder and form.settings.autoresponder.body_template else 'Hello,\n\nThank you for contacting us. We have received your submission and will get back to you shortly.\n\nBest regards,\nThe Team' }}</textarea>
<small class="text-muted d-block mt-1" style="font-size: 0.75rem;">You can use field variables like <code>{{'{{'}} field_name {{'}}'}}</code> in the body.</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- JSON Editor Area -->
<div id="json-editor-container" class="mb-4" style="display:none;">
<div class="alert alert-warning py-2 small">
<i class="bi bi-exclamation-triangle"></i> Editing JSON directly will override the visual builder.
</div>
<textarea class="form-control font-monospace" id="json_textarea" rows="15">{{ form.form_structure | tojson(indent=4) }}</textarea>
<div class="form-text text-danger" id="json-error" style="display:none;">Invalid JSON format.</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="submit" class="btn btn-primary" id="save-btn"><i class="bi bi-save"></i> Update Configuration</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4 mb-4">
<!-- Embed Snippet Card -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Embed Snippet</h5>
</div>
<div class="card-body">
<p class="small text-muted mb-2">Copy this HTML code to embed the form on the tenant's website:</p>
<div class="position-relative">
<pre class="bg-dark text-light p-3 rounded small" style="white-space: pre-wrap;" id="embed-code">
&lt;!-- Formeleon Embed --&gt;
&lt;script src="{{ request.url_root }}static/js/embed.js"
data-form-id="{{ form.id }}"
data-public-key="{{ tenant.public_key }}"&gt;&lt;/script&gt;
&lt;div id="formeleon-form"&gt;&lt;/div&gt;</pre>
<button class="btn btn-sm btn-outline-light position-absolute top-0 end-0 m-2" onclick="navigator.clipboard.writeText(document.getElementById('embed-code').innerText); alert('Copied!');">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
</div>
<!-- Recent Submissions Card -->
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Recent Submissions</h5>
<a href="{{ url_for('admin.list_submissions') }}" class="btn btn-sm btn-outline-secondary">View All</a>
</div>
<ul class="list-group list-group-flush">
{% for sub in submissions %}
<a href="{{ url_for('admin.view_submission', submission_id=sub.id) }}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between align-items-center">
<small>{{ sub.created_at.strftime('%Y-%m-%d %H:%M') }}</small>
{% if sub.status.value == 'pending' %}
<span class="badge bg-warning text-dark">Pending</span>
{% elif sub.status.value == 'processed' %}
<span class="badge bg-success">Processed</span>
{% else %}
<span class="badge bg-secondary">{{ sub.status.value }}</span>
{% endif %}
</div>
</a>
{% else %}
<li class="list-group-item text-center text-muted py-4">No submissions yet.</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<!-- Template for a new field -->
<template id="field-template">
<div class="card field-card mb-3 shadow-sm">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 text-primary"><i class="bi bi-grip-vertical drag-handle me-2"></i> Form Field</h6>
<button type="button" class="btn btn-sm btn-outline-danger remove-field-btn"><i class="bi bi-trash"></i></button>
</div>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small mb-1">Field Type</label>
<select class="form-select form-select-sm field-type">
<option value="text">Text Input</option>
<option value="email">Email</option>
<option value="textarea">Textarea (Multiline)</option>
<option value="select">Dropdown (Select)</option>
<option value="checkbox">Checkbox</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Field Name (ID) *</label>
<input type="text" class="form-control form-control-sm field-name" required placeholder="e.g. first_name">
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Display Label</label>
<input type="text" class="form-control form-control-sm field-label" placeholder="e.g. First Name">
</div>
<div class="col-md-4 mt-2">
<label class="form-label small mb-1">Placeholder</label>
<input type="text" class="form-control form-control-sm field-placeholder" placeholder="Optional text inside field">
</div>
<div class="col-md-4 mt-2">
<label class="form-label small mb-1">Custom CSS Classes</label>
<input type="text" class="form-control form-control-sm field-css" placeholder="e.g. form-control my-custom-input">
</div>
<div class="col-md-4 mt-2 d-flex align-items-end">
<div class="form-check form-switch mb-1">
<input class="form-check-input field-required" type="checkbox">
<label class="form-check-label small">Required Field</label>
</div>
</div>
<div class="col-12 mt-2 options-container" style="display:none;">
<label class="form-label small mb-1">Options (Comma separated)</label>
<input type="text" class="form-control form-control-sm field-options" placeholder="Option 1, Option 2, Option 3">
</div>
</div>
</div>
</div>
</template>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Core Elements
const fieldsContainer = document.getElementById('fields-container');
const addFieldBtn = document.getElementById('add-field-btn');
const toggleJsonBtn = document.getElementById('toggle-json-view');
const visualBuilder = document.getElementById('visual-builder');
const jsonEditor = document.getElementById('json-editor-container');
const jsonTextarea = document.getElementById('json_textarea');
const hiddenFormStructure = document.getElementById('hidden_form_structure');
const formEl = document.getElementById('edit-form-form');
// Initialize Drag & Drop Sortable
if (typeof Sortable !== 'undefined') {
new Sortable(fieldsContainer, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'bg-light',
dragClass: 'shadow-lg'
});
}
let isJsonView = false;
let fieldCounter = 0;
// Load existing form structure into visual builder
try {
const existingStructure = JSON.parse(jsonTextarea.value);
if (existingStructure && existingStructure.fields && Array.isArray(existingStructure.fields)) {
existingStructure.fields.forEach(field => {
const options = field.options ? field.options.join(', ') : '';
addField(field.type, field.name, field.label, field.placeholder, field.css_classes, field.required, options);
});
}
} catch (e) {
console.error("Could not parse initial form structure for visual builder:", e);
// Fallback to JSON view if it's too broken
isJsonView = true;
visualBuilder.style.display = 'none';
jsonEditor.style.display = 'block';
toggleJsonBtn.innerHTML = '<i class="bi bi-palette"></i> Back to Builder';
addFieldBtn.style.display = 'none';
}
// Add Field Button
addFieldBtn.addEventListener('click', () => {
addField();
});
// Toggle JSON / Visual View
toggleJsonBtn.addEventListener('click', () => {
isJsonView = !isJsonView;
if (isJsonView) {
jsonTextarea.value = JSON.stringify(buildJsonFromVisual(), null, 4);
visualBuilder.style.display = 'none';
jsonEditor.style.display = 'block';
toggleJsonBtn.innerHTML = '<i class="bi bi-palette"></i> Back to Builder';
addFieldBtn.style.display = 'none';
} else {
// Here in edit mode, if a user switches BACK from JSON to visual,
// we should technically re-render the visual builder from the JSON they just edited!
try {
const updatedStruct = JSON.parse(jsonTextarea.value);
// Clear existing cards
fieldsContainer.innerHTML = '';
fieldCounter = 0;
// Rebuild from JSON
if (updatedStruct.fields) {
updatedStruct.fields.forEach(field => {
const options = field.options ? field.options.join(', ') : '';
addField(field.type, field.name, field.label, field.placeholder, field.css_classes, field.required, options);
});
}
if (updatedStruct.submit_button) {
document.getElementById('submit-btn-text').value = updatedStruct.submit_button.text || '';
document.getElementById('submit-btn-css').value = updatedStruct.submit_button.css_classes || '';
}
document.getElementById('json-error').style.display = 'none';
document.getElementById('save-btn').disabled = false;
visualBuilder.style.display = 'block';
jsonEditor.style.display = 'none';
toggleJsonBtn.innerHTML = '<i class="bi bi-code-slash"></i> Toggle JSON View';
addFieldBtn.style.display = 'inline-block';
} catch (err) {
document.getElementById('json-error').style.display = 'block';
alert("Cannot switch to visual builder: JSON is invalid.");
isJsonView = true; // revert state
}
}
});
// Toggle Settings UI
const notifyEnabled = document.getElementById('notify_enabled');
const notifyOptions = document.getElementById('notify_options');
notifyEnabled.addEventListener('change', (e) => { notifyOptions.style.display = e.target.checked ? 'block' : 'none'; });
const autorespondEnabled = document.getElementById('autorespond_enabled');
const autorespondOptions = document.getElementById('autorespond_options');
autorespondEnabled.addEventListener('change', (e) => {
autorespondOptions.style.display = e.target.checked ? 'block' : 'none';
if(e.target.checked) updateEmailFieldDropdown();
});
// Helper to extract email fields for the dropdown
function updateEmailFieldDropdown() {
const select = document.getElementById('autorespond_target_field');
const currentVal = select.value || select.getAttribute('data-selected');
select.innerHTML = '<option value="">-- Select an email field from your form --</option>';
const cards = fieldsContainer.querySelectorAll('.field-card');
cards.forEach(card => {
const type = card.querySelector('.field-type').value;
if(type === 'email' || type === 'text') {
const name = card.querySelector('.field-name').value;
const label = card.querySelector('.field-label').value || name;
if(name) {
const opt = document.createElement('option');
opt.value = name;
opt.text = `${label} (${name})`;
select.appendChild(opt);
}
}
});
if(currentVal) select.value = currentVal;
}
// Trigger initial dropdown population if needed
setTimeout(updateEmailFieldDropdown, 100);
// Handle Form Submit
formEl.addEventListener('submit', function(e) {
// Build settings JSON
const settingsJson = {
notifications: {
enabled: notifyEnabled.checked,
send_to: document.getElementById('notify_emails').value.split(',').map(s => s.trim()).filter(s => s)
},
autoresponder: {
enabled: autorespondEnabled.checked,
email_field_name: document.getElementById('autorespond_target_field').value,
subject: document.getElementById('autorespond_subject').value,
body_template: document.getElementById('autorespond_body').value
}
};
document.getElementById('hidden_settings').value = JSON.stringify(settingsJson);
if (!isJsonView) {
hiddenFormStructure.value = JSON.stringify(buildJsonFromVisual());
} else {
try {
JSON.parse(jsonTextarea.value);
hiddenFormStructure.value = jsonTextarea.value;
} catch (err) {
e.preventDefault();
alert("Invalid JSON configuration. Please fix errors before saving.");
return false;
}
}
});
// Function to add a new visual field
function addField(type = 'text', name = '', label = '', placeholder = '', css_classes = '', required = false, options = '') {
fieldCounter++;
const template = document.getElementById('field-template');
const clone = template.content.cloneNode(true);
const card = clone.querySelector('.field-card');
card.querySelector('.field-type').value = type || 'text';
card.querySelector('.field-name').value = name || `field_${fieldCounter}`;
card.querySelector('.field-label').value = label || '';
card.querySelector('.field-placeholder').value = placeholder || '';
card.querySelector('.field-css').value = css_classes || '';
card.querySelector('.field-required').checked = required;
card.querySelector('.field-options').value = options || '';
setupCardEvents(card);
fieldsContainer.appendChild(clone);
}
function setupCardEvents(card) {
card.querySelector('.remove-field-btn').addEventListener('click', function() {
card.remove();
updateEmailFieldDropdown();
});
const typeSelect = card.querySelector('.field-type');
const optionsContainer = card.querySelector('.options-container');
typeSelect.addEventListener('change', function() {
const val = this.value;
if (val === 'select' || val === 'radio') {
optionsContainer.style.display = 'block';
} else {
optionsContainer.style.display = 'none';
}
updateEmailFieldDropdown();
});
card.querySelector('.field-name').addEventListener('change', updateEmailFieldDropdown);
typeSelect.dispatchEvent(new Event('change'));
}
function buildJsonFromVisual() {
const json = {
version: "1.0",
fields: [],
submit_button: {
text: document.getElementById('submit-btn-text').value || "Submit",
css_classes: document.getElementById('submit-btn-css').value || ""
}
};
const cards = fieldsContainer.querySelectorAll('.field-card');
cards.forEach(card => {
const type = card.querySelector('.field-type').value;
const fieldDef = {
name: card.querySelector('.field-name').value,
type: type,
label: card.querySelector('.field-label').value,
placeholder: card.querySelector('.field-placeholder').value,
required: card.querySelector('.field-required').checked,
css_classes: card.querySelector('.field-css').value
};
if (type === 'select' || type === 'radio') {
const optsString = card.querySelector('.field-options').value;
if (optsString) {
fieldDef.options = optsString.split(',').map(s => s.trim()).filter(s => s);
}
}
json.fields.push(fieldDef);
});
return json;
}
// Live JSON Validation (only active when in JSON mode)
jsonTextarea.addEventListener('input', function() {
if (!isJsonView) return;
try {
JSON.parse(this.value);
document.getElementById('json-error').style.display = 'none';
document.getElementById('save-btn').disabled = false;
} catch (e) {
document.getElementById('json-error').style.display = 'block';
document.getElementById('save-btn').disabled = true;
}
});
});
</script>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}My Profile - Formeleon{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-person-badge me-2"></i> My Profile</h2>
</div>
<div class="row">
<!-- User Information -->
<div class="col-lg-5 mb-4">
<div class="card shadow-sm h-100 border-top border-primary border-4">
<div class="card-header bg-light bg-gradient">
<h5 class="card-title mb-0"><i class="bi bi-person-circle me-2"></i> Account Details</h5>
</div>
<div class="card-body">
<div class="text-center mb-4 mt-2">
<div class="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center mx-auto shadow-sm" style="width: 80px; height: 80px; font-size: 2rem;">
{{ user.name[0]|upper if user.name else '?' }}
</div>
<h4 class="mt-3 mb-1">{{ user.name }}</h4>
<p class="text-muted mb-0">{{ user.email }}</p>
</div>
<ul class="list-group list-group-flush mb-4">
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted">Account Role</span>
{% if user.role.value == 'admin' %}
<span class="badge bg-danger">Global Admin</span>
{% elif user.role.value == 'tenant_admin' %}
<span class="badge bg-primary">Tenant Admin</span>
{% elif user.role.value == 'user' %}
<span class="badge bg-info text-dark">User</span>
{% else %}
<span class="badge bg-secondary">{{ user.role.value|title }}</span>
{% endif %}
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted">Member Since</span>
<span>{{ user.created_at.strftime('%Y-%m-%d') if user.created_at else 'Unknown' }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted">Last Login</span>
<span>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else 'Unknown' }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted">Authentication</span>
{% if dev_mode %}
<span class="badge bg-warning text-dark"><i class="bi bi-bug"></i> Dev Mode Bypass</span>
{% else %}
<span class="badge bg-success"><i class="bi bi-shield-check"></i> OIDC (Zitadel)</span>
{% endif %}
</li>
</ul>
{% if not dev_mode %}
<div class="alert alert-info py-2 small mb-0">
<i class="bi bi-info-circle me-1"></i>
To change your name, email, or password, please visit the <a href="{{ config.get('ZITADEL_ISSUER') }}/ui/console/users/me" target="_blank" class="alert-link">Zitadel Identity Console <i class="bi bi-box-arrow-up-right"></i></a>.
</div>
{% endif %}
</div>
</div>
</div>
<!-- Assigned Tenants -->
<div class="col-lg-7 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-light bg-gradient d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><i class="bi bi-buildings me-2"></i> My Workspaces (Tenants)</h5>
<span class="badge bg-secondary rounded-pill">{{ user.tenants|length if not user.is_admin() else 'All' }}</span>
</div>
<div class="card-body p-0">
{% if user.is_admin() %}
<div class="p-4 text-center">
<div class="text-danger mb-3">
<i class="bi bi-shield-lock-fill" style="font-size: 3rem;"></i>
</div>
<h5>Global Admin Access</h5>
<p class="text-muted">As a global administrator, you automatically have access to view and manage <strong>all</strong> tenants in the system.</p>
<a href="{{ url_for('admin.list_tenants') }}" class="btn btn-outline-primary">Manage All Tenants</a>
</div>
{% else %}
{% if user.tenants %}
<div class="list-group list-group-flush">
{% for t in user.tenants %}
<a href="{{ url_for('admin.view_tenant', tenant_id=t.id) }}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center p-3">
<div>
<h6 class="mb-1">{{ t.name }}</h6>
<small class="text-muted font-monospace">{{ t.id }}</small>
</div>
<span class="badge bg-{{ 'success' if t.is_active() else 'secondary' }}">
{{ 'Active' if t.is_active() else 'Inactive' }}
</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="p-5 text-center text-muted">
<i class="bi bi-building-x fs-1 d-block mb-3"></i>
<p>You are not currently assigned to any workspaces.</p>
<p class="small">Please contact your administrator to request access.</p>
</div>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+131
View File
@@ -0,0 +1,131 @@
{% extends "base.html" %}
{% block title %}Settings - Admin Dashboard{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-gear me-2"></i> Application Settings</h2>
</div>
<div class="row">
<!-- System Overview -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-light">
<h5 class="card-title mb-0"><i class="bi bi-info-circle me-2"></i> System Overview</h5>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Total Tenants
<span class="badge bg-primary rounded-pill">{{ tenant_count }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Total Users
<span class="badge bg-primary rounded-pill">{{ user_count }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Total Forms
<span class="badge bg-primary rounded-pill">{{ form_count }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Total Submissions
<span class="badge bg-primary rounded-pill">{{ submission_count }}</span>
</li>
</ul>
<h6 class="mt-4 mb-3 text-muted">Environment</h6>
<table class="table table-sm table-borderless">
<tbody>
<tr>
<td class="text-muted" style="width: 150px;">Dev Mode</td>
<td>
{% if dev_mode %}
<span class="badge bg-warning text-dark"><i class="bi bi-bug"></i> Active</span>
{% else %}
<span class="badge bg-success">Inactive (Production)</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Security & API -->
<div class="col-lg-6 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-light">
<h5 class="card-title mb-0"><i class="bi bi-shield-lock me-2"></i> Security & API</h5>
</div>
<div class="card-body">
<p class="text-muted small">Most security settings are configured via environment variables (<code>.env</code> file) and require a server restart to take effect.</p>
<h6 class="mt-3">reCAPTCHA v3 Integration</h6>
<div class="alert {{ 'alert-success' if config.get('RECAPTCHA_SITE_KEY') or dev_mode else 'alert-warning' }} py-2">
<i class="bi {{ 'bi-check-circle' if config.get('RECAPTCHA_SITE_KEY') or dev_mode else 'bi-exclamation-triangle' }} me-2"></i>
{% if dev_mode %}
Mock Mode Active (Dev Mode)
{% elif config.get('RECAPTCHA_SITE_KEY') %}
Configured and Active
{% else %}
Not Configured (Missing Keys)
{% endif %}
</div>
<h6 class="mt-4">OIDC Authentication (Zitadel)</h6>
<div class="alert {{ 'alert-warning' if dev_mode else 'alert-success' }} py-2">
<i class="bi {{ 'bi-exclamation-triangle' if dev_mode else 'bi-check-circle' }} me-2"></i>
{% if dev_mode %}
Bypassed (Dev Mode Active)
{% else %}
Active
{% endif %}
</div>
</div>
</div>
</div>
<!-- Audit Log -->
<div class="col-12 mb-4">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="card-title mb-0"><i class="bi bi-journal-text me-2"></i> Recent Audit Logs</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 400px;">
<table class="table table-hover table-sm mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Resource</th>
<th>Details</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for log in audit_logs %}
<tr>
<td><small>{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small></td>
<td><span class="badge bg-secondary">{{ log.action }}</span></td>
<td>{{ log.resource_type }} <code>{{ log.resource_id|truncate(8, true, '') if log.resource_id else '' }}</code></td>
<td><small class="text-muted">{{ log.details|tojson if log.details else '' }}</small></td>
<td>
{% if log.status == 'success' %}
<i class="bi bi-check-circle text-success"></i>
{% else %}
<i class="bi bi-x-circle text-danger"></i>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">No audit logs found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+145
View File
@@ -0,0 +1,145 @@
{% extends "base.html" %}
{% block title %}Submissions - Admin Dashboard{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<i class="bi bi-inbox me-2"></i>
Form Submissions
{% if show_archived %} <span class="badge bg-secondary fs-6 align-middle ms-2">Archive View</span> {% endif %}
</h2>
<div>
<div class="btn-group me-2">
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-download"></i> Export
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('admin.export_submissions', format='csv') }}"><i class="bi bi-filetype-csv me-2 text-success"></i> Download CSV</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.export_submissions', format='json') }}"><i class="bi bi-filetype-json me-2 text-warning"></i> Download JSON</a></li>
</ul>
</div>
{% if show_archived %}
<a href="{{ url_for('admin.list_submissions') }}" class="btn btn-outline-primary"><i class="bi bi-inbox"></i> Active View</a>
{% else %}
<a href="{{ url_for('admin.list_submissions', show_archived='true') }}" class="btn btn-outline-secondary"><i class="bi bi-archive"></i> View Archive</a>
{% endif %}
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
{% if submissions %}
<form action="{{ url_for('admin.bulk_action_submissions') }}" method="POST" id="bulk-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() if csrf_token else '' }}">
<!-- Bulk Actions Toolbar -->
<div class="bg-light p-2 mb-3 rounded d-flex align-items-center" id="bulk-toolbar" style="display: none !important;">
<span class="me-3 fw-bold"><span id="selected-count">0</span> selected</span>
{% if show_archived %}
<button type="submit" name="action" value="unarchive" class="btn btn-sm btn-success"><i class="bi bi-box-arrow-up"></i> Restore (Unarchive)</button>
{% else %}
<button type="submit" name="action" value="archive" class="btn btn-sm btn-warning"><i class="bi bi-archive"></i> Move to Archive</button>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th style="width: 40px;">
<input class="form-check-input" type="checkbox" id="select-all">
</th>
<th>Date</th>
<th>Form</th>
<th>Tenant</th>
<th>Status</th>
<th>reCAPTCHA</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for sub in submissions %}
<tr class="{% if sub.status.value == 'archived' %}opacity-75{% endif %}">
<td>
<input class="form-check-input sub-checkbox" type="checkbox" name="submission_ids" value="{{ sub.id }}">
</td>
<td><small>{{ sub.created_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>{{ sub.form_config.name }}</td>
<td><small class="text-muted">{{ sub.tenant.name }}</small></td>
<td>
{% if sub.status.value == 'pending' %}
<span class="badge bg-warning text-dark">Pending</span>
{% elif sub.status.value == 'processed' %}
<span class="badge bg-success">Processed</span>
{% elif sub.status.value == 'spam' %}
<span class="badge bg-danger">Spam</span>
{% elif sub.status.value == 'archived' %}
<span class="badge bg-secondary"><i class="bi bi-archive"></i> Archived</span>
{% else %}
<span class="badge bg-secondary">{{ sub.status.value }}</span>
{% endif %}
</td>
<td>
{% if sub.recaptcha_score is not none %}
<span class="badge {{ 'bg-success' if sub.recaptcha_passed else 'bg-danger' }}">
Score: {{ "%.2f"|format(sub.recaptcha_score) }}
</span>
{% else %}
<span class="text-muted small">N/A</span>
{% endif %}
</td>
<td class="text-end">
<a href="{{ url_for('admin.view_submission', submission_id=sub.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi {% if show_archived %}bi-archive{% else %}bi-inbox{% endif %} fs-1 d-block mb-3"></i>
<p>No {% if show_archived %}archived {% endif %}submissions found.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.getElementById('select-all');
const checkboxes = document.querySelectorAll('.sub-checkbox');
const bulkToolbar = document.getElementById('bulk-toolbar');
const countDisplay = document.getElementById('selected-count');
function updateToolbar() {
if(!bulkToolbar) return;
const checkedCount = document.querySelectorAll('.sub-checkbox:checked').length;
countDisplay.textContent = checkedCount;
if (checkedCount > 0) {
bulkToolbar.style.setProperty('display', 'flex', 'important');
} else {
bulkToolbar.style.setProperty('display', 'none', 'important');
}
}
if(selectAll) {
selectAll.addEventListener('change', function() {
checkboxes.forEach(cb => cb.checked = selectAll.checked);
updateToolbar();
});
}
checkboxes.forEach(cb => {
cb.addEventListener('change', updateToolbar);
});
});
</script>
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Submission Details - Admin Dashboard{% endblock %}
{% block content %}
<div class="mb-4">
<a href="{{ url_for('admin.list_submissions') }}" class="btn btn-outline-secondary btn-sm mb-3">
<i class="bi bi-arrow-left"></i> Back to Submissions
</a>
<div class="d-flex justify-content-between align-items-center">
<h2><i class="bi bi-file-text me-2"></i> Submission Details</h2>
<div>
<form action="{{ url_for('admin.update_submission_status', submission_id=submission.id) }}" method="POST" class="d-inline-flex align-items-center">
<input type="hidden" name="csrf_token" value="{{ csrf_token() if csrf_token else '' }}">
<select name="status" class="form-select form-select-sm me-2" style="width: auto;" onchange="this.form.submit()">
<option value="pending" {% if submission.status.value == 'pending' %}selected{% endif %}>Pending</option>
<option value="processed" {% if submission.status.value == 'processed' %}selected{% endif %}>Processed</option>
<option value="spam" {% if submission.status.value == 'spam' %}selected{% endif %}>Spam</option>
<option value="failed" {% if submission.status.value == 'failed' %}selected{% endif %}>Failed</option>
</select>
<noscript><button type="submit" class="btn btn-sm btn-primary">Update</button></noscript>
</form>
</div>
</div>
</div>
<div class="row">
<!-- Meta Info Sidebar -->
<div class="col-md-4 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Metadata</h5>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item px-0">
<small class="text-muted d-block">ID</small>
<code>{{ submission.id }}</code>
</li>
<li class="list-group-item px-0">
<small class="text-muted d-block">Date Received</small>
{{ submission.created_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}
</li>
<li class="list-group-item px-0">
<small class="text-muted d-block">Form Source</small>
<a href="{{ url_for('admin.view_form', form_id=form.id) }}">{{ form.name }}</a>
</li>
<li class="list-group-item px-0">
<small class="text-muted d-block">Tenant</small>
<a href="{{ url_for('admin.view_tenant', tenant_id=tenant.id) }}">{{ tenant.name }}</a>
</li>
<li class="list-group-item px-0">
<small class="text-muted d-block">reCAPTCHA Score</small>
{% if submission.recaptcha_score is not none %}
<span class="badge {{ 'bg-success' if submission.recaptcha_passed else 'bg-danger' }}">
{{ "%.2f"|format(submission.recaptcha_score) }}
</span>
{% else %}
<span class="text-muted">Not validated</span>
{% endif %}
</li>
<li class="list-group-item px-0">
<small class="text-muted d-block">Referer</small>
<span class="text-truncate d-block" title="{{ submission.referer }}">{{ submission.referer or 'Unknown' }}</span>
</li>
<li class="list-group-item px-0">
<small class="text-muted d-block">IP Hash (Anonymized)</small>
<code class="text-truncate d-block">{{ submission.ip_hash or 'Not tracked' }}</code>
</li>
</ul>
</div>
</div>
</div>
<!-- Payload Content -->
<div class="col-md-8 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Submitted Data</h5>
</div>
<div class="card-body p-0">
<table class="table table-striped table-hover mb-0">
<tbody>
{% for key, value in submission.payload.items() %}
<tr>
<th style="width: 30%;" class="ps-4">{{ key }}</th>
<td class="pe-4">
{% if value is string and '\n' in value %}
<pre class="mb-0 bg-light p-2 rounded" style="white-space: pre-wrap; font-family: inherit; font-size: inherit;">{{ value }}</pre>
{% elif value is mapping or value is iterable and value is not string %}
<code>{{ value|tojson }}</code>
{% else %}
{{ value }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
+45 -69
View File
@@ -1,75 +1,58 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Template: admin/users/list.html
# Description: List all users with filtering and role management.
# ============================================================================== #}
{% extends "base.html" %}
{% block title %}Users - Formeleon{% endblock %}
{% block content %}
<div class="container">
<div class="row mb-4">
<div class="col-12 d-flex justify-content-between align-items-center">
<h1><i class="bi bi-people"></i> Users</h1>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-people me-2"></i> Users</h2>
</div>
{% if users %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5><i class="bi bi-list"></i> All Users ({{ users|length }})</h5>
<h5 class="mb-0">All Users</h5>
<div class="input-group" style="width: 300px;">
<input type="text" class="form-control" id="searchInput" placeholder="Search users...">
<button class="btn btn-outline-secondary" type="button">
<i class="bi bi-search"></i>
</button>
<input type="text" class="form-control form-control-sm" id="searchInput" placeholder="Search users...">
</div>
</div>
<div class="card-body p-0">
{% if users %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th class="ps-3">Name</th>
<th>Email</th>
<th>Role</th>
<th>Tenants</th>
<th>Last Login</th>
<th>Created</th>
<th>Actions</th>
<th class="text-end pe-3">Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.name }}</td>
<td class="ps-3"><strong>{{ u.name }}</strong></td>
<td>{{ u.email }}</td>
<td>
<span class="badge bg-{{ 'primary' if u.role.value == 'admin' else 'success' if u.role.value == 'tenant_admin' else 'info' if u.role.value == 'user' else 'secondary' }}">
{{ u.role.value }}
</span>
{% if u.role.value == 'admin' %}
<span class="badge bg-danger">Admin</span>
{% elif u.role.value == 'tenant_admin' %}
<span class="badge bg-primary">Tenant Admin</span>
{% elif u.role.value == 'user' %}
<span class="badge bg-info text-dark">User</span>
{% else %}
<span class="badge bg-secondary">{{ u.role.value }}</span>
{% endif %}
</td>
<td>{{ u.tenants|length }}</td>
<td>{{ u.last_login.strftime('%Y-%m-%d %H:%M') if u.last_login else 'Never' }}</td>
<td>{{ u.created_at.strftime('%Y-%m-%d') if u.created_at else 'N/A' }}</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('admin.view_user', user_id=u.id) }}" class="btn btn-outline-secondary" title="View">
<i class="bi bi-eye"></i>
</a>
<a href="{{ url_for('admin.manage_user_tenants', user_id=u.id) }}" class="btn btn-outline-info" title="Manage Tenants">
<i class="bi bi-buildings"></i>
</a>
<form action="{{ url_for('admin.delete_user', user_id=u.id) }}" method="POST"
onsubmit="return confirm('Are you sure you want to delete user {{ u.email }}? This action cannot be undone.');">
<button type="submit" class="btn btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
<span class="badge bg-secondary rounded-pill">{{ u.tenants|length }}</span>
</td>
<td><small class="text-muted">{{ u.last_login.strftime('%Y-%m-%d %H:%M') if u.last_login else 'Never' }}</small></td>
<td class="text-end pe-3">
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.view_user', user_id=u.id) }}" class="btn btn-outline-secondary" title="View"><i class="bi bi-eye"></i></a>
<a href="{{ url_for('admin.manage_user_tenants', user_id=u.id) }}" class="btn btn-outline-primary" title="Manage Tenants"><i class="bi bi-buildings"></i></a>
<form action="{{ url_for('admin.delete_user', user_id=u.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Delete user {{ u.email }}?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() if csrf_token else '' }}">
<button type="submit" class="btn btn-outline-danger" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
@@ -78,32 +61,25 @@
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<i class="bi bi-people display-4 text-muted mb-3"></i>
<h3>No Users</h3>
<p class="text-muted">No users have been registered yet.</p>
<p class="text-muted">Users are created through OIDC authentication (Zitadel) or in development mode.</p>
</div>
</div>
</div>
<div class="text-center py-5 text-muted">
<i class="bi bi-people fs-1 d-block mb-3"></i>
<p>No users found.</p>
</div>
{% endif %}
</div>
{% if dev_mode %}
<div class="container mt-4">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> <strong>Development Mode:</strong>
User management is available. In production, users are managed through your OIDC provider.
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
$(document).ready(function() {
$('#searchInput').on('input', function() {
const term = $(this).val().toLowerCase();
$('tbody tr').each(function() {
const text = $(this).text().toLowerCase();
$(this).toggle(text.includes(term));
});
});
});
</script>
{% endblock %}
+1 -1
View File
@@ -1,4 +1,4 @@
# ==============================================================================
{# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Template: admin/users/tenants.html
+1 -1
View File
@@ -9,7 +9,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Formeleon - Form Generator{% endblock %}</title>
<title>{% block title %}{{ _('Formeleon - Form Generator') }}{% endblock %}</title>
{# Bootstrap 5 CSS #}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
+1 -1
View File
@@ -10,7 +10,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Formeleon - Form Generator</title>
<title>{{_('Formeleon - Form Generator')}}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
<style>
+7 -7
View File
@@ -20,7 +20,7 @@
{# Dashboard #}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.dashboard') }}">
<i class="bi bi-speedometer2"></i> Dashboard
<i class="bi bi-speedometer2"></i> {{_('Dashboard')}}
</a>
</li>
@@ -28,7 +28,7 @@
{% if user and user.is_admin() %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="tenantsDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-buildings"></i> Tenants
<i class="bi bi-buildings"></i> {{_('Tenants')}}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('admin.list_tenants') }}">All Tenants</a></li>
@@ -40,7 +40,7 @@
{# Forms #}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="formsDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-file-earmark-text"></i> Forms
<i class="bi bi-file-earmark-text"></i> {{_('Forms')}}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('admin.list_forms') }}">All Forms</a></li>
@@ -53,7 +53,7 @@
{# Submissions #}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.list_submissions') }}">
<i class="bi bi-inbox"></i> Submissions
<i class="bi bi-inbox"></i> {{_('Submissions')}}
</a>
</li>
@@ -61,7 +61,7 @@
{% if user and user.is_admin() %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="usersDropdown" role="button" data-bs-toggle="dropdown">
<i class="bi bi-people"></i> Users
<i class="bi bi-people"></i> {{_('Users')}}
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('admin.list_users') }}">All Users</a></li>
@@ -73,7 +73,7 @@
{% if user and user.is_admin() %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.settings') }}">
<i class="bi bi-gear"></i> Settings
<i class="bi bi-gear"></i> {{_('Settings')}}
</a>
</li>
{% endif %}
@@ -96,7 +96,7 @@
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">
<i class="bi bi-box-arrow-in-right"></i> Login
<i class="bi bi-box-arrow-in-right"></i> {{_('Login')}}
</a>
</li>
{% endif %}
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Formeleon Test</title>
</head>
<body>
<h1>Formeleon Embed Test</h1>
<div id="formeleon-form"></div>
<!-- We will need a valid data-form-id and data-public-key from the DB to replace here -->
<script src="http://localhost:5000/static/js/embed.js" data-form-id="form_sample_001" data-public-key="dev-public-key-12345"></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
# German translations for PROJECT.
# Copyright (C) 2026 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2026-09-28 17:10+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
"Language-Team: de <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.18.0\n"
#: routes/admin.py:76
msgid "Admin privileges required"
msgstr "Admin Privilegien erforderlich"
#: templates/base.html:12 templates/index.html:13
msgid "Formeleon - Form Generator"
msgstr "Formeleon - Formular Generator"
#: templates/navbar.html:23
msgid "Dashboard"
msgstr "Übersicht"
#: templates/navbar.html:31
msgid "Tenants"
msgstr "Mandanten"
#: templates/navbar.html:43
msgid "Forms"
msgstr "Formulare"
#: templates/navbar.html:56
msgid "Submissions"
msgstr "Einträge"
#: templates/navbar.html:64
msgid "Users"
msgstr "Benutzer"
#: templates/navbar.html:76
msgid "Settings"
msgstr "Einstellungen"
#: templates/navbar.html:99
msgid "Login"
msgstr "Anmelden"
+31
View File
@@ -0,0 +1,31 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: utils/i18n.py
# Description: Internationalization setup using Flask-Babel.
# ==============================================================================
from flask import request, current_app
from flask_babel import Babel
# Initialize Babel
babel = Babel()
def get_locale():
"""
Selects the best matching language for the user.
"""
# Force language via query parameter (e.g. ?lang=de)
lang_param = request.args.get('lang')
if lang_param and lang_param in current_app.config.get('LANGUAGES', ['en']):
return lang_param
# Accept-Language header from browser
return request.accept_languages.best_match(current_app.config.get('LANGUAGES', ['en']))
def get_timezone():
"""
Returns the user's timezone if available, fallback to default.
"""
# For now, default to UTC. Can be extended to read from user session.
return current_app.config.get('BABEL_DEFAULT_TIMEZONE', 'UTC')