diff --git a/app/api/deps.py b/app/api/deps.py index 72bba9c7..55ebd74e 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -620,9 +620,9 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"): ) if user_context.is_super_admin: - # Super admin: check user-level config - platform_id = None - user_id = user_context.id + # Super admin: use platform from token if selected, else global (no filtering) + platform_id = user_context.token_platform_id + user_id = None else: # Platform admin: need platform context # Try to get from request state diff --git a/app/modules/analytics/definition.py b/app/modules/analytics/definition.py index 823a2f72..c45ddde3 100644 --- a/app/modules/analytics/definition.py +++ b/app/modules/analytics/definition.py @@ -96,6 +96,7 @@ analytics_module = ModuleDefinition( icon="chart-bar", route="/store/{store_code}/analytics", order=20, + requires_permission="analytics.view", ), ], ), diff --git a/app/modules/billing/static/shared/js/upgrade-prompts.js b/app/modules/billing/static/shared/js/upgrade-prompts.js index a81a7d79..3113af3a 100644 --- a/app/modules/billing/static/shared/js/upgrade-prompts.js +++ b/app/modules/billing/static/shared/js/upgrade-prompts.js @@ -82,7 +82,7 @@ this.loading = true; this.error = null; - const response = await apiClient.get('/store/usage'); + const response = await apiClient.get('/store/billing/usage'); this.usage = response; this.loaded = true; diff --git a/app/modules/checkout/routes/api/storefront.py b/app/modules/checkout/routes/api/storefront.py index 1c8fe66e..3fae32d8 100644 --- a/app/modules/checkout/routes/api/storefront.py +++ b/app/modules/checkout/routes/api/storefront.py @@ -11,7 +11,6 @@ Requires customer authentication for order placement. """ import logging -from datetime import UTC, datetime from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session @@ -92,15 +91,21 @@ def place_order( }, ) - # Update customer stats - customer.total_orders = (customer.total_orders or 0) + 1 - customer.total_spent = (customer.total_spent or 0) + order.total_amount - customer.last_order_date = datetime.now(UTC) - db.flush() + # Update customer order stats (owned by orders module) + from app.modules.orders.services.customer_order_service import ( + customer_order_service, + ) + + stats = customer_order_service.record_order( + db=db, + store_id=store.id, + customer_id=customer.id, + total_amount_cents=order.total_amount_cents, + ) logger.debug( - f"Updated customer stats: total_orders={customer.total_orders}, " - f"total_spent={customer.total_spent}" + f"Updated customer order stats: total_orders={stats.total_orders}, " + f"total_spent_cents={stats.total_spent_cents}" ) # Clear cart (get session_id from request cookies or headers) diff --git a/app/modules/contracts/onboarding.py b/app/modules/contracts/onboarding.py index 1113151e..6090d434 100644 --- a/app/modules/contracts/onboarding.py +++ b/app/modules/contracts/onboarding.py @@ -23,8 +23,8 @@ Usage: return [ OnboardingStepDefinition( key="marketplace.connect_api", - title_key="onboarding.marketplace.connect_api.title", - description_key="onboarding.marketplace.connect_api.description", + title_key="marketplace.onboarding.connect_api.title", + description_key="marketplace.onboarding.connect_api.description", icon="plug", route_template="/store/{store_code}/letzshop", order=200, diff --git a/app/modules/core/definition.py b/app/modules/core/definition.py index eebddf3d..2af30a51 100644 --- a/app/modules/core/definition.py +++ b/app/modules/core/definition.py @@ -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, - ), ], ), ], diff --git a/app/modules/core/locales/de.json b/app/modules/core/locales/de.json index f357a56f..37efd140 100644 --- a/app/modules/core/locales/de.json +++ b/app/modules/core/locales/de.json @@ -72,7 +72,6 @@ "dashboard": "Dashboard", "platform_settings": "Plattform-Einstellungen", "general": "Allgemein", - "my_menu": "Mein Menü", "account_settings": "Kontoeinstellungen", "profile": "Profil", "settings": "Einstellungen" diff --git a/app/modules/core/locales/en.json b/app/modules/core/locales/en.json index 44b4521b..4c492645 100644 --- a/app/modules/core/locales/en.json +++ b/app/modules/core/locales/en.json @@ -92,7 +92,6 @@ "dashboard": "Dashboard", "platform_settings": "Platform Settings", "general": "General", - "my_menu": "My Menu", "account_settings": "Account Settings", "profile": "Profile", "settings": "Settings" diff --git a/app/modules/core/locales/fr.json b/app/modules/core/locales/fr.json index 62156d14..98dac978 100644 --- a/app/modules/core/locales/fr.json +++ b/app/modules/core/locales/fr.json @@ -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" diff --git a/app/modules/core/locales/lb.json b/app/modules/core/locales/lb.json index ff56ed40..69d46f0e 100644 --- a/app/modules/core/locales/lb.json +++ b/app/modules/core/locales/lb.json @@ -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" diff --git a/app/modules/core/models/admin_menu_config.py b/app/modules/core/models/admin_menu_config.py index 0217cd81..023f4867 100644 --- a/app/modules/core/models/admin_menu_config.py +++ b/app/modules/core/models/admin_menu_config.py @@ -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.", ) # ======================================================================== diff --git a/app/modules/core/routes/api/admin_menu_config.py b/app/modules/core/routes/api/admin_menu_config.py index 518a3d96..5475b8cc 100644 --- a/app/modules/core/routes/api/admin_menu_config.py +++ b/app/modules/core/routes/api/admin_menu_config.py @@ -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: diff --git a/app/modules/core/routes/api/merchant_menu.py b/app/modules/core/routes/api/merchant_menu.py index b52a9f55..29428412 100644 --- a/app/modules/core/routes/api/merchant_menu.py +++ b/app/modules/core/routes/api/merchant_menu.py @@ -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, ) diff --git a/app/modules/core/routes/api/store_settings.py b/app/modules/core/routes/api/store_settings.py index bbb02b9f..5b96fa26 100644 --- a/app/modules/core/routes/api/store_settings.py +++ b/app/modules/core/routes/api/store_settings.py @@ -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, diff --git a/app/modules/core/routes/pages/admin.py b/app/modules/core/routes/pages/admin.py index dd8aff34..7464aea2 100644 --- a/app/modules/core/routes/pages/admin.py +++ b/app/modules/core/routes/pages/admin.py @@ -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, diff --git a/app/modules/core/routes/pages/merchant.py b/app/modules/core/routes/pages/merchant.py index 534b58b6..320de998 100644 --- a/app/modules/core/routes/pages/merchant.py +++ b/app/modules/core/routes/pages/merchant.py @@ -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) # ============================================================================ diff --git a/app/modules/core/services/menu_service.py b/app/modules/core/services/menu_service.py index 64d8992c..a791b3d6 100644 --- a/app/modules/core/services/menu_service.py +++ b/app/modules/core/services/menu_service.py @@ -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, diff --git a/app/modules/core/services/onboarding_aggregator.py b/app/modules/core/services/onboarding_aggregator.py index be097db3..b8212eaa 100644 --- a/app/modules/core/services/onboarding_aggregator.py +++ b/app/modules/core/services/onboarding_aggregator.py @@ -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: diff --git a/app/modules/core/services/stats_aggregator.py b/app/modules/core/services/stats_aggregator.py index f8f636d2..30f80601 100644 --- a/app/modules/core/services/stats_aggregator.py +++ b/app/modules/core/services/stats_aggregator.py @@ -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: diff --git a/app/modules/core/static/admin/js/init-alpine.js b/app/modules/core/static/admin/js/init-alpine.js index 7702c3aa..b21f4d40 100644 --- a/app/modules/core/static/admin/js/init-alpine.js +++ b/app/modules/core/static/admin/js/init-alpine.js @@ -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', diff --git a/app/modules/core/static/admin/js/login.js b/app/modules/core/static/admin/js/login.js index f46edda7..5b01a8fa 100644 --- a/app/modules/core/static/admin/js/login.js +++ b/app/modules/core/static/admin/js/login.js @@ -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; diff --git a/app/modules/core/static/admin/js/my-menu-config.js b/app/modules/core/static/admin/js/my-menu-config.js deleted file mode 100644 index 7d1e5a76..00000000 --- a/app/modules/core/static/admin/js/my-menu-config.js +++ /dev/null @@ -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; - } - } - }; -} diff --git a/app/modules/core/static/merchant/js/login.js b/app/modules/core/static/merchant/js/login.js index 4f0506c6..2b640122 100644 --- a/app/modules/core/static/merchant/js/login.js +++ b/app/modules/core/static/merchant/js/login.js @@ -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, diff --git a/app/modules/core/static/store/js/dashboard.js b/app/modules/core/static/store/js/dashboard.js index 51128e5c..79bc5997 100644 --- a/app/modules/core/static/store/js/dashboard.js +++ b/app/modules/core/static/store/js/dashboard.js @@ -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; diff --git a/app/modules/core/static/storefront/js/storefront-layout.js b/app/modules/core/static/storefront/js/storefront-layout.js index 9565bd48..30632b16 100644 --- a/app/modules/core/static/storefront/js/storefront-layout.js +++ b/app/modules/core/static/storefront/js/storefront-layout.js @@ -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', diff --git a/app/modules/core/templates/core/admin/my-menu-config.html b/app/modules/core/templates/core/admin/my-menu-config.html deleted file mode 100644 index 0181027a..00000000 --- a/app/modules/core/templates/core/admin/my-menu-config.html +++ /dev/null @@ -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') }} - - -
- This configures your personal admin sidebar menu. These settings only affect your view. -
-- To configure menus for platform admins or stores, go to Platforms and select a platform's Menu Configuration. -
-Total Items
- -Visible
- -Hidden
- -- Toggle visibility for menu items. Mandatory items cannot be hidden. -
-No menu items available.
-Basic store configuration
-Contact support to change your subdomain
-Store Status
-Your store is currently visible to customers
-Verification Status
-Verified stores get a badge on their store
-- Store details and contact information - - (inheriting from ) - -
-- Leave empty to use merchant default -
-Configure language and regional settings
-- Controls how prices and numbers are displayed (e.g., "29,99 EUR" vs "EUR29.99") -
-- Language for the store dashboard interface -
-- Primary language for products, emails, and other content -
-- Default language shown to customers visiting your shop -
-- Languages available in the storefront language selector -
-Configure Letzshop marketplace feed settings
-- Store ID: - - () - -
- -- Auto-sync is enabled -
- -- Enter the URLs for your Letzshop product feeds in different languages. -
- - -- Default VAT rate for products without explicit rate -
-- Higher values boost product visibility (0.0 - 10.0) -
-Configure invoice generation and billing details
-
-
-
-
-
-
- Contact support to update invoice settings. -
-No invoice settings configured
-Contact support to set up invoicing.
-Customize your storefront appearance
-Active Theme
- -Logo
-Favicon
-Social Links
-- Theme customization coming soon. Contact support for custom branding requests. -
-Using default theme
-Contact support for custom branding.
-Configure your email sending settings for customer communications
-Email not configured
-- Configure your SMTP settings to send emails to your customers (order confirmations, shipping updates, etc.) -
-Email configured but not verified
-- Send a test email to verify your settings are working correctly. -
-Email configured and verified
-- Your email settings are ready. Emails will be sent from -
-- Email address that customers will see in their inbox -
-- Name that appears as the sender (e.g., "Your Store Name") -
-- Optional: Where replies should go (defaults to From Email) -
-Manage your storefront domains
-Default Subdomain
- -- Need a custom domain? Contact support to set up your own domain with SSL. -
-Payment integrations and API access
-Payment processing
-Marketplace integration
-- API keys and payment credentials are managed securely. Contact support for changes. -
-Control how you receive notifications
-Email Notifications
-Receive important updates via email
-Order Notifications
-Get notified when you receive new orders
-Marketing Emails
-Receive tips, updates, and promotional content
-- Note: Notification settings are currently display-only. Full notification management coming soon. -
-- With Orders -
-- 0 -
-- Total Revenue -
-- 0 -
-Loading customers...
No customers found
Try adjusting your search or filters
@@ -189,16 +157,6 @@Customer #
+ +Phone
+ +Joined
+ +Language
+ +Marketing
+ +No orders yet
+