12 Commits

Author SHA1 Message Date
145ea68d2b Remove server settings from navigation and add tenant display name support
- Remove server settings link from main navigation bar
- Add separate display name field for tenant creation
- Support uppercase characters in tenant display names while keeping IDs lowercase
- Improve tenant creation UX with clearer field descriptions
2025-10-20 21:44:26 -04:00
fbf62f71b1 Merge pull request #11 from mattintech/feature/default-fields
Add optional default AR fields with proper validation
2025-10-20 21:38:21 -04:00
03b4264239 Fix foreign key cascades and add validation for missing AR fields
- Enable SQLite foreign key constraints to properly cascade deletes
- Add validation to prevent adding products when no AR fields configured
- Display helpful warnings on product pages when fields are missing
- Redirect users to AR field configuration when needed
2025-10-20 21:36:01 -04:00
a727796ed2 Add optional default product fields on tenant creation
When creating a new tenant, users can now choose to initialize with default product fields (Product Name, Item ID, Sale Price, and Image). The option is presented via a checkbox in the tenant creation modal and is enabled by default.
2025-10-20 21:13:05 -04:00
25e3848710 Merge pull request #10 from mattintech/main
Updating Develop
2025-10-20 21:03:23 -04:00
a25ec8a457 Merge pull request #9 from mattintech/feature/cicd
Add CI/CD pipeline with Docker support
2025-10-20 20:33:16 -04:00
4062ccf4e3 Merge pull request #8 from mattintech/feature/entra-id
Add Entra ID authentication with optional no-auth mode
2025-10-20 20:09:07 -04:00
bcfa6cac43 Fix: Prevent access to role selection page when not in no-auth mode
Return 403 Forbidden instead of redirecting to prevent potential redirect loops.
Add proper logging for unauthorized access attempts.
2025-10-20 20:06:31 -04:00
2b471ef09f Add optional no-auth mode for simplified local testing
This update introduces an AUTH_MODE configuration that allows developers
to easily test the application locally without requiring Entra ID setup.

Changes:
- Add AUTH_MODE environment variable (entra/none) to .env.example and config
- Update auth decorators to support both authentication modes
- Create role selection page for no-auth mode (admin/user choice)
- Modify auth routes to conditionally use MSAL only in Entra ID mode
- Update UserModel to support mock test users with string IDs
- Add visual indicator in navbar when running in no-auth mode

Benefits:
- Zero Azure AD configuration needed for local development
- Quick testing of role-based features (admin vs user)
- Existing Entra ID functionality remains unchanged
- Simple toggle via environment variable

To use no-auth mode, set AUTH_MODE=none in .env file.
For production, use AUTH_MODE=entra (default).
2025-10-20 20:03:46 -04:00
fc16517726 Merge pull request #7 from mattintech/feature/cicd
Add GitHub issue templates
2025-10-20 19:44:17 -04:00
c8ad220b4f Merge pull request #4 from mattintech/development
Merge pull request #3 from mattintech/refactor
2025-10-20 18:22:48 -04:00
db22ff01fd Merge pull request #2 from mattintech/refactor
Refactor to MVC architecture and modernize UI
2025-10-20 18:21:51 -04:00
14 changed files with 390 additions and 24 deletions

View File

@@ -2,6 +2,10 @@
SECRET_KEY=your-secret-key-change-this-in-production SECRET_KEY=your-secret-key-change-this-in-production
FLASK_DEBUG=1 FLASK_DEBUG=1
# Authentication Mode
# Options: 'entra' (Entra ID/Azure AD) or 'none' (no authentication for local testing)
AUTH_MODE=none
# Entra ID (Azure AD) Configuration # Entra ID (Azure AD) Configuration
# Get these values from your Azure Portal > App Registrations # Get these values from your Azure Portal > App Registrations
AZURE_CLIENT_ID=your-client-id-here AZURE_CLIENT_ID=your-client-id-here

View File

@@ -10,6 +10,11 @@ def add_product(tenant_id):
"""Add a new product""" """Add a new product"""
custom_fields = ARFieldModel.get_all(tenant_id) custom_fields = ARFieldModel.get_all(tenant_id)
# Prevent adding products if no fields are configured
if not custom_fields:
flash('Cannot add products. Please configure AR fields first.', 'warning')
return redirect(url_for('admin.manage_ar_fields', tenant_id=tenant_id))
if request.method == 'POST': if request.method == 'POST':
product_id = request.form.get('product_id') product_id = request.form.get('product_id')

View File

@@ -1,14 +1,20 @@
"""Authentication routes for Entra ID login/logout""" """Authentication routes for Entra ID login/logout and no-auth mode"""
import secrets import secrets
from flask import session, redirect, url_for, request, current_app, render_template, flash from flask import session, redirect, url_for, request, current_app, render_template, flash, abort
from . import auth_bp from . import auth_bp
from app.services.msal_service import MSALService
from app.models.user import UserModel from app.models.user import UserModel
@auth_bp.route('/login') @auth_bp.route('/login')
def login(): def login():
"""Initiate Entra ID login flow""" """Initiate Entra ID login flow or redirect to role selection in no-auth mode"""
# Check if we're in no-auth mode
if current_app.config.get('AUTH_MODE') == 'none':
return redirect(url_for('auth.select_role'))
# Entra ID mode
from app.services.msal_service import MSALService
# Generate state token for CSRF protection # Generate state token for CSRF protection
state = secrets.token_urlsafe(16) state = secrets.token_urlsafe(16)
session['auth_state'] = state session['auth_state'] = state
@@ -23,9 +29,51 @@ def login():
return redirect(auth_url) return redirect(auth_url)
@auth_bp.route('/select-role', methods=['GET', 'POST'])
def select_role():
"""Role selection page for no-auth mode"""
# Only available in no-auth mode
if current_app.config.get('AUTH_MODE') != 'none':
current_app.logger.warning(f"Attempted to access role selection in {current_app.config.get('AUTH_MODE')} mode")
abort(403, description="Role selection is only available in no-auth mode")
if request.method == 'POST':
role = request.form.get('role', 'user')
# Validate role
if role not in ['admin', 'user']:
role = 'user'
# Create a mock user for testing
# Use a consistent ID based on role for session persistence
user_id = f"test-{role}-user"
# Store user in session
session['user'] = {
'id': user_id,
'email': f'{role}@test.local',
'name': f'Test {role.capitalize()}',
'role': role
}
current_app.logger.info(f"No-auth mode: User selected {role} role")
# Redirect to the originally requested page or home
next_url = session.pop('next', None)
if next_url:
return redirect(next_url)
return redirect(url_for('main.index'))
return render_template('select_role.html')
@auth_bp.route('/callback') @auth_bp.route('/callback')
def callback(): def callback():
"""Handle the callback from Entra ID after authentication""" """Handle the callback from Entra ID after authentication"""
# Import MSAL service only when needed (Entra ID mode)
from app.services.msal_service import MSALService
# Verify state to prevent CSRF # Verify state to prevent CSRF
state = request.args.get('state') state = request.args.get('state')
if state != session.get('auth_state'): if state != session.get('auth_state'):
@@ -104,6 +152,14 @@ def logout():
# Clear session # Clear session
session.clear() session.clear()
# Check if we're in no-auth mode
if current_app.config.get('AUTH_MODE') == 'none':
# Just redirect to home in no-auth mode
return redirect(url_for('main.index'))
# Entra ID mode - logout from Azure AD
from app.services.msal_service import MSALService
# Remove from MSAL cache # Remove from MSAL cache
MSALService.remove_account() MSALService.remove_account()

View File

@@ -9,13 +9,38 @@ import os
@tenant_access_required @tenant_access_required
def index(tenant_id): def index(tenant_id):
"""Tenant home page - product listing""" """Tenant home page - product listing"""
tenant = TenantModel.get_or_create(tenant_id) # Get the tenant name from query parameter if provided (for new tenant creation)
tenant_name = request.args.get('tenant_name')
# Check if tenant exists first
existing_tenant = TenantModel.get_by_id(tenant_id)
if existing_tenant:
tenant = existing_tenant
elif tenant_name:
# Create new tenant with display name
tenant = TenantModel.create(tenant_id, tenant_name)
else:
# Create with default name (tenant_id)
tenant = TenantModel.create(tenant_id)
if tenant is None: if tenant is None:
return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404 return jsonify({"error": f"'{tenant_id}' is a reserved name and cannot be used as a tenant ID"}), 404
products = ProductModel.get_all(tenant_id) # Check if we should create default fields for a new tenant
create_defaults = request.args.get('create_defaults', '0') == '1'
custom_fields = ARFieldModel.get_all(tenant_id) custom_fields = ARFieldModel.get_all(tenant_id)
# If no custom fields exist and user requested defaults, create them
if create_defaults and not custom_fields:
ARFieldModel.create_default_fields(tenant_id)
custom_fields = ARFieldModel.get_all(tenant_id)
flash('Tenant created with default product fields!', 'success')
# Redirect to remove the query parameter
return redirect(f'/{tenant_id}/')
products = ProductModel.get_all(tenant_id)
return render_template('index.html', tenant=tenant, products=products, custom_fields=custom_fields) return render_template('index.html', tenant=tenant, products=products, custom_fields=custom_fields)
@tenant_bp.route('/delete', methods=['POST']) @tenant_bp.route('/delete', methods=['POST'])

View File

@@ -28,6 +28,10 @@ class Config:
SESSION_USE_SIGNER = True SESSION_USE_SIGNER = True
SESSION_FILE_DIR = os.path.join(DATA_FOLDER, 'flask_session') SESSION_FILE_DIR = os.path.join(DATA_FOLDER, 'flask_session')
# Authentication Mode
# Options: 'entra' (Entra ID/Azure AD) or 'none' (no authentication)
AUTH_MODE = os.environ.get('AUTH_MODE', 'entra').lower()
# Entra ID (Azure AD) Configuration # Entra ID (Azure AD) Configuration
AZURE_CLIENT_ID = os.environ.get('AZURE_CLIENT_ID') AZURE_CLIENT_ID = os.environ.get('AZURE_CLIENT_ID')
AZURE_CLIENT_SECRET = os.environ.get('AZURE_CLIENT_SECRET') AZURE_CLIENT_SECRET = os.environ.get('AZURE_CLIENT_SECRET')

View File

@@ -4,17 +4,36 @@ from flask import session, redirect, url_for, request, abort, current_app
from app.models.user import UserModel from app.models.user import UserModel
def _ensure_no_auth_user():
"""
Helper function to ensure a user exists in session when AUTH_MODE is 'none'.
If no user in session, redirect to role selection page.
"""
if current_app.config.get('AUTH_MODE') == 'none' and 'user' not in session:
# Redirect to role selection page
session['next'] = request.url
return redirect(url_for('auth.select_role'))
return None
def login_required(f): def login_required(f):
""" """
Decorator to require user authentication. Decorator to require user authentication.
Redirects to login if user is not authenticated. Redirects to login if user is not authenticated.
In 'none' auth mode, redirects to role selection if no session exists.
""" """
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if 'user' not in session: # Check if we're in no-auth mode and need to set up a user
# Store the requested URL to redirect back after login if current_app.config.get('AUTH_MODE') == 'none':
redirect_response = _ensure_no_auth_user()
if redirect_response:
return redirect_response
elif 'user' not in session:
# Entra ID mode - redirect to login
session['next'] = request.url session['next'] = request.url
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated_function return decorated_function
@@ -26,7 +45,13 @@ def admin_required(f):
""" """
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if 'user' not in session: # Check if we're in no-auth mode and need to set up a user
if current_app.config.get('AUTH_MODE') == 'none':
redirect_response = _ensure_no_auth_user()
if redirect_response:
return redirect_response
elif 'user' not in session:
# Entra ID mode - redirect to login
session['next'] = request.url session['next'] = request.url
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -47,7 +72,13 @@ def tenant_access_required(f):
""" """
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if 'user' not in session: # Check if we're in no-auth mode and need to set up a user
if current_app.config.get('AUTH_MODE') == 'none':
redirect_response = _ensure_no_auth_user()
if redirect_response:
return redirect_response
elif 'user' not in session:
# Entra ID mode - redirect to login
session['next'] = request.url session['next'] = request.url
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -76,7 +107,13 @@ def settings_access_required(f):
""" """
@wraps(f) @wraps(f)
def decorated_function(*args, **kwargs): def decorated_function(*args, **kwargs):
if 'user' not in session: # Check if we're in no-auth mode and need to set up a user
if current_app.config.get('AUTH_MODE') == 'none':
redirect_response = _ensure_no_auth_user()
if redirect_response:
return redirect_response
elif 'user' not in session:
# Entra ID mode - redirect to login
session['next'] = request.url session['next'] = request.url
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))

View File

@@ -82,3 +82,56 @@ class ARFieldModel:
cursor.execute('DELETE FROM custom_ar_fields WHERE id = ? AND tenant_id = ?', cursor.execute('DELETE FROM custom_ar_fields WHERE id = ? AND tenant_id = ?',
(field_id, tenant_id)) (field_id, tenant_id))
conn.commit() conn.commit()
@staticmethod
def create_default_fields(tenant_id: str):
"""Create default product fields for a new tenant"""
tenant_id = tenant_id.lower()
default_fields = [
{
'fieldName': '_name',
'label': 'Product Name',
'fieldType': 'TEXT',
'editable': 'true',
'displayOrder': 0
},
{
'fieldName': '_id',
'label': 'Item ID',
'fieldType': 'TEXT',
'editable': 'false',
'displayOrder': 1
},
{
'fieldName': '_price',
'label': 'Sale Price',
'fieldType': 'TEXT',
'editable': 'true',
'displayOrder': 2
},
{
'fieldName': '_image',
'label': 'Image',
'fieldType': 'IMAGE_URI',
'editable': 'true',
'displayOrder': 3
}
]
with get_db() as conn:
cursor = conn.cursor()
for field in default_fields:
cursor.execute('''
INSERT INTO custom_ar_fields
(tenant_id, field_name, label, field_type, editable, display_order)
VALUES (?, ?, ?, ?, ?, ?)
''', (
tenant_id,
field['fieldName'],
field['label'],
field['fieldType'],
field['editable'],
field['displayOrder']
))
conn.commit()

View File

@@ -8,6 +8,8 @@ def get_db():
db_path = current_app.config['DATABASE_PATH'] db_path = current_app.config['DATABASE_PATH']
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
# Enable foreign key constraints
conn.execute('PRAGMA foreign_keys = ON')
try: try:
yield conn yield conn
finally: finally:

View File

@@ -67,8 +67,22 @@ class UserModel:
return None return None
@staticmethod @staticmethod
def get_by_id(user_id: int) -> Optional[Dict]: def get_by_id(user_id) -> Optional[Dict]:
"""Get user by ID""" """Get user by ID (supports both integer IDs and string test IDs for no-auth mode)"""
# Handle test user IDs for no-auth mode
if isinstance(user_id, str) and user_id.startswith('test-'):
# Mock test user
role = 'admin' if 'admin' in user_id else 'user'
return {
'id': user_id,
'email': f'{role}@test.local',
'azure_oid': None,
'name': f'Test {role.capitalize()}',
'role': role,
'created_at': None,
'updated_at': None
}
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
@@ -211,13 +225,17 @@ class UserModel:
return [row[0] for row in cursor.fetchall()] return [row[0] for row in cursor.fetchall()]
@staticmethod @staticmethod
def has_access_to_tenant(user_id: int, tenant_id: str) -> bool: def has_access_to_tenant(user_id, tenant_id: str) -> bool:
"""Check if user has access to a specific tenant""" """Check if user has access to a specific tenant"""
# Admin has access to everything # Admin has access to everything
user = UserModel.get_by_id(user_id) user = UserModel.get_by_id(user_id)
if user and user['role'] == UserModel.ROLE_ADMIN: if user and user['role'] == UserModel.ROLE_ADMIN:
return True return True
# Test users in no-auth mode don't have tenant associations
if isinstance(user_id, str) and user_id.startswith('test-'):
return False
# Check if user owns the tenant # Check if user owns the tenant
with get_db() as conn: with get_db() as conn:
cursor = conn.cursor() cursor = conn.cursor()
@@ -228,7 +246,7 @@ class UserModel:
return cursor.fetchone() is not None return cursor.fetchone() is not None
@staticmethod @staticmethod
def is_admin(user_id: int) -> bool: def is_admin(user_id) -> bool:
"""Check if user has admin role""" """Check if user has admin role"""
user = UserModel.get_by_id(user_id) user = UserModel.get_by_id(user_id)
return user and user['role'] == UserModel.ROLE_ADMIN return user and user['role'] == UserModel.ROLE_ADMIN

View File

@@ -10,6 +10,21 @@
<h2>Add New Product</h2> <h2>Add New Product</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if not custom_fields %}
<div class="alert alert-warning" role="alert">
<h4 class="alert-heading"><i class="bi bi-exclamation-triangle-fill me-2"></i>No Fields Configured</h4>
<p>Unable to add products. You must configure custom AR fields before adding products.</p>
<hr>
<p class="mb-0">
<a href="/{{ tenant_id }}/ar_fields" class="btn btn-primary">
<i class="bi bi-gear-fill me-1"></i>Configure AR Fields
</a>
<a href="/{{ tenant_id }}/" class="btn btn-secondary ms-2">
<i class="bi bi-arrow-left me-1"></i>Back to Products
</a>
</p>
</div>
{% else %}
<form action="/{{ tenant_id }}/add" method="POST" enctype="multipart/form-data"> <form action="/{{ tenant_id }}/add" 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>
@@ -71,6 +86,7 @@
<button type="submit" class="btn btn-primary">Add Product</button> <button type="submit" class="btn btn-primary">Add Product</button>
</div> </div>
</form> </form>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -16,6 +16,18 @@
<hr class="my-4"> <hr class="my-4">
<div class="mt-4"> <div class="mt-4">
{% if not custom_fields %}
<div class="alert alert-warning" role="alert">
<h4 class="alert-heading"><i class="bi bi-exclamation-triangle-fill me-2"></i>No AR Fields Configured</h4>
<p>Before you can add products, you need to configure custom AR fields that define what information is returned for each product.</p>
<hr>
<p class="mb-0">
<a href="/{{ tenant.id }}/ar_fields" class="btn btn-primary">
<i class="bi bi-gear-fill me-1"></i>Configure AR Fields
</a>
</p>
</div>
{% else %}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h2>Products</h2> <h2>Products</h2>
<div> <div>
@@ -91,6 +103,7 @@
<i class="bi bi-info-circle"></i> No products available yet. Click "Add New Product" to get started. <i class="bi bi-info-circle"></i> No products available yet. Click "Add New Product" to get started.
</div> </div>
{% endif %} {% endif %}
{% endif %}
</div> </div>
<div class="mt-4"> <div class="mt-4">

View File

@@ -65,13 +65,17 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/">Home</a> <a class="nav-link" href="/">Home</a>
</li> </li>
{% if session.user and session.user.role == 'admin' %}
<li class="nav-item">
<a class="nav-link" href="/settings">Server Settings</a>
</li>
{% endif %}
</ul> </ul>
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
{% if config.AUTH_MODE == 'none' %}
<li class="nav-item">
<span class="nav-link">
<span class="badge bg-warning text-dark">
<i class="bi bi-shield-lock"></i> No Auth Mode
</span>
</span>
</li>
{% endif %}
{% if session.user %} {% if session.user %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown"> <a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">

View File

@@ -0,0 +1,105 @@
{% extends "layout.html" %}
{% block title %}Select Role - KCAP Demo Server{% endblock %}
{% block extra_css %}
<style>
.role-selection-container {
max-width: 600px;
margin: 100px auto;
}
.role-card {
transition: all 0.3s ease;
cursor: pointer;
border: 2px solid transparent;
}
.role-card:hover {
transform: translateY(-5px);
border-color: #0d6efd;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.role-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.auth-mode-badge {
position: fixed;
top: 80px;
right: 20px;
z-index: 1000;
}
</style>
{% endblock %}
{% block content %}
<span class="badge bg-warning text-dark auth-mode-badge">
<i class="bi bi-shield-lock"></i> No Auth Mode
</span>
<div class="role-selection-container">
<div class="text-center mb-4">
<h2>Welcome to KCAP Demo Server</h2>
<p class="text-muted">Select your role to continue</p>
<small class="text-muted">
<i class="bi bi-info-circle"></i> Running in no-auth mode for local testing
</small>
</div>
<div class="row g-4">
<div class="col-md-6">
<div class="card role-card h-100 text-center" onclick="selectRole('admin')">
<div class="card-body p-4">
<div class="role-icon text-danger">
<i class="bi bi-shield-fill-check"></i>
</div>
<h4>Admin</h4>
<p class="text-muted mb-0">
Full access to all tenants, server settings, and user management
</p>
<ul class="list-unstyled text-start mt-3">
<li><i class="bi bi-check-circle-fill text-success"></i> Manage all tenants</li>
<li><i class="bi bi-check-circle-fill text-success"></i> Server settings</li>
<li><i class="bi bi-check-circle-fill text-success"></i> User management</li>
</ul>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card role-card h-100 text-center" onclick="selectRole('user')">
<div class="card-body p-4">
<div class="role-icon text-primary">
<i class="bi bi-person-circle"></i>
</div>
<h4>User</h4>
<p class="text-muted mb-0">
Access to assigned tenants only
</p>
<ul class="list-unstyled text-start mt-3">
<li><i class="bi bi-check-circle-fill text-success"></i> Assigned tenants</li>
<li><i class="bi bi-x-circle-fill text-danger"></i> Server settings</li>
<li><i class="bi bi-x-circle-fill text-danger"></i> User management</li>
</ul>
</div>
</div>
</div>
</div>
<div class="alert alert-info mt-4" role="alert">
<i class="bi bi-lightbulb"></i>
<strong>Testing Mode:</strong> This selection is temporary and only for local development.
To use Entra ID authentication, set <code>AUTH_MODE=entra</code> in your .env file.
</div>
</div>
<form id="roleForm" method="POST" action="{{ url_for('auth.select_role') }}" style="display: none;">
<input type="hidden" name="role" id="roleInput">
</form>
<script>
function selectRole(role) {
document.getElementById('roleInput').value = role;
document.getElementById('roleForm').submit();
}
</script>
{% endblock %}

View File

@@ -155,10 +155,24 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="newTenantForm"> <form id="newTenantForm">
<div class="mb-3">
<label for="tenantName" class="form-label">Tenant Display Name</label>
<input type="text" class="form-control" id="tenantName" placeholder="e.g., Demo Company" required>
<small class="form-text text-muted">This is how the tenant name will be displayed</small>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="tenantId" class="form-label">Tenant ID</label> <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_-]+"> <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> <small class="form-text text-muted">Only letters, numbers, hyphens, and underscores allowed. Will be converted to lowercase.</small>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="createDefaultFields" checked>
<label class="form-check-label" for="createDefaultFields">
Create products with default fields
</label>
</div>
<small class="form-text text-muted">Adds: Product Name, Item ID, Sale Price, and Image fields</small>
</div> </div>
<div class="alert alert-info"> <div class="alert alert-info">
<i class="bi bi-info-circle"></i> <i class="bi bi-info-circle"></i>
@@ -237,13 +251,23 @@
document.getElementById('newTenantForm').addEventListener('submit', function(e) { document.getElementById('newTenantForm').addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
const tenantId = document.getElementById('tenantId').value; const tenantId = document.getElementById('tenantId').value;
if (tenantId) { const tenantName = document.getElementById('tenantName').value;
const createDefaults = document.getElementById('createDefaultFields').checked;
if (tenantId && tenantName) {
const normalizedId = tenantId.toLowerCase(); const normalizedId = tenantId.toLowerCase();
if (reservedIds.includes(normalizedId)) { if (reservedIds.includes(normalizedId)) {
alert(`"${tenantId}" is a reserved name and cannot be used as a tenant ID.`); alert(`"${tenantId}" is a reserved name and cannot be used as a tenant ID.`);
return; return;
} }
window.location.href = '/' + normalizedId + '/';
// Redirect with query parameters for default fields and tenant name
const params = new URLSearchParams();
if (createDefaults) params.append('create_defaults', '1');
params.append('tenant_name', tenantName);
const url = '/' + normalizedId + '/?' + params.toString();
window.location.href = url;
} }
}); });