Compare commits
13
Commits
1c789aa8d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fff17531d | ||
|
|
7f81932fa5 | ||
|
|
8498781c85 | ||
|
|
03b0e33723 | ||
|
|
1dc0d9588f | ||
|
|
78f1a68438 | ||
|
|
6f0d0a952d | ||
|
|
574e3cb6c4 | ||
|
|
0739de4b44 | ||
|
|
a54f26f808 | ||
|
|
b5f1e7a469 | ||
|
|
e72a66fe94 | ||
|
|
8f92e53fa8 |
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -88,3 +88,8 @@ migrations/
|
||||
# Cache
|
||||
.cache/
|
||||
*.pyc
|
||||
|
||||
# Babel i18n
|
||||
*.pot
|
||||
*.mo
|
||||
|
||||
@@ -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)
|
||||
|
||||
+48
-2
@@ -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
|
||||
* **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
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -127,10 +137,15 @@ def initialize_extensions(app: Flask) -> None:
|
||||
'allow_headers': ['Content-Type', 'Authorization', 'X-Request-ID']
|
||||
}
|
||||
})
|
||||
|
||||
# Babel (i18n)
|
||||
babel.init_app(app, locale_selector=get_locale, timezone_selector=get_timezone)
|
||||
|
||||
# Session
|
||||
app.config['SESSION_TYPE'] = 'sqlalchemy'
|
||||
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'])
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
+4
-1
@@ -33,4 +33,7 @@ python-dateutil~=2.8.0
|
||||
|
||||
# Production
|
||||
gunicorn~=22.0.0
|
||||
# gthread ist als Worker-Typ in Python 3.13 out-of-the-box stabil
|
||||
# gthread ist als Worker-Typ in Python 3.13 out-of-the-box stabil
|
||||
|
||||
# i18n
|
||||
Flask-Babel~=4.0.0
|
||||
+197
-18
@@ -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():
|
||||
|
||||
+141
-360
@@ -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')
|
||||
|
||||
# Validate origin
|
||||
return jsonify({'error': 'Unauthorized. Invalid public key.'}), 401
|
||||
|
||||
if not tenant.is_active():
|
||||
raise Forbidden('Tenant is not active')
|
||||
|
||||
if not TenantService.validate_origin(origin, tenant):
|
||||
logger.warning(f'Handshake failed: Invalid origin {origin} for tenant {tenant.id}')
|
||||
raise Forbidden('Origin not allowed for this tenant')
|
||||
|
||||
# Get form ID from request
|
||||
form_id = request.form.get('form_id') or request.json.get('form_id') if request.json else None
|
||||
|
||||
if not form_id:
|
||||
raise BadRequest('form_id is required')
|
||||
|
||||
# Get form configuration
|
||||
form = FormConfig.query.filter_by(
|
||||
id=form_id,
|
||||
tenant_id=tenant.id
|
||||
).first()
|
||||
|
||||
return jsonify({'error': 'Tenant is inactive.'}), 403
|
||||
|
||||
# Check origin (CORS / allowed domains)
|
||||
origin = request.headers.get('Origin')
|
||||
if origin and not tenant.is_origin_allowed(origin):
|
||||
logger.warning(f"Origin rejected: {origin} for tenant {tenant.id}")
|
||||
return jsonify({'error': 'Origin not allowed'}), 403
|
||||
|
||||
form = FormConfig.query.filter_by(id=form_id, tenant_id=tenant.id).first()
|
||||
if not form:
|
||||
raise NotFound('Form not found or does not belong to tenant')
|
||||
|
||||
return jsonify({'error': 'Form not found or access denied.'}), 404
|
||||
|
||||
if not form.is_active:
|
||||
raise Forbidden('Form is not active')
|
||||
|
||||
# Generate handshake token (can be used for subsequent requests)
|
||||
handshake_token = str(uuid.uuid4())
|
||||
|
||||
# Return handshake response
|
||||
response = {
|
||||
'status': 'success',
|
||||
'tenant_id': tenant.id,
|
||||
'form_id': form.id,
|
||||
'form_name': form.name,
|
||||
'form_config': form.form_structure,
|
||||
'settings': form.settings or {},
|
||||
'handshake_token': handshake_token,
|
||||
'recaptcha_enabled': form.settings.get('recaptcha_enabled', True) if form.settings else True
|
||||
}
|
||||
|
||||
logger.info(f'Handshake successful for tenant {tenant.id}, form {form.id}')
|
||||
|
||||
return jsonify(response)
|
||||
return jsonify({'error': 'Form is inactive.'}), 403
|
||||
|
||||
recaptcha_service = get_recaptcha_service()
|
||||
|
||||
@api_bp.route('/form/<form_id>', methods=['GET'])
|
||||
def get_form(form_id):
|
||||
"""
|
||||
Get form configuration.
|
||||
|
||||
:param form_id: ID of the form to retrieve
|
||||
:return: JSON with form configuration
|
||||
"""
|
||||
# Get public key from header
|
||||
public_key = request.headers.get('X-Public-Key')
|
||||
|
||||
if not public_key:
|
||||
raise BadRequest('Public key required')
|
||||
|
||||
# Find tenant by public key
|
||||
tenant = TenantService.get_tenant_by_public_key(public_key)
|
||||
|
||||
if not tenant:
|
||||
raise Unauthorized('Invalid public key')
|
||||
|
||||
if not tenant.is_active():
|
||||
raise Forbidden('Tenant is not active')
|
||||
|
||||
# Get form
|
||||
form = FormConfig.query.filter_by(
|
||||
id=form_id,
|
||||
tenant_id=tenant.id
|
||||
).first()
|
||||
|
||||
if not form:
|
||||
raise NotFound('Form not found')
|
||||
|
||||
if not form.is_active:
|
||||
raise Forbidden('Form is not active')
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'form': form.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route('/submit/<form_id>', methods=['POST'])
|
||||
def submit_form(form_id):
|
||||
"""
|
||||
Submit form data.
|
||||
|
||||
Handles form submissions from client websites with:
|
||||
- Origin validation
|
||||
- Public key authentication
|
||||
- reCAPTCHA validation (optional)
|
||||
- Data validation against form structure
|
||||
|
||||
:param form_id: ID of the form being submitted
|
||||
:return: JSON with submission result
|
||||
"""
|
||||
# Get public key from header
|
||||
public_key = request.headers.get('X-Public-Key')
|
||||
|
||||
if not public_key:
|
||||
raise BadRequest('Public key required')
|
||||
|
||||
# Find tenant by public key
|
||||
tenant = TenantService.get_tenant_by_public_key(public_key)
|
||||
|
||||
if not tenant:
|
||||
logger.warning(f'Submission failed: Invalid public key {public_key[:8]}...')
|
||||
raise Unauthorized('Invalid public key')
|
||||
|
||||
if not tenant.is_active():
|
||||
raise Forbidden('Tenant is not active')
|
||||
|
||||
# Validate origin
|
||||
origin = request.headers.get('Origin') or request.headers.get('Referer') or ''
|
||||
if not TenantService.validate_origin(origin, tenant):
|
||||
logger.warning(f'Submission failed: Invalid origin {origin} for tenant {tenant.id}')
|
||||
raise Forbidden('Origin not allowed')
|
||||
|
||||
# Get form data from request
|
||||
try:
|
||||
if request.content_type == 'application/json':
|
||||
payload = request.get_json()
|
||||
else:
|
||||
payload = request.form.to_dict()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to parse form data: {e}')
|
||||
raise BadRequest('Invalid form data format')
|
||||
|
||||
recaptcha_token = payload.get('g-recaptcha-response') or request.headers.get('X-ReCAPTCHA-Token') if payload else None
|
||||
|
||||
response, status_code = SubmissionService.process_submission(
|
||||
form_id=form_id,
|
||||
tenant=tenant,
|
||||
origin=origin,
|
||||
payload=payload,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.user_agent.string if request.user_agent else None,
|
||||
recaptcha_token=recaptcha_token,
|
||||
content_type=request.content_type
|
||||
)
|
||||
|
||||
return jsonify(response), status_code
|
||||
|
||||
|
||||
@api_bp.route('/validate/recaptcha', methods=['POST'])
|
||||
def validate_recaptcha():
|
||||
"""
|
||||
Validate a reCAPTCHA token.
|
||||
|
||||
This endpoint can be used by client snippets to pre-validate
|
||||
reCAPTCHA tokens before form submission.
|
||||
|
||||
:return: JSON with validation result
|
||||
"""
|
||||
recaptcha_token = request.form.get('token') or request.json.get('token') if request.json else None
|
||||
|
||||
if not recaptcha_token:
|
||||
raise BadRequest('reCAPTCHA token required')
|
||||
|
||||
recaptcha_service = RecaptchaService()
|
||||
|
||||
if not recaptcha_service.is_enabled():
|
||||
# If reCAPTCHA not configured, return success in dev mode
|
||||
if current_app.config.get('DEV_MODE', False):
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'score': current_app.config.get('MOCK_RECAPTCHA_SCORE', 0.9),
|
||||
'passed': True,
|
||||
'dev_mode': True
|
||||
})
|
||||
else:
|
||||
raise BadRequest('reCAPTCHA not configured')
|
||||
|
||||
is_valid, score = recaptcha_service.verify(
|
||||
recaptcha_token,
|
||||
request.remote_addr
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'score': score,
|
||||
'passed': is_valid,
|
||||
'threshold': recaptcha_service.get_threshold(),
|
||||
'dev_mode': recaptcha_service.is_dev_mode()
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route('/status')
|
||||
def api_status():
|
||||
"""
|
||||
API health check endpoint.
|
||||
|
||||
:return: JSON with API status
|
||||
"""
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'version': '1.0.0',
|
||||
'dev_mode': current_app.config.get('DEV_MODE', False),
|
||||
'timestamp': datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
|
||||
|
||||
@api_bp.route('/stats/<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]
|
||||
'id': form.id,
|
||||
'name': form.name,
|
||||
'description': form.description,
|
||||
'form_structure': form.form_structure,
|
||||
'recaptcha': {
|
||||
'enabled': recaptcha_service.is_enabled() and not recaptcha_service.is_dev_mode(),
|
||||
'site_key': recaptcha_service.get_site_key()
|
||||
}
|
||||
})
|
||||
}), 200
|
||||
|
||||
|
||||
@api_bp.route('/submissions', methods=['POST'])
|
||||
def submit_form():
|
||||
"""
|
||||
Submission Endpoint.
|
||||
Receives JSON payload, validates reCAPTCHA (mocked in dev), and saves to DB.
|
||||
"""
|
||||
tenant = _get_tenant_from_auth()
|
||||
if not tenant:
|
||||
return jsonify({'error': 'Unauthorized. Invalid public key.'}), 401
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'Invalid JSON payload.'}), 400
|
||||
|
||||
form_id = data.get('form_id')
|
||||
payload = data.get('payload')
|
||||
recaptcha_token = data.get('recaptcha_token')
|
||||
|
||||
if not form_id or not payload:
|
||||
return jsonify({'error': 'Missing form_id or payload.'}), 400
|
||||
|
||||
form = FormConfig.query.filter_by(id=form_id, tenant_id=tenant.id).first()
|
||||
if not form:
|
||||
return jsonify({'error': 'Form not found.'}), 404
|
||||
|
||||
# --- reCAPTCHA Validation ---
|
||||
recaptcha_service = get_recaptcha_service()
|
||||
|
||||
# Get client IP for additional reCAPTCHA validation
|
||||
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
if client_ip and ',' in client_ip:
|
||||
client_ip = client_ip.split(',')[0].strip()
|
||||
|
||||
recaptcha_passed, recaptcha_score = recaptcha_service.verify(recaptcha_token, client_ip)
|
||||
|
||||
status = SubmissionStatus.PENDING if recaptcha_passed else SubmissionStatus.SPAM
|
||||
|
||||
# Save Submission
|
||||
sub_id = f"sub_{uuid.uuid4().hex[:12]}"
|
||||
new_submission = Submission(
|
||||
id=sub_id,
|
||||
form_config_id=form.id,
|
||||
tenant_id=tenant.id,
|
||||
payload=payload,
|
||||
recaptcha_score=recaptcha_score,
|
||||
recaptcha_passed=recaptcha_passed,
|
||||
status=status,
|
||||
user_agent=request.headers.get('User-Agent'),
|
||||
referer=request.headers.get('Referer')
|
||||
)
|
||||
|
||||
db.session.add(new_submission)
|
||||
db.session.commit()
|
||||
|
||||
# --- Trigger Email Notifications (if not spam) ---
|
||||
if recaptcha_passed:
|
||||
# Check Tenant Level Permissions
|
||||
allow_instant_mail = tenant.config.get('features', {}).get('instant_notifications', True) # Default to true for now
|
||||
allow_autoresponder = tenant.config.get('features', {}).get('submitter_autoresponder', True)
|
||||
|
||||
if form.settings:
|
||||
# 1. Admin Notification
|
||||
notif_settings = form.settings.get('notifications', {})
|
||||
if allow_instant_mail and notif_settings.get('enabled'):
|
||||
send_to = notif_settings.get('send_to', [])
|
||||
if send_to:
|
||||
# In a production app, you might want to queue this with Celery instead of blocking the request
|
||||
try:
|
||||
MailerService.send_admin_notification(form.name, send_to, payload)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send admin notification: {e}")
|
||||
|
||||
# 2. Submitter Autoresponder
|
||||
auto_settings = form.settings.get('autoresponder', {})
|
||||
if allow_autoresponder and auto_settings.get('enabled'):
|
||||
target_field = auto_settings.get('email_field_name')
|
||||
target_email = payload.get(target_field) if target_field else None
|
||||
|
||||
if target_email and '@' in str(target_email):
|
||||
try:
|
||||
MailerService.send_autoresponder(
|
||||
to_address=str(target_email),
|
||||
subject_template=auto_settings.get('subject', 'Thank you!'),
|
||||
body_template=auto_settings.get('body_template', ''),
|
||||
payload=payload
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send autoresponder to {target_email}: {e}")
|
||||
|
||||
if not recaptcha_passed:
|
||||
return jsonify({'error': 'Spam detected. Submission rejected.'}), 400
|
||||
|
||||
return jsonify({
|
||||
'message': 'Submission received successfully',
|
||||
'submission_id': sub_id
|
||||
}), 201
|
||||
|
||||
+123
-177
@@ -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():
|
||||
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()
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
_init_zitadel_client()
|
||||
|
||||
# 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 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
|
||||
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
|
||||
|
||||
try:
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
oauth = OAuth(current_app)
|
||||
|
||||
redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI',
|
||||
url_for('auth.callback', _external=True))
|
||||
|
||||
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)
|
||||
_init_zitadel_client()
|
||||
|
||||
next_url = session.pop('oauth_next', url_for('admin.dashboard'))
|
||||
|
||||
# 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()
|
||||
})
|
||||
+118
-12
@@ -20,6 +20,11 @@ class AuthService:
|
||||
def is_dev_mode() -> bool:
|
||||
"""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'),
|
||||
@@ -95,11 +99,87 @@ 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})')
|
||||
|
||||
return user, None
|
||||
db.session.commit()
|
||||
return user, None
|
||||
@@ -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}"}
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}')
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -21,45 +21,51 @@
|
||||
{# Statistics Cards #}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 col-6 mb-3">
|
||||
<div class="card bg-primary text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-buildings display-6"></i>
|
||||
<div class="ms-3">
|
||||
<h3 class="mb-0">{{ tenant_count }}</h3>
|
||||
<p class="mb-0">Tenants</p>
|
||||
<a href="{{ url_for('admin.list_tenants') }}" class="text-decoration-none">
|
||||
<div class="card bg-primary text-white h-100 hover-shadow">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-buildings display-6"></i>
|
||||
<div class="ms-3">
|
||||
<h3 class="mb-0">{{ tenant_count }}</h3>
|
||||
<p class="mb-0">Tenants</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-6 mb-3">
|
||||
<div class="card bg-success text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-file-earmark-text display-6"></i>
|
||||
<div class="ms-3">
|
||||
<h3 class="mb-0">{{ form_count }}</h3>
|
||||
<p class="mb-0">Forms</p>
|
||||
<a href="{{ url_for('admin.list_forms') }}" class="text-decoration-none">
|
||||
<div class="card bg-success text-white h-100 hover-shadow">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-file-earmark-text display-6"></i>
|
||||
<div class="ms-3">
|
||||
<h3 class="mb-0">{{ form_count }}</h3>
|
||||
<p class="mb-0">Forms</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-6 mb-3">
|
||||
<div class="card bg-info text-white">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-inbox display-6"></i>
|
||||
<div class="ms-3">
|
||||
<h3 class="mb-0">{{ submission_count }}</h3>
|
||||
<p class="mb-0">Submissions</p>
|
||||
<a href="{{ url_for('admin.list_submissions') }}" class="text-decoration-none">
|
||||
<div class="card bg-info text-white h-100 hover-shadow">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-inbox display-6"></i>
|
||||
<div class="ms-3">
|
||||
<h3 class="mb-0">{{ submission_count }}</h3>
|
||||
<p class="mb-0">Submissions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
<!-- Formeleon Embed -->
|
||||
<script src="{{ request.url_root }}static/js/embed.js"
|
||||
data-form-id="{{ form.id }}"
|
||||
data-public-key="{{ tenant.public_key }}"></script>
|
||||
<div id="formeleon-form"></div></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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
Reference in new issue
Block a user