Compare commits

..
13 Commits
Author SHA1 Message Date
br 5fff17531d 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.
2026-09-29 05:16:01 +02:00
br 7f81932fa5 auth ist im kasten
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Successful in 4s
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Has been skipped
2026-08-04 23:07:18 +02:00
br 8498781c85 fix: OIDC Authentication with PKCE for dev bypass
- Fixed operational auth session bug returning False despite valid Mock Session
- Removed dev user ID hardcoding logic ensuring auth flow uses the defined config.
2026-08-04 13:49:37 +02:00
br 03b0e33723 fix: Database operational error for sessions
- Switched back `SESSION_SQLALCHEMY_TABLE` and `SESSION_CLEANUP_N_REQUESTS` configuration
- `flask_session` extension is now generating the `sessions` table without issues
- Database permission issue resolved by clearing corrupt database volumes and running `flask db init`/`upgrade` manually

Re-initialized test environment via setup commands.
2026-08-04 13:49:17 +02:00
br 1dc0d9588f typo
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Has been skipped
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Successful in 4s
2026-08-03 17:50:14 +02:00
br 78f1a68438 app-name
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Has been skipped
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Failing after 3s
2026-08-03 17:47:18 +02:00
br 6f0d0a952d corrections für https behind webproxy
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Has been skipped
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Failing after 4s
2026-08-03 16:59:44 +02:00
br 574e3cb6c4 instance folder in compose
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Successful in 4s
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Has been skipped
2026-08-03 16:35:45 +02:00
br 0739de4b44 portsettings für docker + cli noch angepasst. jetzt ist .env führend
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Successful in 5s
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Has been skipped
2026-08-03 16:26:23 +02:00
br a54f26f808 deploy korrigiert
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Failing after 4s
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Has been skipped
2026-08-03 16:13:24 +02:00
br b5f1e7a469 nonsense retour
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Failing after 3s
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Has been skipped
2026-08-03 16:08:26 +02:00
br e72a66fe94 nonsense
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Failing after 2s
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Has been skipped
2026-08-03 16:01:27 +02:00
br 8f92e53fa8 deploy-jedönse drin
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Failing after 30s
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Has been skipped
2026-08-03 15:59:00 +02:00
41 changed files with 3564 additions and 816 deletions

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`)*
+68 -25
View File
@@ -22,9 +22,43 @@ FLASK_DEBUG=true
# Development Mode - enables fake auth and mocking
DEV_MODE=true
# Application port (default: 5000)
# PORT=5000
# Mock Authentication Mode
# When MOCK_AUTH=true AND DEV_MODE=true, uses local mock users instead of Zitadel OIDC
# Useful for local development without external dependencies
MOCK_AUTH=false
# Application name
APP_NAME=formeleon
# Application port (default: 5000)
PORT=5000
# =============================================================================
# MOCK USER SETTINGS (used when MOCK_AUTH=true and DEV_MODE=true)
# =============================================================================
# Mock user credentials (only used in mock auth mode)
MOCK_USER_ID=mock_user_001
MOCK_USER_ROLE=admin
MOCK_USER_NAME=Mock Admin
MOCK_USER_EMAIL=mock@formeleon.local
MOCK_TENANT_ID=mock_tenant_001
# =============================================================================
# DEVELOPMENT USER SETTINGS (used when DEV_MODE=true but MOCK_AUTH=false)
# =============================================================================
# Dev Mode User Role (admin, tenant_admin, user, viewer)
DEV_USER_ROLE=admin
# Dev Mode Tenant ID
DEV_TENANT_ID=tenant_dev_123
# Dev Mode User Name
DEV_USER_NAME="Dev Admin"
# Dev Mode User Email
DEV_USER_EMAIL=dev@formeleon.local
# =============================================================================
# DATABASE SETTINGS
# =============================================================================
@@ -78,22 +112,6 @@ RECAPTCHA_THRESHOLD=0.5
# Mock score for development mode (default: 0.9)
MOCK_RECAPTCHA_SCORE=0.9
# =============================================================================
# DEVELOPMENT USER SETTINGS
# =============================================================================
# Dev Mode User Role (admin, tenant_admin, user, viewer)
DEV_USER_ROLE=admin
# Dev Mode Tenant ID
DEV_TENANT_ID=tenant_dev_123
# Dev Mode User Name
DEV_USER_NAME=Dev Admin
# Dev Mode User Email
DEV_USER_EMAIL=dev@formeleon.local
# =============================================================================
# CORS SETTINGS
# =============================================================================
@@ -114,6 +132,9 @@ SESSION_COOKIE_SECURE=false
# Session Cookie SameSite (Lax, Strict, None)
SESSION_COOKIE_SAMESITE=Lax
# Session lifetime in seconds (default: 3600 = 1 hour)
PERMANENT_SESSION_LIFETIME=3600
# =============================================================================
# FILE UPLOAD SETTINGS
# =============================================================================
@@ -150,10 +171,32 @@ UPLOAD_FOLDER=/tmp/formeleon_uploads
# PRODUCTION SETTINGS
# =============================================================================
# In production, set these values:
# FLASK_ENV=production
# FLASK_DEBUG=false
# DEV_MODE=false
# SESSION_COOKIE_SECURE=true
# SESSION_COOKIE_SAMESITE=Strict
# SQLALCHEMY_DATABASE_URI=your-production-database-uri
# Default page size for paginated responses
DEFAULT_PAGE_SIZE=20
# Maximum page size for paginated responses
MAX_PAGE_SIZE=100
# =============================================================================
# NOTES FOR PRODUCTION
# =============================================================================
# For production deployment:
# 1. Set FLASK_ENV=production
# 2. Set FLASK_DEBUG=false
# 3. Set DEV_MODE=false
# 4. Set MOCK_AUTH=false
# 5. Set SESSION_COOKIE_SECURE=true
# 6. Set SESSION_COOKIE_SAMESITE=Strict
# 7. Configure proper ZITADEL_* settings
# 8. Configure proper RECAPTCHA_* settings
# 9. Use a production-ready database (PostgreSQL, MySQL)
# 10. Generate a strong SECRET_KEY
# 11. Restrict ALLOWED_ORIGINS to your specific domains
# 12. Set up proper HTTPS/TLS
# For local development without external services:
# 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
+55
View File
@@ -0,0 +1,55 @@
name: Formeleon Deployment Pipeline
on:
push:
branches:
- main
- stable
jobs:
# ---------------------------------------------------
# 1. DEPLOYMENT TO DEVELOPMENT (branch: main)
# ---------------------------------------------------
deploy-dev:
name: Deploy to Dev Environment
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH to Dev
uses: appleboy/ssh-action@v1
with:
# Flexibel über "Settings -> Actions -> Variables" editierbar:
host: ${{ vars.DEV_HOST }}
username: ${{ vars.DEV_DEPLOY_USER }}
# Passwörter/Keys weiterhin über "Secrets":
key: ${{ secrets.DEV_SSH_KEY }}
port: 22
script: |
cd /cryptDisk/formeleon-dev
git pull origin main
docker compose down
docker compose up -d --build
# ---------------------------------------------------
# 2. DEPLOYMENT TO PRODUCTION (branch: stable)
# ---------------------------------------------------
deploy-prod:
name: Deploy to Production Environment
if: github.ref == 'refs/heads/stable'
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH to Prod
uses: appleboy/ssh-action@v1
with:
host: ${{ vars.PROD_HOST }}
username: ${{ vars.PROD_DEPLOY_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
port: 22
script: |
cd /cryptDisk/formeleon-prod
git pull origin stable
docker compose down
docker compose up -d --build
+5
View File
@@ -88,3 +88,8 @@ migrations/
# Cache
.cache/
*.pyc
# Babel i18n
*.pot
*.mo
+1
View File
@@ -27,6 +27,7 @@ Jede neu erstellte oder modifizierte Quelltext-Datei (`.py`, `.j2`, `.html`, `.j
Bitte lies vor der Bearbeitung spezifischer Module die folgenden Dokumente im Repository:
* **Feinkonzept & Spezifikation:** Read [`docs/feinkonzept.md`](./docs/feinkonzept.md)
* **Header & PyDoc Standards:** Read [`docs/coding-standards.md`](./docs/coding-standards.md)
* **System-Architektur & DB:** Read [`ARCHITEKTUR.md`](./ARCHITEKTUR.md)
* **CI/CD & Deployment:** Read [`docs/deployment.md`](./docs/deployment.md)
+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
```
+53 -7
View File
@@ -70,14 +70,14 @@ Für das vollständige Linux- / Staging-Gefühl kannst du Docker nutzen. Durch V
```bash
# 1. Image bauen und Container starten
docker-compose up --build
docker compose up --build
# 2. Anwendung beenden
# (Strg+C oder:)
docker-compose down
docker compose down
# 3. Datenbank und Volumes komplett zurücksetzen
docker-compose down -v && docker-compose up --build
docker compose down -v && docker-compose up --build
```
### Docker CLI-Kommandos ausführen
@@ -85,16 +85,16 @@ Du kannst `flask` Kommandos direkt gegen den laufenden Container feuern:
```bash
# Datenbank Migrationen anlegen
docker-compose run --rm app flask db migrate -m "neue migration"
docker compose run --rm app flask db migrate -m "neue migration"
# Datenbank-Schema updaten
docker-compose run --rm app flask db upgrade
docker compose run --rm app flask db upgrade
# Datenbank mit Testdaten füllen
docker-compose run --rm app flask seed-db
docker compose run --rm app flask seed-db
# Shell im Container öffnen
docker-compose run --rm app bash
docker compose run --rm app bash
```
### Zugriff auf die Anwendung
@@ -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.
+16 -1
View File
@@ -39,6 +39,10 @@ from utils.cli import register_cli_commands
from utils.middleware import setup_dev_mode_middleware, get_current_user, get_current_tenant
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()
@@ -66,6 +70,12 @@ def create_app(config_class: Optional[type] = None) -> Flask:
app = Flask(__name__)
app.config.from_object(config_class or get_config_class())
# Vertraue 1 Reverse Proxy (NPM) für X-Forwarded-For, Proto, Host, Port
app.wsgi_app = ProxyFix(
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
)
# Set up logging
setup_logging(app)
@@ -128,9 +138,14 @@ 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
app.config['SESSION_SQLALCHEMY_TABLE'] = 'sessions'
app.config['SESSION_CLEANUP_N_REQUESTS'] = None
Session(app)
# Ensure secret key is set
@@ -229,4 +244,4 @@ app = create_app()
if __name__ == '__main__':
# This allows running the app directly with python app.py
# In production, use gunicorn or other WSGI servers
app.run(host='0.0.0.0', port=5000, debug=app.config['DEBUG'])
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 5000)), debug=app.config['DEBUG'])
+3
View File
@@ -0,0 +1,3 @@
[python: **.py]
[jinja2: **.html]
extensions=jinja2.ext.i18n
+45 -6
View File
@@ -18,6 +18,14 @@ Environment Variables:
- SQLALCHEMY_DATABASE_URI: Database connection string
- SQLALCHEMY_TRACK_MODIFICATIONS: Track modifications (default: False)
- MOCK_AUTH: Enable mock authentication mode (default: False)
Requires DEV_MODE=true. When enabled, uses local mock users instead of Zitadel OIDC.
- MOCK_USER_ID: User ID for mock auth (default: 'mock_user_001')
- MOCK_USER_ROLE: Role for mock user (default: 'admin')
- MOCK_USER_NAME: Name for mock user (default: 'Mock Admin')
- MOCK_USER_EMAIL: Email for mock user (default: 'mock@formeleon.local')
- MOCK_TENANT_ID: Tenant ID for mock auth (default: 'mock_tenant_001')
- ZITADEL_ISSUER: OIDC issuer URL for Zitadel
- ZITADEL_CLIENT_ID: OIDC client ID
- ZITADEL_CLIENT_SECRET: OIDC client secret
@@ -52,15 +60,31 @@ 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'
# Mock Authentication Mode
# When MOCK_AUTH=true and DEV_MODE=true, use local mock users instead of Zitadel OIDC
MOCK_AUTH: bool = os.environ.get('MOCK_AUTH', 'false').lower() == 'true'
# Dev User Settings
DEV_USER_ROLE: str = os.environ.get('DEV_USER_ROLE', 'admin')
DEV_TENANT_ID: str = os.environ.get('DEV_TENANT_ID', 'tenant_dev_123')
DEV_USER_NAME: str = os.environ.get('DEV_USER_NAME', 'Dev Admin')
DEV_USER_EMAIL: str = os.environ.get('DEV_USER_EMAIL', 'dev@formeleon.local')
# Mock User Settings (used when MOCK_AUTH is enabled)
MOCK_USER_ID: str = os.environ.get('MOCK_USER_ID', 'mock_user_001')
MOCK_USER_ROLE: str = os.environ.get('MOCK_USER_ROLE', 'admin')
MOCK_USER_NAME: str = os.environ.get('MOCK_USER_NAME', 'Mock Admin')
MOCK_USER_EMAIL: str = os.environ.get('MOCK_USER_EMAIL', 'mock@formeleon.local')
MOCK_TENANT_ID: str = os.environ.get('MOCK_TENANT_ID', 'mock_tenant_001')
# Database Configuration
if 'SQLALCHEMY_DATABASE_URI' in os.environ:
SQLALCHEMY_DATABASE_URI: str = os.environ['SQLALCHEMY_DATABASE_URI']
@@ -106,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.
@@ -130,8 +162,15 @@ class Config:
:return: True if all required Zitadel settings are present, False otherwise.
"""
return (self.ZITADEL_ISSUER is not None and
self.ZITADEL_CLIENT_ID is not None and
self.ZITADEL_CLIENT_SECRET is not None)
self.ZITADEL_CLIENT_ID is not None )
def is_mock_auth_enabled(self) -> bool:
"""
Check if mock authentication mode is enabled.
:return: True if both DEV_MODE and MOCK_AUTH are enabled, False otherwise.
"""
return self.DEV_MODE and self.MOCK_AUTH
class DevelopmentConfig(Config):
@@ -140,10 +179,10 @@ class DevelopmentConfig(Config):
Overrides base configuration with development-friendly settings.
"""
DEBUG: bool = True
DEV_MODE: bool = True
SQLALCHEMY_ECHO: bool = True
SESSION_COOKIE_SECURE: bool = False
# DEBUG: bool = True
# DEV_MODE: bool = True
# SQLALCHEMY_ECHO: bool = True
# SESSION_COOKIE_SECURE: bool = False
class ProductionConfig(Config):
-178
View File
@@ -1,178 +0,0 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Module: config.py
# Description: Application configuration with environment variable support.
# Handles development mode, reCAPTCHA, Zitadel OIDC, and database settings.
# ==============================================================================
"""
Application configuration module for Formeleon.
This module centralizes all configuration settings for the Flask application,
loading from environment variables with sensible defaults for development.
Environment Variables:
- DEV_MODE: Enable development mode (default: False)
- SECRET_KEY: Flask secret key for session management
- SQLALCHEMY_DATABASE_URI: Database connection string
- SQLALCHEMY_TRACK_MODIFICATIONS: Track modifications (default: False)
- ZITADEL_ISSUER: OIDC issuer URL for Zitadel
- ZITADEL_CLIENT_ID: OIDC client ID
- ZITADEL_CLIENT_SECRET: OIDC client secret
- ZITADEL_REDIRECT_URI: OIDC redirect URI
- RECAPTCHA_SECRET_KEY: reCAPTCHA v3 secret key
- RECAPTCHA_SITE_KEY: reCAPTCHA v3 site key
- RECAPTCHA_THRESHOLD: Minimum score threshold (default: 0.5)
- MOCK_RECAPTCHA_SCORE: Mock score for dev mode (default: 0.9)
- DEV_USER_ROLE: Role for dev mode user (default: 'admin')
- DEV_TENANT_ID: Tenant ID for dev mode (default: 'tenant_dev_123')
- DEV_USER_NAME: Username for dev mode (default: 'Dev Admin')
- DEV_USER_EMAIL: Email for dev mode (default: 'dev@formeleon.local')
"""
import os
from typing import Optional
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
class Config:
"""
Base configuration class with default values.
All configuration values can be overridden via environment variables.
"""
# Flask Core Settings
SECRET_KEY: str = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
DEBUG: bool = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
TESTING: bool = os.environ.get('FLASK_TESTING', 'false').lower() == 'true'
# Development Mode
DEV_MODE: bool = os.environ.get('DEV_MODE', 'false').lower() == 'true'
# Dev User Settings
DEV_USER_ROLE: str = os.environ.get('DEV_USER_ROLE', 'admin')
DEV_TENANT_ID: str = os.environ.get('DEV_TENANT_ID', 'tenant_dev_123')
DEV_USER_NAME: str = os.environ.get('DEV_USER_NAME', 'Dev Admin')
DEV_USER_EMAIL: str = os.environ.get('DEV_USER_EMAIL', 'dev@formeleon.local')
# Database Configuration
SQLALCHEMY_DATABASE_URI: str = os.environ.get(
'SQLALCHEMY_DATABASE_URI',
f'sqlite:///{os.path.join(BASE_DIR, "instance", "formeleon.db")}'
)
SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
SQLALCHEMY_ECHO: bool = os.environ.get('SQLALCHEMY_ECHO', 'false').lower() == 'true'
# Zitadel OIDC Configuration
ZITADEL_ISSUER: Optional[str] = os.environ.get('ZITADEL_ISSUER')
ZITADEL_CLIENT_ID: Optional[str] = os.environ.get('ZITADEL_CLIENT_ID')
ZITADEL_CLIENT_SECRET: Optional[str] = os.environ.get('ZITADEL_CLIENT_SECRET')
ZITADEL_REDIRECT_URI: Optional[str] = os.environ.get(
'ZITADEL_REDIRECT_URI',
'http://localhost:5000/auth/callback'
)
ZITADEL_SCOPES: list = ['openid', 'profile', 'email']
# reCAPTCHA v3 Configuration
RECAPTCHA_SECRET_KEY: Optional[str] = os.environ.get('RECAPTCHA_SECRET_KEY')
RECAPTCHA_SITE_KEY: Optional[str] = os.environ.get('RECAPTCHA_SITE_KEY')
RECAPTCHA_THRESHOLD: float = float(os.environ.get('RECAPTCHA_THRESHOLD', '0.5'))
MOCK_RECAPTCHA_SCORE: float = float(os.environ.get('MOCK_RECAPTCHA_SCORE', '0.9'))
RECAPTCHA_VERIFY_URL: str = 'https://www.google.com/recaptcha/api/siteverify'
# Application Settings
DEFAULT_PAGE_SIZE: int = 20
MAX_PAGE_SIZE: int = 100
# Form Submission Settings
ALLOWED_ORIGINS: list = os.environ.get('ALLOWED_ORIGINS', '*').split(',')
ENABLE_CORS: bool = os.environ.get('ENABLE_CORS', 'true').lower() == 'true'
# File Upload Settings
MAX_CONTENT_LENGTH: int = 16 * 1024 * 1024 # 16MB
UPLOAD_FOLDER: str = os.environ.get('UPLOAD_FOLDER', '/tmp/formeleon_uploads')
ALLOWED_EXTENSIONS: set = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'txt'}
# Session Configuration
SESSION_COOKIE_SECURE: bool = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
SESSION_COOKIE_HTTPONLY: bool = True
SESSION_COOKIE_SAMESITE: str = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
PERMANENT_SESSION_LIFETIME: int = 3600 # 1 hour in seconds
def is_dev_mode(self) -> bool:
"""
Check if application is running in development mode.
:return: True if DEV_MODE is enabled, False otherwise.
"""
return self.DEV_MODE
def is_recaptcha_enabled(self) -> bool:
"""
Check if reCAPTCHA is properly configured.
:return: True if both secret and site keys are configured, False otherwise.
"""
return (self.RECAPTCHA_SECRET_KEY is not None and
self.RECAPTCHA_SITE_KEY is not None)
def is_zitadel_enabled(self) -> bool:
"""
Check if Zitadel OIDC is properly configured.
:return: True if all required Zitadel settings are present, False otherwise.
"""
return (self.ZITADEL_ISSUER is not None and
self.ZITADEL_CLIENT_ID is not None and
self.ZITADEL_CLIENT_SECRET is not None)
class DevelopmentConfig(Config):
"""
Development-specific configuration.
Overrides base configuration with development-friendly settings.
"""
DEBUG: bool = True
DEV_MODE: bool = True
SQLALCHEMY_ECHO: bool = True
SESSION_COOKIE_SECURE: bool = False
class ProductionConfig(Config):
"""
Production-specific configuration.
Overrides base configuration with production-safe settings.
"""
DEBUG: bool = False
DEV_MODE: bool = False
SQLALCHEMY_ECHO: bool = False
SESSION_COOKIE_SECURE: bool = True
SESSION_COOKIE_SAMESITE: str = 'Strict'
class TestingConfig(Config):
"""
Testing-specific configuration.
Overrides base configuration with test-friendly settings.
"""
TESTING: bool = True
SQLALCHEMY_DATABASE_URI: str = 'sqlite:///:memory:'
WTF_CSRF_ENABLED: bool = False
# Configuration mapping for easy access
config_map: dict = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig,
'default': DevelopmentConfig if os.environ.get('FLASK_ENV', 'development') == 'development' else ProductionConfig
}
+5
View File
@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_127.0.0.1 FALSE / FALSE 1785847477 session jE1XElUmcswK63EciLDkqYqHXtKQnMMBzkt6TN40b7c
+15 -2
View File
@@ -3,9 +3,9 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: formeleon-app
container_name: "${APP_NAME:-formeleon}"
ports:
- "5000:5000"
- "${PORT:-5000}:5000"
volumes:
# Mount entire project directory for hot-reload in development
# Exclude the virtual environment if it exists locally to prevent conflicts with container venv
@@ -13,8 +13,21 @@ services:
- /app/venv
# Persistent data volumes mapping to host
# - ./data:/app/data
- ./instance:/app/instance
- ./logs:/app/logs
# - ./formeleon.db:/app/formeleon.db
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
+68
View File
@@ -0,0 +1,68 @@
# Formeleon - Feinkonzept & Spezifikation
* **Autor:** Björn Runden <br@runden.net>
* **Status:** Freigegeben / In Umsetzung
* **Version:** 1.0
* **Datum:** August 2026
---
## 1. Übersicht & Systemkontext
Formeleon ist ein mandantenfähiges Formular-Service-System (Multi-tenant Form Generator & Backend API). Das System nimmt Formular-Submissions von externen Mandanten-Webseiten entgegen, schützt diese vor Bot-Spam, speichert die Daten isoliert nach Mandanten ab und stellt ein Admin-Dashboard zur Verwaltung bereit.
---
## 2. Authentifizierung & Identity Management (Zitadel OIDC)
* **Identity Provider:** Zitadel via OIDC (OAuth2) unter Nutzung von `authlib.integrations.flask_client.OAuth`.
* **Scope & Mapping:** Zitadel wird ausschließlich für die Authentifizierung und Übermittlung von User-Attributen (z. B. E-Mail, Name, Sub-ID) genutzt. Es findet kein Rollen-Mapping aus Token-Claims statt.
* **Lokale Rechteverwaltung (RBAC):** Die Zuweisung von Rollen (`admin`, `tenant_admin`, `user`, `viewer`) erfolgt strikt innerhalb der lokalen PostgreSQL/SQLite-Datenbank über die E-Mail-Adresse des Benutzers.
* **Development Bypass:** Bei aktivierter Option `DEV_MODE=true` umgeht eine Middleware den OIDC-Redirect und injiziert eine Mock-Session für die lokale Entwicklung.
---
## 3. Bot-Schutz & Spam-Handling (Google reCAPTCHA v3)
| Parameter | Spezifikation |
| :--- | :--- |
| **Technologie** | Google reCAPTCHA v3 (unsichtbare Score-basierte Validierung) |
| **Schwellenwert (Threshold)** | Standard: `0.5` (konfigurierbar via `RECAPTCHA_MIN_SCORE` in `.env`) |
| **Fehlerfall (Bot Risk)** | Rückgabe von HTTP Status `400 Bad Request` an den Client mit lesbarer Fehlermeldung. |
| **Datenhaltung bei Spam** | Submissions mit Score < 0.5 werden als `flagged_as_spam` in der Datenbank gespeichert (nicht direkt gelöscht). |
| **Benachrichtigungen** | Automatischer E-Mail-Hinweis an Admin/Mandant bei erkannter Spam-Submission (sofern Mandant auflösbar). |
| **Dev Mode Simulation** | Mock-Service gibt standardmäßig Score `0.9` zurück (einstellbar via `MOCK_RECAPTCHA_SCORE`). |
---
## 4. Frontend Integration (Form-Embed Snippet)
### 4.1 Embed Workflow
1. **Einbindung beim Mandanten:**
```html
<script src="https://formeleon.domain/embed.js" data-form-id="123" data-public-key="pk_live_xyz"></script>
<div id="formeleon-form"></div>
```
2. **Handshake & Validation:** Das Snippet führt beim Laden eine Asynchron-Anforderung via Fetch API durch (`GET /api/v1/forms/<form_id>`). Das Backend prüft den `Origin`-Header gegen die `allowed_origins` des Mandanten.
3. **Rendering (Lightweight DOM Insertion):** Direktes HTML-Injecting (via `innerHTML`/DOM-Parser) in das Ziel-DOM. Bewusst kein Shadow-DOM, um das Customizing via CSS des Mandanten zu erleichtern.
4. **Submission Handling:** Das Snippet fängt das `onsubmit`-Event ab, fordert im Hintergrund ein reCAPTCHA v3 Token an und sendet die Formulardaten per POST/JSON an das Backend.
---
## 5. Datenmodell & Multi-Tenancy
* **Tenant Isolation:** Logische Mandantentrennung auf Datenbankebene über `tenant_id` an den Entitäten `FormConfig`, `Submission` und `User`.
* **Public Keys & Hashes:** Öffentliche Schlüssel für das Embedding haben das Format `pk_live_<hash>` und enthalten intern einen SHA-basierten Verweis auf die `tenant_id`.
* **Flexible Payloads:** Die Submissions speichern dynamische Eingabefelder in einem JSON-Payload-Feld der Datenbank ab, um feingranulare Schema-Änderungen ohne Schema-Migrationen zu ermöglichen.
---
## 6. Anweisungen für KI-Coding-Agenten
Bei der Generierung und Bearbeitung von Code in diesem Repository sind folgende Punkte strikt zu beachten:
1. **Modularität:** Jede neue Route muss im entsprechenden Blueprint (`routes/auth.py`, `routes/api.py`, `routes/admin.py`) registriert werden.
2. **ORM-Pflicht:** Datenbankzugriffe ausschließlich über Flask-SQLAlchemy durchführen. Schema-Änderungen über Flask-Migrate anlegen.
3. **Header & PyDoc:** Alle neu erstellten Dateien müssen den Kopf gemäß `docs/coding-standards.md` tragen. PyDoc-Docstrings sind für Funktionen Pflicht.
4. **Git-Branching:** Entwicklung erfolgt ausschließlich auf dem Branch `main`. Merges auf `stable` sind der CI/CD-Pipeline vorbehalten.
-18
View File
@@ -1,18 +0,0 @@
--- routes/api.py
+++ routes/api.py
@@ -58,15 +58,6 @@
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'
-
# Handle OPTIONS requests
if request.method == 'OPTIONS':
- return response
+ return current_app.make_default_options_response()
# Skip authentication for status endpoint and options requests
+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
+197 -18
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
@@ -524,20 +608,21 @@ def update_submission_status(submission_id):
@admin_required
def list_users():
"""List all users (admin only)."""
user = get_current_user()
admin_user = get_current_user()
users = UserAdminService.get_users()
return render_template('admin/users/list.html',
user=user,
admin_user=admin_user,
users=users,
dev_mode=current_app.config.get('DEV_MODE', False))
dev_mode=current_app.config.get('DEV_MODE', False),
is_admin=admin_user.is_admin())
@admin_bp.route('/users/<user_id>', methods=['GET', 'POST'])
@admin_required
def view_user(user_id):
"""View and edit a user (admin only)."""
user = get_current_user()
admin_user = get_current_user()
target_user = User.query.get(user_id)
if not target_user:
@@ -546,7 +631,7 @@ def view_user(user_id):
if request.method == 'POST':
UserAdminService.update_user(
target_user, request.form.get('email'), request.form.get('name'),
request.form.get('role'), user
request.form.get('role'), admin_user
)
flash('User updated successfully!', 'success')
@@ -556,7 +641,7 @@ def view_user(user_id):
tenants = target_user.tenants
return render_template('admin/users/view.html',
user=user,
admin_user=admin_user,
target_user=target_user,
tenants=tenants,
dev_mode=current_app.config.get('DEV_MODE', False),
@@ -584,6 +669,100 @@ def delete_user(user_id):
return redirect(url_for('admin.list_users'))
@admin_bp.route('/users/<user_id>/tenants', methods=['GET', 'POST'])
@admin_required
def manage_user_tenants(user_id):
"""Manage tenant assignments for a user (admin only)."""
admin_user = get_current_user()
target_user = User.query.get(user_id)
if not target_user:
abort(404, description='User not found')
if request.method == 'POST':
# Add or remove tenant assignments
action = request.form.get('action')
tenant_id = request.form.get('tenant_id')
if action == 'add' and tenant_id:
tenant = Tenant.query.get(tenant_id)
if tenant and tenant not in target_user.tenants:
target_user.tenants.append(tenant)
db.session.commit()
AuditLog.log_action(
action='assign_tenant',
resource_type='user',
user=admin_user,
resource_id=target_user.id,
details={'tenant_id': tenant_id, 'tenant_name': tenant.name}
)
flash(f'User assigned to tenant {tenant.name}!', 'success')
elif action == 'remove' and tenant_id:
tenant = Tenant.query.get(tenant_id)
if tenant and tenant in target_user.tenants:
target_user.tenants.remove(tenant)
db.session.commit()
AuditLog.log_action(
action='remove_tenant',
resource_type='user',
user=admin_user,
resource_id=target_user.id,
details={'tenant_id': tenant_id, 'tenant_name': tenant.name}
)
flash(f'User removed from tenant {tenant.name}!', 'success')
return redirect(url_for('admin.manage_user_tenants', user_id=target_user.id))
# Get all tenants and user's current tenants
all_tenants = Tenant.query.order_by(Tenant.name).all()
user_tenants = list(target_user.tenants)
return render_template('admin/users/tenants.html',
admin_user=admin_user,
target_user=target_user,
all_tenants=all_tenants,
user_tenants=user_tenants,
dev_mode=current_app.config.get('DEV_MODE', False))
@admin_bp.route('/users/<user_id>/role', methods=['POST'])
@admin_required
def update_user_role(user_id):
"""Update user role (admin only)."""
admin_user = get_current_user()
target_user = User.query.get(user_id)
if not target_user:
abort(404, description='User not found')
# Don't allow changing own role
if target_user.id == admin_user.id:
flash('Cannot change your own role!', 'error')
return redirect(url_for('admin.view_user', user_id=target_user.id))
new_role = request.form.get('role')
if new_role and new_role in [r.value for r in UserRole]:
old_role = target_user.role.value
target_user.role = UserRole(new_role)
db.session.commit()
AuditLog.log_action(
action='update_role',
resource_type='user',
user=admin_user,
resource_id=target_user.id,
details={'old_role': old_role, 'new_role': new_role}
)
flash(f'User role updated to {new_role}!', 'success')
else:
flash('Invalid role!', 'error')
return redirect(url_for('admin.view_user', user_id=target_user.id))
@admin_bp.route('/profile')
@login_required
def profile():
+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]
}
})
+110 -164
View File
@@ -8,84 +8,117 @@
"""
Authentication routes for Formeleon.
This module provides:
- OIDC authentication via Zitadel (production)
- Development mode bypass with fake user injection
- Login/logout routes
- Session management
Routes:
GET /auth/login - Initiate OIDC login flow or redirect to admin
GET /auth/callback - OIDC callback handler
GET /auth/logout - Logout and clear session
GET /auth/status - Check current authentication status
In development mode (DEV_MODE=true), the login route automatically
creates a fake authenticated session without redirecting to Zitadel.
"""
import uuid
import os
import logging
from typing import Optional
from urllib.parse import urlencode
from flask import Blueprint, request, redirect, url_for, session, current_app, jsonify
from flask import render_template_string
from authlib.integrations.flask_client import OAuth
from models import db, User, UserRole, Tenant, AuditLog
from services.auth_service import AuthService
logger = logging.getLogger(__name__)
# Modul-globales OAuth Objekt
oauth = OAuth()
# Create auth blueprint
auth_bp = Blueprint('auth', __name__, template_folder='../templates')
os.environ['AUTHLIB_INSECURE_TRANSPORT'] = '1'
def _init_zitadel_client():
"""Hilfsfunktion: Registriert den Zitadel-Client sicher bei Authlib."""
# HTTP im Dev/Mock-Modus für Authlib erlauben (nur lokal!)
if AuthService.is_dev_mode() or AuthService.is_mock_auth_enabled():
os.environ['AUTHLIB_INSECURE_TRANSPORT'] = '1'
if 'zitadel' not in oauth._clients:
issuer = current_app.config.get('ZITADEL_ISSUER', '').rstrip('/')
client_secret = current_app.config.get('ZITADEL_CLIENT_SECRET')
scopes = current_app.config.get('ZITADEL_SCOPES', ['openid', 'profile', 'email'])
scope_string = ' '.join(scopes) if isinstance(scopes, list) else scopes
zitadel_config = {
'client_id': current_app.config.get('ZITADEL_CLIENT_ID'),
'client_secret': client_secret,
'server_metadata_url': f"{issuer}/.well-known/openid-configuration",
'client_kwargs': {
'scope': scope_string,
}
}
# Pure PKCE (Public App) ohne Client Secret
if not client_secret:
zitadel_config['client_kwargs']['token_endpoint_auth_method'] = 'none'
oauth.init_app(current_app)
oauth.register('zitadel', **zitadel_config)
@auth_bp.before_request
def check_dev_mode_auth():
"""
Middleware to handle development mode authentication.
If DEV_MODE is enabled and user is not authenticated,
automatically create a dev user session.
"""
# Skip for auth routes themselves
"""Middleware to handle development mode and mock authentication."""
if request.path in ['/auth/login', '/auth/callback', '/auth/logout', '/auth/status']:
return
# Skip if already authenticated
if 'user_id' in session and session.get('authenticated'):
return
# In dev mode, auto-authenticate
if AuthService.is_dev_mode():
if AuthService.is_mock_auth_enabled():
mock_user = AuthService.create_mock_user_session()
session['user_id'] = mock_user['id']
session['user'] = mock_user
session['authenticated'] = True
session.permanent = True
AuthService.ensure_mock_user_exists()
AuthService.ensure_mock_tenant_exists()
else:
dev_user = AuthService.create_dev_user_session()
session['user_id'] = dev_user['id']
session['user'] = dev_user
session['authenticated'] = True
session.permanent = True
# Ensure user and tenant exist in database
AuthService.ensure_dev_user_exists()
AuthService.ensure_dev_tenant_exists()
@auth_bp.route('/login')
def login():
"""
Login route - initiates OIDC flow or handles dev mode.
In production: Redirects to Zitadel for OIDC authentication
In development: Creates fake session and redirects to admin
Query parameters:
next: URL to redirect to after successful login
"""
"""Initiates OIDC flow via Authlib or handles dev/mock mode."""
next_url = request.args.get('next', url_for('admin.dashboard'))
# Development mode: create fake session and redirect
# Mock authentication mode
if AuthService.is_mock_auth_enabled():
mock_user = AuthService.create_mock_user_session()
session.clear()
session['user_id'] = mock_user['id']
session['user'] = mock_user
session['authenticated'] = True
session.permanent = True
AuthService.ensure_mock_user_exists()
AuthService.ensure_mock_tenant_exists()
AuditLog.log_action(
action='login',
resource_type='auth',
user=User.query.get(mock_user['id']),
tenant=Tenant.query.get(mock_user['tenant_id']),
details={'mode': 'mock', 'mock_auth': True, 'dev_mode': True}
)
return redirect(next_url)
# Development mode (without MOCK_AUTH)
if AuthService.is_dev_mode():
dev_user = AuthService.create_dev_user_session()
session.clear()
@@ -94,12 +127,9 @@ def login():
session['authenticated'] = True
session.permanent = True
# Ensure user and tenant exist in database
AuthService.ensure_dev_user_exists()
AuthService.ensure_dev_tenant_exists()
logger.info(f'Dev mode login: {dev_user["email"]}')
AuditLog.log_action(
action='login',
resource_type='auth',
@@ -107,114 +137,50 @@ def login():
tenant=Tenant.query.get(dev_user['tenant_id']),
details={'mode': 'dev', 'dev_mode': True}
)
return redirect(next_url)
# Production mode: redirect to Zitadel OIDC
if not current_app.config['ZITADEL_ISSUER']:
# Production OIDC mode
if not current_app.config.get('ZITADEL_ISSUER'):
return jsonify({'error': 'OIDC not configured'}), 500
# Build authorization URL
from authlib.integrations.flask_client import OAuth
from flask import current_app
_init_zitadel_client()
oauth = OAuth(current_app)
# Configure Zitadel if not already configured
if 'zitadel' not in oauth._clients:
zitadel_config = {
'client_id': current_app.config.get('ZITADEL_CLIENT_ID'),
'client_secret': current_app.config.get('ZITADEL_CLIENT_SECRET'),
'authorize_url': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/authorize",
'access_token_url': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/token",
'userinfo_endpoint': f"{current_app.config.get('ZITADEL_ISSUER')}/v1/userinfo",
'client_kwargs': {
'scope': ' '.join(current_app.config.get('ZITADEL_SCOPES', []))
}
}
oauth.register('zitadel', **zitadel_config)
# Generate state for CSRF protection
state = str(uuid.uuid4())
session['oauth_state'] = state
# Nach dem Callback gewünschte Ziel-URL speichern
session['oauth_next'] = next_url
# Build authorization URL
redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI',
url_for('auth.callback', _external=True))
redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI') or url_for('auth.callback', _external=True)
authorization_url = f"{current_app.config.get('ZITADEL_ISSUER')}/v1/oauth/authorize"
params = {
'client_id': current_app.config.get('ZITADEL_CLIENT_ID'),
'redirect_uri': redirect_uri,
'response_type': 'code',
'scope': ' '.join(current_app.config.get('ZITADEL_SCOPES', [])),
'state': state,
'nonce': str(uuid.uuid4())
}
auth_url = f"{authorization_url}?{urlencode(params)}"
logger.debug(f'Redirecting to OIDC provider: {auth_url}')
return redirect(auth_url)
# Authlib kümmert sich hier vollautomatisch um CSRF-State, Nonce und PKCE Code Challenge!
return oauth.zitadel.authorize_redirect(redirect_uri)
@auth_bp.route('/callback')
def callback():
"""
OIDC callback handler.
Handles the callback from Zitadel after successful authentication.
Exchanges authorization code for tokens and retrieves user info.
"""
if AuthService.is_dev_mode():
# In dev mode, shouldn't reach here, but handle gracefully
"""OIDC callback handler."""
if AuthService.is_mock_auth_enabled() or AuthService.is_dev_mode():
return redirect(url_for('admin.dashboard'))
# Verify state for CSRF protection
state = request.args.get('state')
stored_state = session.pop('oauth_state', None)
if 'error' in request.args:
error_msg = request.args.get('error_description', request.args.get('error'))
logger.error(f'OIDC provider returned error: {error_msg}')
return jsonify({'error': error_msg}), 400
if not state or state != stored_state:
logger.error('OIDC callback: Invalid state parameter')
return jsonify({'error': 'Invalid state parameter'}), 403
# Get authorization code
code = request.args.get('code')
if not code:
error = request.args.get('error', 'No authorization code received')
logger.error(f'OIDC callback error: {error}')
return jsonify({'error': error}), 400
# Exchange code for tokens
try:
from authlib.integrations.flask_client import OAuth
oauth = OAuth(current_app)
_init_zitadel_client()
redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI',
url_for('auth.callback', _external=True))
next_url = session.pop('oauth_next', url_for('admin.dashboard'))
zitadel = oauth.create_client('zitadel')
# Get access token
token = zitadel.authorize_access_token(
redirect_uri=redirect_uri,
code=code
)
# Get user info
user_info = zitadel.userinfo(token=token)
# Token-Exchange & automatische CSRF-State-Prüfung durch Authlib
token = oauth.zitadel.authorize_access_token()
user_info = oauth.zitadel.userinfo(token=token)
logger.debug(f'OIDC userinfo: {user_info}')
# Create or update user
user, error = AuthService.process_oidc_callback(user_info)
if error:
return jsonify({'error': error}), 400
# Store in session
# Login-State in Flask-Session ablegen
session.clear()
session['user_id'] = user.id
session['user'] = {
@@ -232,7 +198,6 @@ def callback():
session['token_expires'] = token.get('expires_at')
session.permanent = True
# Log the login
AuditLog.log_action(
action='login',
resource_type='auth',
@@ -240,53 +205,41 @@ def callback():
details={'mode': 'oidc', 'provider': 'zitadel'}
)
# Redirect to next URL or dashboard
next_url = session.pop('oauth_next', url_for('admin.dashboard'))
return redirect(next_url)
except Exception as e:
logger.error(f'OIDC callback failed: {e}')
return jsonify({'error': 'Authentication failed'}), 500
logger.error(f'OIDC callback failed: {e}', exc_info=True)
return jsonify({'error': 'Authentication failed', 'details': str(e)}), 500
@auth_bp.route('/logout')
def logout():
"""
Logout route - clears session and redirects to login.
In production: Also revokes tokens with Zitadel if configured
In development: Simply clears the session
"""
# Get current user for audit logging
"""Logout route - clears session and redirects to login."""
current_user = None
if 'user_id' in session:
current_user = User.query.get(session['user_id'])
# Clear session
auth_mode = 'mock' if AuthService.is_mock_auth_enabled() else ('dev' if AuthService.is_dev_mode() else 'oidc')
token_to_revoke = session.get('access_token')
session.clear()
# In production, revoke token with Zitadel
if not AuthService.is_dev_mode() and 'access_token' in session:
if not AuthService.is_dev_mode() and token_to_revoke:
try:
from authlib.integrations.flask_client import OAuth
oauth = OAuth(current_app)
zitadel = oauth.create_client('zitadel')
# Revoke token
zitadel.revoke_token(
token=session['access_token'],
_init_zitadel_client()
oauth.zitadel.revoke_token(
token=token_to_revoke,
token_type_hint='access_token'
)
except Exception as e:
logger.warning(f'Failed to revoke token: {e}')
# Log the logout
if current_user:
AuditLog.log_action(
action='logout',
resource_type='auth',
user=current_user,
details={'mode': 'dev' if AuthService.is_dev_mode() else 'oidc'}
details={'mode': auth_mode}
)
return redirect(url_for('auth.login'))
@@ -294,34 +247,26 @@ def logout():
@auth_bp.route('/status')
def status():
"""
Check current authentication status.
:return: JSON with current authentication status
"""
"""Check current authentication status."""
if 'user_id' in session and session.get('authenticated'):
user_data = session.get('user', {})
return jsonify({
'authenticated': True,
'user': user_data,
'dev_mode': AuthService.is_dev_mode()
'dev_mode': AuthService.is_dev_mode(),
'mock_auth': AuthService.is_mock_auth_enabled()
})
return jsonify({
'authenticated': False,
'dev_mode': AuthService.is_dev_mode()
'dev_mode': AuthService.is_dev_mode(),
'mock_auth': AuthService.is_mock_auth_enabled()
})
@auth_bp.route('/userinfo')
def userinfo():
"""
Get detailed user information.
Requires authentication.
:return: JSON with user information
"""
"""Get detailed user information."""
if not ('user_id' in session and session.get('authenticated')):
return jsonify({'error': 'Unauthorized'}), 401
@@ -334,5 +279,6 @@ def userinfo():
return jsonify({
'user': user.to_dict(),
'tenants': [tenant.to_dict() for tenant in user.tenants],
'dev_mode': AuthService.is_dev_mode()
'dev_mode': AuthService.is_dev_mode(),
'mock_auth': AuthService.is_mock_auth_enabled()
})
+117 -11
View File
@@ -21,6 +21,11 @@ class AuthService:
"""Check if application is in development mode."""
return current_app.config.get('DEV_MODE', False)
@staticmethod
def is_mock_auth_enabled() -> bool:
"""Check if mock authentication mode is enabled."""
return current_app.config.get('MOCK_AUTH', False) and current_app.config.get('DEV_MODE', False)
@staticmethod
def ensure_dev_user_exists() -> User:
"""
@@ -82,7 +87,6 @@ class AuthService:
"""
config = current_app.config
# Use configured dev user settings or defaults
dev_user = {
'id': config.get('DEV_USER_ID', 'user_dev_admin_001'),
'email': config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
@@ -96,10 +100,86 @@ class AuthService:
return dev_user
@staticmethod
def create_mock_user_session() -> dict:
"""
Create a mock authentication user session with local mock user.
:return: Dictionary with mock user session data
"""
config = current_app.config
mock_user = {
'id': config.get('MOCK_USER_ID', 'mock_user_001'),
'email': config.get('MOCK_USER_EMAIL', 'mock@formeleon.local'),
'name': config.get('MOCK_USER_NAME', 'Mock Admin'),
'role': config.get('MOCK_USER_ROLE', 'admin'),
'sub': f'mock-{uuid.uuid4()}',
'tenant_id': config.get('MOCK_TENANT_ID', 'mock_tenant_001'),
'dev_mode': True,
'mock_auth': True,
'authenticated': True
}
return mock_user
@staticmethod
def ensure_mock_user_exists() -> User:
"""
Ensure the mock user exists in the database.
:return: User object from database
"""
config = current_app.config
user_id = config.get('MOCK_USER_ID', 'mock_user_001')
user = User.query.get(user_id)
if not user:
role = UserRole.ADMIN if config.get('MOCK_USER_ROLE', 'admin') == 'admin' else UserRole.USER
user = User(
id=user_id,
email=config.get('MOCK_USER_EMAIL', 'mock@formeleon.local'),
name=config.get('MOCK_USER_NAME', 'Mock Admin'),
role=role,
sub=f'mock-{uuid.uuid4()}'
)
db.session.add(user)
db.session.commit()
logger.info(f'Created mock user: {user.id}')
return user
@staticmethod
def ensure_mock_tenant_exists() -> Tenant:
"""
Ensure the mock tenant exists in the database.
:return: Tenant object from database
"""
config = current_app.config
tenant_id = config.get('MOCK_TENANT_ID', 'mock_tenant_001')
tenant = Tenant.query.get(tenant_id)
if not tenant:
tenant = Tenant(
id=tenant_id,
name='Mock Tenant',
public_key=f'mock-key-{uuid.uuid4().hex[:16]}',
allowed_origins='*'
)
db.session.add(tenant)
db.session.commit()
logger.info(f'Created mock tenant: {tenant.id}')
return tenant
@staticmethod
def process_oidc_callback(user_info: dict) -> Tuple[Optional[User], Optional[str]]:
"""
Process user information from OIDC callback and create/update user.
Process user information from OIDC callback, evaluate Zitadel roles,
and create/update the local user record.
:param user_info: User information dictionary from OIDC provider
:return: Tuple of (User object or None, error message or None)
@@ -108,11 +188,35 @@ class AuthService:
email = user_info.get('email')
name = user_info.get('name', user_info.get('preferred_username', 'Unknown'))
if not email:
return None, 'No email in user info'
if not email or not sub:
return None, 'Incomplete user info from OIDC provider (missing sub or email).'
# Create or update user
user = User.query.filter_by(sub=sub).first()
# 1. Zitadel-Rollen extrahieren
# Zitadel liefert Projektrollen im Claim 'urn:zitadel:iam:org:project:roles'
raw_roles = user_info.get('urn:zitadel:iam:org:project:roles', {})
if isinstance(raw_roles, dict):
user_roles = list(raw_roles.keys())
elif isinstance(raw_roles, list):
user_roles = raw_roles
else:
user_roles = []
logger.debug(f"Extracted OIDC roles for {email}: {user_roles}")
# 2. Hard Gate: Zugriff verweigern, wenn keine der erlaubt Formeleon-Rollen zugewiesen ist
has_admin_role = 'form-admins' in user_roles
has_user_role = 'form-users' in user_roles
if not (has_admin_role or has_user_role):
logger.warning(f"Access denied for {email}. Missing required roles ('form-admins' or 'form-users'). Roles present: {user_roles}")
return None, 'Access denied: You do not have permission to access Formeleon.'
# 3. Formeleon-Rolle festlegen (Admin schlägt User)
assigned_role = UserRole.ADMIN if has_admin_role else UserRole.USER
# 4. User suchen oder neu anlegen
user = User.query.filter((User.sub == sub) | (User.email == email)).first()
if not user:
user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}'
@@ -120,16 +224,18 @@ class AuthService:
id=user_id,
email=email,
name=name,
role=UserRole.USER, # Default role for new OIDC users
role=assigned_role,
sub=sub
)
db.session.add(user)
db.session.commit()
logger.info(f'Created new user from OIDC: {user.id}')
logger.info(f'Created new user from OIDC: {user.id} with role {assigned_role.value}')
else:
# Update existing user
# Re-sync Name, Sub und Rolle bei jedem Login
user.name = name
user.sub = sub
user.email = email
db.session.commit()
user.role = assigned_role
logger.info(f'Updated existing user from OIDC: {user.id} (Role: {assigned_role.value})')
db.session.commit()
return user, None
+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)
+50 -1
View File
@@ -1,5 +1,5 @@
from typing import Optional, List
from models import db, User, UserRole
from models import db, User, UserRole, Tenant
from models import AuditLog
import logging
@@ -39,3 +39,52 @@ class UserAdminService:
resource_id=target_user.id,
details={'email': target_user.email}
)
@staticmethod
def assign_tenant_to_user(user, tenant, admin_user):
"""Assign a tenant to a user."""
if tenant not in user.tenants:
user.tenants.append(tenant)
db.session.commit()
AuditLog.log_action(
action='assign_tenant',
resource_type='user',
user=admin_user,
resource_id=user.id,
details={'tenant_id': tenant.id, 'tenant_name': tenant.name}
)
logger.info(f'Assigned user {user.id} to tenant {tenant.id}')
@staticmethod
def remove_tenant_from_user(user, tenant, admin_user):
"""Remove a tenant from a user."""
if tenant in user.tenants:
user.tenants.remove(tenant)
db.session.commit()
AuditLog.log_action(
action='remove_tenant',
resource_type='user',
user=admin_user,
resource_id=user.id,
details={'tenant_id': tenant.id, 'tenant_name': tenant.name}
)
logger.info(f'Removed user {user.id} from tenant {tenant.id}')
@staticmethod
def update_user_role(user, role, admin_user):
"""Update a user's role."""
if role in [r.value for r in UserRole]:
old_role = user.role.value
user.role = UserRole(role)
db.session.commit()
AuditLog.log_action(
action='update_role',
resource_type='user',
user=admin_user,
resource_id=user.id,
details={'old_role': old_role, 'new_role': role}
)
logger.info(f'Updated user {user.id} role from {old_role} to {role}')
+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 %}
+85
View File
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}Users - Formeleon{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-people me-2"></i> Users</h2>
</div>
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">All Users</h5>
<div class="input-group" style="width: 300px;">
<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 align-middle mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Name</th>
<th>Email</th>
<th>Role</th>
<th>Tenants</th>
<th>Last Login</th>
<th class="text-end pe-3">Actions</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="ps-3"><strong>{{ u.name }}</strong></td>
<td>{{ u.email }}</td>
<td>
{% 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>
<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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<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>
</div>
{% 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 %}
+123
View File
@@ -0,0 +1,123 @@
{# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Template: admin/users/tenants.html
# Description: Manage tenant assignments for a user.
# ============================================================================== #}
{% extends "base.html" %}
{% block title %}Manage Tenants for {{ target_user.name }} - Formeleon{% endblock %}
{% block content %}
<div class="container">
<div class="row mb-4">
<div class="col-12 d-flex justify-content-between align-items-center">
<div>
<h1><i class="bi bi-buildings"></i> Manage Tenants for {{ target_user.name }}</h1>
<p class="text-muted mb-0">
Assign or remove tenants from this user's access list.
</p>
</div>
<div>
<a href="{{ url_for('admin.view_user', user_id=target_user.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to User
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header bg-light">
<h5><i class="bi bi-list-check"></i> Tenant Assignments</h5>
</div>
<div class="card-body">
<div class="row">
{# Available Tenants #}
<div class="col-md-6">
<h6><i class="bi bi-plus-circle"></i> Available Tenants</h6>
<p class="text-muted small">Tenants not yet assigned to this user</p>
{% if all_tenants %}
<div class="list-group" style="max-height: 400px; overflow-y: auto;">
{% for tenant in all_tenants %}
{% if tenant not in user_tenants %}
<form method="POST" action="{{ url_for('admin.manage_user_tenants', user_id=target_user.id) }}" class="list-group-item">
<input type="hidden" name="action" value="add">
<input type="hidden" name="tenant_id" value="{{ tenant.id }}">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>{{ tenant.name }}</strong>
<span class="badge bg-{{ 'success' if tenant.status.value == 'active' else 'warning' if tenant.status.value == 'suspended' else 'danger' }} ms-2">
{{ tenant.status.value }}
</span>
<div class="text-muted small">{{ tenant.id }}</div>
</div>
<button type="submit" class="btn btn-sm btn-outline-success" title="Add tenant">
<i class="bi bi-plus-circle"></i>
</button>
</div>
</form>
{% endif %}
{% endfor %}
</div>
{% else %}
<p class="text-muted">No tenants available to assign.</p>
{% endif %}
</div>
{# Assigned Tenants #}
<div class="col-md-6">
<h6><i class="bi bi-check-circle"></i> Assigned Tenants</h6>
<p class="text-muted small">Tenants currently assigned to this user</p>
{% if user_tenants %}
<div class="list-group" style="max-height: 400px; overflow-y: auto;">
{% for tenant in user_tenants %}
<form method="POST" action="{{ url_for('admin.manage_user_tenants', user_id=target_user.id) }}" class="list-group-item">
<input type="hidden" name="action" value="remove">
<input type="hidden" name="tenant_id" value="{{ tenant.id }}">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>{{ tenant.name }}</strong>
<span class="badge bg-{{ 'success' if tenant.status.value == 'active' else 'warning' if tenant.status.value == 'suspended' else 'danger' }} ms-2">
{{ tenant.status.value }}
</span>
<div class="text-muted small">{{ tenant.id }}</div>
</div>
<button type="submit" class="btn btn-sm btn-outline-danger" title="Remove tenant">
<i class="bi bi-trash"></i>
</button>
</div>
</form>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No tenants assigned to this user.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<h6><i class="bi bi-info-circle"></i> Information</h6>
<ul>
<li><strong>Admin users</strong> have access to all tenants and can manage any user.</li>
<li><strong>Tenant Admin users</strong> have access only to tenants they are assigned to.</li>
<li><strong>Regular users</strong> can only access tenants they are explicitly assigned to.</li>
<li>Changes to tenant assignments take effect immediately.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+176
View File
@@ -0,0 +1,176 @@
# ==============================================================================
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden <br@runden.net>
# Template: admin/users/view.html
# Description: View and edit user details with role management.
# ============================================================================== #}
{% extends "base.html" %}
{% block title %}{{ target_user.name }} - Formeleon{% endblock %}
{% block content %}
<div class="container">
<div class="row mb-4">
<div class="col-12 d-flex justify-content-between align-items-center">
<div>
<h1><i class="bi bi-person"></i> {{ target_user.name }}</h1>
<p class="text-muted mb-0">
Email: {{ target_user.email }} |
Role: <span class="badge bg-{{ 'primary' if target_user.role.value == 'admin' else 'success' if target_user.role.value == 'tenant_admin' else 'info' if target_user.role.value == 'user' else 'secondary' }}">
{{ target_user.role.value }}
</span>
</p>
</div>
<div>
<a href="{{ url_for('admin.list_users') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Users
</a>
<a href="{{ url_for('admin.manage_user_tenants', user_id=target_user.id) }}" class="btn btn-outline-info">
<i class="bi bi-buildings"></i> Manage Tenants
</a>
</div>
</div>
</div>
<div class="row">
{# User Details Card #}
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header bg-light">
<h5><i class="bi bi-info-circle"></i> User Details</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4">User ID:</dt>
<dd class="col-sm-8"><code>{{ target_user.id }}</code></dd>
<dt class="col-sm-4">Email:</dt>
<dd class="col-sm-8">{{ target_user.email }}</dd>
<dt class="col-sm-4">Name:</dt>
<dd class="col-sm-8">{{ target_user.name }}</dd>
<dt class="col-sm-4">OIDC Subject:</dt>
<dd class="col-sm-8"><code>{{ target_user.sub or 'N/A' }}</code></dd>
<dt class="col-sm-4">Role:</dt>
<dd class="col-sm-8">
<form method="POST" action="{{ url_for('admin.update_user_role', user_id=target_user.id) }}" class="d-inline">
<select name="role" class="form-select form-select-sm d-inline-block" style="width: 200px;"
onchange="this.form.submit()">
{% for role in ['admin', 'tenant_admin', 'user', 'viewer'] %}
<option value="{{ role }}" {% if target_user.role.value == role %}selected{% endif %}>{{ role }}</option>
{% endfor %}
</select>
</form>
</dd>
<dt class="col-sm-4">Created:</dt>
<dd class="col-sm-8">{{ target_user.created_at.strftime('%Y-%m-%d %H:%M:%S') if target_user.created_at else 'N/A' }}</dd>
<dt class="col-sm-4">Last Updated:</dt>
<dd class="col-sm-8">{{ target_user.updated_at.strftime('%Y-%m-%d %H:%M:%S') if target_user.updated_at else 'N/A' }}</dd>
<dt class="col-sm-4">Last Login:</dt>
<dd class="col-sm-8">{{ target_user.last_login.strftime('%Y-%m-%d %H:%M:%S') if target_user.last_login else 'Never' }}</dd>
</dl>
</div>
</div>
</div>
{# Associated Tenants Card #}
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5><i class="bi bi-buildings"></i> Associated Tenants ({{ tenants|length }})</h5>
<a href="{{ url_for('admin.manage_user_tenants', user_id=target_user.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i> Manage
</a>
</div>
<div class="card-body">
{% if tenants %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>ID</th>
<th>Status</th>
<th>Forms</th>
</tr>
</thead>
<tbody>
{% for tenant in tenants %}
<tr>
<td>{{ tenant.name }}</td>
<td><code>{{ tenant.id }}</code></td>
<td>
<span class="badge bg-{{ 'success' if tenant.status.value == 'active' else 'warning' if tenant.status.value == 'suspended' else 'danger' }}">
{{ tenant.status.value }}
</span>
</td>
<td>{{ tenant.forms|length }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">No tenants assigned.</p>
<a href="{{ url_for('admin.manage_user_tenants', user_id=target_user.id) }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus"></i> Assign Tenants
</a>
{% endif %}
</div>
</div>
</div>
</div>
{# Edit User Modal #}
<div class="modal fade" id="editUserModal" tabindex="-1" aria-labelledby="editUserModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editUserModalLabel"><i class="bi bi-pencil"></i> Edit User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="POST" action="{{ url_for('admin.view_user', user_id=target_user.id) }}">
<div class="modal-body">
<div class="row g-3">
<div class="col-12">
<label for="edit_email" class="form-label">Email *</label>
<input type="email" class="form-control" id="edit_email" name="email"
value="{{ target_user.email }}" required>
</div>
<div class="col-12">
<label for="edit_name" class="form-label">Name *</label>
<input type="text" class="form-control" id="edit_name" name="name"
value="{{ target_user.name }}" required>
</div>
<div class="col-12">
<label for="edit_role" class="form-label">Role</label>
<select class="form-select" id="edit_role" name="role">
{% for role in UserRole %}
<option value="{{ role.value }}" {% if target_user.role.value == role.value %}selected{% endif %}>{{ role.value }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
<i class="bi bi-x"></i> Cancel
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check"></i> Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+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')