made multi tenant

This commit is contained in:
2025-06-19 14:25:42 -04:00
parent f041266ac3
commit 705f2859fa
11 changed files with 473 additions and 157 deletions

View File

@@ -1,34 +1,43 @@
from flask import Flask, jsonify, request, send_file, render_template, flash, Response from flask import Flask, jsonify, request, send_file, render_template, flash, Response, redirect
import os import os
import base64 import base64
from routes.admin import admin_bp
from routes.api import api_bp
import database import database
import qrcode import qrcode
import barcode import barcode
from barcode.writer import ImageWriter from barcode.writer import ImageWriter
from io import BytesIO from io import BytesIO
import shutil import shutil
from werkzeug.routing import BaseConverter
app = Flask(__name__) app = Flask(__name__)
# Set DATA_FOLDER to the absolute path of the data directory inside src # Set DATA_FOLDER to the absolute path of the data directory inside src
app.config['DATA_FOLDER'] = os.path.join(os.path.dirname(__file__), 'data') app.config['DATA_FOLDER'] = os.path.join(os.path.dirname(__file__), 'data')
app.config['SECRET_KEY'] = 'dev-key-for-demo-only' app.config['SECRET_KEY'] = 'dev-key-for-demo-only'
# Register blueprints # Custom converter for tenant IDs
app.register_blueprint(admin_bp) class TenantConverter(BaseConverter):
app.register_blueprint(api_bp) regex = '[a-zA-Z0-9_-]+'
app.url_map.converters['tenant'] = TenantConverter
# Load product data from database # Load product data from database
def load_products(): def load_products(tenant_id=None):
return database.get_all_products() return database.get_all_products(tenant_id)
@app.route('/') @app.route('/')
def index(): def index():
return render_template('index.html') # Show a tenant selection page or redirect to default
tenants = database.get_all_tenants()
return render_template('tenant_selection.html', tenants=tenants)
def check_basic_auth(auth_header): @app.route('/<tenant:tenant_id>/')
"""Validate Basic authentication credentials""" def tenant_index(tenant_id):
# Auto-create tenant if it doesn't exist
tenant = database.get_or_create_tenant(tenant_id)
return render_template('index.html', tenant=tenant)
def check_basic_auth(auth_header, tenant_id):
"""Validate Basic authentication credentials for a tenant"""
if not auth_header or not auth_header.startswith('Basic '): if not auth_header or not auth_header.startswith('Basic '):
return False return False
@@ -37,27 +46,27 @@ def check_basic_auth(auth_header):
credentials = base64.b64decode(auth_header[6:]).decode('utf-8') credentials = base64.b64decode(auth_header[6:]).decode('utf-8')
username, password = credentials.split(':', 1) username, password = credentials.split(':', 1)
# Simple hardcoded credentials - in production, use secure storage # Get tenant credentials from database
# You can change these credentials as needed tenant = database.get_or_create_tenant(tenant_id)
if username == 'admin' and password == 'knox123': if username == tenant['username'] and password == tenant['password']:
return True return True
except Exception: except Exception:
pass pass
return False return False
@app.route('/login', methods=['GET']) @app.route('/<tenant:tenant_id>/login', methods=['GET'])
def login(): def login(tenant_id):
auth_header = request.headers.get('Authorization') auth_header = request.headers.get('Authorization')
if check_basic_auth(auth_header): if check_basic_auth(auth_header, tenant_id):
return jsonify({"status": "success", "message": "Authentication successful"}), 200 return jsonify({"status": "success", "message": "Authentication successful"}), 200
return jsonify({"error": "Unauthorized"}), 401 return jsonify({"error": "Unauthorized"}), 401
@app.route('/arcontentfields', methods=['GET']) @app.route('/<tenant:tenant_id>/arcontentfields', methods=['GET'])
def get_ar_content_fields(): def get_ar_content_fields(tenant_id):
# Return a fixed set of attributes # Return a fixed set of attributes (tenant_id could be used for custom fields in the future)
fields = [ fields = [
{"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"}, {"fieldName": "_id", "label": "Item ID", "editable": "false", "fieldType": "TEXT"},
{"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"}, {"fieldName": "_price", "label": "Sale Price", "editable": "true", "fieldType": "TEXT"},
@@ -65,10 +74,10 @@ def get_ar_content_fields():
] ]
return jsonify(fields), 200 return jsonify(fields), 200
@app.route('/arinfo', methods=['GET']) @app.route('/<tenant:tenant_id>/arinfo', methods=['GET'])
def get_ar_info(): def get_ar_info(tenant_id):
barcode = request.args.get('barcode') barcode = request.args.get('barcode')
products = load_products() products = load_products(tenant_id)
# Helper function to convert relative image paths to absolute URLs # Helper function to convert relative image paths to absolute URLs
def make_absolute_urls(product_fields): def make_absolute_urls(product_fields):
@@ -77,8 +86,8 @@ def get_ar_info():
if field['fieldName'] == '_image' and field['value']: if field['fieldName'] == '_image' and field['value']:
# If it's already an absolute URL, leave it as is # If it's already an absolute URL, leave it as is
if not field['value'].startswith('http'): if not field['value'].startswith('http'):
# Build absolute URL using request host # Build absolute URL using request host with tenant
field['value'] = f"{request.url_root.rstrip('/')}{field['value']}" field['value'] = f"{request.url_root.rstrip('/')}/{tenant_id}{field['value']}"
return product_fields return product_fields
# If barcode is provided, return specific product # If barcode is provided, return specific product
@@ -99,16 +108,16 @@ def get_ar_info():
response.headers['Access-Control-Allow-Origin'] = '*' response.headers['Access-Control-Allow-Origin'] = '*'
return response, 200 return response, 200
@app.route('/images/<path:filename>', methods=['GET']) @app.route('/<tenant:tenant_id>/images/<path:filename>', methods=['GET'])
def serve_image(filename): def serve_image(tenant_id, filename):
# Log the request for debugging # Log the request for debugging
app.logger.info(f"Image requested: {filename}") app.logger.info(f"Image requested for tenant {tenant_id}: {filename}")
# Extract product ID from filename (e.g., "123456.jpg" -> "123456") # Extract product ID from filename (e.g., "123456.jpg" -> "123456")
product_id = os.path.splitext(filename)[0] product_id = os.path.splitext(filename)[0]
# Get image from database # Get image from database for this tenant
image_data = database.get_product_image(product_id) image_data = database.get_product_image(product_id, tenant_id)
if image_data: if image_data:
image_bytes, mime_type = image_data image_bytes, mime_type = image_data
response = Response(image_bytes, mimetype=mime_type) response = Response(image_bytes, mimetype=mime_type)
@@ -129,8 +138,8 @@ def serve_image(filename):
app.logger.warning(f"Image not found: {filename}") app.logger.warning(f"Image not found: {filename}")
return jsonify({"error": "Image not found"}), 404 return jsonify({"error": "Image not found"}), 404
@app.route('/barcodes/<path:filename>', methods=['GET']) @app.route('/<tenant:tenant_id>/barcodes/<path:filename>', methods=['GET'])
def serve_barcode(filename): def serve_barcode(tenant_id, filename):
# Parse filename to extract product_id and barcode type # Parse filename to extract product_id and barcode type
# Expected format: {product_id}_{type}.png # Expected format: {product_id}_{type}.png
name_parts = os.path.splitext(filename)[0].split('_') name_parts = os.path.splitext(filename)[0].split('_')
@@ -145,9 +154,9 @@ def serve_barcode(filename):
buffer = BytesIO() buffer = BytesIO()
if code_type == 'qr': if code_type == 'qr':
# Generate QR code # Generate QR code with tenant in URL
qr = qrcode.QRCode(version=1, box_size=10, border=5) qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(f'http://{request.host}/arinfo?barcode={product_id}') qr.add_data(f'http://{request.host}/{tenant_id}/arinfo?barcode={product_id}')
qr.make(fit=True) qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white") img = qr.make_image(fill_color="black", back_color="white")
img.save(buffer, format='PNG') img.save(buffer, format='PNG')
@@ -181,6 +190,31 @@ def serve_barcode(filename):
app.logger.error(f"Barcode generation error: {str(e)}") app.logger.error(f"Barcode generation error: {str(e)}")
return jsonify({"error": "Failed to generate barcode"}), 500 return jsonify({"error": "Failed to generate barcode"}), 500
# Import the admin routes directly and register them with tenant support
from routes.admin import (index as admin_index, add_product, edit_product,
delete_product, view_product, generate_barcode,
generate_barcode_page, manage_credentials)
# Register admin routes with tenant prefix
app.add_url_rule('/<tenant:tenant_id>/admin/', 'admin.index', admin_index)
app.add_url_rule('/<tenant:tenant_id>/admin/add', 'admin.add_product', add_product, methods=['GET', 'POST'])
app.add_url_rule('/<tenant:tenant_id>/admin/edit/<product_id>', 'admin.edit_product', edit_product, methods=['GET', 'POST'])
app.add_url_rule('/<tenant:tenant_id>/admin/delete/<product_id>', 'admin.delete_product', delete_product, methods=['POST'])
app.add_url_rule('/<tenant:tenant_id>/admin/view/<product_id>', 'admin.view_product', view_product)
app.add_url_rule('/<tenant:tenant_id>/admin/generate_barcode/<product_id>/<code_type>', 'admin.generate_barcode', generate_barcode)
app.add_url_rule('/<tenant:tenant_id>/admin/generate_barcode_page/<product_id>', 'admin.generate_barcode_page', generate_barcode_page)
app.add_url_rule('/<tenant:tenant_id>/admin/credentials', 'admin.manage_credentials', manage_credentials, methods=['GET', 'POST'])
# Register API routes with tenant prefix
from routes.api import api_index
app.add_url_rule('/<tenant:tenant_id>/api/', 'api.api_index', api_index)
# Catch-all route for admin without tenant - redirect to home
@app.route('/admin/')
@app.route('/admin/<path:path>')
def redirect_admin_to_home(path=None):
return redirect('/')
if __name__ == '__main__': if __name__ == '__main__':
# Initialize database # Initialize database
database.init_database() database.init_database()

View File

@@ -24,38 +24,54 @@ def init_database():
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Create products table with image_data as BLOB # Create tenants table
cursor.execute('''
CREATE TABLE IF NOT EXISTS tenants (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
username TEXT,
password TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Create products table with tenant_id
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS products ( CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY, id TEXT,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
price TEXT, price TEXT,
inventory INTEGER, inventory INTEGER,
image_data BLOB, image_data BLOB,
image_mime_type TEXT, image_mime_type TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, tenant_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
) )
''') ''')
# Create product_fields table for extensible fields # Create product_fields table with tenant_id
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS product_fields ( CREATE TABLE IF NOT EXISTS product_fields (
product_id TEXT, product_id TEXT,
tenant_id TEXT,
field_name TEXT, field_name TEXT,
label TEXT, label TEXT,
value TEXT, value TEXT,
editable TEXT, editable TEXT,
field_type TEXT, field_type TEXT,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE, FOREIGN KEY (product_id, tenant_id) REFERENCES products(id, tenant_id) ON DELETE CASCADE,
PRIMARY KEY (product_id, field_name) PRIMARY KEY (product_id, tenant_id, field_name)
) )
''') ''')
conn.commit() conn.commit()
def migrate_from_json(): def migrate_from_json():
"""Migrate existing JSON data to SQLite""" """Migrate existing JSON data to SQLite with default tenant"""
json_path = os.path.join(os.path.dirname(__file__), 'data', 'products.json') json_path = os.path.join(os.path.dirname(__file__), 'data', 'products.json')
if not os.path.exists(json_path): if not os.path.exists(json_path):
@@ -67,6 +83,13 @@ def migrate_from_json():
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Create default tenant if not exists
default_tenant_id = 'default'
cursor.execute('''
INSERT OR IGNORE INTO tenants (id, name, username, password)
VALUES (?, ?, ?, ?)
''', (default_tenant_id, 'Default', 'admin', 'admin'))
for product_id, fields in products_data.items(): for product_id, fields in products_data.items():
# Extract core fields # Extract core fields
name = '' name = ''
@@ -106,20 +129,21 @@ def migrate_from_json():
} }
image_mime_type = mime_types.get(ext, 'image/jpeg') image_mime_type = mime_types.get(ext, 'image/jpeg')
# Insert into products table # Insert into products table with tenant_id
cursor.execute(''' cursor.execute('''
INSERT OR REPLACE INTO products (id, name, price, inventory, image_data, image_mime_type) INSERT OR REPLACE INTO products (id, tenant_id, name, price, inventory, image_data, image_mime_type)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
''', (product_id, name, price, inventory, image_data, image_mime_type)) ''', (product_id, default_tenant_id, name, price, inventory, image_data, image_mime_type))
# Insert all fields into product_fields table # Insert all fields into product_fields table with tenant_id
for field in fields: for field in fields:
cursor.execute(''' cursor.execute('''
INSERT OR REPLACE INTO product_fields INSERT OR REPLACE INTO product_fields
(product_id, field_name, label, value, editable, field_type) (product_id, tenant_id, field_name, label, value, editable, field_type)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
''', ( ''', (
product_id, product_id,
default_tenant_id,
field['fieldName'], field['fieldName'],
field['label'], field['label'],
field['value'], field['value'],
@@ -129,24 +153,35 @@ def migrate_from_json():
conn.commit() conn.commit()
def get_all_products() -> Dict[str, List[Dict[str, Any]]]: def get_all_products(tenant_id: str = None) -> Dict[str, List[Dict[str, Any]]]:
"""Get all products in the legacy format""" """Get all products for a tenant in the legacy format"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Get all product IDs # Get all product IDs for the tenant
cursor.execute('SELECT id FROM products') if tenant_id:
cursor.execute('SELECT id FROM products WHERE tenant_id = ?', (tenant_id,))
else:
cursor.execute('SELECT id FROM products')
product_ids = [row['id'] for row in cursor.fetchall()] product_ids = [row['id'] for row in cursor.fetchall()]
result = {} result = {}
for product_id in product_ids: for product_id in product_ids:
# Get all fields for this product # Get all fields for this product
cursor.execute(''' if tenant_id:
SELECT field_name, label, value, editable, field_type cursor.execute('''
FROM product_fields SELECT field_name, label, value, editable, field_type
WHERE product_id = ? FROM product_fields
ORDER BY field_name WHERE product_id = ? AND tenant_id = ?
''', (product_id,)) ORDER BY field_name
''', (product_id, tenant_id))
else:
cursor.execute('''
SELECT field_name, label, value, editable, field_type
FROM product_fields
WHERE product_id = ?
ORDER BY field_name
''', (product_id,))
fields = [] fields = []
for row in cursor.fetchall(): for row in cursor.fetchall():
@@ -162,17 +197,17 @@ def get_all_products() -> Dict[str, List[Dict[str, Any]]]:
return result return result
def get_product(product_id: str) -> Optional[List[Dict[str, Any]]]: def get_product(product_id: str, tenant_id: str) -> Optional[List[Dict[str, Any]]]:
"""Get a single product by ID""" """Get a single product by ID and tenant"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT field_name, label, value, editable, field_type SELECT field_name, label, value, editable, field_type
FROM product_fields FROM product_fields
WHERE product_id = ? WHERE product_id = ? AND tenant_id = ?
ORDER BY field_name ORDER BY field_name
''', (product_id,)) ''', (product_id, tenant_id))
fields = [] fields = []
for row in cursor.fetchall(): for row in cursor.fetchall():
@@ -186,8 +221,8 @@ def get_product(product_id: str) -> Optional[List[Dict[str, Any]]]:
return fields if fields else None return fields if fields else None
def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None): def save_product(product_id: str, tenant_id: str, fields: List[Dict[str, Any]], image_data: Optional[bytes] = None, image_mime_type: Optional[str] = None):
"""Save or update a product""" """Save or update a product for a tenant"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -205,7 +240,7 @@ def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Opti
inventory = int(field['value']) if field['value'] else None inventory = int(field['value']) if field['value'] else None
# Check if product exists # Check if product exists
cursor.execute('SELECT id FROM products WHERE id = ?', (product_id,)) cursor.execute('SELECT id FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
exists = cursor.fetchone() is not None exists = cursor.fetchone() is not None
if exists: if exists:
@@ -214,32 +249,33 @@ def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Opti
cursor.execute(''' cursor.execute('''
UPDATE products UPDATE products
SET name = ?, price = ?, inventory = ?, image_data = ?, image_mime_type = ?, updated_at = CURRENT_TIMESTAMP SET name = ?, price = ?, inventory = ?, image_data = ?, image_mime_type = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ? AND tenant_id = ?
''', (name, price, inventory, image_data, image_mime_type, product_id)) ''', (name, price, inventory, image_data, image_mime_type, product_id, tenant_id))
else: else:
cursor.execute(''' cursor.execute('''
UPDATE products UPDATE products
SET name = ?, price = ?, inventory = ?, updated_at = CURRENT_TIMESTAMP SET name = ?, price = ?, inventory = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? WHERE id = ? AND tenant_id = ?
''', (name, price, inventory, product_id)) ''', (name, price, inventory, product_id, tenant_id))
else: else:
# Insert new product # Insert new product
cursor.execute(''' cursor.execute('''
INSERT INTO products (id, name, price, inventory, image_data, image_mime_type) INSERT INTO products (id, tenant_id, name, price, inventory, image_data, image_mime_type)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
''', (product_id, name, price, inventory, image_data, image_mime_type)) ''', (product_id, tenant_id, name, price, inventory, image_data, image_mime_type))
# Delete existing fields # Delete existing fields
cursor.execute('DELETE FROM product_fields WHERE product_id = ?', (product_id,)) cursor.execute('DELETE FROM product_fields WHERE product_id = ? AND tenant_id = ?', (product_id, tenant_id))
# Insert all fields # Insert all fields
for field in fields: for field in fields:
cursor.execute(''' cursor.execute('''
INSERT INTO product_fields INSERT INTO product_fields
(product_id, field_name, label, value, editable, field_type) (product_id, tenant_id, field_name, label, value, editable, field_type)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
''', ( ''', (
product_id, product_id,
tenant_id,
field['fieldName'], field['fieldName'],
field['label'], field['label'],
field['value'], field['value'],
@@ -249,20 +285,81 @@ def save_product(product_id: str, fields: List[Dict[str, Any]], image_data: Opti
conn.commit() conn.commit()
def delete_product(product_id: str): def delete_product(product_id: str, tenant_id: str):
"""Delete a product""" """Delete a product for a tenant"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('DELETE FROM products WHERE id = ?', (product_id,)) cursor.execute('DELETE FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
conn.commit() conn.commit()
def get_product_image(product_id: str) -> Optional[tuple[bytes, str]]: def get_product_image(product_id: str, tenant_id: str) -> Optional[tuple[bytes, str]]:
"""Get product image data and mime type""" """Get product image data and mime type for a tenant"""
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ?', (product_id,)) cursor.execute('SELECT image_data, image_mime_type FROM products WHERE id = ? AND tenant_id = ?', (product_id, tenant_id))
row = cursor.fetchone() row = cursor.fetchone()
if row and row['image_data']: if row and row['image_data']:
return row['image_data'], row['image_mime_type'] return row['image_data'], row['image_mime_type']
return None return None
def get_or_create_tenant(tenant_id: str, username: str = None, password: str = None) -> Dict[str, Any]:
"""Get or create a tenant"""
with get_db() as conn:
cursor = conn.cursor()
# Check if tenant exists
cursor.execute('SELECT id, name, username, password FROM tenants WHERE id = ?', (tenant_id,))
row = cursor.fetchone()
if row:
return {
'id': row['id'],
'name': row['name'],
'username': row['username'],
'password': row['password']
}
else:
# Create new tenant with default credentials
default_username = username or 'admin'
default_password = password or 'admin'
cursor.execute('''
INSERT INTO tenants (id, name, username, password)
VALUES (?, ?, ?, ?)
''', (tenant_id, tenant_id.title(), default_username, default_password))
conn.commit()
return {
'id': tenant_id,
'name': tenant_id.title(),
'username': default_username,
'password': default_password
}
def update_tenant_credentials(tenant_id: str, username: str, password: str):
"""Update tenant credentials"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE tenants
SET username = ?, password = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
''', (username, password, tenant_id))
conn.commit()
def get_all_tenants() -> List[Dict[str, Any]]:
"""Get all tenants"""
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('SELECT id, name, username, created_at FROM tenants ORDER BY created_at DESC')
tenants = []
for row in cursor.fetchall():
tenants.append({
'id': row['id'],
'name': row['name'],
'username': row['username'],
'created_at': row['created_at']
})
return tenants

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file from flask import render_template, request, redirect, url_for, flash, jsonify, send_file
import os import os
import json import json
import uuid import uuid
@@ -19,8 +19,6 @@ except ImportError:
def check_dependencies(): def check_dependencies():
return {'qrcode': False, 'barcode': False, 'pillow': False} return {'qrcode': False, 'barcode': False, 'pillow': False}
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
DATA_FOLDER = os.path.join(os.path.dirname(__file__), '../data') DATA_FOLDER = os.path.join(os.path.dirname(__file__), '../data')
PRODUCTS_FILE = os.path.join(DATA_FOLDER, 'products.json') PRODUCTS_FILE = os.path.join(DATA_FOLDER, 'products.json')
UPLOAD_FOLDER = os.path.join(DATA_FOLDER, 'images') UPLOAD_FOLDER = os.path.join(DATA_FOLDER, 'images')
@@ -30,20 +28,19 @@ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
def allowed_file(filename): def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def load_products(): def load_products(tenant_id):
return database.get_all_products() return database.get_all_products(tenant_id)
def save_products(products): def save_products(products):
# This is now handled by database operations # This is now handled by database operations
pass pass
@admin_bp.route('/') def index(tenant_id):
def index(): products = load_products(tenant_id)
products = load_products() tenant = database.get_or_create_tenant(tenant_id)
return render_template('admin/index.html', products=products) return render_template('admin/index.html', products=products, tenant=tenant)
@admin_bp.route('/add', methods=['GET', 'POST']) def add_product(tenant_id):
def add_product():
if request.method == 'POST': if request.method == 'POST':
# Get form data # Get form data
product_id = request.form.get('product_id') product_id = request.form.get('product_id')
@@ -53,13 +50,13 @@ def add_product():
# Basic validation # Basic validation
if not product_id or not name or not price: if not product_id or not name or not price:
flash('Product ID, Name, and Price are required fields.') flash('Product ID, Name, and Price are required fields.')
return render_template('admin/add_product.html') return render_template('admin/add_product.html', tenant_id=tenant_id)
# Check if product ID already exists # Check if product ID already exists
products = load_products() products = load_products(tenant_id)
if product_id in products: if product_id in products:
flash('Product ID already exists.') flash('Product ID already exists.')
return render_template('admin/add_product.html') return render_template('admin/add_product.html', tenant_id=tenant_id)
# Handle image upload # Handle image upload
image_data = None image_data = None
@@ -93,20 +90,19 @@ def add_product():
] ]
# Save to database # Save to database
database.save_product(product_id, product_data, image_data, image_mime_type) database.save_product(product_id, tenant_id, product_data, image_data, image_mime_type)
flash('Product added successfully!') flash('Product added successfully!')
return redirect(url_for('admin.index')) return redirect(url_for('admin.index', tenant_id=tenant_id))
return render_template('admin/add_product.html') return render_template('admin/add_product.html', tenant_id=tenant_id)
@admin_bp.route('/edit/<product_id>', methods=['GET', 'POST']) def edit_product(tenant_id, product_id):
def edit_product(product_id): products = load_products(tenant_id)
products = load_products()
if product_id not in products: if product_id not in products:
flash('Product not found.') flash('Product not found.')
return redirect(url_for('admin.index')) return redirect(url_for('admin.index', tenant_id=tenant_id))
if request.method == 'POST': if request.method == 'POST':
# Get form data # Get form data
@@ -116,7 +112,7 @@ def edit_product(product_id):
# Basic validation # Basic validation
if not name or not price: if not name or not price:
flash('Name and Price are required fields.') flash('Name and Price are required fields.')
return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id]) return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
# Update name and price # Update name and price
for field in products[product_id]: for field in products[product_id]:
@@ -152,41 +148,38 @@ def edit_product(product_id):
field["value"] = image_path field["value"] = image_path
# Save to database # Save to database
database.save_product(product_id, products[product_id], image_data, image_mime_type) database.save_product(product_id, tenant_id, products[product_id], image_data, image_mime_type)
flash('Product updated successfully!') flash('Product updated successfully!')
return redirect(url_for('admin.index')) return redirect(url_for('admin.index', tenant_id=tenant_id))
return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id]) return render_template('admin/edit_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
@admin_bp.route('/delete/<product_id>', methods=['POST']) def delete_product(tenant_id, product_id):
def delete_product(product_id): products = load_products(tenant_id)
products = load_products()
if product_id not in products: if product_id not in products:
flash('Product not found.') flash('Product not found.')
return redirect(url_for('admin.index')) return redirect(url_for('admin.index', tenant_id=tenant_id))
# Delete product from database (image is stored in DB) # Delete product from database (image is stored in DB)
database.delete_product(product_id) database.delete_product(product_id, tenant_id)
flash('Product deleted successfully!') flash('Product deleted successfully!')
return redirect(url_for('admin.index')) return redirect(url_for('admin.index', tenant_id=tenant_id))
@admin_bp.route('/view/<product_id>') def view_product(tenant_id, product_id):
def view_product(product_id): products = load_products(tenant_id)
products = load_products()
if product_id not in products: if product_id not in products:
flash('Product not found.') flash('Product not found.')
return redirect(url_for('admin.index')) return redirect(url_for('admin.index', tenant_id=tenant_id))
return render_template('admin/view_product.html', product_id=product_id, product=products[product_id]) return render_template('admin/view_product.html', product_id=product_id, product=products[product_id], tenant_id=tenant_id)
@admin_bp.route('/generate_barcode/<product_id>/<code_type>') def generate_barcode(tenant_id, product_id, code_type):
def generate_barcode(product_id, code_type):
"""Redirect to the main barcode generation endpoint""" """Redirect to the main barcode generation endpoint"""
products = load_products() products = load_products(tenant_id)
if product_id not in products: if product_id not in products:
return jsonify({"error": "Product not found"}), 404 return jsonify({"error": "Product not found"}), 404
@@ -200,16 +193,15 @@ def generate_barcode(product_id, code_type):
barcode_type = type_mapping.get(code_type, code_type) barcode_type = type_mapping.get(code_type, code_type)
# Redirect to the main barcode endpoint which generates dynamically # Redirect to the main barcode endpoint which generates dynamically
return redirect(f'/barcodes/{product_id}_{barcode_type}.png') return redirect(f'/{tenant_id}/barcodes/{product_id}_{barcode_type}.png')
@admin_bp.route('/generate_barcode_page/<product_id>') def generate_barcode_page(tenant_id, product_id):
def generate_barcode_page(product_id):
"""Show a page with different barcode options for a product""" """Show a page with different barcode options for a product"""
products = load_products() products = load_products(tenant_id)
if product_id not in products: if product_id not in products:
flash('Product not found.') flash('Product not found.')
return redirect(url_for('admin.index')) return redirect(url_for('admin.index', tenant_id=tenant_id))
# Extract product data # Extract product data
product_data = {} product_data = {}
@@ -234,4 +226,22 @@ def generate_barcode_page(product_id):
return render_template('admin/generate_barcode.html', return render_template('admin/generate_barcode.html',
product_id=product_id, product_id=product_id,
product=product_data, product=product_data,
dependencies=dependency_status) dependencies=dependency_status,
tenant_id=tenant_id)
def manage_credentials(tenant_id):
"""Manage tenant login credentials"""
tenant = database.get_or_create_tenant(tenant_id)
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username and password:
database.update_tenant_credentials(tenant_id, username, password)
flash('Credentials updated successfully!')
return redirect(url_for('admin.index', tenant_id=tenant_id))
else:
flash('Username and password are required.')
return render_template('admin/credentials.html', tenant=tenant, tenant_id=tenant_id)

View File

@@ -1,8 +1,5 @@
# API routes blueprint scaffold # API routes scaffold
from flask import Blueprint, jsonify from flask import jsonify
api_bp = Blueprint('api', __name__, url_prefix='/api') def api_index(tenant_id):
return jsonify({'message': 'API Home', 'tenant': tenant_id})
@api_bp.route('/')
def api_index():
return jsonify({'message': 'API Home'})

View File

@@ -10,7 +10,7 @@
<h2>Add New Product</h2> <h2>Add New Product</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form action="{{ url_for('admin.add_product') }}" method="POST" enctype="multipart/form-data"> <form action="{{ url_for('admin.add_product', tenant_id=tenant_id) }}" method="POST" enctype="multipart/form-data">
<div class="mb-3"> <div class="mb-3">
<label for="product_id" class="form-label">Product ID *</label> <label for="product_id" class="form-label">Product ID *</label>
<div class="input-group"> <div class="input-group">
@@ -47,7 +47,7 @@
</div> </div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('admin.index') }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Add Product</button> <button type="submit" class="btn btn-primary">Add Product</button>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Credentials - {{ tenant.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('admin.index', tenant_id=tenant_id) }}">{{ tenant.name }} Admin</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 ms-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.index', tenant_id=tenant_id) }}">Products</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="{{ url_for('admin.manage_credentials', tenant_id=tenant_id) }}">Credentials</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/">Switch Tenant</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container mt-5">
<h1>Manage Login Credentials</h1>
<p class="text-muted">These credentials will be used for KCAP authentication at <code>/{{ tenant_id }}/login</code></p>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="alert alert-info alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3>Current Credentials</h3>
</div>
<div class="card-body">
<p><strong>Username:</strong> {{ tenant.username }}</p>
<p><strong>Password:</strong> {{ tenant.password }}</p>
<p class="text-muted small">Note: In production, passwords should be encrypted and not displayed.</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h3>Update Credentials</h3>
</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" value="{{ tenant.username }}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Enter new password" required>
</div>
<button type="submit" class="btn btn-primary">Update Credentials</button>
<a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
</div>
</div>
<div class="mt-4">
<h3>Testing Instructions</h3>
<p>To test the login endpoint with these credentials:</p>
<pre class="bg-light p-3"><code>curl -u {{ tenant.username }}:{{ tenant.password }} http://{{ request.host }}/{{ tenant_id }}/login</code></pre>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -10,7 +10,7 @@
<h2>Edit Product</h2> <h2>Edit Product</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form action="{{ url_for('admin.edit_product', product_id=product_id) }}" method="POST" enctype="multipart/form-data"> <form action="{{ url_for('admin.edit_product', tenant_id=tenant_id, product_id=product_id) }}" method="POST" enctype="multipart/form-data">
<div class="mb-3"> <div class="mb-3">
<label for="product_id" class="form-label">Product ID</label> <label for="product_id" class="form-label">Product ID</label>
<input type="text" class="form-control" id="product_id" value="{{ product_id }}" disabled> <input type="text" class="form-control" id="product_id" value="{{ product_id }}" disabled>
@@ -65,7 +65,7 @@
</div> </div>
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<a href="{{ url_for('admin.index') }}" class="btn btn-secondary">Cancel</a> <a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Update Product</button> <button type="submit" class="btn btn-primary">Update Product</button>
</div> </div>
</form> </form>

View File

@@ -8,7 +8,7 @@
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h2>Generate Barcodes for Product: {{ product_id }}</h2> <h2>Generate Barcodes for Product: {{ product_id }}</h2>
<a href="{{ url_for('admin.index') }}" class="btn btn-outline-secondary">Back to Products</a> <a href="{{ url_for('admin.index', tenant_id=tenant_id) }}" class="btn btn-outline-secondary">Back to Products</a>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if not dependencies.qrcode or not dependencies.barcode or not dependencies.pillow %} {% if not dependencies.qrcode or not dependencies.barcode or not dependencies.pillow %}
@@ -38,9 +38,9 @@
<h3>QR Code</h3> <h3>QR Code</h3>
</div> </div>
<div class="card-body text-center"> <div class="card-body text-center">
<img src="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='qrcode') }}" alt="QR Code" class="img-fluid mb-3" style="max-width: 250px;"> <img src="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='qrcode') }}" alt="QR Code" class="img-fluid mb-3" style="max-width: 250px;">
<p class="text-muted">QR Code contains a link to the product AR info.</p> <p class="text-muted">QR Code contains a link to the product AR info.</p>
<a href="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='qrcode') }}" class="btn btn-primary" download="product_{{ product_id }}_qrcode.png">Download QR Code</a> <a href="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='qrcode') }}" class="btn btn-primary" download="product_{{ product_id }}_qrcode.png">Download QR Code</a>
</div> </div>
</div> </div>
</div> </div>
@@ -51,9 +51,9 @@
<h3>EAN-13 Barcode</h3> <h3>EAN-13 Barcode</h3>
</div> </div>
<div class="card-body text-center"> <div class="card-body text-center">
<img src="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='ean13') }}" alt="EAN-13 Barcode" class="img-fluid mb-3" style="max-width: 250px;"> <img src="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='ean13') }}" alt="EAN-13 Barcode" class="img-fluid mb-3" style="max-width: 250px;">
<p class="text-muted">Standard EAN-13 barcode format.</p> <p class="text-muted">Standard EAN-13 barcode format.</p>
<a href="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='ean13') }}" class="btn btn-primary" download="product_{{ product_id }}_ean13.png">Download EAN-13</a> <a href="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='ean13') }}" class="btn btn-primary" download="product_{{ product_id }}_ean13.png">Download EAN-13</a>
</div> </div>
</div> </div>
</div> </div>
@@ -64,9 +64,9 @@
<h3>Code 128 Barcode</h3> <h3>Code 128 Barcode</h3>
</div> </div>
<div class="card-body text-center"> <div class="card-body text-center">
<img src="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='code128') }}" alt="Code 128 Barcode" class="img-fluid mb-3" style="max-width: 250px;"> <img src="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='code128') }}" alt="Code 128 Barcode" class="img-fluid mb-3" style="max-width: 250px;">
<p class="text-muted">High-density alphanumeric barcode.</p> <p class="text-muted">High-density alphanumeric barcode.</p>
<a href="{{ url_for('admin.generate_barcode', product_id=product_id, code_type='code128') }}" class="btn btn-primary" download="product_{{ product_id }}_code128.png">Download Code 128</a> <a href="{{ url_for('admin.generate_barcode', tenant_id=tenant_id, product_id=product_id, code_type='code128') }}" class="btn btn-primary" download="product_{{ product_id }}_code128.png">Download Code 128</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,13 +1,20 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Admin Dashboard - KCAP Demo Server{% endblock %} {% block title %}Admin Dashboard - {{ tenant.name }} - KCAP Demo Server{% endblock %}
{% block content %} {% block content %}
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-12"> <div class="col-md-12">
<div class="alert alert-info">
<strong>Current Tenant:</strong> {{ tenant.name }} (ID: {{ tenant.id }})
<div class="mt-2">
<a href="{{ url_for('admin.manage_credentials', tenant_id=tenant.id) }}" class="btn btn-sm btn-secondary">Manage Credentials</a>
<a href="/" class="btn btn-sm btn-outline-secondary">Switch Tenant</a>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1>Product Management</h1> <h1>Product Management</h1>
<a href="{{ url_for('admin.add_product') }}" class="btn btn-primary">Add New Product</a> <a href="{{ url_for('admin.add_product', tenant_id=tenant.id) }}" class="btn btn-primary">Add New Product</a>
</div> </div>
{% if products %} {% if products %}
@@ -50,8 +57,8 @@
</td> </td>
<td> <td>
<div class="d-flex justify-content-center gap-2"> <div class="d-flex justify-content-center gap-2">
<a href="{{ url_for('admin.edit_product', product_id=product_id) }}" class="btn btn-sm btn-outline-primary">Edit</a> <a href="{{ url_for('admin.edit_product', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-primary">Edit</a>
<a href="{{ url_for('admin.generate_barcode_page', product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</a> <a href="{{ url_for('admin.generate_barcode_page', tenant_id=tenant.id, product_id=product_id) }}" class="btn btn-sm btn-outline-success">Barcodes</a>
<button type="button" class="btn btn-sm btn-outline-danger" <button type="button" class="btn btn-sm btn-outline-danger"
data-bs-toggle="modal" data-bs-target="#deleteModal" data-bs-toggle="modal" data-bs-target="#deleteModal"
data-product-id="{{ product_id }}" data-product-id="{{ product_id }}"
@@ -59,7 +66,7 @@
Delete Delete
</button> </button>
</div> </div>
<form id="delete-form-{{ product_id }}" action="{{ url_for('admin.delete_product', product_id=product_id) }}" method="POST" style="display: none;"> <form id="delete-form-{{ product_id }}" action="{{ url_for('admin.delete_product', tenant_id=tenant.id, product_id=product_id) }}" method="POST" style="display: none;">
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -6,7 +6,7 @@
<div class="row mt-4"> <div class="row mt-4">
<div class="col-md-12"> <div class="col-md-12">
<div class="jumbotron"> <div class="jumbotron">
<h1 class="display-4">KCAP Demo Server</h1> <h1 class="display-4">KCAP Demo Server{% if tenant %} - {{ tenant.name }}{% endif %}</h1>
<p class="lead">A simple Flask API for demonstrating AR content retrieval for barcode scanning applications.</p> <p class="lead">A simple Flask API for demonstrating AR content retrieval for barcode scanning applications.</p>
<hr class="my-4"> <hr class="my-4">
<p>This server simulates the Knox Capture API for AR overlays and includes endpoints for managing product attributes.</p> <p>This server simulates the Knox Capture API for AR overlays and includes endpoints for managing product attributes.</p>
@@ -14,25 +14,29 @@
<h2>API Endpoints</h2> <h2>API Endpoints</h2>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item">
<strong>Login:</strong> <code>GET /login</code> <strong>Login:</strong> <code>GET {% if tenant %}/{{ tenant.id }}{% endif %}/login</code>
<p>Simulates a simple login with a 200 response (no authentication required).</p> <p>Authenticates using Basic Auth with tenant-specific credentials.</p>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>Content Fields:</strong> <code>GET /arcontentfields</code> <strong>Content Fields:</strong> <code>GET {% if tenant %}/{{ tenant.id }}{% endif %}/arcontentfields</code>
<p>Returns a list of available attributes (e.g., item ID, price, image URI).</p> <p>Returns a list of available attributes (e.g., item ID, price, image URI).</p>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>AR Info:</strong> <code>GET /arinfo?barcode=123456</code> <strong>AR Info:</strong> <code>GET {% if tenant %}/{{ tenant.id }}{% endif %}/arinfo?barcode=123456</code>
<p>Returns product details for a given barcode, including image URLs.</p> <p>Returns product details for a given barcode, including image URLs.</p>
</li> </li>
<li class="list-group-item"> <li class="list-group-item">
<strong>Static Image Server:</strong> <code>GET /images/123456.png</code> <strong>Static Image Server:</strong> <code>GET {% if tenant %}/{{ tenant.id }}{% endif %}/images/123456.png</code>
<p>Serves image files from the <code>static/images/</code> directory.</p> <p>Serves product images stored in the database.</p>
</li> </li>
</ul> </ul>
</div> </div>
<div class="mt-4"> <div class="mt-4">
{% if tenant %}
<a href="/{{ tenant.id }}/admin/" class="btn btn-primary btn-lg">Go to Admin Interface</a>
{% else %}
<a href="/admin" class="btn btn-primary btn-lg">Go to Admin Interface</a> <a href="/admin" class="btn btn-primary btn-lg">Go to Admin Interface</a>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Select or Create Tenant - KCAP Demo Server</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1 class="text-center mb-4">KCAP Demo Server - Multi-Tenant</h1>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3>Select an Existing Tenant</h3>
</div>
<div class="card-body">
{% if tenants %}
<div class="list-group">
{% for tenant in tenants %}
<a href="/{{ tenant.id }}/" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ tenant.name }}</h5>
<small>{{ tenant.created_at }}</small>
</div>
<p class="mb-1">Username: {{ tenant.username }}</p>
<small>ID: {{ tenant.id }}</small>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-muted">No tenants exist yet. Create one below!</p>
{% endif %}
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<h3>Create a New Tenant</h3>
</div>
<div class="card-body">
<form id="newTenantForm">
<div class="mb-3">
<label for="tenantId" class="form-label">Tenant ID</label>
<input type="text" class="form-control" id="tenantId" placeholder="e.g., demo-company" required pattern="[a-zA-Z0-9_-]+">
<small class="form-text text-muted">Only letters, numbers, hyphens, and underscores allowed</small>
</div>
<button type="submit" class="btn btn-primary">Create & Enter Tenant</button>
</form>
</div>
</div>
<div class="mt-4 text-center">
<p class="text-muted">
<strong>Note:</strong> When you access a tenant URL directly (e.g., /my-tenant/),
it will be automatically created with default credentials (admin/admin).
</p>
</div>
</div>
</div>
</div>
<script>
document.getElementById('newTenantForm').addEventListener('submit', function(e) {
e.preventDefault();
const tenantId = document.getElementById('tenantId').value;
if (tenantId) {
window.location.href = '/' + tenantId + '/';
}
});
</script>
</body>
</html>