feat: multi-module improvements across merchant, store, i18n, and customer systems
All checks were successful
All checks were successful
- Fix platform-grouped merchant sidebar menu with core items at root level - Add merchant store management (detail page, create store, team page) - Fix store settings 500 error by removing dead stripe/API tab - Move onboarding translations to module-owned locale files - Fix onboarding banner i18n with server-side rendering + context inheritance - Refactor login language selectors to use languageSelector() function (LANG-002) - Move HTTPException handling to global exception handler in merchant routes (API-003) - Add language selector to all login pages and portal headers - Fix customer module: drop order stats from customer model, add to orders module - Fix admin menu config visibility for super admin platform context - Fix storefront auth and layout issues - Add missing i18n translations for onboarding steps (en/fr/de/lb) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -66,7 +66,6 @@ core_module = ModuleDefinition(
|
||||
"dashboard",
|
||||
"settings",
|
||||
"email-templates",
|
||||
"my-menu",
|
||||
],
|
||||
FrontendType.STORE: [
|
||||
"dashboard",
|
||||
@@ -112,15 +111,6 @@ core_module = ModuleDefinition(
|
||||
order=10,
|
||||
is_mandatory=True,
|
||||
),
|
||||
MenuItemDefinition(
|
||||
id="my-menu",
|
||||
label_key="core.menu.my_menu",
|
||||
icon="view-grid",
|
||||
route="/admin/my-menu",
|
||||
order=30,
|
||||
is_mandatory=True,
|
||||
is_super_admin_only=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"platform_settings": "Plattform-Einstellungen",
|
||||
"general": "Allgemein",
|
||||
"my_menu": "Mein Menü",
|
||||
"account_settings": "Kontoeinstellungen",
|
||||
"profile": "Profil",
|
||||
"settings": "Einstellungen"
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"platform_settings": "Platform Settings",
|
||||
"general": "General",
|
||||
"my_menu": "My Menu",
|
||||
"account_settings": "Account Settings",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings"
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"dashboard": "Tableau de bord",
|
||||
"platform_settings": "Paramètres de la plateforme",
|
||||
"general": "Général",
|
||||
"my_menu": "Mon menu",
|
||||
"account_settings": "Paramètres du compte",
|
||||
"profile": "Profil",
|
||||
"settings": "Paramètres"
|
||||
|
||||
@@ -72,7 +72,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"platform_settings": "Plattform-Astellungen",
|
||||
"general": "Allgemeng",
|
||||
"my_menu": "Mäi Menü",
|
||||
"account_settings": "Kont-Astellungen",
|
||||
"profile": "Profil",
|
||||
"settings": "Astellungen"
|
||||
|
||||
@@ -100,12 +100,15 @@ class AdminMenuConfig(Base, TimestampMixin):
|
||||
comment="Platform scope - applies to users/stores of this platform",
|
||||
)
|
||||
|
||||
# DEPRECATED: user_id scoping is no longer used. Super admins now use platform
|
||||
# selection instead of personal menu config. DB migration to drop this column
|
||||
# is a separate task.
|
||||
user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="User scope - applies to this specific super admin (admin frontend only)",
|
||||
comment="DEPRECATED - User scope no longer used. Kept for migration compatibility.",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
|
||||
@@ -6,8 +6,6 @@ Provides menu visibility configuration for admin and store frontends:
|
||||
- GET /menu-config/platforms/{platform_id} - Get menu config for a platform
|
||||
- PUT /menu-config/platforms/{platform_id} - Update menu visibility for a platform
|
||||
- POST /menu-config/platforms/{platform_id}/reset - Reset to defaults
|
||||
- GET /menu-config/user - Get current user's menu config (super admins)
|
||||
- PUT /menu-config/user - Update current user's menu config (super admins)
|
||||
- GET /menu/admin - Get rendered admin menu for current user
|
||||
- GET /menu/store - Get rendered store menu for current platform
|
||||
|
||||
@@ -316,108 +314,6 @@ async def reset_platform_menu_config(
|
||||
return {"success": True, "message": "Menu configuration reset to defaults"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Menu Configuration (Super Admin Only)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/user", response_model=MenuConfigResponse)
|
||||
async def get_user_menu_config(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Get the current super admin's personal menu configuration.
|
||||
|
||||
Only super admins can configure their own admin menu.
|
||||
"""
|
||||
items = menu_service.get_user_menu_config(db, current_user.id)
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} fetched their personal menu config"
|
||||
)
|
||||
|
||||
# Use user's preferred language, falling back to middleware-resolved language
|
||||
language = current_user.preferred_language or getattr(
|
||||
request.state, "language", "en"
|
||||
)
|
||||
|
||||
return _build_menu_config_response(
|
||||
items, FrontendType.ADMIN, language=language, user_id=current_user.id
|
||||
)
|
||||
|
||||
|
||||
@router.put("/user")
|
||||
async def update_user_menu_visibility(
|
||||
update_data: MenuVisibilityUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Update visibility for a single menu item for the current super admin.
|
||||
|
||||
Super admin only. Cannot hide mandatory items.
|
||||
"""
|
||||
menu_service.update_menu_visibility(
|
||||
db=db,
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
menu_item_id=update_data.menu_item_id,
|
||||
is_visible=update_data.is_visible,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} updated personal menu: "
|
||||
f"{update_data.menu_item_id}={update_data.is_visible}"
|
||||
)
|
||||
|
||||
return {"success": True, "message": "Menu visibility updated"}
|
||||
|
||||
|
||||
@router.post("/user/reset", response_model=MenuActionResponse)
|
||||
async def reset_user_menu_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Reset the current super admin's menu configuration (hide all except mandatory).
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
menu_service.reset_user_menu_config(db, current_user.id)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} reset their personal menu config (hide all)"
|
||||
)
|
||||
|
||||
return MenuActionResponse(
|
||||
success=True, message="Menu configuration reset - all items hidden"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/user/show-all", response_model=MenuActionResponse)
|
||||
async def show_all_user_menu_config(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: UserContext = Depends(get_current_super_admin),
|
||||
):
|
||||
"""
|
||||
Show all menu items for the current super admin.
|
||||
|
||||
Super admin only.
|
||||
"""
|
||||
menu_service.show_all_user_menu_config(db, current_user.id)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"[MENU_CONFIG] Super admin {current_user.email} enabled all menu items"
|
||||
)
|
||||
|
||||
return MenuActionResponse(success=True, message="All menu items are now visible")
|
||||
|
||||
|
||||
@router.post("/platforms/{platform_id}/show-all")
|
||||
async def show_all_platform_menu_config(
|
||||
platform_id: int = Path(..., description="Platform ID"),
|
||||
@@ -467,11 +363,12 @@ async def get_rendered_admin_menu(
|
||||
Used by the frontend to render the sidebar.
|
||||
"""
|
||||
if current_user.is_super_admin:
|
||||
# Super admin: use user-level config
|
||||
# Super admin: use platform config if platform selected, else global (all modules)
|
||||
platform_id = current_user.token_platform_id
|
||||
menu = menu_service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
user_id=current_user.id,
|
||||
platform_id=platform_id,
|
||||
is_super_admin=True,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -5,9 +5,11 @@ Merchant menu rendering endpoint.
|
||||
Provides the dynamic sidebar menu for the merchant portal:
|
||||
- GET /menu/render/merchant - Get rendered merchant menu for current user
|
||||
|
||||
Menu sections are driven by module definitions (FrontendType.MERCHANT).
|
||||
Only modules enabled on platforms the merchant is actively subscribed to
|
||||
will appear in the sidebar.
|
||||
Menu sections are grouped by platform:
|
||||
- Core items (dashboard, billing, account) appear at root level
|
||||
- Platform-specific items are grouped under their platform name
|
||||
- No AdminMenuConfig visibility filtering — menu is purely driven by
|
||||
module definitions + module enablement + subscription status
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -38,6 +40,9 @@ class MenuSectionResponse(BaseModel):
|
||||
|
||||
id: str
|
||||
label: str | None = None
|
||||
icon: str | None = None
|
||||
platform_code: str | None = None
|
||||
is_collapsible: bool = False
|
||||
items: list[dict[str, Any]]
|
||||
|
||||
|
||||
@@ -83,10 +88,12 @@ async def get_rendered_merchant_menu(
|
||||
"""
|
||||
Get the rendered merchant menu for the current user.
|
||||
|
||||
Returns the filtered menu structure based on modules enabled
|
||||
on platforms the merchant is subscribed to.
|
||||
Returns a platform-grouped menu structure:
|
||||
- Core sections (dashboard, billing, account) at root level
|
||||
- Platform-specific sections grouped under platform name
|
||||
|
||||
Used by the merchant frontend to render the sidebar dynamically.
|
||||
Menu visibility is driven by module definitions + module enablement
|
||||
+ subscription status. No AdminMenuConfig filtering.
|
||||
"""
|
||||
# Resolve the merchant for this user (via service layer)
|
||||
merchant = menu_service.get_merchant_for_menu(db, current_user.id)
|
||||
@@ -98,32 +105,25 @@ async def get_rendered_merchant_menu(
|
||||
sections=[],
|
||||
)
|
||||
|
||||
# Get union of enabled module codes across all subscribed platforms
|
||||
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
|
||||
|
||||
# Resolve primary platform for AdminMenuConfig visibility lookup
|
||||
primary_platform_id = menu_service.get_merchant_primary_platform_id(
|
||||
# Get platform-grouped menu
|
||||
core_sections, platform_sections = menu_service.get_merchant_menu_by_platform(
|
||||
db, merchant.id
|
||||
)
|
||||
|
||||
# Get filtered menu using enabled_module_codes override + platform visibility
|
||||
menu = menu_service.get_menu_for_rendering(
|
||||
db=db,
|
||||
frontend_type=FrontendType.MERCHANT,
|
||||
platform_id=primary_platform_id,
|
||||
enabled_module_codes=enabled_codes,
|
||||
)
|
||||
|
||||
# Resolve language
|
||||
language = current_user.preferred_language or getattr(
|
||||
request.state, "language", "en"
|
||||
)
|
||||
|
||||
# Translate section and item labels
|
||||
sections = []
|
||||
for section in menu:
|
||||
# Build response sections
|
||||
all_sections = []
|
||||
|
||||
# Core sections first (translated labels)
|
||||
for section in core_sections:
|
||||
translated_items = []
|
||||
for item in section.items:
|
||||
if not item.is_module_enabled:
|
||||
continue
|
||||
translated_items.append(
|
||||
{
|
||||
"id": item.id,
|
||||
@@ -133,15 +133,59 @@ async def get_rendered_merchant_menu(
|
||||
}
|
||||
)
|
||||
|
||||
sections.append(
|
||||
MenuSectionResponse(
|
||||
id=section.id,
|
||||
label=_translate_label(section.label_key, language),
|
||||
items=translated_items,
|
||||
if translated_items:
|
||||
all_sections.append(
|
||||
MenuSectionResponse(
|
||||
id=section.id,
|
||||
label=_translate_label(section.label_key, language),
|
||||
icon=section.icon,
|
||||
is_collapsible=section.is_collapsible,
|
||||
items=translated_items,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Platform sections (platform name as label, collapsible)
|
||||
for section in platform_sections:
|
||||
translated_items = []
|
||||
for item in section.items:
|
||||
if not item.is_module_enabled:
|
||||
continue
|
||||
translated_items.append(
|
||||
{
|
||||
"id": item.id,
|
||||
"label": _translate_label(item.label_key, language),
|
||||
"icon": item.icon,
|
||||
"url": item.route,
|
||||
}
|
||||
)
|
||||
|
||||
if translated_items:
|
||||
# Extract platform_code from section id (format: "platform-{code}")
|
||||
platform_code = section.id.removeprefix("platform-")
|
||||
all_sections.append(
|
||||
MenuSectionResponse(
|
||||
id=section.id,
|
||||
label=section.label_key, # Already platform name, not a translation key
|
||||
icon=section.icon,
|
||||
platform_code=platform_code,
|
||||
is_collapsible=True,
|
||||
items=translated_items,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort order: 1) Dashboard (main), 2) Platform sections, 3) Billing, 4) Account
|
||||
# Core sections use their definition order; platform sections slot in between
|
||||
# main=0, platform=25, billing=50, account=900
|
||||
def _section_sort_key(s):
|
||||
if s.platform_code:
|
||||
return 25, s.id
|
||||
# Map known core section IDs to their display order
|
||||
core_order = {"main": 0, "billing": 50, "account": 900}
|
||||
return core_order.get(s.id, 500), s.id
|
||||
|
||||
all_sections.sort(key=_section_sort_key)
|
||||
|
||||
return RenderedMenuResponse(
|
||||
frontend_type=FrontendType.MERCHANT.value,
|
||||
sections=sections,
|
||||
sections=all_sections,
|
||||
)
|
||||
|
||||
@@ -224,14 +224,6 @@ def get_store_settings(
|
||||
"is_verified": domain.is_verified,
|
||||
})
|
||||
|
||||
# Get Stripe info from subscription (read-only, masked)
|
||||
stripe_info = None
|
||||
if store.subscription and store.subscription.stripe_customer_id:
|
||||
stripe_info = {
|
||||
"has_stripe_customer": True,
|
||||
"customer_id_masked": f"cus_***{store.subscription.stripe_customer_id[-4:]}",
|
||||
}
|
||||
|
||||
return {
|
||||
# General info
|
||||
"store_code": store.store_code,
|
||||
@@ -297,9 +289,6 @@ def get_store_settings(
|
||||
"domains": domains,
|
||||
"default_subdomain": f"{store.subdomain}.letzshop.lu",
|
||||
|
||||
# Stripe info (read-only)
|
||||
"stripe_info": stripe_info,
|
||||
|
||||
# Options for dropdowns
|
||||
"options": {
|
||||
"supported_languages": SUPPORTED_LANGUAGES,
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.modules.core.utils.page_context import get_admin_context
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.models import User
|
||||
from app.templates_config import templates
|
||||
from app.utils.i18n import get_jinja2_globals
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -57,10 +58,13 @@ async def admin_login_page(
|
||||
if current_user:
|
||||
return RedirectResponse(url="/admin/dashboard", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("tenancy/admin/login.html", {
|
||||
language = getattr(request.state, "language", "en")
|
||||
context = {
|
||||
"request": request,
|
||||
"current_language": getattr(request.state, "language", "en"),
|
||||
})
|
||||
"current_language": language,
|
||||
**get_jinja2_globals(language),
|
||||
}
|
||||
return templates.TemplateResponse("tenancy/admin/login.html", context)
|
||||
|
||||
|
||||
@router.get("/select-platform", response_class=HTMLResponse, include_in_schema=False)
|
||||
@@ -69,18 +73,15 @@ async def admin_select_platform_page(
|
||||
current_user: User | None = Depends(get_current_admin_optional),
|
||||
):
|
||||
"""
|
||||
Render platform selection page for platform admins.
|
||||
Render platform selection page for admins.
|
||||
|
||||
Platform admins with access to multiple platforms must select
|
||||
which platform they want to manage before accessing the dashboard.
|
||||
Super admins are redirected to dashboard (they have global access).
|
||||
Super admins can optionally select a platform to scope their view.
|
||||
"""
|
||||
if not current_user:
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
|
||||
if current_user.is_super_admin:
|
||||
return RedirectResponse(url="/admin/dashboard", status_code=302)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"tenancy/admin/select-platform.html",
|
||||
{"request": request, "user": current_user},
|
||||
@@ -124,26 +125,6 @@ async def admin_settings_page(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/my-menu", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_my_menu_config(
|
||||
request: Request,
|
||||
current_user: User = Depends(require_menu_access("my-menu", FrontendType.ADMIN)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render personal menu configuration page for super admins.
|
||||
Allows super admins to customize their own sidebar menu.
|
||||
"""
|
||||
# Only super admins can configure their own menu
|
||||
if not current_user.is_super_admin:
|
||||
return RedirectResponse(url="/admin/settings", status_code=302)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"core/admin/my-menu-config.html",
|
||||
get_admin_context(request, db, current_user),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/features", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def admin_features_page(
|
||||
request: Request,
|
||||
|
||||
@@ -24,6 +24,7 @@ from app.modules.core.utils.page_context import get_context_for_frontend
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.tenancy.schemas.auth import UserContext
|
||||
from app.templates_config import templates
|
||||
from app.utils.i18n import get_jinja2_globals
|
||||
|
||||
ROUTE_CONFIG = {
|
||||
"prefix": "",
|
||||
@@ -67,10 +68,13 @@ async def merchant_login_page(
|
||||
if current_user:
|
||||
return RedirectResponse(url="/merchants/dashboard", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("tenancy/merchant/login.html", {
|
||||
language = getattr(request.state, "language", "fr")
|
||||
context = {
|
||||
"request": request,
|
||||
"current_language": getattr(request.state, "language", "fr"),
|
||||
})
|
||||
"current_language": language,
|
||||
**get_jinja2_globals(language),
|
||||
}
|
||||
return templates.TemplateResponse("tenancy/merchant/login.html", context)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -274,6 +274,103 @@ class MenuService:
|
||||
# Merchant Menu
|
||||
# =========================================================================
|
||||
|
||||
def get_merchant_menu_by_platform(
|
||||
self,
|
||||
db: Session,
|
||||
merchant_id: int,
|
||||
) -> tuple[list, list]:
|
||||
"""
|
||||
Get merchant menu items grouped by platform.
|
||||
|
||||
Returns two lists of DiscoveredMenuSection:
|
||||
- core_sections: items from core modules (always visible, no platform grouping)
|
||||
- platform_sections: items from non-core modules, with platform metadata
|
||||
|
||||
Each platform section has its label_key replaced with the platform name
|
||||
and a `platform_code` attribute added for frontend identification.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
merchant_id: Merchant ID
|
||||
|
||||
Returns:
|
||||
Tuple of (core_sections, platform_sections)
|
||||
"""
|
||||
from app.modules.billing.services.subscription_service import (
|
||||
subscription_service,
|
||||
)
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.tenancy.services.platform_service import platform_service
|
||||
|
||||
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
|
||||
|
||||
# Get all sections from core modules (no platform filtering needed)
|
||||
core_sections = menu_discovery_service.get_menu_sections_for_frontend(
|
||||
db,
|
||||
FrontendType.MERCHANT,
|
||||
enabled_module_codes=core_codes,
|
||||
)
|
||||
# Filter to only items from core modules
|
||||
for section in core_sections:
|
||||
section.items = [i for i in section.items if i.module_code in core_codes]
|
||||
core_sections = [s for s in core_sections if s.items]
|
||||
|
||||
# Get platform-specific sections
|
||||
platform_sections = []
|
||||
platform_ids = subscription_service.get_active_subscription_platform_ids(
|
||||
db, merchant_id
|
||||
)
|
||||
|
||||
for pid in platform_ids:
|
||||
platform = platform_service.get_platform_by_id(db, pid)
|
||||
if not platform:
|
||||
continue
|
||||
|
||||
# Get modules enabled on this platform (excluding core — already at root)
|
||||
platform_module_codes = module_service.get_enabled_module_codes(db, pid)
|
||||
non_core_codes = platform_module_codes - core_codes
|
||||
|
||||
if not non_core_codes:
|
||||
continue # No platform-specific items
|
||||
|
||||
# Get sections for this platform's non-core modules
|
||||
sections = menu_discovery_service.get_menu_sections_for_frontend(
|
||||
db,
|
||||
FrontendType.MERCHANT,
|
||||
enabled_module_codes=non_core_codes,
|
||||
)
|
||||
# Filter to only items from non-core modules
|
||||
for section in sections:
|
||||
section.items = [
|
||||
i for i in section.items if i.module_code in non_core_codes
|
||||
]
|
||||
|
||||
# Flatten all platform sections into one section per platform
|
||||
all_items = []
|
||||
for section in sections:
|
||||
all_items.extend(section.items)
|
||||
|
||||
if not all_items:
|
||||
continue
|
||||
|
||||
# Create a single section for this platform
|
||||
from app.modules.core.services.menu_discovery_service import (
|
||||
DiscoveredMenuSection,
|
||||
)
|
||||
|
||||
platform_section = DiscoveredMenuSection(
|
||||
id=f"platform-{platform.code}",
|
||||
label_key=platform.name, # Use platform name directly
|
||||
icon=getattr(platform, "icon", None) or "globe-alt",
|
||||
order=30 + platform_ids.index(pid),
|
||||
is_super_admin_only=False,
|
||||
is_collapsible=True,
|
||||
items=sorted(all_items, key=lambda i: (i.section_order, i.order)),
|
||||
)
|
||||
platform_sections.append(platform_section)
|
||||
|
||||
return core_sections, platform_sections
|
||||
|
||||
def get_merchant_enabled_module_codes(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -472,60 +569,6 @@ class MenuService:
|
||||
|
||||
return result
|
||||
|
||||
def get_user_menu_config(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
) -> list[MenuItemConfig]:
|
||||
"""
|
||||
Get admin menu configuration for a super admin user.
|
||||
|
||||
Super admins don't have platform context, so all modules are shown.
|
||||
Module enablement is always True for super admin menu config.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Super admin user ID
|
||||
|
||||
Returns:
|
||||
List of MenuItemConfig with current visibility state
|
||||
"""
|
||||
shown_items = self._get_shown_items(db, FrontendType.ADMIN, user_id=user_id)
|
||||
mandatory_items = menu_discovery_service.get_mandatory_item_ids(
|
||||
FrontendType.ADMIN
|
||||
)
|
||||
|
||||
# Get all menu items from discovery service
|
||||
all_items = menu_discovery_service.get_all_menu_items(FrontendType.ADMIN)
|
||||
|
||||
result = []
|
||||
for item in all_items:
|
||||
# If no config exists (shown_items is None), show all by default
|
||||
# Otherwise, item is visible if in shown_items or mandatory
|
||||
is_visible = (
|
||||
shown_items is None
|
||||
or item.id in shown_items
|
||||
or item.id in mandatory_items
|
||||
)
|
||||
|
||||
result.append(
|
||||
MenuItemConfig(
|
||||
id=item.id,
|
||||
label=item.label_key,
|
||||
icon=item.icon,
|
||||
url=item.route,
|
||||
section_id=item.section_id,
|
||||
section_label=item.section_label_key,
|
||||
is_visible=is_visible,
|
||||
is_mandatory=item.id in mandatory_items,
|
||||
is_super_admin_only=item.is_super_admin_only,
|
||||
is_module_enabled=True, # Super admins see all modules
|
||||
module_code=item.module_code,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def update_menu_visibility(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -692,54 +735,6 @@ class MenuService:
|
||||
f"Created {len(all_items) - len(mandatory_items)} hidden records for platform {platform_id}"
|
||||
)
|
||||
|
||||
def reset_user_menu_config(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Reset menu configuration for a super admin user to defaults (all hidden except mandatory).
|
||||
|
||||
In opt-in model, reset means hide everything so user can opt-in to what they want.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Super admin user ID
|
||||
"""
|
||||
# Delete all existing records
|
||||
deleted = (
|
||||
db.query(AdminMenuConfig)
|
||||
.filter(
|
||||
AdminMenuConfig.frontend_type == FrontendType.ADMIN,
|
||||
AdminMenuConfig.user_id == user_id,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
logger.info(f"Reset menu config for user {user_id}: deleted {deleted} rows")
|
||||
|
||||
# Create records with is_visible=False for all non-mandatory items
|
||||
all_items = menu_discovery_service.get_all_menu_item_ids(FrontendType.ADMIN)
|
||||
mandatory_items = menu_discovery_service.get_mandatory_item_ids(
|
||||
FrontendType.ADMIN
|
||||
)
|
||||
|
||||
configs = [
|
||||
AdminMenuConfig(
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
platform_id=None,
|
||||
user_id=user_id,
|
||||
menu_item_id=item_id,
|
||||
is_visible=False,
|
||||
)
|
||||
for item_id in all_items
|
||||
if item_id not in mandatory_items
|
||||
]
|
||||
db.add_all(configs)
|
||||
|
||||
logger.info(
|
||||
f"Created {len(all_items) - len(mandatory_items)} hidden records for user {user_id}"
|
||||
)
|
||||
|
||||
def show_all_platform_menu_config(
|
||||
self,
|
||||
db: Session,
|
||||
@@ -789,52 +784,6 @@ class MenuService:
|
||||
f"Created {len(all_items) - len(mandatory_items)} visible records for platform {platform_id}"
|
||||
)
|
||||
|
||||
def show_all_user_menu_config(
|
||||
self,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""
|
||||
Show all menu items for a super admin user.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Super admin user ID
|
||||
"""
|
||||
# Delete all existing records
|
||||
deleted = (
|
||||
db.query(AdminMenuConfig)
|
||||
.filter(
|
||||
AdminMenuConfig.frontend_type == FrontendType.ADMIN,
|
||||
AdminMenuConfig.user_id == user_id,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
logger.info(f"Show all menu config for user {user_id}: deleted {deleted} rows")
|
||||
|
||||
# Create records with is_visible=True for all non-mandatory items
|
||||
all_items = menu_discovery_service.get_all_menu_item_ids(FrontendType.ADMIN)
|
||||
mandatory_items = menu_discovery_service.get_mandatory_item_ids(
|
||||
FrontendType.ADMIN
|
||||
)
|
||||
|
||||
configs = [
|
||||
AdminMenuConfig(
|
||||
frontend_type=FrontendType.ADMIN,
|
||||
platform_id=None,
|
||||
user_id=user_id,
|
||||
menu_item_id=item_id,
|
||||
is_visible=True,
|
||||
)
|
||||
for item_id in all_items
|
||||
if item_id not in mandatory_items
|
||||
]
|
||||
db.add_all(configs)
|
||||
|
||||
logger.info(
|
||||
f"Created {len(all_items) - len(mandatory_items)} visible records for user {user_id}"
|
||||
)
|
||||
|
||||
def initialize_menu_config(
|
||||
self,
|
||||
db: Session,
|
||||
|
||||
@@ -39,11 +39,28 @@ class OnboardingAggregatorService:
|
||||
a unified interface for the dashboard onboarding banner.
|
||||
"""
|
||||
|
||||
def _get_store_platform_ids(self, db: Session, store_id: int) -> set[int]:
|
||||
"""Get platform IDs the store is actively subscribed to."""
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
rows = (
|
||||
db.query(StorePlatform.platform_id)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {r.platform_id for r in rows}
|
||||
|
||||
def _get_enabled_providers(
|
||||
self, db: Session, platform_id: int
|
||||
self, db: Session, store_id: int, platform_id: int
|
||||
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
|
||||
"""
|
||||
Get onboarding providers from enabled modules.
|
||||
Get onboarding providers from modules enabled on the store's subscribed platforms.
|
||||
|
||||
Filters non-core modules to only those enabled on platforms the store
|
||||
is actively subscribed to, preventing cross-platform content leakage.
|
||||
|
||||
Returns:
|
||||
List of (module, provider) tuples for enabled modules with providers
|
||||
@@ -51,6 +68,11 @@ class OnboardingAggregatorService:
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.service import module_service
|
||||
|
||||
store_platform_ids = self._get_store_platform_ids(db, store_id)
|
||||
if not store_platform_ids:
|
||||
# Fallback to the passed platform_id if no subscriptions found
|
||||
store_platform_ids = {platform_id}
|
||||
|
||||
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []
|
||||
|
||||
for module in MODULES.values():
|
||||
@@ -59,13 +81,19 @@ class OnboardingAggregatorService:
|
||||
|
||||
# Core modules are always enabled, check others
|
||||
if not module.is_core:
|
||||
try:
|
||||
if not module_service.is_module_enabled(db, platform_id, module.code):
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check if module {module.code} is enabled: {e}"
|
||||
)
|
||||
# Check if module is enabled on ANY of the store's subscribed platforms
|
||||
enabled_on_any = False
|
||||
for pid in store_platform_ids:
|
||||
try:
|
||||
if module_service.is_module_enabled(db, pid, module.code):
|
||||
enabled_on_any = True
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check if module {module.code} is enabled "
|
||||
f"on platform {pid}: {e}"
|
||||
)
|
||||
if not enabled_on_any:
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -88,7 +116,7 @@ class OnboardingAggregatorService:
|
||||
Returns:
|
||||
Sorted list of OnboardingStepStatus objects
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
providers = self._get_enabled_providers(db, store_id, platform_id)
|
||||
steps: list[OnboardingStepStatus] = []
|
||||
|
||||
for module, provider in providers:
|
||||
|
||||
@@ -52,15 +52,33 @@ class StatsAggregatorService:
|
||||
when modules are disabled or providers fail.
|
||||
"""
|
||||
|
||||
def _get_store_platform_ids(self, db: Session, store_id: int) -> set[int]:
|
||||
"""Get platform IDs the store is actively subscribed to."""
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
|
||||
rows = (
|
||||
db.query(StorePlatform.platform_id)
|
||||
.filter(
|
||||
StorePlatform.store_id == store_id,
|
||||
StorePlatform.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return {r.platform_id for r in rows}
|
||||
|
||||
def _get_enabled_providers(
|
||||
self, db: Session, platform_id: int
|
||||
self, db: Session, platform_id: int, store_id: int | None = None
|
||||
) -> list[tuple["ModuleDefinition", MetricsProviderProtocol]]:
|
||||
"""
|
||||
Get metrics providers from enabled modules.
|
||||
|
||||
When store_id is provided, filters to modules enabled on the store's
|
||||
subscribed platforms only (prevents cross-platform content leakage).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
platform_id: Platform ID to check module enablement
|
||||
store_id: Optional store ID for subscription-aware filtering
|
||||
|
||||
Returns:
|
||||
List of (module, provider) tuples for enabled modules with providers
|
||||
@@ -68,6 +86,14 @@ class StatsAggregatorService:
|
||||
from app.modules.registry import MODULES
|
||||
from app.modules.service import module_service
|
||||
|
||||
# Determine which platform IDs to check
|
||||
if store_id:
|
||||
check_platform_ids = self._get_store_platform_ids(db, store_id)
|
||||
if not check_platform_ids:
|
||||
check_platform_ids = {platform_id}
|
||||
else:
|
||||
check_platform_ids = {platform_id}
|
||||
|
||||
providers: list[tuple[ModuleDefinition, MetricsProviderProtocol]] = []
|
||||
|
||||
for module in MODULES.values():
|
||||
@@ -77,13 +103,18 @@ class StatsAggregatorService:
|
||||
|
||||
# Core modules are always enabled, check others
|
||||
if not module.is_core:
|
||||
try:
|
||||
if not module_service.is_module_enabled(db, platform_id, module.code):
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check if module {module.code} is enabled: {e}"
|
||||
)
|
||||
enabled_on_any = False
|
||||
for pid in check_platform_ids:
|
||||
try:
|
||||
if module_service.is_module_enabled(db, pid, module.code):
|
||||
enabled_on_any = True
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to check if module {module.code} is enabled "
|
||||
f"on platform {pid}: {e}"
|
||||
)
|
||||
if not enabled_on_any:
|
||||
continue
|
||||
|
||||
# Get the provider instance
|
||||
@@ -119,7 +150,7 @@ class StatsAggregatorService:
|
||||
Returns:
|
||||
Dict mapping category name to list of MetricValue objects
|
||||
"""
|
||||
providers = self._get_enabled_providers(db, platform_id)
|
||||
providers = self._get_enabled_providers(db, platform_id, store_id=store_id)
|
||||
result: dict[str, list[MetricValue]] = {}
|
||||
|
||||
for module, provider in providers:
|
||||
|
||||
@@ -264,7 +264,7 @@ function languageSelector(currentLang, enabledLanguages) {
|
||||
return {
|
||||
isLangOpen: false,
|
||||
currentLang: currentLang || 'fr',
|
||||
languages: enabledLanguages || ['fr', 'de', 'en'],
|
||||
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
|
||||
languageNames: {
|
||||
'en': 'English',
|
||||
'fr': 'Français',
|
||||
|
||||
@@ -6,6 +6,27 @@
|
||||
// Create custom logger for login page
|
||||
const loginLog = window.LogConfig.createLogger('LOGIN');
|
||||
|
||||
function languageSelector(currentLang, enabledLanguages) {
|
||||
return {
|
||||
currentLang: currentLang || 'en',
|
||||
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
|
||||
async setLanguage(lang) {
|
||||
if (lang === this.currentLang) return;
|
||||
try {
|
||||
// noqa: JS-008 - Login page has no apiClient; raw fetch is intentional
|
||||
await fetch('/api/v1/platform/language/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: lang }),
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
loginLog.error('Failed to set language:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function adminLogin() {
|
||||
return {
|
||||
dark: false,
|
||||
@@ -17,6 +38,10 @@ function adminLogin() {
|
||||
error: null,
|
||||
success: null,
|
||||
errors: {},
|
||||
rememberMe: false,
|
||||
showForgotPassword: false,
|
||||
forgotPasswordEmail: '',
|
||||
forgotPasswordLoading: false,
|
||||
|
||||
init() {
|
||||
// Guard against multiple initialization
|
||||
@@ -196,6 +221,14 @@ function adminLogin() {
|
||||
window.location.href = '/admin/select-platform';
|
||||
return;
|
||||
}
|
||||
|
||||
if (platformsResponse.is_super_admin && !platformsResponse.current_platform_id) {
|
||||
// Super admin with no platform selected - offer platform selection
|
||||
loginLog.info('Super admin without platform, redirecting to platform selector...');
|
||||
this.success = 'Login successful! Select a platform or stay in global mode...';
|
||||
window.location.href = '/admin/select-platform';
|
||||
return;
|
||||
}
|
||||
} catch (platformError) {
|
||||
loginLog.warn('Could not check platforms, proceeding to dashboard:', platformError);
|
||||
}
|
||||
@@ -233,6 +266,28 @@ function adminLogin() {
|
||||
}
|
||||
},
|
||||
|
||||
async handleForgotPassword() {
|
||||
if (!this.forgotPasswordEmail) {
|
||||
this.error = 'Please enter your email address';
|
||||
return;
|
||||
}
|
||||
this.forgotPasswordLoading = true;
|
||||
this.clearErrors();
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/admin/auth/forgot-password', {
|
||||
email: this.forgotPasswordEmail
|
||||
});
|
||||
this.success = response.message || 'If an account exists with that email, a reset link has been sent.';
|
||||
this.forgotPasswordEmail = '';
|
||||
} catch (error) {
|
||||
// Show generic message to prevent email enumeration
|
||||
this.success = 'If an account exists with that email, a reset link has been sent.';
|
||||
} finally {
|
||||
this.forgotPasswordLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleDarkMode() {
|
||||
loginLog.debug('Toggling dark mode...');
|
||||
this.dark = !this.dark;
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
// static/admin/js/my-menu-config.js
|
||||
// Personal menu configuration for super admins
|
||||
//
|
||||
// NOTE: The page method for loading user menu config is named loadUserMenuConfig()
|
||||
// (not loadMenuConfig()) to avoid shadowing the sidebar's loadMenuConfig() inherited
|
||||
// from data() via the spread operator. Shadowing caused the sidebar to never populate
|
||||
// its menuData, resulting in a blank sidebar on this page.
|
||||
|
||||
const myMenuConfigLog = window.LogConfig?.loggers?.myMenuConfig || window.LogConfig?.createLogger?.('myMenuConfig') || console;
|
||||
|
||||
function adminMyMenuConfig() {
|
||||
return {
|
||||
// Inherit base layout functionality from init-alpine.js
|
||||
...data(),
|
||||
|
||||
// Page-specific state
|
||||
currentPage: 'my-menu',
|
||||
loading: true,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
saving: false,
|
||||
|
||||
// Data
|
||||
menuConfig: null,
|
||||
showShowAllModal: false,
|
||||
showHideAllModal: false,
|
||||
|
||||
// Computed grouped items
|
||||
get groupedItems() {
|
||||
if (!this.menuConfig?.items) return [];
|
||||
|
||||
// Group items by section
|
||||
const sections = {};
|
||||
for (const item of this.menuConfig.items) {
|
||||
const sectionId = item.section_id;
|
||||
if (!sections[sectionId]) {
|
||||
sections[sectionId] = {
|
||||
id: sectionId,
|
||||
label: item.section_label,
|
||||
isSuperAdminOnly: item.is_super_admin_only,
|
||||
items: [],
|
||||
visibleCount: 0
|
||||
};
|
||||
}
|
||||
sections[sectionId].items.push(item);
|
||||
if (item.is_visible) {
|
||||
sections[sectionId].visibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and maintain order
|
||||
return Object.values(sections);
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._adminMyMenuConfigInitialized) {
|
||||
myMenuConfigLog.warn('Already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
window._adminMyMenuConfigInitialized = true;
|
||||
|
||||
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZING ===');
|
||||
|
||||
try {
|
||||
// Load core translations for confirmations
|
||||
await I18n.loadModule('core');
|
||||
await this.loadUserMenuConfig();
|
||||
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZED ===');
|
||||
} catch (error) {
|
||||
myMenuConfigLog.error('Failed to initialize my menu config page:', error);
|
||||
this.error = 'Failed to load page data. Please refresh.';
|
||||
}
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
await this.loadUserMenuConfig();
|
||||
},
|
||||
|
||||
async loadUserMenuConfig() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
this.menuConfig = await apiClient.get('/admin/menu-config/user');
|
||||
myMenuConfigLog.info('Loaded menu config:', {
|
||||
totalItems: this.menuConfig?.total_items,
|
||||
visibleItems: this.menuConfig?.visible_items
|
||||
});
|
||||
} catch (error) {
|
||||
myMenuConfigLog.error('Failed to load menu config:', error);
|
||||
this.error = error.message || 'Failed to load menu configuration';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async toggleVisibility(item) {
|
||||
if (item.is_mandatory) {
|
||||
myMenuConfigLog.warn('Cannot toggle mandatory item:', item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
|
||||
const newVisibility = !item.is_visible;
|
||||
|
||||
try {
|
||||
await apiClient.put('/admin/menu-config/user', {
|
||||
menu_item_id: item.id,
|
||||
is_visible: newVisibility
|
||||
});
|
||||
|
||||
// Update local state
|
||||
item.is_visible = newVisibility;
|
||||
|
||||
// Update counts
|
||||
if (newVisibility) {
|
||||
this.menuConfig.visible_items++;
|
||||
this.menuConfig.hidden_items--;
|
||||
} else {
|
||||
this.menuConfig.visible_items--;
|
||||
this.menuConfig.hidden_items++;
|
||||
}
|
||||
|
||||
myMenuConfigLog.info('Toggled visibility:', item.id, newVisibility);
|
||||
|
||||
// Reload the page to refresh sidebar
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
myMenuConfigLog.error('Failed to toggle visibility:', error);
|
||||
this.error = error.message || 'Failed to update menu visibility';
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async showAll() {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
|
||||
try {
|
||||
await apiClient.post('/admin/menu-config/user/show-all');
|
||||
myMenuConfigLog.info('Showed all menu items');
|
||||
|
||||
// Reload the page to refresh sidebar
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
myMenuConfigLog.error('Failed to show all menu items:', error);
|
||||
this.error = error.message || 'Failed to show all menu items';
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async resetToDefaults() {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
|
||||
try {
|
||||
await apiClient.post('/admin/menu-config/user/reset');
|
||||
myMenuConfigLog.info('Reset menu config to defaults');
|
||||
|
||||
// Reload the page to refresh sidebar
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
myMenuConfigLog.error('Failed to reset menu config:', error);
|
||||
this.error = error.message || 'Failed to reset menu configuration';
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,27 @@
|
||||
// Use centralized logger
|
||||
const loginLog = window.LogConfig.createLogger('MERCHANT-LOGIN');
|
||||
|
||||
function languageSelector(currentLang, enabledLanguages) {
|
||||
return {
|
||||
currentLang: currentLang || 'fr',
|
||||
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
|
||||
async setLanguage(lang) {
|
||||
if (lang === this.currentLang) return;
|
||||
try {
|
||||
// noqa: JS-008 - Login page has no apiClient; raw fetch is intentional
|
||||
await fetch('/api/v1/platform/language/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: lang }),
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
loginLog.error('Failed to set language:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function merchantLogin() {
|
||||
return {
|
||||
dark: false,
|
||||
@@ -134,6 +155,7 @@ function merchantLogin() {
|
||||
},
|
||||
|
||||
// Forgot password state
|
||||
rememberMe: false,
|
||||
showForgotPassword: false,
|
||||
forgotPasswordEmail: '',
|
||||
forgotPasswordLoading: false,
|
||||
|
||||
@@ -15,7 +15,9 @@ storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data);
|
||||
* Fetches onboarding steps from API, supports session-scoped dismiss.
|
||||
*/
|
||||
function onboardingBanner() {
|
||||
const t = (key, vars) => I18n.t(key, vars);
|
||||
return {
|
||||
t,
|
||||
visible: false,
|
||||
steps: [],
|
||||
totalSteps: 0,
|
||||
@@ -30,7 +32,19 @@ function onboardingBanner() {
|
||||
|
||||
try {
|
||||
const response = await apiClient.get('/store/dashboard/onboarding');
|
||||
this.steps = response.steps || [];
|
||||
const steps = response.steps || [];
|
||||
|
||||
// Load module translations BEFORE setting reactive data
|
||||
// Keys are like "tenancy.onboarding...." — first segment is the module
|
||||
const modules = new Set();
|
||||
for (const step of steps) {
|
||||
const mod = step.title_key?.split('.')[0];
|
||||
if (mod) modules.add(mod);
|
||||
}
|
||||
await Promise.all([...modules].map(m => I18n.loadModule(m)));
|
||||
|
||||
// Now set reactive data — Alpine re-renders with translations ready
|
||||
this.steps = steps;
|
||||
this.totalSteps = response.total_steps || 0;
|
||||
this.completedSteps = response.completed_steps || 0;
|
||||
this.progressPercentage = response.progress_percentage || 0;
|
||||
|
||||
@@ -253,7 +253,7 @@ function languageSelector(currentLang, enabledLanguages) {
|
||||
return {
|
||||
isLangOpen: false,
|
||||
currentLang: currentLang || 'fr',
|
||||
languages: enabledLanguages || ['fr', 'de', 'en'],
|
||||
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
|
||||
languageNames: {
|
||||
'en': 'English',
|
||||
'fr': 'Français',
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
{# app/templates/admin/my-menu-config.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/modals.html' import confirm_modal %}
|
||||
|
||||
{% block title %}My Menu{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminMyMenuConfig(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('My Menu Configuration', subtitle='Customize your personal admin sidebar', back_url='/admin/settings') }}
|
||||
|
||||
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
|
||||
{{ error_state('Error', show_condition='error') }}
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3')"></span>
|
||||
<div>
|
||||
<p class="text-sm text-blue-800 dark:text-blue-200">
|
||||
This configures <strong>your personal</strong> admin sidebar menu. These settings only affect your view.
|
||||
</p>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
To configure menus for platform admins or stores, go to <a href="/admin/platforms" class="underline hover:no-underline">Platforms</a> and select a platform's Menu Configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-4 mb-6 md:grid-cols-3">
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('view-grid', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Total Items</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.total_items || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Visible</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.visible_items || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:text-gray-100 dark:bg-gray-600">
|
||||
<span x-html="$icon('eye-off', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Hidden</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.hidden_items || 0"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Toggle visibility for menu items. Mandatory items cannot be hidden.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="showShowAllModal = true"
|
||||
:disabled="saving"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
|
||||
Show All
|
||||
</button>
|
||||
<button
|
||||
@click="showHideAllModal = true"
|
||||
:disabled="saving"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
<span x-html="$icon('eye-off', 'w-4 h-4 mr-2')"></span>
|
||||
Hide All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="flex items-center justify-center py-12 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<span x-html="$icon('refresh', 'w-8 h-8 animate-spin text-purple-600')"></span>
|
||||
<span class="ml-3 text-gray-500 dark:text-gray-400">Loading menu configuration...</span>
|
||||
</div>
|
||||
|
||||
<!-- Menu Items by Section -->
|
||||
<div x-show="!loading" class="space-y-6">
|
||||
<template x-for="section in groupedItems" :key="section.id">
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
|
||||
<!-- Section Header -->
|
||||
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="section.label || 'Main'"></h3>
|
||||
<span
|
||||
x-show="section.isSuperAdminOnly"
|
||||
class="ml-2 px-2 py-0.5 text-xs font-medium rounded bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
|
||||
>
|
||||
Super Admin Only
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="`${section.visibleCount}/${section.items.length} visible`"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Items -->
|
||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<template x-for="item in section.items" :key="item.id">
|
||||
<div class="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<div class="flex items-center">
|
||||
<span x-html="$icon(item.icon, 'w-5 h-5 text-gray-400 mr-3')"></span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="item.label"></p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="item.url"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Mandatory Badge -->
|
||||
<span
|
||||
x-show="item.is_mandatory"
|
||||
class="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
Mandatory
|
||||
</span>
|
||||
|
||||
<!-- Toggle Switch -->
|
||||
<button
|
||||
@click="toggleVisibility(item)"
|
||||
:disabled="item.is_mandatory || saving"
|
||||
:class="{
|
||||
'bg-purple-600': item.is_visible,
|
||||
'bg-gray-200 dark:bg-gray-600': !item.is_visible,
|
||||
'opacity-50 cursor-not-allowed': item.is_mandatory
|
||||
}"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
|
||||
role="switch"
|
||||
:aria-checked="item.is_visible"
|
||||
>
|
||||
<span
|
||||
:class="{
|
||||
'translate-x-5': item.is_visible,
|
||||
'translate-x-0': !item.is_visible
|
||||
}"
|
||||
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="groupedItems.length === 0" class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-8 text-center">
|
||||
<span x-html="$icon('view-grid', 'w-12 h-12 mx-auto text-gray-400')"></span>
|
||||
<p class="mt-4 text-gray-500 dark:text-gray-400">No menu items available.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modals -->
|
||||
{{ confirm_modal(
|
||||
'showAllModal',
|
||||
'Show All Menu Items',
|
||||
'This will make all menu items visible in your sidebar. Are you sure?',
|
||||
'showAll()',
|
||||
'showShowAllModal',
|
||||
'Show All',
|
||||
'Cancel',
|
||||
'info',
|
||||
'eye'
|
||||
) }}
|
||||
|
||||
{{ confirm_modal(
|
||||
'hideAllModal',
|
||||
'Hide All Menu Items',
|
||||
'This will hide all non-mandatory menu items from your sidebar. Are you sure?',
|
||||
'resetToDefaults()',
|
||||
'showHideAllModal',
|
||||
'Hide All',
|
||||
'Cancel',
|
||||
'warning',
|
||||
'eye-off'
|
||||
) }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('core_static', path='admin/js/my-menu-config.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
{% block alpine_data %}storeDashboard(){% endblock %}
|
||||
|
||||
{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner %}
|
||||
{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner with context %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Email Settings Warning -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -263,34 +263,49 @@ class TestMerchantMenuModuleGating:
|
||||
|
||||
def test_loyalty_appears_when_module_enabled(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||
menu_loyalty_module,
|
||||
menu_loyalty_module, menu_platform,
|
||||
):
|
||||
"""Loyalty section appears when loyalty module is enabled on subscribed platform."""
|
||||
"""Loyalty items appear under platform section when module is enabled."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Loyalty now appears under a platform-{code} section
|
||||
platform_section_id = f"platform-{menu_platform.code}"
|
||||
section_ids = {s["id"] for s in data["sections"]}
|
||||
assert "loyalty" in section_ids
|
||||
assert platform_section_id in section_ids
|
||||
# Check loyalty item exists in that platform section
|
||||
platform_section = next(
|
||||
s for s in data["sections"] if s["id"] == platform_section_id
|
||||
)
|
||||
item_ids = {i["id"] for i in platform_section["items"]}
|
||||
assert "loyalty-overview" in item_ids
|
||||
|
||||
def test_loyalty_hidden_when_module_not_enabled(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||
menu_platform,
|
||||
):
|
||||
"""Loyalty section is hidden when loyalty module is NOT enabled."""
|
||||
"""No platform section when no non-core modules are enabled."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
platform_section_id = f"platform-{menu_platform.code}"
|
||||
section_ids = {s["id"] for s in data["sections"]}
|
||||
assert "loyalty" not in section_ids
|
||||
assert platform_section_id not in section_ids
|
||||
|
||||
def test_loyalty_item_has_correct_route(
|
||||
self, client, db, menu_auth, menu_merchant, menu_subscription,
|
||||
menu_loyalty_module,
|
||||
menu_loyalty_module, menu_platform,
|
||||
):
|
||||
"""Loyalty overview item has the correct URL."""
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
data = response.json()
|
||||
loyalty = next(s for s in data["sections"] if s["id"] == "loyalty")
|
||||
overview = next(i for i in loyalty["items"] if i["id"] == "loyalty-overview")
|
||||
platform_section_id = f"platform-{menu_platform.code}"
|
||||
platform_section = next(
|
||||
s for s in data["sections"] if s["id"] == platform_section_id
|
||||
)
|
||||
overview = next(
|
||||
i for i in platform_section["items"] if i["id"] == "loyalty-overview"
|
||||
)
|
||||
assert overview["url"] == "/merchants/loyalty/overview"
|
||||
|
||||
|
||||
@@ -352,7 +367,8 @@ class TestMerchantMenuSubscriptionStatus:
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
section_ids = {s["id"] for s in response.json()["sections"]}
|
||||
assert "loyalty" in section_ids
|
||||
platform_section_id = f"platform-{menu_platform.code}"
|
||||
assert platform_section_id in section_ids
|
||||
|
||||
def test_expired_subscription_hides_non_core_modules(
|
||||
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
|
||||
@@ -374,8 +390,9 @@ class TestMerchantMenuSubscriptionStatus:
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
section_ids = {s["id"] for s in response.json()["sections"]}
|
||||
# Loyalty should NOT appear because subscription is expired
|
||||
assert "loyalty" not in section_ids
|
||||
platform_section_id = f"platform-{menu_platform.code}"
|
||||
# Platform section should NOT appear because subscription is expired
|
||||
assert platform_section_id not in section_ids
|
||||
# Core sections always appear
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
@@ -468,9 +485,20 @@ class TestMerchantMenuMultiPlatform:
|
||||
|
||||
response = client.get(BASE, headers=menu_auth)
|
||||
assert response.status_code == 200
|
||||
section_ids = {s["id"] for s in response.json()["sections"]}
|
||||
# Loyalty enabled on Platform A should appear in the union
|
||||
assert "loyalty" in section_ids
|
||||
data = response.json()
|
||||
section_ids = {s["id"] for s in data["sections"]}
|
||||
# Loyalty enabled on Platform A appears under platform-a's section
|
||||
platform_a_section_id = f"platform-{platform_a.code}"
|
||||
assert platform_a_section_id in section_ids
|
||||
# Platform B has no non-core modules, so no section
|
||||
platform_b_section_id = f"platform-{platform_b.code}"
|
||||
assert platform_b_section_id not in section_ids
|
||||
# Check loyalty item exists in Platform A section
|
||||
pa_section = next(
|
||||
s for s in data["sections"] if s["id"] == platform_a_section_id
|
||||
)
|
||||
item_ids = {i["id"] for i in pa_section["items"]}
|
||||
assert "loyalty-overview" in item_ids
|
||||
# Core sections always present
|
||||
assert "main" in section_ids
|
||||
assert "billing" in section_ids
|
||||
|
||||
@@ -110,3 +110,62 @@ class TestMenuServiceMerchantRendering:
|
||||
without_ids = {s.id for s in without_loyalty}
|
||||
assert "loyalty" in with_ids
|
||||
assert "loyalty" not in without_ids
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.core
|
||||
class TestMenuServiceMerchantByPlatform:
|
||||
"""Test get_merchant_menu_by_platform grouping logic."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = MenuService()
|
||||
|
||||
def test_core_sections_contain_only_core_items(self, db):
|
||||
"""Core sections should only have items from core modules."""
|
||||
from app.modules.core.services.menu_discovery_service import (
|
||||
menu_discovery_service,
|
||||
)
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
|
||||
|
||||
core_sections = menu_discovery_service.get_menu_sections_for_frontend(
|
||||
db,
|
||||
FrontendType.MERCHANT,
|
||||
enabled_module_codes=core_codes,
|
||||
)
|
||||
for section in core_sections:
|
||||
section.items = [i for i in section.items if i.module_code in core_codes]
|
||||
|
||||
for section in core_sections:
|
||||
for item in section.items:
|
||||
assert item.module_code in core_codes, (
|
||||
f"Non-core item '{item.id}' (module={item.module_code}) "
|
||||
f"found in core section '{section.id}'"
|
||||
)
|
||||
|
||||
def test_non_core_sections_for_loyalty(self, db):
|
||||
"""Non-core modules like loyalty produce merchant menu sections."""
|
||||
from app.modules.core.services.menu_discovery_service import (
|
||||
menu_discovery_service,
|
||||
)
|
||||
from app.modules.registry import MODULES
|
||||
|
||||
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
|
||||
non_core = {"loyalty"}
|
||||
|
||||
sections = menu_discovery_service.get_menu_sections_for_frontend(
|
||||
db,
|
||||
FrontendType.MERCHANT,
|
||||
enabled_module_codes=non_core,
|
||||
)
|
||||
sections = [
|
||||
s
|
||||
for s in sections
|
||||
if any(i.module_code in non_core for i in s.items)
|
||||
]
|
||||
|
||||
assert len(sections) > 0, "Loyalty module should produce merchant menu sections"
|
||||
for section in sections:
|
||||
for item in section.items:
|
||||
assert item.module_code not in core_codes
|
||||
|
||||
Reference in New Issue
Block a user