initiales bauen mit mistral
This commit is contained in:
1 parent
7e2e8bdf59
commit
c74bca1e13
22 files changed
+5642
No files matched your search
+159
@@ -0,0 +1,159 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# File: .env.example
|
||||||
|
# Description: Environment configuration template. Copy to .env and customize.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# APPLICATION SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Flask Environment: development, production, testing
|
||||||
|
FLASK_ENV=development
|
||||||
|
|
||||||
|
# Secret Key for Flask session management
|
||||||
|
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
|
||||||
|
# Enable/Disable Debug Mode
|
||||||
|
FLASK_DEBUG=true
|
||||||
|
|
||||||
|
# Development Mode - enables fake auth and mocking
|
||||||
|
DEV_MODE=true
|
||||||
|
|
||||||
|
# Application port (default: 5000)
|
||||||
|
# PORT=5000
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# SQLite (default for development)
|
||||||
|
# SQLALCHEMY_DATABASE_URI=sqlite:///formeleon.db
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
# SQLALCHEMY_DATABASE_URI=postgresql://username:password@localhost/formeleon
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
# SQLALCHEMY_DATABASE_URI=mysql+pymysql://username:password@localhost/formeleon
|
||||||
|
|
||||||
|
# Enable SQL query logging
|
||||||
|
SQLALCHEMY_ECHO=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ZITADEL OIDC SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Zitadel Issuer URL (OIDC Provider)
|
||||||
|
# Example: https://your-zitadel-instance.zitadel.cloud
|
||||||
|
ZITADEL_ISSUER=
|
||||||
|
|
||||||
|
# OIDC Client ID from Zitadel
|
||||||
|
ZITADEL_CLIENT_ID=
|
||||||
|
|
||||||
|
# OIDC Client Secret from Zitadel
|
||||||
|
ZITADEL_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Redirect URI for OIDC callback
|
||||||
|
# Default: http://localhost:5000/auth/callback
|
||||||
|
ZITADEL_REDIRECT_URI=http://localhost:5000/auth/callback
|
||||||
|
|
||||||
|
# OIDC Scopes (comma-separated)
|
||||||
|
# ZITADEL_SCOPES=openid,profile,email
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RECAPTCHA SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Google reCAPTCHA v3 Site Key (for client-side)
|
||||||
|
RECAPTCHA_SITE_KEY=
|
||||||
|
|
||||||
|
# Google reCAPTCHA v3 Secret Key (for server-side validation)
|
||||||
|
RECAPTCHA_SECRET_KEY=
|
||||||
|
|
||||||
|
# Minimum score threshold (0.0 - 1.0, default: 0.5)
|
||||||
|
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
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Enable CORS (default: true)
|
||||||
|
ENABLE_CORS=true
|
||||||
|
|
||||||
|
# Allowed Origins (comma-separated, default: *)
|
||||||
|
ALLOWED_ORIGINS=*
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SESSION SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Session Cookie Secure (HTTPS only, default: false for development)
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# Session Cookie SameSite (Lax, Strict, None)
|
||||||
|
SESSION_COOKIE_SAMESITE=Lax
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FILE UPLOAD SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Maximum upload size in bytes (default: 16MB = 16 * 1024 * 1024)
|
||||||
|
MAX_CONTENT_LENGTH=16777216
|
||||||
|
|
||||||
|
# Upload folder
|
||||||
|
UPLOAD_FOLDER=/tmp/formeleon_uploads
|
||||||
|
|
||||||
|
# Allowed file extensions (comma-separated)
|
||||||
|
# ALLOWED_EXTENSIONS=png,jpg,jpeg,gif,pdf,txt
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EMAIL SETTINGS (for notifications)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# SMTP Server
|
||||||
|
# SMTP_SERVER=localhost
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USERNAME=
|
||||||
|
# SMTP_PASSWORD=
|
||||||
|
# SMTP_USE_TLS=true
|
||||||
|
# EMAIL_FROM=formeleon@yourdomain.com
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOGGING SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Log Level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
|
# LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# File: .gitignore
|
||||||
|
# Description: Git ignore patterns for Formeleon project.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
formeleon.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Environment files (keep .env.example)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# Static files cache
|
||||||
|
.static/
|
||||||
|
|
||||||
|
# Uploads (keep directory, ignore contents)
|
||||||
|
/tmp/formeleon_uploads/*
|
||||||
|
!/tmp/formeleon_uploads/.gitkeep
|
||||||
|
|
||||||
|
# Migration files
|
||||||
|
migrations/
|
||||||
|
|
||||||
|
# Session files
|
||||||
|
.flask_session*
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
.cache/
|
||||||
|
*.pyc
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# File: Dockerfile
|
||||||
|
# Description: Docker container configuration for Formeleon application.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Use official Python runtime as parent image
|
||||||
|
# Use official Python 3.13 runtime as parent image
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
FLASK_ENV=production \
|
||||||
|
FLASK_APP=app.py
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies (einschließlich curl für Healthcheck)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libpq-dev \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements file first to leverage Docker cache
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create uploads directory
|
||||||
|
RUN mkdir -p /tmp/formeleon_uploads && \
|
||||||
|
touch /tmp/formeleon_uploads/.gitkeep
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s \
|
||||||
|
CMD curl -f http://localhost:5000/api/v1/status || exit 1
|
||||||
|
|
||||||
|
# Run the application with gthread workers for high concurrency
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--threads", "2", "--worker-class", "gthread", "app:app"]
|
||||||
|
|
||||||
|
# For development (uncomment and use docker-compose)
|
||||||
|
# CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# Module: app.py
|
||||||
|
# Description: Main Flask application initialization and Blueprint registration.
|
||||||
|
# Sets up database, configuration, and registers all route blueprints.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
Main Flask application module for Formeleon.
|
||||||
|
|
||||||
|
This module:
|
||||||
|
- Loads application configuration
|
||||||
|
- Initializes Flask extensions (SQLAlchemy, Migrate, CORS, Session)
|
||||||
|
- Registers all route blueprints (API, Admin, Auth)
|
||||||
|
- Sets up error handlers
|
||||||
|
- Provides utility functions for request context
|
||||||
|
|
||||||
|
The application uses Flask Blueprints for modular route organization:
|
||||||
|
- /api/v1/* - Public Form API for form submissions
|
||||||
|
- /admin/* - Admin Dashboard for form management
|
||||||
|
- /auth/* - Authentication routes (OIDC callbacks, logout)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from flask import Flask, request, session, g
|
||||||
|
from flask_cors import CORS
|
||||||
|
from flask_session import Session
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# corrected for py3.13 from config import Config, config_map
|
||||||
|
from config import Config, config_map
|
||||||
|
from models import db, Tenant, User, UserRole, Submission, FormConfig, AuditLog, SubmissionStatus
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize extensions globally (will be bound to app in create_app)
|
||||||
|
migrate = Migrate()
|
||||||
|
cors = CORS()
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_class() -> type:
|
||||||
|
"""
|
||||||
|
Get the appropriate configuration class based on FLASK_ENV.
|
||||||
|
|
||||||
|
:return: Configuration class to use
|
||||||
|
"""
|
||||||
|
env = os.environ.get('FLASK_ENV', 'development')
|
||||||
|
return config_map.get(env, Config)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_class: Optional[type] = None) -> Flask:
|
||||||
|
"""
|
||||||
|
Create and configure the Flask application instance.
|
||||||
|
|
||||||
|
:param config_class: Configuration class to use (defaults to environment-based)
|
||||||
|
:return: Configured Flask application instance
|
||||||
|
"""
|
||||||
|
# Initialize Flask app
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config_class or get_config_class())
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
setup_logging(app)
|
||||||
|
|
||||||
|
# Initialize extensions with app
|
||||||
|
initialize_extensions(app)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
register_blueprints(app)
|
||||||
|
|
||||||
|
# Register error handlers
|
||||||
|
register_error_handlers(app)
|
||||||
|
|
||||||
|
# Register CLI commands
|
||||||
|
register_cli_commands(app)
|
||||||
|
|
||||||
|
# Set up dev mode middleware if enabled
|
||||||
|
if app.config['DEV_MODE']:
|
||||||
|
setup_dev_mode_middleware(app)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(app: Flask) -> None:
|
||||||
|
"""
|
||||||
|
Configure logging for the application.
|
||||||
|
|
||||||
|
:param app: Flask application instance
|
||||||
|
"""
|
||||||
|
log_level = logging.DEBUG if app.config['DEBUG'] else logging.INFO
|
||||||
|
logging.basicConfig(
|
||||||
|
level=log_level,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
app.logger.setLevel(log_level)
|
||||||
|
|
||||||
|
# Suppress Flask-SQLAlchemy logs unless in debug mode
|
||||||
|
if not app.config['DEBUG']:
|
||||||
|
logging.getLogger('flask_sqlalchemy').setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_extensions(app: Flask) -> None:
|
||||||
|
"""
|
||||||
|
Initialize Flask extensions with the application.
|
||||||
|
|
||||||
|
:param app: Flask application instance
|
||||||
|
"""
|
||||||
|
# Database
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
# Migration
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
if app.config['ENABLE_CORS']:
|
||||||
|
cors.init_app(app, resources={
|
||||||
|
r'/api/*': {
|
||||||
|
'origins': app.config.get('ALLOWED_ORIGINS', '*'),
|
||||||
|
'methods': ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
'allow_headers': ['Content-Type', 'Authorization', 'X-Request-ID']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Session
|
||||||
|
app.config['SESSION_TYPE'] = 'sqlalchemy'
|
||||||
|
app.config['SESSION_SQLALCHEMY'] = db
|
||||||
|
Session(app)
|
||||||
|
|
||||||
|
# Ensure secret key is set
|
||||||
|
if not app.config.get('SECRET_KEY'):
|
||||||
|
app.config['SECRET_KEY'] = str(uuid.uuid4())
|
||||||
|
app.logger.warning('Generated random SECRET_KEY - set explicitly in production!')
|
||||||
|
|
||||||
|
|
||||||
|
def register_blueprints(app: Flask) -> None:
|
||||||
|
"""
|
||||||
|
Register all route blueprints with the application.
|
||||||
|
|
||||||
|
:param app: Flask application instance
|
||||||
|
"""
|
||||||
|
# Import blueprints here to avoid circular imports
|
||||||
|
from routes.api import api_bp
|
||||||
|
from routes.admin import admin_bp
|
||||||
|
from routes.auth import auth_bp
|
||||||
|
|
||||||
|
# Register blueprints with URL prefixes
|
||||||
|
app.register_blueprint(api_bp, url_prefix='/api/v1')
|
||||||
|
app.register_blueprint(admin_bp, url_prefix='/admin')
|
||||||
|
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||||
|
|
||||||
|
# Register root route for health check
|
||||||
|
@app.route('/')
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'dev_mode': app.config['DEV_MODE']
|
||||||
|
}, 200
|
||||||
|
|
||||||
|
|
||||||
|
def register_error_handlers(app: Flask) -> None:
|
||||||
|
"""
|
||||||
|
Register custom error handlers for the application.
|
||||||
|
|
||||||
|
:param app: Flask application instance
|
||||||
|
"""
|
||||||
|
@app.errorhandler(400)
|
||||||
|
def bad_request_error(error):
|
||||||
|
"""Handle 400 Bad Request errors."""
|
||||||
|
return {
|
||||||
|
'error': 'Bad Request',
|
||||||
|
'message': str(error.description) if error.description else 'Invalid request data'
|
||||||
|
}, 400
|
||||||
|
|
||||||
|
@app.errorhandler(401)
|
||||||
|
def unauthorized_error(error):
|
||||||
|
"""Handle 401 Unauthorized errors."""
|
||||||
|
return {
|
||||||
|
'error': 'Unauthorized',
|
||||||
|
'message': str(error.description) if error.description else 'Authentication required'
|
||||||
|
}, 401
|
||||||
|
|
||||||
|
@app.errorhandler(403)
|
||||||
|
def forbidden_error(error):
|
||||||
|
"""Handle 403 Forbidden errors."""
|
||||||
|
return {
|
||||||
|
'error': 'Forbidden',
|
||||||
|
'message': str(error.description) if error.description else 'Access denied'
|
||||||
|
}, 403
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found_error(error):
|
||||||
|
"""Handle 404 Not Found errors."""
|
||||||
|
return {
|
||||||
|
'error': 'Not Found',
|
||||||
|
'message': str(error.description) if error.description else 'Resource not found'
|
||||||
|
}, 404
|
||||||
|
|
||||||
|
@app.errorhandler(429)
|
||||||
|
def too_many_requests_error(error):
|
||||||
|
"""Handle 429 Too Many Requests errors."""
|
||||||
|
return {
|
||||||
|
'error': 'Too Many Requests',
|
||||||
|
'message': str(error.description) if error.description else 'Rate limit exceeded'
|
||||||
|
}, 429
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def internal_server_error(error):
|
||||||
|
"""Handle 500 Internal Server Error."""
|
||||||
|
app.logger.error(f'Internal Server Error: {error}')
|
||||||
|
return {
|
||||||
|
'error': 'Internal Server Error',
|
||||||
|
'message': 'An unexpected error occurred'
|
||||||
|
}, 500
|
||||||
|
|
||||||
|
|
||||||
|
def register_cli_commands(app: Flask) -> None:
|
||||||
|
"""
|
||||||
|
Register custom CLI commands for the application.
|
||||||
|
|
||||||
|
:param app: Flask application instance
|
||||||
|
"""
|
||||||
|
import click
|
||||||
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
|
@app.cli.command('seed-db')
|
||||||
|
@with_appcontext
|
||||||
|
def seed_db():
|
||||||
|
"""Seed the database with test data for development."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# Check if data already exists
|
||||||
|
if Tenant.query.first():
|
||||||
|
print('Database already contains data. Use --force to overwrite.')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create dev tenant
|
||||||
|
tenant = Tenant(
|
||||||
|
id='tenant_dev_123',
|
||||||
|
name='Development Tenant',
|
||||||
|
public_key='dev-public-key-12345',
|
||||||
|
allowed_origins='http://localhost:5000,http://localhost:8080',
|
||||||
|
status=TenantStatus.ACTIVE
|
||||||
|
)
|
||||||
|
db.session.add(tenant)
|
||||||
|
|
||||||
|
# Create sample form
|
||||||
|
sample_form = FormConfig(
|
||||||
|
id='form_sample_001',
|
||||||
|
tenant_id='tenant_dev_123',
|
||||||
|
name='Contact Form',
|
||||||
|
description='Sample contact form for testing',
|
||||||
|
form_structure={
|
||||||
|
'version': '1.0',
|
||||||
|
'fields': [
|
||||||
|
{
|
||||||
|
'name': 'name',
|
||||||
|
'type': 'text',
|
||||||
|
'label': 'Your Name',
|
||||||
|
'required': True,
|
||||||
|
'validation': {'min_length': 2, 'max_length': 100}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'email',
|
||||||
|
'type': 'email',
|
||||||
|
'label': 'Email Address',
|
||||||
|
'required': True,
|
||||||
|
'validation': {'pattern': r'^[^@]+@[^@]+\.[^@]+$'}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'subject',
|
||||||
|
'type': 'text',
|
||||||
|
'label': 'Subject',
|
||||||
|
'required': True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'message',
|
||||||
|
'type': 'textarea',
|
||||||
|
'label': 'Message',
|
||||||
|
'required': True,
|
||||||
|
'validation': {'min_length': 10}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'submit_button': {
|
||||||
|
'text': 'Send Message',
|
||||||
|
'style': 'primary'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
settings={
|
||||||
|
'email_notification': {
|
||||||
|
'enabled': False,
|
||||||
|
'recipients': ['admin@formeleon.local']
|
||||||
|
},
|
||||||
|
'success_message': 'Thank you for your message!',
|
||||||
|
'recaptcha_enabled': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.session.add(sample_form)
|
||||||
|
|
||||||
|
# Create dev admin user
|
||||||
|
admin_user = User(
|
||||||
|
id='user_dev_admin_001',
|
||||||
|
email=app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
|
||||||
|
name=app.config.get('DEV_USER_NAME', 'Dev Admin'),
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
sub='dev-admin-sub-123'
|
||||||
|
)
|
||||||
|
db.session.add(admin_user)
|
||||||
|
|
||||||
|
# Associate user with tenant
|
||||||
|
from models import user_tenant_association
|
||||||
|
db.session.execute(
|
||||||
|
user_tenant_association.insert().values(
|
||||||
|
user_id=admin_user.id,
|
||||||
|
tenant_id=tenant.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print(f'Seeded database with tenant {tenant.id}, form {sample_form.id}, and user {admin_user.id}')
|
||||||
|
|
||||||
|
@app.cli.command('create-tenant')
|
||||||
|
@click.argument('name')
|
||||||
|
@click.argument('public_key')
|
||||||
|
@click.option('--allowed-origins', default='*', help='Comma-separated allowed origins')
|
||||||
|
@with_appcontext
|
||||||
|
def create_tenant(name, public_key, allowed_origins):
|
||||||
|
"""Create a new tenant."""
|
||||||
|
tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
|
||||||
|
tenant = Tenant(
|
||||||
|
id=tenant_id,
|
||||||
|
name=name,
|
||||||
|
public_key=public_key,
|
||||||
|
allowed_origins=allowed_origins
|
||||||
|
)
|
||||||
|
db.session.add(tenant)
|
||||||
|
db.session.commit()
|
||||||
|
print(f'Created tenant {tenant_id} with public key {public_key}')
|
||||||
|
|
||||||
|
@app.cli.command('create-user')
|
||||||
|
@click.argument('email')
|
||||||
|
@click.argument('name')
|
||||||
|
@click.option('--role', default='user', help='User role: admin, tenant_admin, user, viewer')
|
||||||
|
@with_appcontext
|
||||||
|
def create_user(email, name, role):
|
||||||
|
"""Create a new user."""
|
||||||
|
from models import UserRole as UR
|
||||||
|
role_enum = getattr(UR, role.upper(), UR.USER)
|
||||||
|
user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}'
|
||||||
|
user = User(
|
||||||
|
id=user_id,
|
||||||
|
email=email,
|
||||||
|
name=name,
|
||||||
|
role=role_enum
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
print(f'Created user {user_id} with email {email} and role {role}')
|
||||||
|
|
||||||
|
|
||||||
|
def setup_dev_mode_middleware(app: Flask) -> None:
|
||||||
|
"""
|
||||||
|
Set up development mode middleware for fake authentication.
|
||||||
|
|
||||||
|
This middleware injects a fake Zitadel user session when DEV_MODE is enabled,
|
||||||
|
bypassing the need for actual OIDC authentication during development.
|
||||||
|
|
||||||
|
:param app: Flask application instance
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def inject_dev_user():
|
||||||
|
"""Inject dev user into session if DEV_MODE is enabled."""
|
||||||
|
# Skip for static files, auth routes, and API routes
|
||||||
|
if (request.path.startswith('/static/') or
|
||||||
|
request.path.startswith('/auth/') or
|
||||||
|
request.path.startswith('/api/')):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Skip if user is already authenticated
|
||||||
|
if 'user_id' in session:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Inject dev user
|
||||||
|
dev_user = {
|
||||||
|
'id': 'user_dev_admin_001',
|
||||||
|
'email': app.config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
|
||||||
|
'name': app.config.get('DEV_USER_NAME', 'Dev Admin'),
|
||||||
|
'role': app.config.get('DEV_USER_ROLE', 'admin'),
|
||||||
|
'sub': 'dev-admin-sub-123',
|
||||||
|
'tenant_id': app.config.get('DEV_TENANT_ID', 'tenant_dev_123'),
|
||||||
|
'dev_mode': True
|
||||||
|
}
|
||||||
|
session['user_id'] = dev_user['id']
|
||||||
|
session['user'] = dev_user
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
# Load or create dev user in database
|
||||||
|
from models import User, UserRole, Tenant
|
||||||
|
user = User.query.get(dev_user['id'])
|
||||||
|
if not user:
|
||||||
|
role = UserRole.ADMIN if dev_user['role'] == 'admin' else UserRole.USER
|
||||||
|
user = User(
|
||||||
|
id=dev_user['id'],
|
||||||
|
email=dev_user['email'],
|
||||||
|
name=dev_user['name'],
|
||||||
|
role=role,
|
||||||
|
sub=dev_user['sub']
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Ensure dev tenant exists
|
||||||
|
tenant = Tenant.query.get(dev_user['tenant_id'])
|
||||||
|
if not tenant:
|
||||||
|
tenant = Tenant(
|
||||||
|
id=dev_user['tenant_id'],
|
||||||
|
name='Development Tenant',
|
||||||
|
public_key='dev-public-key-12345',
|
||||||
|
allowed_origins='*'
|
||||||
|
)
|
||||||
|
db.session.add(tenant)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user() -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Get the current authenticated user from the request context.
|
||||||
|
|
||||||
|
:return: User object if authenticated, None otherwise
|
||||||
|
"""
|
||||||
|
from flask import session
|
||||||
|
if 'user_id' not in session:
|
||||||
|
return None
|
||||||
|
return User.query.get(session['user_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_tenant() -> Optional[Tenant]:
|
||||||
|
"""
|
||||||
|
Get the current tenant from the request context.
|
||||||
|
|
||||||
|
:return: Tenant object if available, None otherwise
|
||||||
|
"""
|
||||||
|
from flask import session
|
||||||
|
if 'tenant_id' in session:
|
||||||
|
return Tenant.query.get(session['tenant_id'])
|
||||||
|
|
||||||
|
user = get_current_user()
|
||||||
|
if user and user.tenants:
|
||||||
|
return user.tenants[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_context() -> dict:
|
||||||
|
"""
|
||||||
|
Get standardized request context for audit logging.
|
||||||
|
|
||||||
|
:return: Dictionary with request context information
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'ip_address': request.remote_addr,
|
||||||
|
'user_agent': request.user_agent.string if request.user_agent else None,
|
||||||
|
'path': request.path,
|
||||||
|
'method': request.method
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Create application instance
|
||||||
|
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'])
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
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',
|
||||||
|
'sqlite:///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,83 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# File: docker-compose.yml
|
||||||
|
# Description: Docker Compose configuration for local development.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Formeleon Application
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: formeleon-app
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- ./formeleon.db:/app/formeleon.db
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- /tmp/formeleon_uploads:/tmp/formeleon_uploads
|
||||||
|
environment:
|
||||||
|
- FLASK_ENV=development
|
||||||
|
- FLASK_DEBUG=1
|
||||||
|
- DEV_MODE=true
|
||||||
|
- SECRET_KEY=dev-secret-key-change-in-production
|
||||||
|
- SQLALCHEMY_DATABASE_URI=sqlite:///formeleon.db
|
||||||
|
- ZITADEL_ISSUER=
|
||||||
|
- ZITADEL_CLIENT_ID=
|
||||||
|
- ZITADEL_CLIENT_SECRET=
|
||||||
|
- RECAPTCHA_SITE_KEY=
|
||||||
|
- RECAPTCHA_SECRET_KEY=
|
||||||
|
- RECAPTCHA_THRESHOLD=0.5
|
||||||
|
- MOCK_RECAPTCHA_SCORE=0.9
|
||||||
|
- DEV_USER_ROLE=admin
|
||||||
|
- DEV_TENANT_ID=tenant_dev_123
|
||||||
|
- DEV_USER_NAME=Dev Admin
|
||||||
|
- DEV_USER_EMAIL=dev@formeleon.local
|
||||||
|
- ENABLE_CORS=true
|
||||||
|
- ALLOWED_ORIGINS=*
|
||||||
|
command: flask run --host=0.0.0.0 --port=5000
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- formeleon-network
|
||||||
|
|
||||||
|
# PostgreSQL Database (optional, for production-like development)
|
||||||
|
# Uncomment to use PostgreSQL instead of SQLite
|
||||||
|
# postgres:
|
||||||
|
# image: postgres:15-alpine
|
||||||
|
# container_name: formeleon-postgres
|
||||||
|
# environment:
|
||||||
|
# - POSTGRES_DB=formeleon
|
||||||
|
# - POSTGRES_USER=formeleon
|
||||||
|
# - POSTGRES_PASSWORD=formeleon
|
||||||
|
# volumes:
|
||||||
|
# - postgres_data:/var/lib/postgresql/data
|
||||||
|
# ports:
|
||||||
|
# - "5432:5432"
|
||||||
|
# restart: unless-stopped
|
||||||
|
# networks:
|
||||||
|
# - formeleon-network
|
||||||
|
|
||||||
|
# Redis for Session Storage (optional)
|
||||||
|
# redis:
|
||||||
|
# image: redis:7-alpine
|
||||||
|
# container_name: formeleon-redis
|
||||||
|
# ports:
|
||||||
|
# - "6379:6379"
|
||||||
|
# volumes:
|
||||||
|
# - redis_data:/data
|
||||||
|
# restart: unless-stopped
|
||||||
|
# networks:
|
||||||
|
# - formeleon-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
formeleon-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
# volumes:
|
||||||
|
# postgres_data:
|
||||||
|
# redis_data:
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# Module: models.py
|
||||||
|
# Description: SQLAlchemy database models for multi-tenant form management.
|
||||||
|
# Includes Tenant, FormConfig, Submission, and User models.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
Database models for Formeleon.
|
||||||
|
|
||||||
|
This module defines the core data models using Flask-SQLAlchemy:
|
||||||
|
- Tenant: Mandant/Client information with public keys and allowed origins
|
||||||
|
- FormConfig: Dynamic form configurations with JSON structure
|
||||||
|
- Submission: Form submissions with JSON payload data
|
||||||
|
- User: Admin users for the management interface
|
||||||
|
- AuditLog: Tracking of administrative actions
|
||||||
|
|
||||||
|
All models support Flask-Migrate for database migrations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional, Any
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
|
# Initialize SQLAlchemy without binding to a Flask app yet
|
||||||
|
# This will be bound in app.py after configuration is loaded
|
||||||
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(Enum):
|
||||||
|
"""
|
||||||
|
Enumeration of user roles for access control.
|
||||||
|
|
||||||
|
Roles:
|
||||||
|
ADMIN: Full access to all tenants and configurations
|
||||||
|
TENANT_ADMIN: Admin access limited to specific tenant(s)
|
||||||
|
USER: Standard user with form creation/view permissions
|
||||||
|
VIEWER: Read-only access to forms and submissions
|
||||||
|
"""
|
||||||
|
ADMIN = 'admin'
|
||||||
|
TENANT_ADMIN = 'tenant_admin'
|
||||||
|
USER = 'user'
|
||||||
|
VIEWER = 'viewer'
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionStatus(Enum):
|
||||||
|
"""
|
||||||
|
Enumeration of submission processing statuses.
|
||||||
|
|
||||||
|
Statuses:
|
||||||
|
PENDING: Submission received but not yet processed
|
||||||
|
PROCESSED: Submission has been processed successfully
|
||||||
|
FAILED: Submission processing failed
|
||||||
|
SPAM: Submission flagged as spam (reCAPTCHA failed)
|
||||||
|
"""
|
||||||
|
PENDING = 'pending'
|
||||||
|
PROCESSED = 'processed'
|
||||||
|
FAILED = 'failed'
|
||||||
|
SPAM = 'spam'
|
||||||
|
|
||||||
|
|
||||||
|
class TenantStatus(Enum):
|
||||||
|
"""
|
||||||
|
Enumeration of tenant statuses.
|
||||||
|
|
||||||
|
Statuses:
|
||||||
|
ACTIVE: Tenant is active and can receive submissions
|
||||||
|
SUSPENDED: Tenant is temporarily suspended
|
||||||
|
INACTIVE: Tenant is inactive/disabled
|
||||||
|
"""
|
||||||
|
ACTIVE = 'active'
|
||||||
|
SUSPENDED = 'suspended'
|
||||||
|
INACTIVE = 'inactive'
|
||||||
|
|
||||||
|
|
||||||
|
# Association table for many-to-many relationship between User and Tenant
|
||||||
|
user_tenant_association = db.Table(
|
||||||
|
'user_tenant_association',
|
||||||
|
db.Column('user_id', db.String(64), db.ForeignKey('user.id'), primary_key=True),
|
||||||
|
db.Column('tenant_id', db.String(64), db.ForeignKey('tenant.id'), primary_key=True),
|
||||||
|
db.Column('created_at', db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Tenant(db.Model):
|
||||||
|
"""
|
||||||
|
Tenant model representing a client/mandant in the multi-tenant system.
|
||||||
|
|
||||||
|
Each tenant has:
|
||||||
|
- Unique identifier and name
|
||||||
|
- Public API key for form submissions
|
||||||
|
- Allowed origins for CORS/handshake validation
|
||||||
|
- Status and configuration settings
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique tenant identifier
|
||||||
|
name: Display name of the tenant
|
||||||
|
public_key: API key used by client snippets for authentication
|
||||||
|
allowed_origins: Comma-separated list of allowed origin domains
|
||||||
|
status: Current status of the tenant
|
||||||
|
config: Additional JSON configuration
|
||||||
|
created_at: Timestamp when tenant was created
|
||||||
|
updated_at: Timestamp when tenant was last updated
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'tenant'
|
||||||
|
|
||||||
|
id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False)
|
||||||
|
name = db.Column(db.String(255), nullable=False)
|
||||||
|
public_key = db.Column(db.String(128), unique=True, nullable=False)
|
||||||
|
allowed_origins = db.Column(db.Text, nullable=False, default='*')
|
||||||
|
status = db.Column(db.Enum(TenantStatus), nullable=False, default=TenantStatus.ACTIVE)
|
||||||
|
config = db.Column(db.JSON, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
forms = db.relationship('FormConfig', backref='tenant', lazy=True, cascade='all, delete-orphan')
|
||||||
|
submissions = db.relationship('Submission', backref='tenant', lazy=True)
|
||||||
|
users = db.relationship('User', secondary=user_tenant_association, backref='tenants', lazy=True)
|
||||||
|
|
||||||
|
def __init__(self, id: str, name: str, public_key: str, allowed_origins: str = '*',
|
||||||
|
status: TenantStatus = TenantStatus.ACTIVE, config: Optional[dict] = None):
|
||||||
|
"""
|
||||||
|
Initialize a new Tenant instance.
|
||||||
|
|
||||||
|
:param id: Unique tenant identifier
|
||||||
|
:param name: Display name of the tenant
|
||||||
|
:param public_key: API key for client authentication
|
||||||
|
:param allowed_origins: Comma-separated allowed origins
|
||||||
|
:param status: Initial tenant status
|
||||||
|
:param config: Additional configuration as JSON
|
||||||
|
"""
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.public_key = public_key
|
||||||
|
self.allowed_origins = allowed_origins
|
||||||
|
self.status = status
|
||||||
|
self.config = config or {}
|
||||||
|
|
||||||
|
def get_allowed_origins_list(self) -> list:
|
||||||
|
"""
|
||||||
|
Get allowed origins as a list.
|
||||||
|
|
||||||
|
:return: List of allowed origin strings
|
||||||
|
"""
|
||||||
|
return [origin.strip() for origin in self.allowed_origins.split(',') if origin.strip()]
|
||||||
|
|
||||||
|
def is_origin_allowed(self, origin: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a given origin is allowed for this tenant.
|
||||||
|
|
||||||
|
:param origin: Origin to check
|
||||||
|
:return: True if origin is allowed, False otherwise
|
||||||
|
"""
|
||||||
|
allowed = self.get_allowed_origins_list()
|
||||||
|
if '*' in allowed:
|
||||||
|
return True
|
||||||
|
return origin in allowed
|
||||||
|
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if tenant is active.
|
||||||
|
|
||||||
|
:return: True if tenant status is ACTIVE, False otherwise
|
||||||
|
"""
|
||||||
|
return self.status == TenantStatus.ACTIVE
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""
|
||||||
|
Convert tenant to dictionary for API responses.
|
||||||
|
|
||||||
|
:return: Dictionary representation of tenant
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'public_key': self.public_key,
|
||||||
|
'allowed_origins': self.allowed_origins,
|
||||||
|
'status': self.status.value,
|
||||||
|
'config': self.config or {},
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f'<Tenant {self.id} - {self.name}>'
|
||||||
|
|
||||||
|
|
||||||
|
class FormConfig(db.Model):
|
||||||
|
"""
|
||||||
|
Form configuration model storing dynamic form definitions.
|
||||||
|
|
||||||
|
Each form configuration has:
|
||||||
|
- Unique identifier
|
||||||
|
- Reference to tenant
|
||||||
|
- Name and description
|
||||||
|
- JSON structure defining form fields and validation
|
||||||
|
- Status and settings
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique form identifier
|
||||||
|
tenant_id: Foreign key to tenant
|
||||||
|
name: Display name of the form
|
||||||
|
description: Optional description
|
||||||
|
form_structure: JSON defining form fields, types, and validation
|
||||||
|
settings: Additional form settings (email notifications, etc.)
|
||||||
|
is_active: Whether the form is active and accepting submissions
|
||||||
|
created_at: Timestamp when form was created
|
||||||
|
updated_at: Timestamp when form was last updated
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'form_config'
|
||||||
|
|
||||||
|
id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False)
|
||||||
|
tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=False)
|
||||||
|
name = db.Column(db.String(255), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
form_structure = db.Column(db.JSON, nullable=False)
|
||||||
|
settings = db.Column(db.JSON, nullable=True)
|
||||||
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
submissions = db.relationship('Submission', backref='form_config', lazy=True, cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def __init__(self, id: str, tenant_id: str, name: str, form_structure: dict,
|
||||||
|
description: Optional[str] = None, settings: Optional[dict] = None,
|
||||||
|
is_active: bool = True):
|
||||||
|
"""
|
||||||
|
Initialize a new FormConfig instance.
|
||||||
|
|
||||||
|
:param id: Unique form identifier
|
||||||
|
:param tenant_id: Tenant this form belongs to
|
||||||
|
:param name: Display name of the form
|
||||||
|
:param form_structure: JSON defining form fields and structure
|
||||||
|
:param description: Optional description
|
||||||
|
:param settings: Additional form settings
|
||||||
|
:param is_active: Whether form is active
|
||||||
|
"""
|
||||||
|
self.id = id
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.form_structure = form_structure
|
||||||
|
self.settings = settings or {}
|
||||||
|
self.is_active = is_active
|
||||||
|
|
||||||
|
def get_field_definitions(self) -> list:
|
||||||
|
"""
|
||||||
|
Get the list of field definitions from form structure.
|
||||||
|
|
||||||
|
:return: List of field definition dictionaries
|
||||||
|
"""
|
||||||
|
return self.form_structure.get('fields', [])
|
||||||
|
|
||||||
|
def get_field_names(self) -> list:
|
||||||
|
"""
|
||||||
|
Get list of field names from form structure.
|
||||||
|
|
||||||
|
:return: List of field name strings
|
||||||
|
"""
|
||||||
|
return [field.get('name') for field in self.get_field_definitions() if field.get('name')]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""
|
||||||
|
Convert form config to dictionary for API responses.
|
||||||
|
|
||||||
|
:return: Dictionary representation of form config
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'tenant_id': self.tenant_id,
|
||||||
|
'name': self.name,
|
||||||
|
'description': self.description,
|
||||||
|
'form_structure': self.form_structure,
|
||||||
|
'settings': self.settings or {},
|
||||||
|
'is_active': self.is_active,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f'<FormConfig {self.id} - {self.name}>'
|
||||||
|
|
||||||
|
|
||||||
|
class Submission(db.Model):
|
||||||
|
"""
|
||||||
|
Submission model storing form submission data.
|
||||||
|
|
||||||
|
Each submission has:
|
||||||
|
- Unique identifier
|
||||||
|
- Reference to form configuration
|
||||||
|
- JSON payload with submitted data
|
||||||
|
- IP hash for privacy-preserving identification
|
||||||
|
- Status and submission_metadata
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique submission identifier
|
||||||
|
form_config_id: Foreign key to form configuration
|
||||||
|
tenant_id: Foreign key to tenant (denormalized for easier queries)
|
||||||
|
payload: JSON containing submitted form data
|
||||||
|
ip_hash: Hash of submitter's IP address for rate limiting/duplicate detection
|
||||||
|
user_agent: User agent string of submitter
|
||||||
|
referer: Referer header from submission request
|
||||||
|
recaptcha_score: reCAPTCHA score if validation was performed
|
||||||
|
recaptcha_passed: Whether reCAPTCHA validation passed
|
||||||
|
status: Current processing status
|
||||||
|
submission_metadata: Additional metadata (headers, etc.)
|
||||||
|
created_at: Timestamp when submission was created
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'submission'
|
||||||
|
|
||||||
|
id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False)
|
||||||
|
form_config_id = db.Column(db.String(64), db.ForeignKey('form_config.id'), nullable=False)
|
||||||
|
tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=False)
|
||||||
|
payload = db.Column(db.JSON, nullable=False)
|
||||||
|
ip_hash = db.Column(db.String(64), nullable=True)
|
||||||
|
user_agent = db.Column(db.Text, nullable=True)
|
||||||
|
referer = db.Column(db.Text, nullable=True)
|
||||||
|
recaptcha_score = db.Column(db.Float, nullable=True)
|
||||||
|
recaptcha_passed = db.Column(db.Boolean, nullable=True)
|
||||||
|
status = db.Column(db.Enum(SubmissionStatus), nullable=False, default=SubmissionStatus.PENDING)
|
||||||
|
submission_metadata = db.Column(db.JSON, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
|
||||||
|
def __init__(self, id: str, form_config_id: str, tenant_id: str, payload: dict,
|
||||||
|
ip_hash: Optional[str] = None, user_agent: Optional[str] = None,
|
||||||
|
referer: Optional[str] = None, recaptcha_score: Optional[float] = None,
|
||||||
|
recaptcha_passed: Optional[bool] = None, submission_metadata: Optional[dict] = None,
|
||||||
|
status: SubmissionStatus = SubmissionStatus.PENDING):
|
||||||
|
"""
|
||||||
|
Initialize a new Submission instance.
|
||||||
|
|
||||||
|
:param id: Unique submission identifier
|
||||||
|
:param form_config_id: Form configuration this submission belongs to
|
||||||
|
:param tenant_id: Tenant this submission belongs to
|
||||||
|
:param payload: JSON containing submitted form data
|
||||||
|
:param ip_hash: Hash of submitter's IP
|
||||||
|
:param user_agent: User agent string
|
||||||
|
:param referer: Referer header
|
||||||
|
:param recaptcha_score: reCAPTCHA score
|
||||||
|
:param recaptcha_passed: Whether reCAPTCHA passed
|
||||||
|
:param submission_metadata: Additional submission_metadata
|
||||||
|
:param status: Initial submission status
|
||||||
|
"""
|
||||||
|
self.id = id
|
||||||
|
self.form_config_id = form_config_id
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.payload = payload
|
||||||
|
self.ip_hash = ip_hash
|
||||||
|
self.user_agent = user_agent
|
||||||
|
self.referer = referer
|
||||||
|
self.recaptcha_score = recaptcha_score
|
||||||
|
self.recaptcha_passed = recaptcha_passed
|
||||||
|
self.submission_metadata = submission_metadata or {}
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def get_field_value(self, field_name: str) -> Any:
|
||||||
|
"""
|
||||||
|
Get the value of a specific field from the payload.
|
||||||
|
|
||||||
|
:param field_name: Name of the field to retrieve
|
||||||
|
:return: Value of the field or None if not found
|
||||||
|
"""
|
||||||
|
return self.payload.get(field_name)
|
||||||
|
|
||||||
|
def mark_as_processed(self) -> None:
|
||||||
|
"""
|
||||||
|
Mark submission as processed.
|
||||||
|
"""
|
||||||
|
self.status = SubmissionStatus.PROCESSED
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def mark_as_spam(self) -> None:
|
||||||
|
"""
|
||||||
|
Mark submission as spam.
|
||||||
|
"""
|
||||||
|
self.status = SubmissionStatus.SPAM
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def mark_as_failed(self) -> None:
|
||||||
|
"""
|
||||||
|
Mark submission as failed.
|
||||||
|
"""
|
||||||
|
self.status = SubmissionStatus.FAILED
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""
|
||||||
|
Convert submission to dictionary for API responses.
|
||||||
|
|
||||||
|
:return: Dictionary representation of submission
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'form_config_id': self.form_config_id,
|
||||||
|
'tenant_id': self.tenant_id,
|
||||||
|
'payload': self.payload,
|
||||||
|
'ip_hash': self.ip_hash,
|
||||||
|
'user_agent': self.user_agent,
|
||||||
|
'referer': self.referer,
|
||||||
|
'recaptcha_score': self.recaptcha_score,
|
||||||
|
'recaptcha_passed': self.recaptcha_passed,
|
||||||
|
'status': self.status.value,
|
||||||
|
'submission_metadata': self.submission_metadata or {},
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f'<Submission {self.id} - {self.form_config_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
"""
|
||||||
|
User model for authentication and authorization.
|
||||||
|
|
||||||
|
Users authenticate via Zitadel OIDC in production, with a dev mode bypass.
|
||||||
|
Each user has:
|
||||||
|
- Unique identifier (from OIDC or dev mode)
|
||||||
|
- Email and name
|
||||||
|
- Role for access control
|
||||||
|
- Session management fields
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique user identifier
|
||||||
|
email: User email address
|
||||||
|
name: Display name
|
||||||
|
role: User role for access control
|
||||||
|
sub: OIDC subject identifier (from Zitadel)
|
||||||
|
last_login: Timestamp of last successful login
|
||||||
|
created_at: Timestamp when user was created
|
||||||
|
updated_at: Timestamp when user was last updated
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'user'
|
||||||
|
|
||||||
|
id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False)
|
||||||
|
email = db.Column(db.String(255), unique=True, nullable=False)
|
||||||
|
name = db.Column(db.String(255), nullable=False)
|
||||||
|
role = db.Column(db.Enum(UserRole), nullable=False, default=UserRole.USER)
|
||||||
|
sub = db.Column(db.String(255), unique=True, nullable=True)
|
||||||
|
last_login = db.Column(db.DateTime, nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
updated_at = db.Column(
|
||||||
|
db.DateTime,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, id: str, email: str, name: str, role: UserRole = UserRole.USER,
|
||||||
|
sub: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize a new User instance.
|
||||||
|
|
||||||
|
:param id: Unique user identifier
|
||||||
|
:param email: User email address
|
||||||
|
:param name: Display name
|
||||||
|
:param role: User role
|
||||||
|
:param sub: OIDC subject identifier
|
||||||
|
"""
|
||||||
|
self.id = id
|
||||||
|
self.email = email
|
||||||
|
self.name = name
|
||||||
|
self.role = role
|
||||||
|
self.sub = sub
|
||||||
|
|
||||||
|
def update_last_login(self) -> None:
|
||||||
|
"""
|
||||||
|
Update the last login timestamp to now.
|
||||||
|
"""
|
||||||
|
self.last_login = datetime.now(timezone.utc)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def has_role(self, role: UserRole) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user has a specific role or higher privilege.
|
||||||
|
|
||||||
|
:param role: Role to check
|
||||||
|
:return: True if user has the role or a higher-privilege role
|
||||||
|
"""
|
||||||
|
role_hierarchy = {
|
||||||
|
UserRole.ADMIN: 4,
|
||||||
|
UserRole.TENANT_ADMIN: 3,
|
||||||
|
UserRole.USER: 2,
|
||||||
|
UserRole.VIEWER: 1
|
||||||
|
}
|
||||||
|
return role_hierarchy.get(self.role, 0) >= role_hierarchy.get(role, 0)
|
||||||
|
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user has admin privileges.
|
||||||
|
|
||||||
|
:return: True if user is ADMIN or TENANT_ADMIN
|
||||||
|
"""
|
||||||
|
return self.role in [UserRole.ADMIN, UserRole.TENANT_ADMIN]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""
|
||||||
|
Convert user to dictionary for API responses.
|
||||||
|
|
||||||
|
:return: Dictionary representation of user
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'email': self.email,
|
||||||
|
'name': self.name,
|
||||||
|
'role': self.role.value,
|
||||||
|
'sub': self.sub,
|
||||||
|
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f'<User {self.id} - {self.email} ({self.role.value})>'
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(db.Model):
|
||||||
|
"""
|
||||||
|
Audit log model for tracking administrative actions.
|
||||||
|
|
||||||
|
Each audit log entry records:
|
||||||
|
- Who performed an action
|
||||||
|
- What action was performed
|
||||||
|
- On which resource
|
||||||
|
- With what result
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Unique log entry identifier
|
||||||
|
user_id: User who performed the action (nullable for system actions)
|
||||||
|
tenant_id: Tenant context (nullable for global actions)
|
||||||
|
action: Type of action performed
|
||||||
|
resource_type: Type of resource affected
|
||||||
|
resource_id: Identifier of resource affected
|
||||||
|
details: Additional details about the action
|
||||||
|
ip_address: IP address of requester
|
||||||
|
user_agent: User agent string
|
||||||
|
status: Whether action succeeded or failed
|
||||||
|
created_at: Timestamp when action was logged
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = 'audit_log'
|
||||||
|
|
||||||
|
id = db.Column(db.String(64), primary_key=True, unique=True, nullable=False)
|
||||||
|
user_id = db.Column(db.String(64), db.ForeignKey('user.id'), nullable=True)
|
||||||
|
tenant_id = db.Column(db.String(64), db.ForeignKey('tenant.id'), nullable=True)
|
||||||
|
action = db.Column(db.String(64), nullable=False)
|
||||||
|
resource_type = db.Column(db.String(64), nullable=False)
|
||||||
|
resource_id = db.Column(db.String(64), nullable=True)
|
||||||
|
details = db.Column(db.JSON, nullable=True)
|
||||||
|
ip_address = db.Column(db.String(64), nullable=True)
|
||||||
|
user_agent = db.Column(db.Text, nullable=True)
|
||||||
|
status = db.Column(db.String(16), nullable=False, default='success')
|
||||||
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||||
|
|
||||||
|
def __init__(self, id: str, action: str, resource_type: str,
|
||||||
|
user_id: Optional[str] = None, tenant_id: Optional[str] = None,
|
||||||
|
resource_id: Optional[str] = None, details: Optional[dict] = None,
|
||||||
|
ip_address: Optional[str] = None, user_agent: Optional[str] = None,
|
||||||
|
status: str = 'success'):
|
||||||
|
"""
|
||||||
|
Initialize a new AuditLog instance.
|
||||||
|
|
||||||
|
:param id: Unique log identifier
|
||||||
|
:param action: Type of action performed
|
||||||
|
:param resource_type: Type of resource affected
|
||||||
|
:param user_id: User who performed the action
|
||||||
|
:param tenant_id: Tenant context
|
||||||
|
:param resource_id: Resource identifier
|
||||||
|
:param details: Additional details
|
||||||
|
:param ip_address: Requester IP address
|
||||||
|
:param user_agent: Requester user agent
|
||||||
|
:param status: Action status (success/failure)
|
||||||
|
"""
|
||||||
|
self.id = id
|
||||||
|
self.user_id = user_id
|
||||||
|
self.tenant_id = tenant_id
|
||||||
|
self.action = action
|
||||||
|
self.resource_type = resource_type
|
||||||
|
self.resource_id = resource_id
|
||||||
|
self.details = details or {}
|
||||||
|
self.ip_address = ip_address
|
||||||
|
self.user_agent = user_agent
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def log_action(cls, action: str, resource_type: str, user: Optional[User] = None,
|
||||||
|
tenant: Optional[Tenant] = None, resource_id: Optional[str] = None,
|
||||||
|
details: Optional[dict] = None, request_context: Optional[dict] = None,
|
||||||
|
status: str = 'success') -> 'AuditLog':
|
||||||
|
"""
|
||||||
|
Convenience method to create an audit log entry.
|
||||||
|
|
||||||
|
:param action: Type of action
|
||||||
|
:param resource_type: Type of resource
|
||||||
|
:param user: User performing the action
|
||||||
|
:param tenant: Tenant context
|
||||||
|
:param resource_id: Resource identifier
|
||||||
|
:param details: Additional details
|
||||||
|
:param request_context: Request context (ip_address, user_agent)
|
||||||
|
:param status: Action status
|
||||||
|
:return: Created audit log entry
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
log_entry = cls(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
tenant_id=tenant.id if tenant else None,
|
||||||
|
resource_id=resource_id,
|
||||||
|
details=details,
|
||||||
|
ip_address=request_context.get('ip_address') if request_context else None,
|
||||||
|
user_agent=request_context.get('user_agent') if request_context else None,
|
||||||
|
status=status
|
||||||
|
)
|
||||||
|
db.session.add(log_entry)
|
||||||
|
db.session.commit()
|
||||||
|
return log_entry
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f'<AuditLog {self.id} - {self.action} on {self.resource_type}:{self.resource_id}>'
|
||||||
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# File: requirements.txt
|
||||||
|
# Description: Python dependencies for Formeleon application.
|
||||||
|
# Uses ~= for compatible releases (allows patch updates, blocks minor updates)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Flask Core
|
||||||
|
Flask~=3.0.0
|
||||||
|
Flask-CORS~=4.0.0
|
||||||
|
Flask-SQLAlchemy~=3.1.0
|
||||||
|
Flask-Migrate~=4.0.0
|
||||||
|
Flask-Session~=0.8.0
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# Mindestens 2.0.36 wegen Python 3.13 Typing-Deprecations
|
||||||
|
SQLAlchemy>=2.0.36,<2.1.0
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
Authlib~=1.3.0
|
||||||
|
PyJWT~=2.8.0
|
||||||
|
|
||||||
|
# HTTP requests
|
||||||
|
requests~=2.31.0
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
python-dotenv~=1.0.0
|
||||||
|
|
||||||
|
# Development
|
||||||
|
python-dateutil~=2.8.0
|
||||||
|
|
||||||
|
# Production
|
||||||
|
gunicorn~=22.0.0
|
||||||
|
# gthread ist als Worker-Typ in Python 3.13 out-of-the-box stabil
|
||||||
+758
@@ -0,0 +1,758 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# Module: routes/admin.py
|
||||||
|
# Description: Admin Dashboard routes for form management.
|
||||||
|
# Provides CRUD operations for tenants, forms, and submissions.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
Admin Dashboard routes for Formeleon.
|
||||||
|
|
||||||
|
This module provides the management interface for:
|
||||||
|
- Tenant management (CRUD)
|
||||||
|
- Form configuration (CRUD)
|
||||||
|
- Submission viewing and export
|
||||||
|
- User management
|
||||||
|
- Dashboard analytics
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
GET /admin/ - Dashboard overview
|
||||||
|
GET /admin/tenants/ - List all tenants
|
||||||
|
GET /admin/tenants/<id> - View/edit tenant
|
||||||
|
GET /admin/forms/ - List all forms
|
||||||
|
GET /admin/forms/<id> - View/edit form
|
||||||
|
GET /admin/forms/create - Create new form
|
||||||
|
GET /admin/submissions/ - List submissions
|
||||||
|
GET /admin/submissions/<id> - View submission details
|
||||||
|
GET /admin/users/ - List users
|
||||||
|
GET /admin/users/<id> - View/edit user
|
||||||
|
|
||||||
|
Authentication is handled via the auth middleware or dev mode bypass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, request, redirect, url_for, session, current_app, jsonify
|
||||||
|
from flask import render_template, flash, abort
|
||||||
|
|
||||||
|
from models import db, Tenant, FormConfig, Submission, User, UserRole, TenantStatus, SubmissionStatus
|
||||||
|
from models import AuditLog
|
||||||
|
from services.recaptcha import RecaptchaService
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create admin blueprint
|
||||||
|
admin_bp = Blueprint('admin', __name__, template_folder='../templates', static_folder='../static')
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(func):
|
||||||
|
"""Decorator to require login for admin routes."""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not (session.get('user_id') and session.get('authenticated')):
|
||||||
|
return redirect(url_for('auth.login', next=request.url))
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
wrapper.__name__ = func.__name__
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(func):
|
||||||
|
"""Decorator to require admin privileges."""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not (session.get('user_id') and session.get('authenticated')):
|
||||||
|
return redirect(url_for('auth.login', next=request.url))
|
||||||
|
|
||||||
|
user = User.query.get(session['user_id'])
|
||||||
|
if not user or not user.is_admin():
|
||||||
|
abort(403, description='Admin privileges required')
|
||||||
|
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
wrapper.__name__ = func.__name__
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user() -> Optional[User]:
|
||||||
|
"""Get current authenticated user."""
|
||||||
|
if 'user_id' in session:
|
||||||
|
return User.query.get(session['user_id'])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_tenant() -> Optional[Tenant]:
|
||||||
|
"""Get current tenant from session or user context."""
|
||||||
|
if 'tenant_id' in session:
|
||||||
|
return Tenant.query.get(session['tenant_id'])
|
||||||
|
|
||||||
|
user = get_current_user()
|
||||||
|
if user and user.tenants:
|
||||||
|
return user.tenants[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.before_request
|
||||||
|
def before_request():
|
||||||
|
"""Before request handler for admin routes."""
|
||||||
|
# Ensure user exists in database if authenticated in session
|
||||||
|
if session.get('user_id'):
|
||||||
|
user = User.query.get(session['user_id'])
|
||||||
|
if not user:
|
||||||
|
# Clean up invalid session
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
"""
|
||||||
|
Admin dashboard overview.
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
- Number of tenants
|
||||||
|
- Number of forms
|
||||||
|
- Recent submissions
|
||||||
|
- Quick access links
|
||||||
|
"""
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get statistics
|
||||||
|
tenant_count = Tenant.query.count()
|
||||||
|
form_count = FormConfig.query.count()
|
||||||
|
submission_count = Submission.query.count()
|
||||||
|
|
||||||
|
# Recent submissions
|
||||||
|
recent_submissions = Submission.query.order_by(
|
||||||
|
Submission.created_at.desc()
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
# Recent forms
|
||||||
|
recent_forms = FormConfig.query.order_by(
|
||||||
|
FormConfig.created_at.desc()
|
||||||
|
).limit(5).all()
|
||||||
|
|
||||||
|
# Tenant list
|
||||||
|
tenants = Tenant.query.order_by(Tenant.created_at.desc()).limit(5).all()
|
||||||
|
|
||||||
|
return render_template('admin/dashboard.html',
|
||||||
|
user=user,
|
||||||
|
tenant_count=tenant_count,
|
||||||
|
form_count=form_count,
|
||||||
|
submission_count=submission_count,
|
||||||
|
recent_submissions=recent_submissions,
|
||||||
|
recent_forms=recent_forms,
|
||||||
|
tenants=tenants,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/tenants/')
|
||||||
|
@login_required
|
||||||
|
def list_tenants():
|
||||||
|
"""List all tenants."""
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Filter tenants based on user role
|
||||||
|
if user.role == UserRole.ADMIN:
|
||||||
|
tenants = Tenant.query.order_by(Tenant.created_at.desc()).all()
|
||||||
|
else:
|
||||||
|
# Non-admin users can only see their assigned tenants
|
||||||
|
tenants = user.tenants if user else []
|
||||||
|
|
||||||
|
return render_template('admin/tenants/list.html',
|
||||||
|
user=user,
|
||||||
|
tenants=tenants,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/tenants/create', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def create_tenant():
|
||||||
|
"""Create a new tenant."""
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Validate form data
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
public_key = request.form.get('public_key', '').strip()
|
||||||
|
allowed_origins = request.form.get('allowed_origins', '*').strip()
|
||||||
|
status = request.form.get('status', 'active')
|
||||||
|
|
||||||
|
# Generate tenant ID
|
||||||
|
tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
|
||||||
|
|
||||||
|
# Create tenant
|
||||||
|
tenant = Tenant(
|
||||||
|
id=tenant_id,
|
||||||
|
name=name,
|
||||||
|
public_key=public_key or str(uuid.uuid4()).hex[:32],
|
||||||
|
allowed_origins=allowed_origins,
|
||||||
|
status=TenantStatus(status) if status in [s.value for s in TenantStatus] else TenantStatus.ACTIVE
|
||||||
|
)
|
||||||
|
db.session.add(tenant)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='create',
|
||||||
|
resource_type='tenant',
|
||||||
|
user=user,
|
||||||
|
resource_id=tenant.id,
|
||||||
|
details={'name': name, 'public_key': public_key}
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Tenant created successfully!', 'success')
|
||||||
|
return redirect(url_for('admin.list_tenants'))
|
||||||
|
|
||||||
|
return render_template('admin/tenants/create.html',
|
||||||
|
user=user,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/tenants/<tenant_id>', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def view_tenant(tenant_id):
|
||||||
|
"""View and edit a tenant."""
|
||||||
|
user = get_current_user()
|
||||||
|
tenant = Tenant.query.get(tenant_id)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
abort(404, description='Tenant not found')
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if user.role != UserRole.ADMIN and tenant not in user.tenants:
|
||||||
|
abort(403, description='Access denied')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not user.is_admin():
|
||||||
|
abort(403, description='Admin privileges required to edit tenant')
|
||||||
|
|
||||||
|
# Update tenant
|
||||||
|
tenant.name = request.form.get('name', tenant.name)
|
||||||
|
tenant.public_key = request.form.get('public_key', tenant.public_key)
|
||||||
|
tenant.allowed_origins = request.form.get('allowed_origins', tenant.allowed_origins)
|
||||||
|
|
||||||
|
status = request.form.get('status')
|
||||||
|
if status in [s.value for s in TenantStatus]:
|
||||||
|
tenant.status = TenantStatus(status)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='update',
|
||||||
|
resource_type='tenant',
|
||||||
|
user=user,
|
||||||
|
resource_id=tenant.id,
|
||||||
|
details={'name': tenant.name, 'status': tenant.status.value}
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Tenant updated successfully!', 'success')
|
||||||
|
return redirect(url_for('admin.view_tenant', tenant_id=tenant.id))
|
||||||
|
|
||||||
|
# Get tenant forms
|
||||||
|
forms = FormConfig.query.filter_by(tenant_id=tenant.id).order_by(
|
||||||
|
FormConfig.created_at.desc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return render_template('admin/tenants/view.html',
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
forms=forms,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False),
|
||||||
|
is_admin=user.is_admin())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/tenants/<tenant_id>/delete', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def delete_tenant(tenant_id):
|
||||||
|
"""Delete a tenant."""
|
||||||
|
user = get_current_user()
|
||||||
|
tenant = Tenant.query.get(tenant_id)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
abort(404, description='Tenant not found')
|
||||||
|
|
||||||
|
# Delete tenant (cascade will handle related forms and submissions)
|
||||||
|
db.session.delete(tenant)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='delete',
|
||||||
|
resource_type='tenant',
|
||||||
|
user=user,
|
||||||
|
resource_id=tenant.id,
|
||||||
|
details={'name': tenant.name}
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Tenant deleted successfully!', 'success')
|
||||||
|
return redirect(url_for('admin.list_tenants'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/forms/')
|
||||||
|
@login_required
|
||||||
|
def list_forms():
|
||||||
|
"""List all forms for the current tenant/user."""
|
||||||
|
user = get_current_user()
|
||||||
|
tenant = get_current_tenant()
|
||||||
|
|
||||||
|
# Get forms based on user access
|
||||||
|
if user.role == UserRole.ADMIN:
|
||||||
|
# Admin can see all forms
|
||||||
|
forms = FormConfig.query.join(Tenant).order_by(
|
||||||
|
FormConfig.created_at.desc()
|
||||||
|
).all()
|
||||||
|
elif tenant:
|
||||||
|
# Tenant user sees only their tenant's forms
|
||||||
|
forms = FormConfig.query.filter_by(tenant_id=tenant.id).order_by(
|
||||||
|
FormConfig.created_at.desc()
|
||||||
|
).all()
|
||||||
|
else:
|
||||||
|
forms = []
|
||||||
|
|
||||||
|
return render_template('admin/forms/list.html',
|
||||||
|
user=user,
|
||||||
|
forms=forms,
|
||||||
|
tenant=tenant,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False),
|
||||||
|
is_admin=user.is_admin())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/forms/create', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def create_form():
|
||||||
|
"""Create a new form."""
|
||||||
|
user = get_current_user()
|
||||||
|
tenant = get_current_tenant()
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
flash('No tenant selected. Please select a tenant.', 'error')
|
||||||
|
return redirect(url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Get form data
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
description = request.form.get('description', '').strip()
|
||||||
|
|
||||||
|
# Parse form structure from JSON
|
||||||
|
form_structure_str = request.form.get('form_structure', '{}')
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
form_structure = json.loads(form_structure_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
form_structure = {'version': '1.0', 'fields': []}
|
||||||
|
|
||||||
|
# Parse settings
|
||||||
|
settings_str = request.form.get('settings', '{}')
|
||||||
|
try:
|
||||||
|
settings = json.loads(settings_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
# Generate form ID
|
||||||
|
form_id = f'form_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
|
||||||
|
|
||||||
|
# Create form
|
||||||
|
form = FormConfig(
|
||||||
|
id=form_id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
form_structure=form_structure,
|
||||||
|
settings=settings,
|
||||||
|
is_active=request.form.get('is_active', 'true').lower() == 'true'
|
||||||
|
)
|
||||||
|
db.session.add(form)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='create',
|
||||||
|
resource_type='form',
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
resource_id=form.id,
|
||||||
|
details={'name': name, 'tenant_id': tenant.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Form created successfully!', 'success')
|
||||||
|
return redirect(url_for('admin.view_form', form_id=form.id))
|
||||||
|
|
||||||
|
# Provide default form structure for new forms
|
||||||
|
default_form_structure = {
|
||||||
|
'version': '1.0',
|
||||||
|
'fields': [
|
||||||
|
{
|
||||||
|
'name': 'name',
|
||||||
|
'type': 'text',
|
||||||
|
'label': 'Your Name',
|
||||||
|
'required': True,
|
||||||
|
'validation': {'min_length': 2, 'max_length': 100}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'email',
|
||||||
|
'type': 'email',
|
||||||
|
'label': 'Email Address',
|
||||||
|
'required': True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'message',
|
||||||
|
'type': 'textarea',
|
||||||
|
'label': 'Message',
|
||||||
|
'required': True
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'submit_button': {
|
||||||
|
'text': 'Submit',
|
||||||
|
'style': 'primary'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template('admin/forms/create.html',
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
default_form_structure=default_form_structure,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/forms/<form_id>', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def view_form(form_id):
|
||||||
|
"""View and edit a form."""
|
||||||
|
user = get_current_user()
|
||||||
|
form = FormConfig.query.get(form_id)
|
||||||
|
|
||||||
|
if not form:
|
||||||
|
abort(404, description='Form not found')
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
tenant = Tenant.query.get(form.tenant_id)
|
||||||
|
if not tenant:
|
||||||
|
abort(404, description='Tenant not found')
|
||||||
|
|
||||||
|
if user.role != UserRole.ADMIN and tenant not in user.tenants:
|
||||||
|
abort(403, description='Access denied')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Update form
|
||||||
|
form.name = request.form.get('name', form.name)
|
||||||
|
form.description = request.form.get('description', form.description)
|
||||||
|
form.is_active = request.form.get('is_active', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Update form structure
|
||||||
|
form_structure_str = request.form.get('form_structure', '{}')
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
form.form_structure = json.loads(form_structure_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass # Keep existing structure
|
||||||
|
|
||||||
|
# Update settings
|
||||||
|
settings_str = request.form.get('settings', '{}')
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
form.settings = json.loads(settings_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass # Keep existing settings
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='update',
|
||||||
|
resource_type='form',
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
resource_id=form.id,
|
||||||
|
details={'name': form.name, 'is_active': form.is_active}
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Form updated successfully!', 'success')
|
||||||
|
return redirect(url_for('admin.view_form', form_id=form.id))
|
||||||
|
|
||||||
|
# Get form submissions
|
||||||
|
submissions = Submission.query.filter_by(form_config_id=form.id).order_by(
|
||||||
|
Submission.created_at.desc()
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
return render_template('admin/forms/view.html',
|
||||||
|
user=user,
|
||||||
|
form=form,
|
||||||
|
tenant=tenant,
|
||||||
|
submissions=submissions,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False),
|
||||||
|
is_admin=user.is_admin())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/forms/<form_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete_form(form_id):
|
||||||
|
"""Delete a form."""
|
||||||
|
user = get_current_user()
|
||||||
|
form = FormConfig.query.get(form_id)
|
||||||
|
|
||||||
|
if not form:
|
||||||
|
abort(404, description='Form not found')
|
||||||
|
|
||||||
|
tenant = Tenant.query.get(form.tenant_id)
|
||||||
|
if not tenant:
|
||||||
|
abort(404, description='Tenant not found')
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if user.role != UserRole.ADMIN and tenant not in user.tenants:
|
||||||
|
abort(403, description='Access denied')
|
||||||
|
|
||||||
|
# Delete form (cascade will handle submissions)
|
||||||
|
db.session.delete(form)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='delete',
|
||||||
|
resource_type='form',
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
resource_id=form.id,
|
||||||
|
details={'name': form.name}
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Form deleted successfully!', 'success')
|
||||||
|
return redirect(url_for('admin.list_forms'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/submissions/')
|
||||||
|
@login_required
|
||||||
|
def list_submissions():
|
||||||
|
"""List form submissions."""
|
||||||
|
user = get_current_user()
|
||||||
|
tenant = get_current_tenant()
|
||||||
|
|
||||||
|
# Get submissions based on user access
|
||||||
|
if user.role == UserRole.ADMIN:
|
||||||
|
submissions = Submission.query.join(FormConfig).order_by(
|
||||||
|
Submission.created_at.desc()
|
||||||
|
).all()
|
||||||
|
elif tenant:
|
||||||
|
submissions = Submission.query.filter_by(tenant_id=tenant.id).order_by(
|
||||||
|
Submission.created_at.desc()
|
||||||
|
).all()
|
||||||
|
else:
|
||||||
|
submissions = []
|
||||||
|
|
||||||
|
return render_template('admin/submissions/list.html',
|
||||||
|
user=user,
|
||||||
|
submissions=submissions,
|
||||||
|
tenant=tenant,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False),
|
||||||
|
is_admin=user.is_admin())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/submissions/<submission_id>')
|
||||||
|
@login_required
|
||||||
|
def view_submission(submission_id):
|
||||||
|
"""View submission details."""
|
||||||
|
user = get_current_user()
|
||||||
|
submission = Submission.query.get(submission_id)
|
||||||
|
|
||||||
|
if not submission:
|
||||||
|
abort(404, description='Submission not found')
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
form = FormConfig.query.get(submission.form_config_id)
|
||||||
|
tenant = Tenant.query.get(submission.tenant_id)
|
||||||
|
|
||||||
|
if not form or not tenant:
|
||||||
|
abort(404, description='Related form or tenant not found')
|
||||||
|
|
||||||
|
if user.role != UserRole.ADMIN and tenant not in user.tenants:
|
||||||
|
abort(403, description='Access denied')
|
||||||
|
|
||||||
|
return render_template('admin/submissions/view.html',
|
||||||
|
user=user,
|
||||||
|
submission=submission,
|
||||||
|
form=form,
|
||||||
|
tenant=tenant,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False),
|
||||||
|
is_admin=user.is_admin())
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/submissions/<submission_id>/status', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_submission_status(submission_id):
|
||||||
|
"""Update submission status."""
|
||||||
|
user = get_current_user()
|
||||||
|
submission = Submission.query.get(submission_id)
|
||||||
|
|
||||||
|
if not submission:
|
||||||
|
abort(404, description='Submission not found')
|
||||||
|
|
||||||
|
tenant = Tenant.query.get(submission.tenant_id)
|
||||||
|
if not tenant:
|
||||||
|
abort(404, description='Tenant not found')
|
||||||
|
|
||||||
|
if user.role != UserRole.ADMIN and tenant not in user.tenants:
|
||||||
|
abort(403, description='Access denied')
|
||||||
|
|
||||||
|
status = request.form.get('status')
|
||||||
|
if status in [s.value for s in SubmissionStatus]:
|
||||||
|
submission.status = SubmissionStatus(status)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='update_status',
|
||||||
|
resource_type='submission',
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
resource_id=submission.id,
|
||||||
|
details={'status': status}
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('Submission status updated!', 'success')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.view_submission', submission_id=submission.id))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/users/')
|
||||||
|
@admin_required
|
||||||
|
def list_users():
|
||||||
|
"""List all users (admin only)."""
|
||||||
|
user = get_current_user()
|
||||||
|
users = User.query.order_by(User.created_at.desc()).all()
|
||||||
|
|
||||||
|
return render_template('admin/users/list.html',
|
||||||
|
user=user,
|
||||||
|
users=users,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False))
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
target_user = User.query.get(user_id)
|
||||||
|
|
||||||
|
if not target_user:
|
||||||
|
abort(404, description='User not found')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Update user
|
||||||
|
target_user.email = request.form.get('email', target_user.email)
|
||||||
|
target_user.name = request.form.get('name', target_user.name)
|
||||||
|
|
||||||
|
role = request.form.get('role')
|
||||||
|
if role in [r.value for r in UserRole]:
|
||||||
|
target_user.role = UserRole(role)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='update',
|
||||||
|
resource_type='user',
|
||||||
|
user=user,
|
||||||
|
resource_id=target_user.id,
|
||||||
|
details={'email': target_user.email, 'role': target_user.role.value}
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('User updated successfully!', 'success')
|
||||||
|
return redirect(url_for('admin.view_user', user_id=target_user.id))
|
||||||
|
|
||||||
|
# Get user tenants
|
||||||
|
tenants = target_user.tenants
|
||||||
|
|
||||||
|
return render_template('admin/users/view.html',
|
||||||
|
user=user,
|
||||||
|
target_user=target_user,
|
||||||
|
tenants=tenants,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False),
|
||||||
|
UserRole=UserRole)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/users/<user_id>/delete', methods=['POST'])
|
||||||
|
@admin_required
|
||||||
|
def delete_user(user_id):
|
||||||
|
"""Delete a user (admin only)."""
|
||||||
|
user = get_current_user()
|
||||||
|
target_user = User.query.get(user_id)
|
||||||
|
|
||||||
|
if not target_user:
|
||||||
|
abort(404, description='User not found')
|
||||||
|
|
||||||
|
# Don't allow deleting self
|
||||||
|
if target_user.id == user.id:
|
||||||
|
flash('Cannot delete your own account!', 'error')
|
||||||
|
return redirect(url_for('admin.view_user', user_id=target_user.id))
|
||||||
|
|
||||||
|
db.session.delete(target_user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='delete',
|
||||||
|
resource_type='user',
|
||||||
|
user=user,
|
||||||
|
resource_id=target_user.id,
|
||||||
|
details={'email': target_user.email}
|
||||||
|
)
|
||||||
|
|
||||||
|
flash('User deleted successfully!', 'success')
|
||||||
|
return redirect(url_for('admin.list_users'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/profile')
|
||||||
|
@login_required
|
||||||
|
def profile():
|
||||||
|
"""User profile page."""
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
return render_template('admin/profile.html',
|
||||||
|
user=user,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/api/recaptcha-config')
|
||||||
|
@login_required
|
||||||
|
def get_recaptcha_config():
|
||||||
|
"""Get reCAPTCHA configuration for client-side rendering."""
|
||||||
|
recaptcha_service = RecaptchaService()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'enabled': recaptcha_service.is_enabled(),
|
||||||
|
'site_key': recaptcha_service.get_site_key(),
|
||||||
|
'dev_mode': recaptcha_service.is_dev_mode(),
|
||||||
|
'mock_score': recaptcha_service.get_dev_mode_score() if recaptcha_service.is_dev_mode() else None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/settings')
|
||||||
|
@admin_required
|
||||||
|
def settings():
|
||||||
|
"""Application settings (admin only)."""
|
||||||
|
user = get_current_user()
|
||||||
|
|
||||||
|
# Get system statistics
|
||||||
|
tenant_count = Tenant.query.count()
|
||||||
|
user_count = User.query.count()
|
||||||
|
form_count = FormConfig.query.count()
|
||||||
|
submission_count = Submission.query.count()
|
||||||
|
|
||||||
|
# Get audit logs
|
||||||
|
audit_logs = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(50).all()
|
||||||
|
|
||||||
|
return render_template('admin/settings.html',
|
||||||
|
user=user,
|
||||||
|
tenant_count=tenant_count,
|
||||||
|
user_count=user_count,
|
||||||
|
form_count=form_count,
|
||||||
|
submission_count=submission_count,
|
||||||
|
audit_logs=audit_logs,
|
||||||
|
dev_mode=current_app.config.get('DEV_MODE', False))
|
||||||
+512
@@ -0,0 +1,512 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# 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.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create API blueprint
|
||||||
|
api_bp = Blueprint('api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_origin(origin: str, tenant: Tenant) -> bool:
|
||||||
|
"""
|
||||||
|
Validate that the request origin matches allowed origins for the tenant.
|
||||||
|
|
||||||
|
:param origin: Request origin/referer
|
||||||
|
:param tenant: Tenant object to check against
|
||||||
|
:return: True if origin is allowed, False otherwise
|
||||||
|
"""
|
||||||
|
if not origin:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Remove trailing slash and protocol
|
||||||
|
origin = origin.rstrip('/')
|
||||||
|
|
||||||
|
allowed_origins = tenant.get_allowed_origins_list()
|
||||||
|
|
||||||
|
# Check for wildcard
|
||||||
|
if '*' in allowed_origins:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Exact match
|
||||||
|
if origin in allowed_origins:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if origin matches any allowed domain (with subdomains)
|
||||||
|
for allowed in allowed_origins:
|
||||||
|
if allowed.startswith('*'):
|
||||||
|
# Wildcard subdomain match
|
||||||
|
domain = allowed[1:] # Remove leading *
|
||||||
|
if origin.endswith(domain):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_tenant_by_public_key(public_key: str) -> Optional[Tenant]:
|
||||||
|
"""
|
||||||
|
Get tenant by public key.
|
||||||
|
|
||||||
|
:param public_key: Public API key
|
||||||
|
:return: Tenant object or None
|
||||||
|
"""
|
||||||
|
return Tenant.query.filter_by(public_key=public_key).first()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_ip(ip: str) -> str:
|
||||||
|
"""
|
||||||
|
Create a hash of an IP address for privacy-preserving storage.
|
||||||
|
|
||||||
|
:param ip: IP address string
|
||||||
|
:return: SHA256 hash of the IP
|
||||||
|
"""
|
||||||
|
if not ip:
|
||||||
|
return ''
|
||||||
|
return hashlib.sha256(ip.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@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_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 jsonify({'status': 'ok'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@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():
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
# 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 = get_tenant_by_public_key(public_key)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
logger.warning(f'Handshake failed: Invalid public key {public_key[:8]}...')
|
||||||
|
raise Unauthorized('Invalid public key')
|
||||||
|
|
||||||
|
# Validate origin
|
||||||
|
if not tenant.is_active():
|
||||||
|
raise Forbidden('Tenant is not active')
|
||||||
|
|
||||||
|
if not 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()
|
||||||
|
|
||||||
|
if not form:
|
||||||
|
raise NotFound('Form not found or does not belong to tenant')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@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 = 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 = 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 validate_origin(origin, tenant):
|
||||||
|
logger.warning(f'Submission failed: Invalid origin {origin} for tenant {tenant.id}')
|
||||||
|
raise Forbidden('Origin not allowed')
|
||||||
|
|
||||||
|
# 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')
|
||||||
|
|
||||||
|
# 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')
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
raise BadRequest('No form data provided')
|
||||||
|
|
||||||
|
# Validate reCAPTCHA if enabled
|
||||||
|
recaptcha_score = None
|
||||||
|
recaptcha_passed = None
|
||||||
|
|
||||||
|
if form.settings.get('recaptcha_enabled', True) if form.settings else True:
|
||||||
|
recaptcha_token = payload.get('g-recaptcha-response') or request.headers.get('X-ReCAPTCHA-Token')
|
||||||
|
|
||||||
|
if recaptcha_token:
|
||||||
|
recaptcha_service = RecaptchaService()
|
||||||
|
is_valid, score = recaptcha_service.verify(
|
||||||
|
recaptcha_token,
|
||||||
|
request.remote_addr
|
||||||
|
)
|
||||||
|
recaptcha_score = score
|
||||||
|
recaptcha_passed = is_valid
|
||||||
|
|
||||||
|
if not is_valid:
|
||||||
|
logger.warning(f'Submission rejected: reCAPTCHA failed with score {score}')
|
||||||
|
else:
|
||||||
|
# If no token provided, use dev mode score if in dev mode
|
||||||
|
if current_app.config.get('DEV_MODE', False):
|
||||||
|
recaptcha_score = current_app.config.get('MOCK_RECAPTCHA_SCORE', 0.9)
|
||||||
|
recaptcha_passed = True
|
||||||
|
|
||||||
|
# Create submission
|
||||||
|
submission_id = f'sub_{form_id}_{str(uuid.uuid4())[:8]}'
|
||||||
|
|
||||||
|
submission = Submission(
|
||||||
|
id=submission_id,
|
||||||
|
form_config_id=form.id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
payload=payload,
|
||||||
|
ip_hash=hash_ip(request.remote_addr),
|
||||||
|
user_agent=request.user_agent.string if request.user_agent else None,
|
||||||
|
referer=origin,
|
||||||
|
recaptcha_score=recaptcha_score,
|
||||||
|
recaptcha_passed=recaptcha_passed,
|
||||||
|
submission_metadata={
|
||||||
|
'origin': origin,
|
||||||
|
'user_agent': request.user_agent.string if request.user_agent else None,
|
||||||
|
'content_type': request.content_type
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set status based on reCAPTCHA
|
||||||
|
if recaptcha_passed is False:
|
||||||
|
submission.status = SubmissionStatus.SPAM
|
||||||
|
else:
|
||||||
|
submission.status = SubmissionStatus.PENDING
|
||||||
|
|
||||||
|
db.session.add(submission)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info(f'Form submission: {submission_id} for form {form_id}, tenant {tenant.id}')
|
||||||
|
|
||||||
|
# Prepare response
|
||||||
|
response = {
|
||||||
|
'status': 'success',
|
||||||
|
'submission_id': submission.id,
|
||||||
|
'message': form.settings.get('success_message', 'Form submitted successfully!') if form.settings else 'Form submitted successfully!'
|
||||||
|
}
|
||||||
|
|
||||||
|
# If reCAPTCHA failed, indicate in response
|
||||||
|
if submission.status == SubmissionStatus.SPAM:
|
||||||
|
response['status'] = 'spam'
|
||||||
|
response['message'] = 'Submission flagged as spam'
|
||||||
|
logger.warning(f'Spam submission: {submission_id}')
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
|
||||||
|
@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 = 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]
|
||||||
|
}
|
||||||
|
})
|
||||||
+440
@@ -0,0 +1,440 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# Module: routes/auth.py
|
||||||
|
# Description: Authentication routes for Zitadel OIDC integration.
|
||||||
|
# Provides login, logout, and callback handlers with dev mode bypass.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
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 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 models import db, User, UserRole, Tenant, AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Create auth blueprint
|
||||||
|
auth_bp = Blueprint('auth', __name__, template_folder='../templates')
|
||||||
|
|
||||||
|
|
||||||
|
def is_dev_mode() -> bool:
|
||||||
|
"""Check if application is in development mode."""
|
||||||
|
return current_app.config.get('DEV_MODE', False)
|
||||||
|
|
||||||
|
|
||||||
|
def create_dev_user_session() -> dict:
|
||||||
|
"""
|
||||||
|
Create a development mode user session with fake Zitadel user.
|
||||||
|
|
||||||
|
:return: Dictionary with user session data
|
||||||
|
"""
|
||||||
|
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'),
|
||||||
|
'name': config.get('DEV_USER_NAME', 'Dev Admin'),
|
||||||
|
'role': config.get('DEV_USER_ROLE', 'admin'),
|
||||||
|
'sub': f'dev-{uuid.uuid4()}',
|
||||||
|
'tenant_id': config.get('DEV_TENANT_ID', 'tenant_dev_123'),
|
||||||
|
'dev_mode': True,
|
||||||
|
'authenticated': True
|
||||||
|
}
|
||||||
|
|
||||||
|
return dev_user
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dev_user_exists() -> User:
|
||||||
|
"""
|
||||||
|
Ensure the development user exists in the database.
|
||||||
|
|
||||||
|
:return: User object from database
|
||||||
|
"""
|
||||||
|
config = current_app.config
|
||||||
|
|
||||||
|
user_id = config.get('DEV_USER_ID', 'user_dev_admin_001')
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
role = UserRole.ADMIN if config.get('DEV_USER_ROLE', 'admin') == 'admin' else UserRole.USER
|
||||||
|
user = User(
|
||||||
|
id=user_id,
|
||||||
|
email=config.get('DEV_USER_EMAIL', 'dev@formeleon.local'),
|
||||||
|
name=config.get('DEV_USER_NAME', 'Dev Admin'),
|
||||||
|
role=role,
|
||||||
|
sub=f'dev-{uuid.uuid4()}'
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f'Created dev user: {user.id}')
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dev_tenant_exists() -> Tenant:
|
||||||
|
"""
|
||||||
|
Ensure the development tenant exists in the database.
|
||||||
|
|
||||||
|
:return: Tenant object from database
|
||||||
|
"""
|
||||||
|
config = current_app.config
|
||||||
|
|
||||||
|
tenant_id = config.get('DEV_TENANT_ID', 'tenant_dev_123')
|
||||||
|
tenant = Tenant.query.get(tenant_id)
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
tenant = Tenant(
|
||||||
|
id=tenant_id,
|
||||||
|
name='Development Tenant',
|
||||||
|
public_key=f'dev-key-{uuid.uuid4().hex[:16]}',
|
||||||
|
allowed_origins='*'
|
||||||
|
)
|
||||||
|
db.session.add(tenant)
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f'Created dev tenant: {tenant.id}')
|
||||||
|
|
||||||
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
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 is_dev_mode():
|
||||||
|
dev_user = 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
|
||||||
|
ensure_dev_user_exists()
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
next_url = request.args.get('next', url_for('admin.dashboard'))
|
||||||
|
|
||||||
|
# Development mode: create fake session and redirect
|
||||||
|
if is_dev_mode():
|
||||||
|
dev_user = create_dev_user_session()
|
||||||
|
session.clear()
|
||||||
|
session['user_id'] = dev_user['id']
|
||||||
|
session['user'] = dev_user
|
||||||
|
session['authenticated'] = True
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
# Ensure user and tenant exist in database
|
||||||
|
ensure_dev_user_exists()
|
||||||
|
ensure_dev_tenant_exists()
|
||||||
|
|
||||||
|
logger.info(f'Dev mode login: {dev_user["email"]}')
|
||||||
|
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='login',
|
||||||
|
resource_type='auth',
|
||||||
|
user=User.query.get(dev_user['id']),
|
||||||
|
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']:
|
||||||
|
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
|
||||||
|
session['oauth_next'] = next_url
|
||||||
|
|
||||||
|
# Build authorization URL
|
||||||
|
redirect_uri = current_app.config.get('ZITADEL_REDIRECT_URI',
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@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 is_dev_mode():
|
||||||
|
# In dev mode, shouldn't reach here, but handle gracefully
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
logger.debug(f'OIDC userinfo: {user_info}')
|
||||||
|
|
||||||
|
# Extract user data
|
||||||
|
sub = user_info.get('sub')
|
||||||
|
email = user_info.get('email')
|
||||||
|
name = user_info.get('name', user_info.get('preferred_username', 'Unknown'))
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return jsonify({'error': 'No email in user info'}), 400
|
||||||
|
|
||||||
|
# Create or update user
|
||||||
|
user = User.query.filter_by(sub=sub).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
user_id = f'user_{email.split("@")[0]}_{str(uuid.uuid4())[:8]}'
|
||||||
|
user = User(
|
||||||
|
id=user_id,
|
||||||
|
email=email,
|
||||||
|
name=name,
|
||||||
|
role=UserRole.USER, # Default role for new OIDC users
|
||||||
|
sub=sub
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
logger.info(f'Created new user from OIDC: {user.id}')
|
||||||
|
else:
|
||||||
|
# Update existing user
|
||||||
|
user.name = name
|
||||||
|
user.email = email
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Store in session
|
||||||
|
session.clear()
|
||||||
|
session['user_id'] = user.id
|
||||||
|
session['user'] = {
|
||||||
|
'id': user.id,
|
||||||
|
'email': user.email,
|
||||||
|
'name': user.name,
|
||||||
|
'role': user.role.value,
|
||||||
|
'sub': user.sub,
|
||||||
|
'authenticated': True,
|
||||||
|
'dev_mode': False
|
||||||
|
}
|
||||||
|
session['authenticated'] = True
|
||||||
|
session['access_token'] = token['access_token']
|
||||||
|
session['refresh_token'] = token.get('refresh_token')
|
||||||
|
session['token_expires'] = token.get('expires_at')
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
# Log the login
|
||||||
|
AuditLog.log_action(
|
||||||
|
action='login',
|
||||||
|
resource_type='auth',
|
||||||
|
user=user,
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
current_user = None
|
||||||
|
if 'user_id' in session:
|
||||||
|
current_user = User.query.get(session['user_id'])
|
||||||
|
|
||||||
|
# Clear session
|
||||||
|
session.clear()
|
||||||
|
|
||||||
|
# In production, revoke token with Zitadel
|
||||||
|
if not is_dev_mode() and 'access_token' in session:
|
||||||
|
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'],
|
||||||
|
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 is_dev_mode() else 'oidc'}
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/status')
|
||||||
|
def status():
|
||||||
|
"""
|
||||||
|
Check current authentication status.
|
||||||
|
|
||||||
|
:return: JSON with 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': is_dev_mode()
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'authenticated': False,
|
||||||
|
'dev_mode': is_dev_mode()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/userinfo')
|
||||||
|
def userinfo():
|
||||||
|
"""
|
||||||
|
Get detailed user information.
|
||||||
|
|
||||||
|
Requires authentication.
|
||||||
|
|
||||||
|
:return: JSON with user information
|
||||||
|
"""
|
||||||
|
if not ('user_id' in session and session.get('authenticated')):
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
|
||||||
|
user_id = session['user_id']
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'User not found'}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'user': user.to_dict(),
|
||||||
|
'tenants': [tenant.to_dict() for tenant in user.tenants],
|
||||||
|
'dev_mode': is_dev_mode()
|
||||||
|
})
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Formeleon - Multi-tenant Form Generator
|
||||||
|
# Author: Björn Runden <br@runden.net>
|
||||||
|
# Module: services/recaptcha.py
|
||||||
|
# Description: reCAPTCHA v3 validation service with development mode mocking.
|
||||||
|
# Provides score-based spam protection for form submissions.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
"""
|
||||||
|
reCAPTCHA v3 validation service for Formeleon.
|
||||||
|
|
||||||
|
This module provides:
|
||||||
|
- reCAPTCHA token verification against Google's API
|
||||||
|
- Development mode mocking with configurable scores
|
||||||
|
- Score threshold configuration
|
||||||
|
- Integration with Flask application context
|
||||||
|
|
||||||
|
In development mode (DEV_MODE=true), the service returns mock scores
|
||||||
|
without contacting Google's API, allowing local testing without API keys.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from services.recaptcha import RecaptchaService
|
||||||
|
|
||||||
|
# In a route handler:
|
||||||
|
recaptcha_service = RecaptchaService(current_app._get_current_object())
|
||||||
|
is_valid, score = recaptcha_service.verify(token)
|
||||||
|
|
||||||
|
if is_valid:
|
||||||
|
# Process form submission
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Reject as spam
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RecaptchaService:
|
||||||
|
"""
|
||||||
|
Service for validating reCAPTCHA v3 tokens.
|
||||||
|
|
||||||
|
This service handles:
|
||||||
|
- Token verification with Google's reCAPTCHA API
|
||||||
|
- Development mode mocking
|
||||||
|
- Score threshold evaluation
|
||||||
|
- Error handling and logging
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
app: Flask application instance
|
||||||
|
secret_key: reCAPTCHA secret key from configuration
|
||||||
|
site_key: reCAPTCHA site key from configuration
|
||||||
|
threshold: Minimum score threshold for passing validation
|
||||||
|
mock_score: Score to return in development mode
|
||||||
|
verify_url: Google reCAPTCHA verification endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app=None):
|
||||||
|
"""
|
||||||
|
Initialize the reCAPTCHA service.
|
||||||
|
|
||||||
|
:param app: Flask application instance (optional, can use current_app)
|
||||||
|
"""
|
||||||
|
self.app = app or current_app._get_current_object()
|
||||||
|
self.secret_key = self.app.config.get('RECAPTCHA_SECRET_KEY')
|
||||||
|
self.site_key = self.app.config.get('RECAPTCHA_SITE_KEY')
|
||||||
|
self.threshold = float(self.app.config.get('RECAPTCHA_THRESHOLD', 0.5))
|
||||||
|
self.mock_score = float(self.app.config.get('MOCK_RECAPTCHA_SCORE', 0.9))
|
||||||
|
self.verify_url = self.app.config.get(
|
||||||
|
'RECAPTCHA_VERIFY_URL',
|
||||||
|
'https://www.google.com/recaptcha/api/siteverify'
|
||||||
|
)
|
||||||
|
self.timeout = 5 # seconds for API requests
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if reCAPTCHA is enabled (keys configured).
|
||||||
|
|
||||||
|
:return: True if both secret and site keys are configured
|
||||||
|
"""
|
||||||
|
return (self.secret_key is not None and
|
||||||
|
self.site_key is not None)
|
||||||
|
|
||||||
|
def is_dev_mode(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if application is in development mode.
|
||||||
|
|
||||||
|
:return: True if DEV_MODE is enabled in configuration
|
||||||
|
"""
|
||||||
|
return self.app.config.get('DEV_MODE', False)
|
||||||
|
|
||||||
|
def verify(self, token: str, remote_ip: Optional[str] = None) -> Tuple[bool, Optional[float]]:
|
||||||
|
"""
|
||||||
|
Verify a reCAPTCHA v3 token.
|
||||||
|
|
||||||
|
:param token: reCAPTCHA response token from client
|
||||||
|
:param remote_ip: Optional client IP address for verification
|
||||||
|
:return: Tuple of (is_valid, score) where is_valid indicates if score >= threshold
|
||||||
|
"""
|
||||||
|
if not token:
|
||||||
|
logger.warning('reCAPTCHA verification: No token provided')
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
# Development mode: return mock score
|
||||||
|
if self.is_dev_mode():
|
||||||
|
logger.debug(f'reCAPTCHA dev mode: Returning mock score {self.mock_score}')
|
||||||
|
return self.mock_score >= self.threshold, self.mock_score
|
||||||
|
|
||||||
|
# Production mode: verify with Google API
|
||||||
|
if not self.is_enabled():
|
||||||
|
logger.warning('reCAPTCHA verification: Service not configured (missing keys)')
|
||||||
|
# In production, if not configured, we might want to fail-open or fail-closed
|
||||||
|
# For security, we fail-closed (treat as spam)
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
return self._verify_with_google(token, remote_ip)
|
||||||
|
|
||||||
|
def _verify_with_google(self, token: str, remote_ip: Optional[str] = None) -> Tuple[bool, Optional[float]]:
|
||||||
|
"""
|
||||||
|
Verify token with Google's reCAPTCHA API.
|
||||||
|
|
||||||
|
:param token: reCAPTCHA response token
|
||||||
|
:param remote_ip: Client IP address
|
||||||
|
:return: Tuple of (is_valid, score)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
'secret': self.secret_key,
|
||||||
|
'response': token
|
||||||
|
}
|
||||||
|
if remote_ip:
|
||||||
|
data['remoteip'] = remote_ip
|
||||||
|
|
||||||
|
logger.debug(f'reCAPTCHA: Verifying token with Google API')
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
self.verify_url,
|
||||||
|
data=data,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if not result.get('success', False):
|
||||||
|
error_codes = result.get('error-codes', [])
|
||||||
|
logger.warning(f'reCAPTCHA verification failed: {error_codes}')
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
score = float(result.get('score', 0))
|
||||||
|
action = result.get('action', 'unknown')
|
||||||
|
|
||||||
|
logger.debug(f'reCAPTCHA: Score={score}, Action={action}')
|
||||||
|
|
||||||
|
return score >= self.threshold, score
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error('reCAPTCHA: Verification request timed out')
|
||||||
|
# Fail-safe: allow submission on timeout to avoid blocking legitimate users
|
||||||
|
return True, None
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f'reCAPTCHA: Verification request failed: {e}')
|
||||||
|
# Fail-safe: allow submission on network errors
|
||||||
|
return True, None
|
||||||
|
except (ValueError, TypeError, KeyError) as e:
|
||||||
|
logger.error(f'reCAPTCHA: Invalid response format: {e}')
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def get_site_key(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the reCAPTCHA site key for client-side rendering.
|
||||||
|
|
||||||
|
:return: Site key string
|
||||||
|
"""
|
||||||
|
return self.site_key or ''
|
||||||
|
|
||||||
|
def get_threshold(self) -> float:
|
||||||
|
"""
|
||||||
|
Get the current score threshold.
|
||||||
|
|
||||||
|
:return: Threshold value
|
||||||
|
"""
|
||||||
|
return self.threshold
|
||||||
|
|
||||||
|
def get_dev_mode_score(self) -> float:
|
||||||
|
"""
|
||||||
|
Get the mock score used in development mode.
|
||||||
|
|
||||||
|
:return: Mock score value
|
||||||
|
"""
|
||||||
|
return self.mock_score
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance for convenience (can be used when app context is available)
|
||||||
|
_recaptcha_service: Optional[RecaptchaService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_recaptcha_service() -> RecaptchaService:
|
||||||
|
"""
|
||||||
|
Get the reCAPTCHA service instance.
|
||||||
|
|
||||||
|
Creates a new instance on first call, then returns the same instance.
|
||||||
|
|
||||||
|
:return: RecaptchaService instance
|
||||||
|
"""
|
||||||
|
global _recaptcha_service
|
||||||
|
if _recaptcha_service is None:
|
||||||
|
_recaptcha_service = RecaptchaService()
|
||||||
|
return _recaptcha_service
|
||||||
|
|
||||||
|
|
||||||
|
def reset_recaptcha_service() -> None:
|
||||||
|
"""
|
||||||
|
Reset the reCAPTCHA service singleton.
|
||||||
|
|
||||||
|
Useful for testing or when configuration changes.
|
||||||
|
"""
|
||||||
|
global _recaptcha_service
|
||||||
|
_recaptcha_service = None
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
Formeleon - Multi-tenant Form Generator
|
||||||
|
Author: Björn Runden <br@runden.net>
|
||||||
|
Script: style.css
|
||||||
|
Description: Custom styles for Formeleon admin interface.
|
||||||
|
=============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Global Styles */
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Styles */
|
||||||
|
.card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.125);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge Styles */
|
||||||
|
.badge {
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.35em 0.65em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button Styles */
|
||||||
|
.btn {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-* {
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Control Styles */
|
||||||
|
.form-control, .form-select {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus, .form-select:focus {
|
||||||
|
border-color: #80bdff;
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Styles */
|
||||||
|
.table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dev Mode Banner */
|
||||||
|
.dev-mode-banner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(90deg, #ffc107, #ff8c00);
|
||||||
|
color: #000;
|
||||||
|
padding: 12px 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 -2px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-mode-banner i {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NavBar Styles */
|
||||||
|
.navbar {
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link:hover {
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover, .dropdown-item:focus {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input Group Styles */
|
||||||
|
.input-group-text {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert Styles */
|
||||||
|
.alert {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-heading {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Blocks */
|
||||||
|
code {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
|
.text-truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-5 {
|
||||||
|
padding-top: 3rem !important;
|
||||||
|
padding-bottom: 3rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-4 {
|
||||||
|
font-size: calc(1.525rem + 3.3vw);
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar-collapse {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: static;
|
||||||
|
float: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-mode-banner {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar Styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Builder Specific Styles */
|
||||||
|
.form-builder {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
border: 1px dashed #ced4da;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field:hover {
|
||||||
|
border-color: #80bdff;
|
||||||
|
background-color: rgba(0, 123, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JSON Editor Styles */
|
||||||
|
.json-editor {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badge Colors */
|
||||||
|
.bg-success {
|
||||||
|
background-color: #198754 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-warning {
|
||||||
|
background-color: #ffc107 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-danger {
|
||||||
|
background-color: #dc3545 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-primary {
|
||||||
|
background-color: #0d6efd !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-info {
|
||||||
|
background-color: #0dcaf0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar Styles */
|
||||||
|
.toolbar {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-content {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.125);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination Styles */
|
||||||
|
.pagination {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-item.active .page-link {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link {
|
||||||
|
color: #0d6efd;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
/** =============================================================================
|
||||||
|
* Formeleon - Multi-tenant Form Generator
|
||||||
|
* Author: Björn Runden <br@runden.net>
|
||||||
|
* Script: main.js
|
||||||
|
* Description: Main JavaScript for Formeleon admin interface.
|
||||||
|
* Handles common functionality, AJAX requests, and UI interactions.
|
||||||
|
* =============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Wait for DOM to be ready
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Initialize tooltips
|
||||||
|
$('[data-bs-toggle="tooltip"]').tooltip();
|
||||||
|
|
||||||
|
// Initialize popovers
|
||||||
|
$('[data-bs-toggle="popover"]').popover();
|
||||||
|
|
||||||
|
// Handle auto-dismiss alerts
|
||||||
|
setTimeout(function() {
|
||||||
|
$('.alert').alert('close');
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Form field management (for form builder)
|
||||||
|
initFormBuilder();
|
||||||
|
|
||||||
|
// JSON editor initialization
|
||||||
|
initJsonEditor();
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
initSearch();
|
||||||
|
|
||||||
|
// Confirmation for delete actions
|
||||||
|
initDeleteConfirmation();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize form builder functionality
|
||||||
|
*/
|
||||||
|
function initFormBuilder() {
|
||||||
|
// Add field button
|
||||||
|
$(document).on('click', '.add-field', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const container = $(this).data('container');
|
||||||
|
addFieldToContainer(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove field button
|
||||||
|
$(document).on('click', '.remove-field', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
$(this).closest('.form-field').remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Field type change handler
|
||||||
|
$(document).on('change', '.field-type-select', function() {
|
||||||
|
const fieldDiv = $(this).closest('.form-field');
|
||||||
|
const fieldType = $(this).val();
|
||||||
|
updateFieldOptions(fieldDiv, fieldType);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new field to the form builder
|
||||||
|
* @param {string} container - CSS selector for the container
|
||||||
|
*/
|
||||||
|
function addFieldToContainer(container) {
|
||||||
|
const containerEl = $(container);
|
||||||
|
const fieldCount = containerEl.find('.form-field').length + 1;
|
||||||
|
|
||||||
|
const fieldHtml = `
|
||||||
|
<div class="form-field border p-3 mb-3 rounded">
|
||||||
|
<div class="form-field-header d-flex justify-content-between mb-2">
|
||||||
|
<h5>Field ${fieldCount}</h5>
|
||||||
|
<div class="form-field-actions">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger remove-field">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Field Name</label>
|
||||||
|
<input type="text" class="form-control" name="fields[${fieldCount}][name]" placeholder="field_name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<select class="form-select field-type-select" name="fields[${fieldCount}][type]">
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="textarea">Textarea</option>
|
||||||
|
<option value="select">Select</option>
|
||||||
|
<option value="checkbox">Checkbox</option>
|
||||||
|
<option value="radio">Radio</option>
|
||||||
|
<option value="number">Number</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
<option value="file">File</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Label</label>
|
||||||
|
<input type="text" class="form-control" name="fields[${fieldCount}][label]" placeholder="Field Label">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="form-check form-switch mt-4">
|
||||||
|
<input class="form-check-input" type="checkbox" name="fields[${fieldCount}][required]" value="true">
|
||||||
|
<label class="form-check-label">Required</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Options (for select/radio/checkbox)</label>
|
||||||
|
<input type="text" class="form-control" name="fields[${fieldCount}][options]" placeholder="option1,option2,option3">
|
||||||
|
<div class="form-text">Comma-separated list of options</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
containerEl.append(fieldHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update field options based on field type
|
||||||
|
* @param {jQuery} fieldDiv - The field div element
|
||||||
|
* @param {string} fieldType - The selected field type
|
||||||
|
*/
|
||||||
|
function updateFieldOptions(fieldDiv, fieldType) {
|
||||||
|
const optionsGroup = fieldDiv.find('.field-options');
|
||||||
|
|
||||||
|
// Remove existing options
|
||||||
|
optionsGroup.remove();
|
||||||
|
|
||||||
|
// Add type-specific options
|
||||||
|
switch(fieldType) {
|
||||||
|
case 'select':
|
||||||
|
case 'radio':
|
||||||
|
case 'checkbox':
|
||||||
|
const optionsHtml = `
|
||||||
|
<div class="field-options row mt-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Options</label>
|
||||||
|
<textarea class="form-control" name="fields[][options]" placeholder="option1\noption2\noption3"></textarea>
|
||||||
|
<div class="form-text">One option per line</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
fieldDiv.append(optionsHtml);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
const textareaHtml = `
|
||||||
|
<div class="field-options row mt-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Rows</label>
|
||||||
|
<input type="number" class="form-control" name="fields[][rows]" value="3">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Placeholder</label>
|
||||||
|
<input type="text" class="form-control" name="fields[][placeholder]" placeholder="Enter text here...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
fieldDiv.append(textareaHtml);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// No additional options for basic field types
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize JSON editor for form structure
|
||||||
|
*/
|
||||||
|
function initJsonEditor() {
|
||||||
|
// Pretty print JSON in textarea
|
||||||
|
$('.json-editor').each(function() {
|
||||||
|
const textarea = $(this);
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(textarea.val());
|
||||||
|
textarea.val(JSON.stringify(json, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
// Not valid JSON, leave as is
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format JSON button
|
||||||
|
$(document).on('click', '.format-json', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = $(this).data('target');
|
||||||
|
const textarea = $(target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(textarea.val());
|
||||||
|
textarea.val(JSON.stringify(json, null, 2));
|
||||||
|
} catch (e) {
|
||||||
|
alert('Invalid JSON: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minify JSON button
|
||||||
|
$(document).on('click', '.minify-json', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = $(this).data('target');
|
||||||
|
const textarea = $(target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(textarea.val());
|
||||||
|
textarea.val(JSON.stringify(json));
|
||||||
|
} catch (e) {
|
||||||
|
alert('Invalid JSON: ' + e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize search functionality
|
||||||
|
*/
|
||||||
|
function initSearch() {
|
||||||
|
// Table search
|
||||||
|
$('#searchInput').on('input', function() {
|
||||||
|
const searchTerm = $(this).val().toLowerCase();
|
||||||
|
const table = $(this).closest('.card').find('table tbody tr');
|
||||||
|
|
||||||
|
table.each(function() {
|
||||||
|
const text = $(this).text().toLowerCase();
|
||||||
|
const visible = text.includes(searchTerm);
|
||||||
|
$(this).toggle(visible);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize delete confirmation dialogs
|
||||||
|
*/
|
||||||
|
function initDeleteConfirmation() {
|
||||||
|
// Already handled inline with onsubmit="return confirm(...)"
|
||||||
|
// This is a fallback for more complex confirmations
|
||||||
|
$(document).on('click', '.confirm-delete', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const message = $(this).data('confirm-message') || 'Are you sure you want to delete this item?';
|
||||||
|
const form = $(this).closest('form');
|
||||||
|
|
||||||
|
if (confirm(message)) {
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX helper function
|
||||||
|
* @param {string} url - URL to request
|
||||||
|
* @param {string} method - HTTP method (GET, POST, PUT, DELETE)
|
||||||
|
* @param {object} data - Data to send
|
||||||
|
* @param {function} success - Success callback
|
||||||
|
* @param {function} error - Error callback
|
||||||
|
*/
|
||||||
|
function ajaxRequest(url, method, data, success, error) {
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
method: method,
|
||||||
|
data: data,
|
||||||
|
contentType: 'application/json',
|
||||||
|
dataType: 'json',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrfToken()
|
||||||
|
},
|
||||||
|
success: function(response) {
|
||||||
|
if (typeof success === 'function') {
|
||||||
|
success(response);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr, status, err) {
|
||||||
|
if (typeof error === 'function') {
|
||||||
|
error(xhr, status, err);
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (xhr.responseJSON?.message || err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CSRF token from meta tag or cookie
|
||||||
|
* @return {string} CSRF token
|
||||||
|
*/
|
||||||
|
function getCsrfToken() {
|
||||||
|
return $('meta[name="csrf-token"]').attr('content') ||
|
||||||
|
getCookie('csrftoken') ||
|
||||||
|
getCookie('csrf_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cookie by name
|
||||||
|
* @param {string} name - Cookie name
|
||||||
|
* @return {string} Cookie value or null
|
||||||
|
*/
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show loading spinner
|
||||||
|
* @param {string|jQuery} element - Element or selector to add spinner to
|
||||||
|
*/
|
||||||
|
function showLoading(element) {
|
||||||
|
const el = $(element);
|
||||||
|
el.append('<span class="spinner-border spinner-border-sm ms-2" role="status" aria-hidden="true"></span>');
|
||||||
|
el.attr('disabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide loading spinner
|
||||||
|
* @param {string|jQuery} element - Element or selector to remove spinner from
|
||||||
|
*/
|
||||||
|
function hideLoading(element) {
|
||||||
|
const el = $(element);
|
||||||
|
el.find('.spinner-border').remove();
|
||||||
|
el.removeAttr('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for display
|
||||||
|
* @param {string} dateString - ISO date string
|
||||||
|
* @return {string} Formatted date
|
||||||
|
*/
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes for display
|
||||||
|
* @param {number} bytes - Number of bytes
|
||||||
|
* @return {string} Formatted size
|
||||||
|
*/
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random ID
|
||||||
|
* @param {number} length - Length of ID (default: 8)
|
||||||
|
* @return {string} Random ID
|
||||||
|
*/
|
||||||
|
function generateId(length = 8) {
|
||||||
|
return Math.random().toString(36).substring(2, 2 + length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy text to clipboard
|
||||||
|
* @param {string} text - Text to copy
|
||||||
|
* @param {function} callback - Optional callback
|
||||||
|
*/
|
||||||
|
function copyToClipboard(text, callback) {
|
||||||
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback(true);
|
||||||
|
}
|
||||||
|
}).catch(function(err) {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback(false, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show toast notification
|
||||||
|
* @param {string} message - Message to display
|
||||||
|
* @param {string} type - Type (success, error, warning, info)
|
||||||
|
*/
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const toastId = 'toast-' + Date.now();
|
||||||
|
const toastHtml = `
|
||||||
|
<div id="${toastId}" class="toast align-items-center text-white bg-${type} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">${message}</div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create toast container if it doesn't exist
|
||||||
|
let container = $('.toast-container');
|
||||||
|
if (container.length === 0) {
|
||||||
|
container = $('<div class="toast-container position-fixed bottom-0 end-0 p-3"></div>');
|
||||||
|
$('body').append(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.append(toastHtml);
|
||||||
|
|
||||||
|
const toastElement = $('#' + toastId);
|
||||||
|
const toast = new bootstrap.Toast(toastElement[0], { autohide: true, delay: 3000 });
|
||||||
|
toast.show();
|
||||||
|
|
||||||
|
// Remove toast after it's hidden
|
||||||
|
toastElement.on('hidden.bs.toast', function() {
|
||||||
|
toastElement.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export functions for use in other scripts
|
||||||
|
window.Formeleon = {
|
||||||
|
ajaxRequest: ajaxRequest,
|
||||||
|
showLoading: showLoading,
|
||||||
|
hideLoading: hideLoading,
|
||||||
|
formatDate: formatDate,
|
||||||
|
formatBytes: formatBytes,
|
||||||
|
generateId: generateId,
|
||||||
|
copyToClipboard: copyToClipboard,
|
||||||
|
showToast: showToast,
|
||||||
|
getCsrfToken: getCsrfToken
|
||||||
|
};
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
{# ==============================================================================
|
||||||
|
Formeleon - Multi-tenant Form Generator
|
||||||
|
Author: Björn Runden <br@runden.net>
|
||||||
|
Template: admin/dashboard.html
|
||||||
|
Description: Admin dashboard overview page with statistics and quick access.
|
||||||
|
============================================================================== #}
|
||||||
|
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - Formeleon{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1><i class="bi bi-speedometer2"></i> Dashboard</h1>
|
||||||
|
<p class="text-muted">Welcome, <strong>{{ user.name }}</strong>! | Role: <span class="badge bg-{{ 'primary' if user.role.value == 'admin' else 'secondary' }}">{{ user.role.value }}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Statistics Cards #}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3 col-6 mb-3">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 col-6 mb-3">
|
||||||
|
<div class="card bg-warning text-dark">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-activity display-6"></i>
|
||||||
|
<div class="ms-3">
|
||||||
|
<h3 class="mb-0">100%</h3>
|
||||||
|
<p class="mb-0">Uptime</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{# Recent Submissions #}
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5><i class="bi bi-inbox"></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">
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Form</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>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>{{ 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>
|
||||||
|
</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>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-light">
|
||||||
|
<a href="{{ url_for('admin.list_submissions') }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-list"></i> View All Submissions
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Recent Forms #}
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5><i class="bi bi-file-earmark-text"></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">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Tenant</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for form in recent_forms %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ form.name }}</td>
|
||||||
|
<td>{{ form.tenant.name if form.tenant else 'N/A' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if form.is_active else 'secondary' }}">
|
||||||
|
{{ 'Active' if form.is_active else 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-light">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if user.is_admin() %}
|
||||||
|
{# Tenants Overview #}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5><i class="bi bi-buildings"></i> Tenants Overview</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if tenants %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Forms</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tenant in tenants %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ tenant.name }}</td>
|
||||||
|
<td><code>{{ tenant.id }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if tenant.status.value == 'active' else 'warning' if tenant.status.value == 'suspended' else 'danger' }}">
|
||||||
|
{{ tenant.status.value }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ tenant.forms|length }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('admin.view_tenant', tenant_id=tenant.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 text-center text-muted">
|
||||||
|
<i class="bi bi-buildings display-4"></i>
|
||||||
|
<p>No tenants yet</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-light">
|
||||||
|
<a href="{{ url_for('admin.list_tenants') }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-list"></i> View All Tenants
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('admin.create_tenant') }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-plus"></i> Create Tenant
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.dev-mode-banner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-6 {
|
||||||
|
font-size: calc(1.2525rem + 0.975vw);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
{# ==============================================================================
|
||||||
|
Formeleon - Multi-tenant Form Generator
|
||||||
|
Author: Björn Runden <br@runden.net>
|
||||||
|
Template: admin/forms/list.html
|
||||||
|
Description: List all forms with filtering and management options.
|
||||||
|
============================================================================== #}
|
||||||
|
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Forms - Formeleon{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12 d-flex justify-content-between align-items-center">
|
||||||
|
<h1><i class="bi bi-file-earmark-text"></i> Forms</h1>
|
||||||
|
<div>
|
||||||
|
{% if user %}
|
||||||
|
<a href="{{ url_for('admin.create_form') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus"></i> Create Form
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if forms %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5><i class="bi bi-list"></i> All Forms ({{ forms|length }})</h5>
|
||||||
|
<div class="input-group" style="width: 300px;">
|
||||||
|
<input type="text" class="form-control" id="searchInput" placeholder="Search forms...">
|
||||||
|
<button class="btn btn-outline-secondary" type="button">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Tenant</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Submissions</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for form in forms %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ form.name }}</td>
|
||||||
|
<td>{{ form.tenant.name if form.tenant else 'N/A' }}</td>
|
||||||
|
<td><code>{{ form.id }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if form.is_active else 'secondary' }}">
|
||||||
|
{{ 'Active' if form.is_active else 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ form.submissions|length }}</td>
|
||||||
|
<td>{{ form.created_at.strftime('%Y-%m-%d') if form.created_at else 'N/A' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-outline-secondary" title="View">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if is_admin or (user and form.tenant and user in form.tenant.users) %}
|
||||||
|
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-outline-primary" title="Edit">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<form action="{{ url_for('admin.delete_form', form_id=form.id) }}" method="POST"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this form? This action cannot be undone.');">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="Delete">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-file-earmark-text display-4 text-muted mb-3"></i>
|
||||||
|
<h3>No Forms</h3>
|
||||||
|
<p class="text-muted">No forms have been created yet.</p>
|
||||||
|
{% if user %}
|
||||||
|
<a href="{{ url_for('admin.create_form') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus"></i> Create First Form
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Search functionality
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#searchInput').on('input', function() {
|
||||||
|
const searchTerm = $(this).val().toLowerCase();
|
||||||
|
const table = $(this).closest('.card').find('table tbody tr');
|
||||||
|
|
||||||
|
table.each(function() {
|
||||||
|
const text = $(this).text().toLowerCase();
|
||||||
|
const visible = text.includes(searchTerm);
|
||||||
|
$(this).toggle(visible);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
{# ==============================================================================
|
||||||
|
Formeleon - Multi-tenant Form Generator
|
||||||
|
Author: Björn Runden <br@runden.net>
|
||||||
|
Template: admin/tenants/create.html
|
||||||
|
Description: Form for creating a new tenant.
|
||||||
|
============================================================================== #}
|
||||||
|
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Create Tenant - Formeleon{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1><i class="bi bi-buildings"></i> Create Tenant</h1>
|
||||||
|
<p class="text-muted">Add a new tenant to the system.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5><i class="bi bi-pencil"></i> Tenant Details</h5>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('admin.create_tenant') }}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="name" class="form-label">Tenant Name *</label>
|
||||||
|
<input type="text" class="form-control" id="name" name="name" required placeholder="My Company">
|
||||||
|
<div class="form-text">Unique display name for the tenant</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="public_key" class="form-label">Public Key *</label>
|
||||||
|
<input type="text" class="form-control" id="public_key" name="public_key" required
|
||||||
|
placeholder="auto-generated-if-empty">
|
||||||
|
<div class="form-text">API key used by client snippets</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="allowed_origins" class="form-label">Allowed Origins *</label>
|
||||||
|
<input type="text" class="form-control" id="allowed_origins" name="allowed_origins"
|
||||||
|
value="*" required placeholder="https://example.com,https://other.com">
|
||||||
|
<div class="form-text">Comma-separated list of allowed domains. Use * for all domains.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="status" class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="status" name="status">
|
||||||
|
<option value="active" selected>Active</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Initial tenant status</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer bg-light d-flex justify-content-end">
|
||||||
|
<a href="{{ url_for('admin.list_tenants') }}" class="btn btn-outline-secondary me-2">
|
||||||
|
<i class="bi bi-arrow-left"></i> Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check"></i> Create Tenant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5><i class="bi bi-info-circle"></i> Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-6">Public Key Usage:</dt>
|
||||||
|
<dd class="col-sm-6">Used in client JavaScript snippets for authentication</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-6">Allowed Origins:</dt>
|
||||||
|
<dd class="col-sm-6">CORS and handshake validation</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-6">Status:</dt>
|
||||||
|
<dd class="col-sm-6">Controls whether tenant can receive submissions</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Auto-generate public key if empty
|
||||||
|
$(document).ready(function() {
|
||||||
|
const publicKeyInput = $('#public_key');
|
||||||
|
|
||||||
|
// Generate a random public key if empty on page load
|
||||||
|
if (!publicKeyInput.val().trim()) {
|
||||||
|
publicKeyInput.val('pub_' + Math.random().toString(36).substring(2, 15) +
|
||||||
|
Math.random().toString(36).substring(2, 15));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{# ==============================================================================
|
||||||
|
Formeleon - Multi-tenant Form Generator
|
||||||
|
Author: Björn Runden <br@runden.net>
|
||||||
|
Template: admin/tenants/list.html
|
||||||
|
Description: List all tenants with filtering and pagination.
|
||||||
|
============================================================================== #}
|
||||||
|
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Tenants - Formeleon{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12 d-flex justify-content-between align-items-center">
|
||||||
|
<h1><i class="bi bi-buildings"></i> Tenants</h1>
|
||||||
|
<div>
|
||||||
|
{% if is_admin %}
|
||||||
|
<a href="{{ url_for('admin.create_tenant') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus"></i> Create Tenant
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if tenants %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5><i class="bi bi-list"></i> All Tenants ({{ tenants|length }})</h5>
|
||||||
|
<div class="input-group" style="width: 300px;">
|
||||||
|
<input type="text" class="form-control" id="searchInput" placeholder="Search tenants...">
|
||||||
|
<button class="btn btn-outline-secondary" type="button">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Public Key</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Forms</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tenant in tenants %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ tenant.name }}</td>
|
||||||
|
<td><code>{{ tenant.id }}</code></td>
|
||||||
|
<td><code class="text-truncate" style="max-width: 200px;" title="{{ tenant.public_key }}">{{ tenant.public_key[:16] }}...</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if tenant.status.value == 'active' else 'warning' if tenant.status.value == 'suspended' else 'danger' }}">
|
||||||
|
{{ tenant.status.value }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ tenant.forms|length }}</td>
|
||||||
|
<td>{{ tenant.created_at.strftime('%Y-%m-%d') if tenant.created_at else 'N/A' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="{{ url_for('admin.view_tenant', tenant_id=tenant.id) }}" class="btn btn-outline-secondary" title="View">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if is_admin %}
|
||||||
|
<a href="{{ url_for('admin.view_tenant', tenant_id=tenant.id) }}" class="btn btn-outline-primary" title="Edit">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<form action="{{ url_for('admin.delete_tenant', tenant_id=tenant.id) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this tenant? This action cannot be undone.');">
|
||||||
|
<button type="submit" class="btn btn-outline-danger" title="Delete">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center py-5">
|
||||||
|
<i class="bi bi-buildings display-4 text-muted mb-3"></i>
|
||||||
|
<h3>No Tenants</h3>
|
||||||
|
<p class="text-muted">No tenants have been created yet.</p>
|
||||||
|
{% if is_admin %}
|
||||||
|
<a href="{{ url_for('admin.create_tenant') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus"></i> Create First Tenant
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
{# ==============================================================================
|
||||||
|
Formeleon - Multi-tenant Form Generator
|
||||||
|
Author: Björn Runden <br@runden.net>
|
||||||
|
Template: admin/tenants/view.html
|
||||||
|
Description: View and edit tenant details with associated forms.
|
||||||
|
============================================================================== #}
|
||||||
|
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ tenant.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> {{ tenant.name }}</h1>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
ID: <code>{{ tenant.id }}</code> |
|
||||||
|
Status: <span class="badge bg-{{ 'success' if tenant.status.value == 'active' else 'warning' if tenant.status.value == 'suspended' else 'danger' }}">
|
||||||
|
{{ tenant.status.value }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ url_for('admin.list_tenants') }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Tenants
|
||||||
|
</a>
|
||||||
|
{% if is_admin %}
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#editTenantModal">
|
||||||
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
{# Tenant 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> Tenant Details</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-4">Public Key:</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<code class="text-break">{{ tenant.public_key }}</code>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||||||
|
onclick="copyToClipboard('{{ tenant.public_key }}', function() { alert('Copied to clipboard!'); })">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Allowed Origins:</dt>
|
||||||
|
<dd class="col-sm-8">{{ tenant.allowed_origins }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Created:</dt>
|
||||||
|
<dd class="col-sm-8">{{ tenant.created_at.strftime('%Y-%m-%d %H:%M:%S') if tenant.created_at else 'N/A' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Last Updated:</dt>
|
||||||
|
<dd class="col-sm-8">{{ tenant.updated_at.strftime('%Y-%m-%d %H:%M:%S') if tenant.updated_at else 'N/A' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Configuration:</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
<pre class="mb-0 bg-light p-2 rounded">{{ tenant.config|tojson(indent=2) if tenant.config else '{}' }}</pre>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Tenant Statistics #}
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5><i class="bi bi-graph-up"></i> Statistics</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="card bg-light h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="mb-0">{{ forms|length }}</h3>
|
||||||
|
<p class="text-muted mb-0">Forms</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3 mb-3">
|
||||||
|
<div class="card bg-light h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="mb-0">{{ tenant.submissions|length }}</h3>
|
||||||
|
<p class="text-muted mb-0">Submissions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 mb-3">
|
||||||
|
<div class="card bg-light h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="mb-0">
|
||||||
|
{% if tenant.users %}
|
||||||
|
{{ tenant.users|length }}
|
||||||
|
{% else %}
|
||||||
|
0
|
||||||
|
{% endif %}
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted mb-0">Associated Users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Forms List #}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5><i class="bi bi-file-earmark-text"></i> Forms ({{ forms|length }})</h5>
|
||||||
|
<a href="{{ url_for('admin.create_form') }}?tenant_id={{ tenant.id }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-plus"></i> Create Form
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{% if forms %}
|
||||||
|
<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>Created</th>
|
||||||
|
<th>Submissions</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for form in forms %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ form.name }}</td>
|
||||||
|
<td><code>{{ form.id }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if form.is_active else 'secondary' }}">
|
||||||
|
{{ 'Active' if form.is_active else 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ form.created_at.strftime('%Y-%m-%d') if form.created_at else 'N/A' }}</td>
|
||||||
|
<td>{{ form.submissions|length }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-outline-secondary" title="View">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if is_admin or user in tenant.users %}
|
||||||
|
<a href="{{ url_for('admin.view_form', form_id=form.id) }}" class="btn btn-outline-primary" title="Edit">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-4 text-center text-muted">
|
||||||
|
<i class="bi bi-file-earmark-text display-4"></i>
|
||||||
|
<p>No forms created yet</p>
|
||||||
|
<a href="{{ url_for('admin.create_form') }}?tenant_id={{ tenant.id }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus"></i> Create First Form
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Edit Tenant Modal #}
|
||||||
|
{% if is_admin %}
|
||||||
|
<div class="modal fade" id="editTenantModal" tabindex="-1" aria-labelledby="editTenantModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="editTenantModalLabel"><i class="bi bi-pencil"></i> Edit Tenant</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('admin.view_tenant', tenant_id=tenant.id) }}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="edit_name" class="form-label">Tenant Name *</label>
|
||||||
|
<input type="text" class="form-control" id="edit_name" name="name"
|
||||||
|
value="{{ tenant.name }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="edit_public_key" class="form-label">Public Key *</label>
|
||||||
|
<input type="text" class="form-control" id="edit_public_key" name="public_key"
|
||||||
|
value="{{ tenant.public_key }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="edit_allowed_origins" class="form-label">Allowed Origins *</label>
|
||||||
|
<input type="text" class="form-control" id="edit_allowed_origins" name="allowed_origins"
|
||||||
|
value="{{ tenant.allowed_origins }}" required>
|
||||||
|
<div class="form-text">Comma-separated list of allowed domains</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="edit_status" class="form-label">Status</label>
|
||||||
|
<select class="form-select" id="edit_status" name="status">
|
||||||
|
<option value="active" {% if tenant.status.value == 'active' %}selected{% endif %}>Active</option>
|
||||||
|
<option value="suspended" {% if tenant.status.value == 'suspended' %}selected{% endif %}>Suspended</option>
|
||||||
|
<option value="inactive" {% if tenant.status.value == 'inactive' %}selected{% endif %}>Inactive</option>
|
||||||
|
</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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Copy to clipboard function
|
||||||
|
function copyToClipboard(text, callback) {
|
||||||
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error('Failed to copy: ', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{# ==============================================================================
|
||||||
|
Formeleon - Multi-tenant Form Generator
|
||||||
|
Author: Björn Runden <br@runden.net>
|
||||||
|
Template: base.html
|
||||||
|
Description: Base template for all HTML pages. Includes Bootstrap 5, jQuery, and common styles.
|
||||||
|
============================================================================== #}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<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">
|
||||||
|
|
||||||
|
{# Bootstrap Icons #}
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
||||||
|
|
||||||
|
{# Custom CSS #}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||||
|
|
||||||
|
{# Font Awesome for additional icons #}
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{# Navigation #}
|
||||||
|
{% include 'navbar.html' %}
|
||||||
|
|
||||||
|
{# Main Content #}
|
||||||
|
<main class="container-fluid">
|
||||||
|
{% block messages %}
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="mt-3">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{# Dev Mode Banner #}
|
||||||
|
{% if dev_mode %}
|
||||||
|
<div class="dev-mode-banner">
|
||||||
|
<i class="bi bi-bug-fill"></i> DEV MODE ENABLED
|
||||||
|
<span class="float-end">
|
||||||
|
User: {{ user.name if user else 'Unknown' }} |
|
||||||
|
Role: {{ user.role.value if user and user.role else 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# jQuery #}
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
||||||
|
|
||||||
|
{# Bootstrap 5 JS Bundle #}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
{# Custom JS #}
|
||||||
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
{# ==============================================================================
|
||||||
|
Formeleon - Multi-tenant Form Generator
|
||||||
|
Author: Björn Runden <br@runden.net>
|
||||||
|
Template: index.html
|
||||||
|
Description: Simple welcome page for root URL.
|
||||||
|
============================================================================== #}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<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>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||||
|
padding: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-card h1 {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-card p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list i {
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="welcome-container">
|
||||||
|
<div class="welcome-card text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="bi bi-file-earmark-text-fill" style="font-size: 4rem; color: #667eea;"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Welcome to Formeleon</h1>
|
||||||
|
<p class="lead">Multi-tenant Form Generator with Bot Protection</p>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<ul class="feature-list text-start">
|
||||||
|
<li><i class="bi bi-check-circle-fill"></i> Dynamic Form Generation</li>
|
||||||
|
<li><i class="bi bi-check-circle-fill"></i> Multi-Tenant Support</li>
|
||||||
|
<li><i class="bi bi-check-circle-fill"></i> reCAPTCHA v3 Protection</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<ul class="feature-list text-start">
|
||||||
|
<li><i class="bi bi-check-circle-fill"></i> Zitadel OIDC Integration</li>
|
||||||
|
<li><i class="bi bi-check-circle-fill"></i> RESTful API</li>
|
||||||
|
<li><i class="bi bi-check-circle-fill"></i> Easy Embedding</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
<a href="/admin/" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-speedometer2"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/admin/forms/" class="btn btn-outline-secondary btn-lg">
|
||||||
|
<i class="bi bi-file-earmark-text"></i> Manage Forms
|
||||||
|
</a>
|
||||||
|
<a href="/api/v1/status" class="btn btn-outline-info btn-lg">
|
||||||
|
<i class="bi bi-check-circle"></i> API Status
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if dev_mode %}
|
||||||
|
<div class="mt-4 p-3 bg-warning bg-opacity-10 rounded">
|
||||||
|
<i class="bi bi-bug-fill text-warning"></i>
|
||||||
|
<strong>Development Mode</strong> is enabled
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{# ==============================================================================
|
||||||
|
Formeleon - Multi-tenant Form Generator
|
||||||
|
Author: Björn Runden <br@runden.net>
|
||||||
|
Template: navbar.html
|
||||||
|
Description: Navigation bar template with responsive design.
|
||||||
|
============================================================================== #}
|
||||||
|
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark" style="margin-bottom: 20px;">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('admin.dashboard') }}">
|
||||||
|
<i class="bi bi-file-earmark-text-fill"></i> Formeleon
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
{# Dashboard #}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin.dashboard') }}">
|
||||||
|
<i class="bi bi-speedometer2"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{# Tenants #}
|
||||||
|
{% 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
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.list_tenants') }}">All Tenants</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.create_tenant') }}">Create Tenant</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# 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
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.list_forms') }}">All Forms</a></li>
|
||||||
|
{% if user %}
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.create_form') }}">Create Form</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{# Submissions #}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('admin.list_submissions') }}">
|
||||||
|
<i class="bi bi-inbox"></i> Submissions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{# Users (Admin only) #}
|
||||||
|
{% 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
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.list_users') }}">All Users</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Settings #}
|
||||||
|
{% 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
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{# Right-side navigation #}
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
{% if user %}
|
||||||
|
{# User dropdown #}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-person-circle"></i> {{ user.name }}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('admin.profile') }}"><i class="bi bi-person"></i> Profile</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"><i class="bi bi-box-arrow-right"></i> Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('auth.login') }}">
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i> Login
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
Reference in new issue
Block a user