Compare commits
12 Commits
v1.0.0
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
| 145ea68d2b | |||
| fbf62f71b1 | |||
| 03b4264239 | |||
| a727796ed2 | |||
| 25e3848710 | |||
| a25ec8a457 | |||
| 4062ccf4e3 | |||
| bcfa6cac43 | |||
| 2b471ef09f | |||
| fc16517726 | |||
| c8ad220b4f | |||
| db22ff01fd |
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -27,7 +39,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if products %}
|
{% if products %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-hover">
|
<table class="table table-striped table-hover">
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
105
src/app/templates/select_role.html
Normal file
105
src/app/templates/select_role.html
Normal 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 %}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user