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