feat(roles): add admin store roles page, permission i18n, and menu integration
Some checks failed
CI / ruff (push) Successful in 9s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has started running

- Add admin store roles page with merchant→store cascading for superadmin
  and store-only selection for platform admin
- Add permission catalog API with translated labels/descriptions (en/fr/de/lb)
- Add permission translations to all 15 module locale files (60 files total)
- Add info icon tooltips for permission descriptions in role editor
- Add store roles menu item and admin menu item in module definition
- Fix store-selector.js URL construction bug when apiEndpoint has query params
- Add admin store roles API (CRUD + platform scoping)
- Add integration tests for admin store roles and permission catalog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 23:31:27 +01:00
parent 2b55e7458b
commit f95db7c0b1
83 changed files with 3491 additions and 513 deletions

View File

@@ -761,3 +761,96 @@ module_rules:
file_pattern: "main.py" file_pattern: "main.py"
validates: validates:
- "module_locales mount BEFORE module_static mount" - "module_locales mount BEFORE module_static mount"
# =========================================================================
# Cross-Module Boundary Rules
# =========================================================================
- id: "MOD-025"
name: "Modules must NOT import models from other modules"
severity: "error"
description: |
Modules must access data from other modules through their SERVICE layer,
never by importing and querying their models directly.
This is the "services over models" principle: if module A needs data
from module B, it MUST call module B's service methods.
WRONG (direct model import):
# app/modules/orders/services/order_service.py
from app.modules.catalog.models import Product # FORBIDDEN
class OrderService:
def get_order_details(self, db, order_id):
product = db.query(Product).filter_by(id=pid).first()
RIGHT (service call):
# app/modules/orders/services/order_service.py
from app.modules.catalog.services import product_service
class OrderService:
def get_order_details(self, db, order_id):
product = product_service.get_product_by_id(db, pid)
ALSO RIGHT (provider protocol for core→optional):
# app/modules/core/services/stats_aggregator.py
from app.modules.contracts.metrics import MetricsProviderProtocol
# Discover providers through registry, no direct imports
EXCEPTIONS:
- Test fixtures may create models from other modules for setup
- TYPE_CHECKING imports for type hints are allowed
- Tenancy models (User, Store, Merchant, Platform) may be imported
as type hints in route signatures where FastAPI requires it,
but queries must go through tenancy services
WHY THIS MATTERS:
- Encapsulation: Modules own their data access patterns
- Refactoring: Module B can change its schema without breaking A
- Testability: Mock services, not database queries
- Consistency: Clear API boundaries between modules
- Decoupling: Modules can evolve independently
pattern:
file_pattern: "app/modules/*/services/**/*.py"
anti_patterns:
- "from app\\.modules\\.(?!<own_module>)\\.models import"
exceptions:
- "TYPE_CHECKING"
- "tests/"
- id: "MOD-026"
name: "Cross-module data access must use service methods"
severity: "error"
description: |
When a module needs data from another module, it must use that
module's public service API. Each module should expose service
methods for common data access patterns.
Service methods a module should expose:
- get_{entity}_by_id(db, id) -> Entity or None
- list_{entities}(db, filters) -> list[Entity]
- get_{entity}_count(db, filters) -> int
- search_{entities}(db, query, filters) -> list[Entity]
WRONG (direct query across module boundary):
# In orders module
count = db.query(func.count(Product.id)).scalar()
RIGHT (call catalog service):
# In orders module
count = product_service.get_product_count(db, store_id=store_id)
This applies to:
- Simple lookups (get by ID)
- List/search queries
- Aggregation queries (count, sum)
- Join queries (should be decomposed into service calls)
WHY THIS MATTERS:
- Single source of truth for data access logic
- Easier to add caching, validation, or access control
- Clear contract between modules
- Simpler testing with service mocks
pattern:
file_pattern: "app/modules/*/services/**/*.py"
check: "cross_module_service_usage"

View File

@@ -16,5 +16,13 @@
}, },
"menu": { "menu": {
"analytics": "Analytik" "analytics": "Analytik"
},
"permissions": {
"view": "Analytik anzeigen",
"view_desc": "Zugriff auf Analytik-Dashboards und Berichte",
"export": "Analytik exportieren",
"export_desc": "Analytikdaten und Berichte exportieren",
"manage_dashboards": "Dashboards verwalten",
"manage_dashboards_desc": "Analytik-Dashboards erstellen und konfigurieren"
} }
} }

View File

@@ -14,6 +14,14 @@
"loading": "Loading analytics...", "loading": "Loading analytics...",
"error_loading": "Failed to load analytics data" "error_loading": "Failed to load analytics data"
}, },
"permissions": {
"view": "View Analytics",
"view_desc": "Access analytics dashboards and reports",
"export": "Export Analytics",
"export_desc": "Export analytics data and reports",
"manage_dashboards": "Manage Dashboards",
"manage_dashboards_desc": "Create and configure analytics dashboards"
},
"menu": { "menu": {
"analytics": "Analytics" "analytics": "Analytics"
} }

View File

@@ -16,5 +16,13 @@
}, },
"menu": { "menu": {
"analytics": "Analytique" "analytics": "Analytique"
},
"permissions": {
"view": "Voir l'analytique",
"view_desc": "Accéder aux tableaux de bord et rapports analytiques",
"export": "Exporter l'analytique",
"export_desc": "Exporter les données et rapports analytiques",
"manage_dashboards": "Gérer les tableaux de bord",
"manage_dashboards_desc": "Créer et configurer les tableaux de bord analytiques"
} }
} }

View File

@@ -16,5 +16,13 @@
}, },
"menu": { "menu": {
"analytics": "Analytik" "analytics": "Analytik"
},
"permissions": {
"view": "Analytik kucken",
"view_desc": "Zougang zu Analytik-Dashboards a Berichter",
"export": "Analytik exportéieren",
"export_desc": "Analytikdaten a Berichter exportéieren",
"manage_dashboards": "Dashboards verwalten",
"manage_dashboards_desc": "Analytik-Dashboards erstellen a konfiguréieren"
} }
} }

View File

@@ -134,5 +134,17 @@
"invoices": "Rechnungen", "invoices": "Rechnungen",
"account_settings": "Kontoeinstellungen", "account_settings": "Kontoeinstellungen",
"billing": "Abrechnung" "billing": "Abrechnung"
},
"permissions": {
"view_tiers": "Tarife anzeigen",
"view_tiers_desc": "Details der Abonnement-Tarife anzeigen",
"manage_tiers": "Tarife verwalten",
"manage_tiers_desc": "Abonnement-Tarife erstellen und konfigurieren",
"view_subscriptions": "Abonnements anzeigen",
"view_subscriptions_desc": "Abonnementdetails anzeigen",
"manage_subscriptions": "Abonnements verwalten",
"manage_subscriptions_desc": "Abonnements und Abrechnung verwalten",
"view_invoices": "Rechnungen anzeigen",
"view_invoices_desc": "Rechnungen und Abrechnungsverlauf anzeigen"
} }
} }

View File

@@ -81,6 +81,18 @@
"current": "Current Plan", "current": "Current Plan",
"recommended": "Recommended" "recommended": "Recommended"
}, },
"permissions": {
"view_tiers": "View Tiers",
"view_tiers_desc": "View subscription tier details",
"manage_tiers": "Manage Tiers",
"manage_tiers_desc": "Create and configure subscription tiers",
"view_subscriptions": "View Subscriptions",
"view_subscriptions_desc": "View store subscription details",
"manage_subscriptions": "Manage Subscriptions",
"manage_subscriptions_desc": "Manage store subscriptions and billing",
"view_invoices": "View Invoices",
"view_invoices_desc": "View billing invoices and history"
},
"messages": { "messages": {
"subscription_updated": "Subscription updated successfully", "subscription_updated": "Subscription updated successfully",
"tier_created": "Tier created successfully", "tier_created": "Tier created successfully",

View File

@@ -134,5 +134,17 @@
"invoices": "Factures", "invoices": "Factures",
"account_settings": "Paramètres du compte", "account_settings": "Paramètres du compte",
"billing": "Facturation" "billing": "Facturation"
},
"permissions": {
"view_tiers": "Voir les niveaux",
"view_tiers_desc": "Voir les détails des niveaux d'abonnement",
"manage_tiers": "Gérer les niveaux",
"manage_tiers_desc": "Créer et configurer les niveaux d'abonnement",
"view_subscriptions": "Voir les abonnements",
"view_subscriptions_desc": "Voir les détails des abonnements",
"manage_subscriptions": "Gérer les abonnements",
"manage_subscriptions_desc": "Gérer les abonnements et la facturation",
"view_invoices": "Voir les factures",
"view_invoices_desc": "Voir les factures et l'historique de facturation"
} }
} }

View File

@@ -134,5 +134,17 @@
"invoices": "Rechnungen", "invoices": "Rechnungen",
"account_settings": "Kont-Astellungen", "account_settings": "Kont-Astellungen",
"billing": "Ofrechnung" "billing": "Ofrechnung"
},
"permissions": {
"view_tiers": "Tariffer kucken",
"view_tiers_desc": "Detailer vun den Abonnement-Tariffer kucken",
"manage_tiers": "Tariffer verwalten",
"manage_tiers_desc": "Abonnement-Tariffer erstellen a konfiguréieren",
"view_subscriptions": "Abonnementer kucken",
"view_subscriptions_desc": "Abonnementdetailer kucken",
"manage_subscriptions": "Abonnementer verwalten",
"manage_subscriptions_desc": "Abonnementer an Ofrechnung verwalten",
"view_invoices": "Rechnunge kucken",
"view_invoices_desc": "Rechnungen an Ofrechnungsverlaf kucken"
} }
} }

View File

@@ -1,42 +1,48 @@
{ {
"title": "Warenkorb", "title": "Warenkorb",
"description": "Warenkorbverwaltung für Kunden", "description": "Warenkorbverwaltung für Kunden",
"cart": { "cart": {
"title": "Ihr Warenkorb", "title": "Ihr Warenkorb",
"empty": "Ihr Warenkorb ist leer", "empty": "Ihr Warenkorb ist leer",
"empty_subtitle": "Fügen Sie Artikel hinzu, um einzukaufen", "empty_subtitle": "Fügen Sie Artikel hinzu, um einzukaufen",
"continue_shopping": "Weiter einkaufen", "continue_shopping": "Weiter einkaufen",
"proceed_to_checkout": "Zur Kasse" "proceed_to_checkout": "Zur Kasse"
}, },
"item": { "item": {
"product": "Produkt", "product": "Produkt",
"quantity": "Menge", "quantity": "Menge",
"price": "Preis", "price": "Preis",
"total": "Gesamt", "total": "Gesamt",
"remove": "Entfernen", "remove": "Entfernen",
"update": "Aktualisieren" "update": "Aktualisieren"
}, },
"summary": { "summary": {
"title": "Bestellübersicht", "title": "Bestellübersicht",
"subtotal": "Zwischensumme", "subtotal": "Zwischensumme",
"shipping": "Versand", "shipping": "Versand",
"estimated_shipping": "Wird an der Kasse berechnet", "estimated_shipping": "Wird an der Kasse berechnet",
"tax": "MwSt.", "tax": "MwSt.",
"total": "Gesamtsumme" "total": "Gesamtsumme"
}, },
"validation": { "validation": {
"invalid_quantity": "Ungültige Menge", "invalid_quantity": "Ungültige Menge",
"min_quantity": "Mindestmenge ist {min}", "min_quantity": "Mindestmenge ist {min}",
"max_quantity": "Höchstmenge ist {max}", "max_quantity": "Höchstmenge ist {max}",
"insufficient_inventory": "Nur {available} verfügbar" "insufficient_inventory": "Nur {available} verfügbar"
}, },
"messages": { "messages": {
"item_added": "Artikel zum Warenkorb hinzugefügt", "item_added": "Artikel zum Warenkorb hinzugefügt",
"item_updated": "Warenkorb aktualisiert", "item_updated": "Warenkorb aktualisiert",
"item_removed": "Artikel aus dem Warenkorb entfernt", "item_removed": "Artikel aus dem Warenkorb entfernt",
"cart_cleared": "Warenkorb geleert", "cart_cleared": "Warenkorb geleert",
"product_not_available": "Produkt nicht verfügbar", "product_not_available": "Produkt nicht verfügbar",
"error_adding": "Fehler beim Hinzufügen zum Warenkorb", "error_adding": "Fehler beim Hinzufügen zum Warenkorb",
"error_updating": "Fehler beim Aktualisieren des Warenkorbs" "error_updating": "Fehler beim Aktualisieren des Warenkorbs"
} },
"permissions": {
"view": "Warenkörbe anzeigen",
"view_desc": "Warenkörbe der Kunden anzeigen",
"manage": "Warenkörbe verwalten",
"manage_desc": "Warenkörbe der Kunden bearbeiten und verwalten"
}
} }

View File

@@ -30,6 +30,12 @@
"max_quantity": "Maximum quantity is {max}", "max_quantity": "Maximum quantity is {max}",
"insufficient_inventory": "Only {available} available" "insufficient_inventory": "Only {available} available"
}, },
"permissions": {
"view": "View Carts",
"view_desc": "View customer shopping carts",
"manage": "Manage Carts",
"manage_desc": "Modify and manage customer carts"
},
"messages": { "messages": {
"item_added": "Item added to cart", "item_added": "Item added to cart",
"item_updated": "Cart updated", "item_updated": "Cart updated",

View File

@@ -1,42 +1,48 @@
{ {
"title": "Panier", "title": "Panier",
"description": "Gestion du panier pour les clients", "description": "Gestion du panier pour les clients",
"cart": { "cart": {
"title": "Votre panier", "title": "Votre panier",
"empty": "Votre panier est vide", "empty": "Votre panier est vide",
"empty_subtitle": "Ajoutez des articles pour commencer vos achats", "empty_subtitle": "Ajoutez des articles pour commencer vos achats",
"continue_shopping": "Continuer mes achats", "continue_shopping": "Continuer mes achats",
"proceed_to_checkout": "Passer à la caisse" "proceed_to_checkout": "Passer à la caisse"
}, },
"item": { "item": {
"product": "Produit", "product": "Produit",
"quantity": "Quantité", "quantity": "Quantité",
"price": "Prix", "price": "Prix",
"total": "Total", "total": "Total",
"remove": "Supprimer", "remove": "Supprimer",
"update": "Mettre à jour" "update": "Mettre à jour"
}, },
"summary": { "summary": {
"title": "Récapitulatif de commande", "title": "Récapitulatif de commande",
"subtotal": "Sous-total", "subtotal": "Sous-total",
"shipping": "Livraison", "shipping": "Livraison",
"estimated_shipping": "Calculé à la caisse", "estimated_shipping": "Calculé à la caisse",
"tax": "TVA", "tax": "TVA",
"total": "Total" "total": "Total"
}, },
"validation": { "validation": {
"invalid_quantity": "Quantité invalide", "invalid_quantity": "Quantité invalide",
"min_quantity": "Quantité minimum: {min}", "min_quantity": "Quantité minimum: {min}",
"max_quantity": "Quantité maximum: {max}", "max_quantity": "Quantité maximum: {max}",
"insufficient_inventory": "Seulement {available} disponible(s)" "insufficient_inventory": "Seulement {available} disponible(s)"
}, },
"messages": { "messages": {
"item_added": "Article ajouté au panier", "item_added": "Article ajouté au panier",
"item_updated": "Panier mis à jour", "item_updated": "Panier mis à jour",
"item_removed": "Article supprimé du panier", "item_removed": "Article supprimé du panier",
"cart_cleared": "Panier vidé", "cart_cleared": "Panier vidé",
"product_not_available": "Produit non disponible", "product_not_available": "Produit non disponible",
"error_adding": "Erreur lors de l'ajout au panier", "error_adding": "Erreur lors de l'ajout au panier",
"error_updating": "Erreur lors de la mise à jour du panier" "error_updating": "Erreur lors de la mise à jour du panier"
} },
"permissions": {
"view": "Voir les paniers",
"view_desc": "Voir les paniers des clients",
"manage": "Gérer les paniers",
"manage_desc": "Modifier et gérer les paniers des clients"
}
} }

View File

@@ -1,42 +1,48 @@
{ {
"title": "Akafskuerf", "title": "Akafskuerf",
"description": "Kuerfverwaltung fir Clienten", "description": "Kuerfverwaltung fir Clienten",
"cart": { "cart": {
"title": "Äre Kuerf", "title": "Äre Kuerf",
"empty": "Äre Kuerf ass eidel", "empty": "Äre Kuerf ass eidel",
"empty_subtitle": "Setzt Artikelen derbäi fir anzekafen", "empty_subtitle": "Setzt Artikelen derbäi fir anzekafen",
"continue_shopping": "Weider akafen", "continue_shopping": "Weider akafen",
"proceed_to_checkout": "Zur Keess" "proceed_to_checkout": "Zur Keess"
}, },
"item": { "item": {
"product": "Produkt", "product": "Produkt",
"quantity": "Unzuel", "quantity": "Unzuel",
"price": "Präis", "price": "Präis",
"total": "Gesamt", "total": "Gesamt",
"remove": "Ewechhuelen", "remove": "Ewechhuelen",
"update": "Aktualiséieren" "update": "Aktualiséieren"
}, },
"summary": { "summary": {
"title": "Bestelliwwersiicht", "title": "Bestelliwwersiicht",
"subtotal": "Zwëschesumm", "subtotal": "Zwëschesumm",
"shipping": "Liwwerung", "shipping": "Liwwerung",
"estimated_shipping": "Gëtt bei der Keess berechent", "estimated_shipping": "Gëtt bei der Keess berechent",
"tax": "MwSt.", "tax": "MwSt.",
"total": "Gesamtsumm" "total": "Gesamtsumm"
}, },
"validation": { "validation": {
"invalid_quantity": "Ongëlteg Unzuel", "invalid_quantity": "Ongëlteg Unzuel",
"min_quantity": "Mindestunzuel ass {min}", "min_quantity": "Mindestunzuel ass {min}",
"max_quantity": "Héichstunzuel ass {max}", "max_quantity": "Héichstunzuel ass {max}",
"insufficient_inventory": "Nëmmen {available} verfügbar" "insufficient_inventory": "Nëmmen {available} verfügbar"
}, },
"messages": { "messages": {
"item_added": "Artikel an de Kuerf gesat", "item_added": "Artikel an de Kuerf gesat",
"item_updated": "Kuerf aktualiséiert", "item_updated": "Kuerf aktualiséiert",
"item_removed": "Artikel aus dem Kuerf ewechgeholl", "item_removed": "Artikel aus dem Kuerf ewechgeholl",
"cart_cleared": "Kuerf eidel gemaach", "cart_cleared": "Kuerf eidel gemaach",
"product_not_available": "Produkt net verfügbar", "product_not_available": "Produkt net verfügbar",
"error_adding": "Feeler beim Derbäisetzen an de Kuerf", "error_adding": "Feeler beim Derbäisetzen an de Kuerf",
"error_updating": "Feeler beim Aktualiséiere vum Kuerf" "error_updating": "Feeler beim Aktualiséiere vum Kuerf"
} },
"permissions": {
"view": "Kuerf kucken",
"view_desc": "Clientekuerf kucken",
"manage": "Kuerf verwalten",
"manage_desc": "Clientekuerf änneren a verwalten"
}
} }

View File

@@ -75,5 +75,19 @@
"menu": { "menu": {
"products_inventory": "Produkte & Inventar", "products_inventory": "Produkte & Inventar",
"all_products": "Alle Produkte" "all_products": "Alle Produkte"
},
"permissions": {
"products_view": "Produkte anzeigen",
"products_view_desc": "Produktkatalog anzeigen",
"products_create": "Produkte erstellen",
"products_create_desc": "Neue Produkte zum Katalog hinzufügen",
"products_edit": "Produkte bearbeiten",
"products_edit_desc": "Bestehende Produktdetails ändern",
"products_delete": "Produkte löschen",
"products_delete_desc": "Produkte aus dem Katalog entfernen",
"products_import": "Produkte importieren",
"products_import_desc": "Massenimport von Produkten",
"products_export": "Produkte exportieren",
"products_export_desc": "Produktdaten exportieren"
} }
} }

View File

@@ -46,6 +46,20 @@
"sort_name_az": "Name: A-Z", "sort_name_az": "Name: A-Z",
"sort_name_za": "Name: Z-A" "sort_name_za": "Name: Z-A"
}, },
"permissions": {
"products_view": "View Products",
"products_view_desc": "View the product catalog",
"products_create": "Create Products",
"products_create_desc": "Add new products to the catalog",
"products_edit": "Edit Products",
"products_edit_desc": "Modify existing product details",
"products_delete": "Delete Products",
"products_delete_desc": "Remove products from the catalog",
"products_import": "Import Products",
"products_import_desc": "Bulk import products from files",
"products_export": "Export Products",
"products_export_desc": "Export product data to files"
},
"messages": { "messages": {
"product_deleted_successfully": "Product deleted successfully", "product_deleted_successfully": "Product deleted successfully",
"product_created_successfully": "Product created successfully", "product_created_successfully": "Product created successfully",

View File

@@ -75,5 +75,19 @@
"menu": { "menu": {
"products_inventory": "Produits et Inventaire", "products_inventory": "Produits et Inventaire",
"all_products": "Tous les produits" "all_products": "Tous les produits"
},
"permissions": {
"products_view": "Voir les produits",
"products_view_desc": "Voir le catalogue de produits",
"products_create": "Créer des produits",
"products_create_desc": "Ajouter de nouveaux produits au catalogue",
"products_edit": "Modifier les produits",
"products_edit_desc": "Modifier les détails des produits existants",
"products_delete": "Supprimer les produits",
"products_delete_desc": "Retirer des produits du catalogue",
"products_import": "Importer les produits",
"products_import_desc": "Importation en masse de produits",
"products_export": "Exporter les produits",
"products_export_desc": "Exporter les données produits"
} }
} }

View File

@@ -75,5 +75,19 @@
"menu": { "menu": {
"products_inventory": "Produkter & Inventar", "products_inventory": "Produkter & Inventar",
"all_products": "All Produkter" "all_products": "All Produkter"
},
"permissions": {
"products_view": "Produiten kucken",
"products_view_desc": "Produitkatalog kucken",
"products_create": "Produiten erstellen",
"products_create_desc": "Nei Produiten an de Katalog derbäisetzen",
"products_edit": "Produiten änneren",
"products_edit_desc": "Bestoend Produitdetailer änneren",
"products_delete": "Produiten läschen",
"products_delete_desc": "Produiten aus dem Katalog ewechhuelen",
"products_import": "Produiten importéieren",
"products_import_desc": "Massenimport vu Produiten",
"products_export": "Produiten exportéieren",
"products_export_desc": "Produitdaten exportéieren"
} }
} }

View File

@@ -1,20 +1,26 @@
{ {
"storefront": { "storefront": {
"welcome": "Willkommen in unserem Shop", "welcome": "Willkommen in unserem Shop",
"browse_products": "Produkte durchstöbern", "browse_products": "Produkte durchstöbern",
"add_to_cart": "In den Warenkorb", "add_to_cart": "In den Warenkorb",
"buy_now": "Jetzt kaufen", "buy_now": "Jetzt kaufen",
"view_cart": "Warenkorb ansehen", "view_cart": "Warenkorb ansehen",
"checkout": "Zur Kasse", "checkout": "Zur Kasse",
"continue_shopping": "Weiter einkaufen", "continue_shopping": "Weiter einkaufen",
"start_shopping": "Einkaufen starten", "start_shopping": "Einkaufen starten",
"empty_cart": "Ihr Warenkorb ist leer", "empty_cart": "Ihr Warenkorb ist leer",
"cart_total": "Warenkorbsumme", "cart_total": "Warenkorbsumme",
"proceed_checkout": "Zur Kasse gehen", "proceed_checkout": "Zur Kasse gehen",
"payment": "Zahlung", "payment": "Zahlung",
"place_order": "Bestellung aufgeben", "place_order": "Bestellung aufgeben",
"order_placed": "Bestellung erfolgreich aufgegeben", "order_placed": "Bestellung erfolgreich aufgegeben",
"thank_you": "Vielen Dank für Ihre Bestellung", "thank_you": "Vielen Dank für Ihre Bestellung",
"order_confirmation": "Bestellbestätigung" "order_confirmation": "Bestellbestätigung"
} },
"permissions": {
"view_settings": "Kassen-Einstellungen anzeigen",
"view_settings_desc": "Kassen-Konfiguration anzeigen",
"manage_settings": "Kassen-Einstellungen verwalten",
"manage_settings_desc": "Kassenprozess und Optionen konfigurieren"
}
} }

View File

@@ -1,4 +1,10 @@
{ {
"permissions": {
"view_settings": "View Checkout Settings",
"view_settings_desc": "View checkout configuration",
"manage_settings": "Manage Checkout Settings",
"manage_settings_desc": "Configure checkout process and options"
},
"storefront": { "storefront": {
"welcome": "Welcome to our store", "welcome": "Welcome to our store",
"browse_products": "Browse Products", "browse_products": "Browse Products",

View File

@@ -1,20 +1,26 @@
{ {
"storefront": { "storefront": {
"welcome": "Bienvenue dans notre boutique", "welcome": "Bienvenue dans notre boutique",
"browse_products": "Parcourir les produits", "browse_products": "Parcourir les produits",
"add_to_cart": "Ajouter au panier", "add_to_cart": "Ajouter au panier",
"buy_now": "Acheter maintenant", "buy_now": "Acheter maintenant",
"view_cart": "Voir le panier", "view_cart": "Voir le panier",
"checkout": "Paiement", "checkout": "Paiement",
"continue_shopping": "Continuer vos achats", "continue_shopping": "Continuer vos achats",
"start_shopping": "Commencer vos achats", "start_shopping": "Commencer vos achats",
"empty_cart": "Votre panier est vide", "empty_cart": "Votre panier est vide",
"cart_total": "Total du panier", "cart_total": "Total du panier",
"proceed_checkout": "Passer à la caisse", "proceed_checkout": "Passer à la caisse",
"payment": "Paiement", "payment": "Paiement",
"place_order": "Passer la commande", "place_order": "Passer la commande",
"order_placed": "Commande passée avec succès", "order_placed": "Commande passée avec succès",
"thank_you": "Merci pour votre commande", "thank_you": "Merci pour votre commande",
"order_confirmation": "Confirmation de commande" "order_confirmation": "Confirmation de commande"
} },
"permissions": {
"view_settings": "Voir les paramètres de paiement",
"view_settings_desc": "Voir la configuration du processus de paiement",
"manage_settings": "Gérer les paramètres de paiement",
"manage_settings_desc": "Configurer le processus et les options de paiement"
}
} }

View File

@@ -1,20 +1,26 @@
{ {
"storefront": { "storefront": {
"welcome": "Wëllkomm an eisem Buttek", "welcome": "Wëllkomm an eisem Buttek",
"browse_products": "Produkter duerchsichen", "browse_products": "Produkter duerchsichen",
"add_to_cart": "An de Kuerf", "add_to_cart": "An de Kuerf",
"buy_now": "Elo kafen", "buy_now": "Elo kafen",
"view_cart": "Kuerf kucken", "view_cart": "Kuerf kucken",
"checkout": "Bezuelen", "checkout": "Bezuelen",
"continue_shopping": "Weider akafen", "continue_shopping": "Weider akafen",
"start_shopping": "Ufänken mat Akafen", "start_shopping": "Ufänken mat Akafen",
"empty_cart": "Äre Kuerf ass eidel", "empty_cart": "Äre Kuerf ass eidel",
"cart_total": "Kuerf Total", "cart_total": "Kuerf Total",
"proceed_checkout": "Zur Bezuelung goen", "proceed_checkout": "Zur Bezuelung goen",
"payment": "Bezuelung", "payment": "Bezuelung",
"place_order": "Bestellung opgi", "place_order": "Bestellung opgi",
"order_placed": "Bestellung erfollegräich opginn", "order_placed": "Bestellung erfollegräich opginn",
"thank_you": "Merci fir Är Bestellung", "thank_you": "Merci fir Är Bestellung",
"order_confirmation": "Bestellungsbestätegung" "order_confirmation": "Bestellungsbestätegung"
} },
"permissions": {
"view_settings": "Keess-Astellunge kucken",
"view_settings_desc": "Keess-Konfiguratioun kucken",
"manage_settings": "Keess-Astellunge verwalten",
"manage_settings_desc": "Keessprozess an Optiounen konfiguréieren"
}
} }

View File

@@ -234,5 +234,17 @@
"content_pages": "Inhaltsseiten", "content_pages": "Inhaltsseiten",
"store_themes": "Shop-Themes", "store_themes": "Shop-Themes",
"media_library": "Mediathek" "media_library": "Mediathek"
},
"permissions": {
"view_pages": "Seiten anzeigen",
"view_pages_desc": "Inhaltsseiten anzeigen",
"manage_pages": "Seiten verwalten",
"manage_pages_desc": "Inhaltsseiten erstellen, bearbeiten und löschen",
"view_media": "Medien anzeigen",
"view_media_desc": "Medienbibliothek durchsuchen",
"manage_media": "Medien verwalten",
"manage_media_desc": "Mediendateien hochladen, bearbeiten und löschen",
"manage_themes": "Themes verwalten",
"manage_themes_desc": "Shop-Themes konfigurieren und anpassen"
} }
} }

View File

@@ -200,6 +200,18 @@
"cta_final_note": "No credit card required. Setup in 5 minutes. Full Professional features during trial." "cta_final_note": "No credit card required. Setup in 5 minutes. Full Professional features during trial."
} }
}, },
"permissions": {
"view_pages": "View Pages",
"view_pages_desc": "View content pages",
"manage_pages": "Manage Pages",
"manage_pages_desc": "Create, edit, and delete content pages",
"view_media": "View Media",
"view_media_desc": "Browse the media library",
"manage_media": "Manage Media",
"manage_media_desc": "Upload, edit, and delete media files",
"manage_themes": "Manage Themes",
"manage_themes_desc": "Configure and customize store themes"
},
"messages": { "messages": {
"failed_to_delete_page": "Failed to delete page: {error}", "failed_to_delete_page": "Failed to delete page: {error}",
"media_updated_successfully": "Media updated successfully", "media_updated_successfully": "Media updated successfully",

View File

@@ -234,5 +234,17 @@
"content_pages": "Pages de contenu", "content_pages": "Pages de contenu",
"store_themes": "Thèmes du magasin", "store_themes": "Thèmes du magasin",
"media_library": "Médiathèque" "media_library": "Médiathèque"
},
"permissions": {
"view_pages": "Voir les pages",
"view_pages_desc": "Voir les pages de contenu",
"manage_pages": "Gérer les pages",
"manage_pages_desc": "Créer, modifier et supprimer les pages de contenu",
"view_media": "Voir les médias",
"view_media_desc": "Parcourir la bibliothèque de médias",
"manage_media": "Gérer les médias",
"manage_media_desc": "Télécharger, modifier et supprimer les fichiers médias",
"manage_themes": "Gérer les thèmes",
"manage_themes_desc": "Configurer et personnaliser les thèmes"
} }
} }

View File

@@ -234,5 +234,17 @@
"content_pages": "Inhaltsäiten", "content_pages": "Inhaltsäiten",
"store_themes": "Buttek-Themen", "store_themes": "Buttek-Themen",
"media_library": "Mediathéik" "media_library": "Mediathéik"
},
"permissions": {
"view_pages": "Säiten kucken",
"view_pages_desc": "Inhaltssäiten kucken",
"manage_pages": "Säiten verwalten",
"manage_pages_desc": "Inhaltssäiten erstellen, änneren a läschen",
"view_media": "Medien kucken",
"view_media_desc": "Mediebibliothéik duerchsichen",
"manage_media": "Medien verwalten",
"manage_media_desc": "Mediefichieren eroplueden, änneren a läschen",
"manage_themes": "Themes verwalten",
"manage_themes_desc": "Buttek-Themes konfiguréieren an upassen"
} }
} }

View File

@@ -76,5 +76,17 @@
"account_settings": "Kontoeinstellungen", "account_settings": "Kontoeinstellungen",
"profile": "Profil", "profile": "Profil",
"settings": "Einstellungen" "settings": "Einstellungen"
},
"permissions": {
"dashboard_view": "Dashboard anzeigen",
"dashboard_view_desc": "Zugriff auf das Dashboard und die Übersicht",
"settings_view": "Einstellungen anzeigen",
"settings_view_desc": "Shop-Konfiguration anzeigen",
"settings_edit": "Einstellungen bearbeiten",
"settings_edit_desc": "Shop-Konfiguration ändern",
"settings_theme": "Theme verwalten",
"settings_theme_desc": "Shop-Theme und Erscheinungsbild anpassen",
"settings_domains": "Domains verwalten",
"settings_domains_desc": "Benutzerdefinierte Domains konfigurieren"
} }
} }

View File

@@ -64,6 +64,18 @@
"save_profile": "Save Profile", "save_profile": "Save Profile",
"profile_updated": "Profile updated successfully" "profile_updated": "Profile updated successfully"
}, },
"permissions": {
"dashboard_view": "View Dashboard",
"dashboard_view_desc": "Access the store dashboard and overview",
"settings_view": "View Settings",
"settings_view_desc": "View store settings and configuration",
"settings_edit": "Edit Settings",
"settings_edit_desc": "Modify store settings and configuration",
"settings_theme": "Manage Theme",
"settings_theme_desc": "Customize the store theme and appearance",
"settings_domains": "Manage Domains",
"settings_domains_desc": "Configure custom domains for the store"
},
"messages": { "messages": {
"failed_to_load_dashboard_data": "Failed to load dashboard data", "failed_to_load_dashboard_data": "Failed to load dashboard data",
"dashboard_refreshed": "Dashboard refreshed", "dashboard_refreshed": "Dashboard refreshed",

View File

@@ -76,5 +76,17 @@
"account_settings": "Paramètres du compte", "account_settings": "Paramètres du compte",
"profile": "Profil", "profile": "Profil",
"settings": "Paramètres" "settings": "Paramètres"
},
"permissions": {
"dashboard_view": "Voir le tableau de bord",
"dashboard_view_desc": "Accéder au tableau de bord et à la vue d'ensemble",
"settings_view": "Voir les paramètres",
"settings_view_desc": "Voir la configuration du magasin",
"settings_edit": "Modifier les paramètres",
"settings_edit_desc": "Modifier la configuration du magasin",
"settings_theme": "Gérer le thème",
"settings_theme_desc": "Personnaliser le thème et l'apparence du magasin",
"settings_domains": "Gérer les domaines",
"settings_domains_desc": "Configurer les domaines personnalisés"
} }
} }

View File

@@ -76,5 +76,17 @@
"account_settings": "Kont-Astellungen", "account_settings": "Kont-Astellungen",
"profile": "Profil", "profile": "Profil",
"settings": "Astellungen" "settings": "Astellungen"
},
"permissions": {
"dashboard_view": "Dashboard kucken",
"dashboard_view_desc": "Zougang zum Dashboard an der Iwwersiicht",
"settings_view": "Astellungen kucken",
"settings_view_desc": "Buttek-Konfiguratioun kucken",
"settings_edit": "Astellungen änneren",
"settings_edit_desc": "Buttek-Konfiguratioun änneren",
"settings_theme": "Theme verwalten",
"settings_theme_desc": "Buttek-Theme an Ausgesinn upassen",
"settings_domains": "Domänen verwalten",
"settings_domains_desc": "Personaliséiert Domäne konfiguréieren"
} }
} }

View File

@@ -166,8 +166,9 @@ function initStoreSelector(selectElement, options = {}) {
} }
try { try {
const sep = config.apiEndpoint.includes('?') ? '&' : '?';
const response = await apiClient.get( const response = await apiClient.get(
`${config.apiEndpoint}?search=${encodeURIComponent(query)}&limit=${config.maxOptions}` `${config.apiEndpoint}${sep}search=${encodeURIComponent(query)}&limit=${config.maxOptions}`
); );
const stores = (response.stores || []).map(v => ({ const stores = (response.stores || []).map(v => ({

View File

@@ -37,5 +37,15 @@
"customers_section": "Kunden", "customers_section": "Kunden",
"customers": "Kunden", "customers": "Kunden",
"all_customers": "Alle Kunden" "all_customers": "Alle Kunden"
},
"permissions": {
"customers_view": "Kunden anzeigen",
"customers_view_desc": "Kundenliste und Details anzeigen",
"customers_edit": "Kunden bearbeiten",
"customers_edit_desc": "Kundeninformationen ändern",
"customers_delete": "Kunden löschen",
"customers_delete_desc": "Kundendatensätze entfernen",
"customers_export": "Kunden exportieren",
"customers_export_desc": "Kundendaten exportieren"
} }
} }

View File

@@ -18,6 +18,16 @@
"no_customers": "No customers found", "no_customers": "No customers found",
"search_customers": "Search customers..." "search_customers": "Search customers..."
}, },
"permissions": {
"customers_view": "View Customers",
"customers_view_desc": "View customer list and details",
"customers_edit": "Edit Customers",
"customers_edit_desc": "Modify customer information",
"customers_delete": "Delete Customers",
"customers_delete_desc": "Remove customer records",
"customers_export": "Export Customers",
"customers_export_desc": "Export customer data to files"
},
"messages": { "messages": {
"failed_to_toggle_customer_status": "Failed to toggle customer status", "failed_to_toggle_customer_status": "Failed to toggle customer status",
"failed_to_load_customer_details": "Failed to load customer details", "failed_to_load_customer_details": "Failed to load customer details",

View File

@@ -37,5 +37,15 @@
"customers_section": "Clients", "customers_section": "Clients",
"customers": "Clients", "customers": "Clients",
"all_customers": "Tous les clients" "all_customers": "Tous les clients"
},
"permissions": {
"customers_view": "Voir les clients",
"customers_view_desc": "Voir la liste et les détails des clients",
"customers_edit": "Modifier les clients",
"customers_edit_desc": "Modifier les informations client",
"customers_delete": "Supprimer les clients",
"customers_delete_desc": "Supprimer les fiches clients",
"customers_export": "Exporter les clients",
"customers_export_desc": "Exporter les données clients"
} }
} }

View File

@@ -37,5 +37,15 @@
"customers_section": "Clienten", "customers_section": "Clienten",
"customers": "Clienten", "customers": "Clienten",
"all_customers": "All Clienten" "all_customers": "All Clienten"
},
"permissions": {
"customers_view": "Clienten kucken",
"customers_view_desc": "Clientelëscht an Detailer kucken",
"customers_edit": "Clienten änneren",
"customers_edit_desc": "Clienteninformatiounen änneren",
"customers_delete": "Clienten läschen",
"customers_delete_desc": "Clientedossieren ewechhuelen",
"customers_export": "Clienten exportéieren",
"customers_export_desc": "Clientedaten exportéieren"
} }
} }

View File

@@ -42,5 +42,13 @@
"products_inventory": "Produkte & Inventar", "products_inventory": "Produkte & Inventar",
"products": "Produkte", "products": "Produkte",
"inventory": "Inventar" "inventory": "Inventar"
},
"permissions": {
"stock_view": "Inventar anzeigen",
"stock_view_desc": "Bestände und Inventardaten anzeigen",
"stock_edit": "Inventar bearbeiten",
"stock_edit_desc": "Bestände und Mengen anpassen",
"stock_transfer": "Bestand transferieren",
"stock_transfer_desc": "Bestand zwischen Standorten transferieren"
} }
} }

View File

@@ -12,6 +12,14 @@
"low_stock_alert": "Low Stock Alert", "low_stock_alert": "Low Stock Alert",
"out_of_stock_alert": "Out of Stock Alert" "out_of_stock_alert": "Out of Stock Alert"
}, },
"permissions": {
"stock_view": "View Inventory",
"stock_view_desc": "View stock levels and inventory data",
"stock_edit": "Edit Inventory",
"stock_edit_desc": "Adjust stock levels and quantities",
"stock_transfer": "Transfer Stock",
"stock_transfer_desc": "Transfer stock between locations"
},
"messages": { "messages": {
"stock_adjusted_successfully": "Stock adjusted successfully", "stock_adjusted_successfully": "Stock adjusted successfully",
"quantity_set_successfully": "Quantity set successfully", "quantity_set_successfully": "Quantity set successfully",

View File

@@ -42,5 +42,13 @@
"products_inventory": "Produits et Inventaire", "products_inventory": "Produits et Inventaire",
"products": "Produits", "products": "Produits",
"inventory": "Inventaire" "inventory": "Inventaire"
},
"permissions": {
"stock_view": "Voir l'inventaire",
"stock_view_desc": "Voir les niveaux de stock et les données d'inventaire",
"stock_edit": "Modifier l'inventaire",
"stock_edit_desc": "Ajuster les niveaux de stock et les quantités",
"stock_transfer": "Transférer le stock",
"stock_transfer_desc": "Transférer le stock entre les emplacements"
} }
} }

View File

@@ -42,5 +42,13 @@
"products_inventory": "Produkter & Inventar", "products_inventory": "Produkter & Inventar",
"products": "Produkter", "products": "Produkter",
"inventory": "Inventar" "inventory": "Inventar"
},
"permissions": {
"stock_view": "Inventar kucken",
"stock_view_desc": "Bestandsniveauen an Inventardaten kucken",
"stock_edit": "Inventar änneren",
"stock_edit_desc": "Bestandsniveauen a Quantitéiten upassen",
"stock_transfer": "Bestand transferéieren",
"stock_transfer_desc": "Bestand tëschent Standuerten transferéieren"
} }
} }

View File

@@ -80,5 +80,15 @@
"statistics": "Statistiken", "statistics": "Statistiken",
"overview": "Übersicht", "overview": "Übersicht",
"settings": "Einstellungen" "settings": "Einstellungen"
},
"permissions": {
"view_programs": "Programme anzeigen",
"view_programs_desc": "Treueprogramme und Details anzeigen",
"manage_programs": "Programme verwalten",
"manage_programs_desc": "Treueprogramme erstellen und konfigurieren",
"view_rewards": "Prämien anzeigen",
"view_rewards_desc": "Prämien und Einlösungen anzeigen",
"manage_rewards": "Prämien verwalten",
"manage_rewards_desc": "Treueprämien erstellen und verwalten"
} }
} }

View File

@@ -69,6 +69,16 @@
"pin_locked": "PIN locked due to too many failed attempts" "pin_locked": "PIN locked due to too many failed attempts"
} }
}, },
"permissions": {
"view_programs": "View Programs",
"view_programs_desc": "View loyalty programs and details",
"manage_programs": "Manage Programs",
"manage_programs_desc": "Create and configure loyalty programs",
"view_rewards": "View Rewards",
"view_rewards_desc": "View loyalty rewards and redemptions",
"manage_rewards": "Manage Rewards",
"manage_rewards_desc": "Create and manage loyalty rewards"
},
"menu": { "menu": {
"loyalty": "Loyalty", "loyalty": "Loyalty",
"loyalty_programs": "Loyalty Programs", "loyalty_programs": "Loyalty Programs",

View File

@@ -80,5 +80,15 @@
"statistics": "Statistiques", "statistics": "Statistiques",
"overview": "Aperçu", "overview": "Aperçu",
"settings": "Paramètres" "settings": "Paramètres"
},
"permissions": {
"view_programs": "Voir les programmes",
"view_programs_desc": "Voir les programmes de fidélité et leurs détails",
"manage_programs": "Gérer les programmes",
"manage_programs_desc": "Créer et configurer les programmes de fidélité",
"view_rewards": "Voir les récompenses",
"view_rewards_desc": "Voir les récompenses et les échanges",
"manage_rewards": "Gérer les récompenses",
"manage_rewards_desc": "Créer et gérer les récompenses de fidélité"
} }
} }

View File

@@ -80,5 +80,15 @@
"statistics": "Statistiken", "statistics": "Statistiken",
"overview": "Iwwersiicht", "overview": "Iwwersiicht",
"settings": "Astellungen" "settings": "Astellungen"
},
"permissions": {
"view_programs": "Programmer kucken",
"view_programs_desc": "Treiheet-Programmer an Detailer kucken",
"manage_programs": "Programmer verwalten",
"manage_programs_desc": "Treiheet-Programmer erstellen a konfiguréieren",
"view_rewards": "Beloununge kucken",
"view_rewards_desc": "Belounungen an Aléisunge kucken",
"manage_rewards": "Beloununge verwalten",
"manage_rewards_desc": "Treiheet-Belounungen erstellen a verwalten"
} }
} }

View File

@@ -1,87 +1,95 @@
{ {
"menu": { "menu": {
"marketplace": "Marktplatz", "marketplace": "Marktplatz",
"letzshop": "Letzshop", "letzshop": "Letzshop",
"products_inventory": "Produkte & Inventar", "products_inventory": "Produkte & Inventar",
"marketplace_import": "Marktplatz Import", "marketplace_import": "Marktplatz Import",
"sales_orders": "Verkäufe & Bestellungen", "sales_orders": "Verkäufe & Bestellungen",
"letzshop_orders": "Letzshop Bestellungen" "letzshop_orders": "Letzshop Bestellungen"
}, },
"marketplace": { "marketplace": {
"title": "Marktplatz", "title": "Marktplatz",
"import": "Importieren", "import": "Importieren",
"export": "Exportieren", "export": "Exportieren",
"sync": "Synchronisieren", "sync": "Synchronisieren",
"source": "Quelle", "source": "Quelle",
"source_url": "Quell-URL", "source_url": "Quell-URL",
"import_products": "Produkte importieren", "import_products": "Produkte importieren",
"start_import": "Import starten", "start_import": "Import starten",
"importing": "Importiere...", "importing": "Importiere...",
"import_complete": "Import abgeschlossen", "import_complete": "Import abgeschlossen",
"import_failed": "Import fehlgeschlagen", "import_failed": "Import fehlgeschlagen",
"import_history": "Import-Verlauf", "import_history": "Import-Verlauf",
"job_id": "Auftrags-ID", "job_id": "Auftrags-ID",
"started_at": "Gestartet um", "started_at": "Gestartet um",
"completed_at": "Abgeschlossen um", "completed_at": "Abgeschlossen um",
"duration": "Dauer", "duration": "Dauer",
"imported_count": "Importiert", "imported_count": "Importiert",
"error_count": "Fehler", "error_count": "Fehler",
"total_processed": "Gesamt verarbeitet", "total_processed": "Gesamt verarbeitet",
"progress": "Fortschritt", "progress": "Fortschritt",
"no_import_jobs": "Noch keine Imports", "no_import_jobs": "Noch keine Imports",
"start_first_import": "Starten Sie Ihren ersten Import mit dem Formular oben" "start_first_import": "Starten Sie Ihren ersten Import mit dem Formular oben"
}, },
"letzshop": { "letzshop": {
"title": "Letzshop-Integration", "title": "Letzshop-Integration",
"connection": "Verbindung", "connection": "Verbindung",
"credentials": "Zugangsdaten", "credentials": "Zugangsdaten",
"api_key": "API-Schlüssel", "api_key": "API-Schlüssel",
"api_endpoint": "API-Endpunkt", "api_endpoint": "API-Endpunkt",
"auto_sync": "Auto-Sync", "auto_sync": "Auto-Sync",
"sync_interval": "Sync-Intervall", "sync_interval": "Sync-Intervall",
"every_hour": "Jede Stunde", "every_hour": "Jede Stunde",
"every_day": "Jeden Tag", "every_day": "Jeden Tag",
"test_connection": "Verbindung testen", "test_connection": "Verbindung testen",
"save_credentials": "Zugangsdaten speichern", "save_credentials": "Zugangsdaten speichern",
"connection_success": "Verbindung erfolgreich", "connection_success": "Verbindung erfolgreich",
"connection_failed": "Verbindung fehlgeschlagen", "connection_failed": "Verbindung fehlgeschlagen",
"last_sync": "Letzte Synchronisation", "last_sync": "Letzte Synchronisation",
"sync_status": "Sync-Status", "sync_status": "Sync-Status",
"import_orders": "Bestellungen importieren", "import_orders": "Bestellungen importieren",
"export_products": "Produkte exportieren", "export_products": "Produkte exportieren",
"no_credentials": "Konfigurieren Sie Ihren API-Schlüssel in den Einstellungen", "no_credentials": "Konfigurieren Sie Ihren API-Schlüssel in den Einstellungen",
"carriers": { "carriers": {
"dhl": "DHL", "dhl": "DHL",
"ups": "UPS", "ups": "UPS",
"fedex": "FedEx", "fedex": "FedEx",
"dpd": "DPD", "dpd": "DPD",
"gls": "GLS", "gls": "GLS",
"post_luxembourg": "Post Luxemburg", "post_luxembourg": "Post Luxemburg",
"other": "Andere" "other": "Andere"
}
},
"messages": {
"no_error_details_available": "No error details available",
"failed_to_load_error_details": "Failed to load error details",
"copied_to_clipboard": "Copied to clipboard",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard"
},
"features": {
"letzshop_sync": {
"name": "Lëtzshop-Synchronisation",
"description": "Produkte mit dem Lëtzshop-Marktplatz synchronisieren"
},
"api_access": {
"name": "API-Zugang",
"description": "Zugang zur Plattform-API"
},
"webhooks": {
"name": "Webhooks",
"description": "Echtzeit-Ereignisbenachrichtigungen über Webhooks"
},
"custom_integrations": {
"name": "Eigene Integrationen",
"description": "Eigene Integrationen mit der Plattform erstellen"
}
},
"permissions": {
"view_integration": "Integration anzeigen",
"view_integration_desc": "Marktplatz-Integrationseinstellungen anzeigen",
"manage_integration": "Integration verwalten",
"manage_integration_desc": "Marktplatz-Integration konfigurieren",
"sync_products": "Produkte synchronisieren",
"sync_products_desc": "Produkte mit dem Marktplatz synchronisieren"
} }
},
"messages": {
"no_error_details_available": "No error details available",
"failed_to_load_error_details": "Failed to load error details",
"copied_to_clipboard": "Copied to clipboard",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard"
},
"features": {
"letzshop_sync": {
"name": "Lëtzshop-Synchronisation",
"description": "Produkte mit dem Lëtzshop-Marktplatz synchronisieren"
},
"api_access": {
"name": "API-Zugang",
"description": "Zugang zur Plattform-API"
},
"webhooks": {
"name": "Webhooks",
"description": "Echtzeit-Ereignisbenachrichtigungen über Webhooks"
},
"custom_integrations": {
"name": "Eigene Integrationen",
"description": "Eigene Integrationen mit der Plattform erstellen"
}
}
} }

View File

@@ -7,6 +7,14 @@
"sales_orders": "Sales & Orders", "sales_orders": "Sales & Orders",
"letzshop_orders": "Letzshop Orders" "letzshop_orders": "Letzshop Orders"
}, },
"permissions": {
"view_integration": "View Integration",
"view_integration_desc": "View marketplace integration settings",
"manage_integration": "Manage Integration",
"manage_integration_desc": "Configure marketplace integration",
"sync_products": "Sync Products",
"sync_products_desc": "Synchronize products with marketplace"
},
"marketplace": { "marketplace": {
"title": "Marketplace", "title": "Marketplace",
"import": "Import", "import": "Import",

View File

@@ -1,87 +1,95 @@
{ {
"menu": { "menu": {
"marketplace": "Marketplace", "marketplace": "Marketplace",
"letzshop": "Letzshop", "letzshop": "Letzshop",
"products_inventory": "Produits et Inventaire", "products_inventory": "Produits et Inventaire",
"marketplace_import": "Import Marketplace", "marketplace_import": "Import Marketplace",
"sales_orders": "Ventes et Commandes", "sales_orders": "Ventes et Commandes",
"letzshop_orders": "Commandes Letzshop" "letzshop_orders": "Commandes Letzshop"
}, },
"marketplace": { "marketplace": {
"title": "Marketplace", "title": "Marketplace",
"import": "Importer", "import": "Importer",
"export": "Exporter", "export": "Exporter",
"sync": "Synchroniser", "sync": "Synchroniser",
"source": "Source", "source": "Source",
"source_url": "URL source", "source_url": "URL source",
"import_products": "Importer des produits", "import_products": "Importer des produits",
"start_import": "Démarrer l'importation", "start_import": "Démarrer l'importation",
"importing": "Importation en cours...", "importing": "Importation en cours...",
"import_complete": "Importation terminée", "import_complete": "Importation terminée",
"import_failed": "Échec de l'importation", "import_failed": "Échec de l'importation",
"import_history": "Historique des importations", "import_history": "Historique des importations",
"job_id": "ID du travail", "job_id": "ID du travail",
"started_at": "Démarré à", "started_at": "Démarré à",
"completed_at": "Terminé à", "completed_at": "Terminé à",
"duration": "Durée", "duration": "Durée",
"imported_count": "Importés", "imported_count": "Importés",
"error_count": "Erreurs", "error_count": "Erreurs",
"total_processed": "Total traité", "total_processed": "Total traité",
"progress": "Progression", "progress": "Progression",
"no_import_jobs": "Aucune importation pour le moment", "no_import_jobs": "Aucune importation pour le moment",
"start_first_import": "Lancez votre première importation avec le formulaire ci-dessus" "start_first_import": "Lancez votre première importation avec le formulaire ci-dessus"
}, },
"letzshop": { "letzshop": {
"title": "Intégration Letzshop", "title": "Intégration Letzshop",
"connection": "Connexion", "connection": "Connexion",
"credentials": "Identifiants", "credentials": "Identifiants",
"api_key": "Clé API", "api_key": "Clé API",
"api_endpoint": "Point d'accès API", "api_endpoint": "Point d'accès API",
"auto_sync": "Synchronisation automatique", "auto_sync": "Synchronisation automatique",
"sync_interval": "Intervalle de synchronisation", "sync_interval": "Intervalle de synchronisation",
"every_hour": "Toutes les heures", "every_hour": "Toutes les heures",
"every_day": "Tous les jours", "every_day": "Tous les jours",
"test_connection": "Tester la connexion", "test_connection": "Tester la connexion",
"save_credentials": "Enregistrer les identifiants", "save_credentials": "Enregistrer les identifiants",
"connection_success": "Connexion réussie", "connection_success": "Connexion réussie",
"connection_failed": "Échec de la connexion", "connection_failed": "Échec de la connexion",
"last_sync": "Dernière synchronisation", "last_sync": "Dernière synchronisation",
"sync_status": "Statut de synchronisation", "sync_status": "Statut de synchronisation",
"import_orders": "Importer les commandes", "import_orders": "Importer les commandes",
"export_products": "Exporter les produits", "export_products": "Exporter les produits",
"no_credentials": "Configurez votre clé API dans les paramètres pour commencer", "no_credentials": "Configurez votre clé API dans les paramètres pour commencer",
"carriers": { "carriers": {
"dhl": "DHL", "dhl": "DHL",
"ups": "UPS", "ups": "UPS",
"fedex": "FedEx", "fedex": "FedEx",
"dpd": "DPD", "dpd": "DPD",
"gls": "GLS", "gls": "GLS",
"post_luxembourg": "Post Luxembourg", "post_luxembourg": "Post Luxembourg",
"other": "Autre" "other": "Autre"
}
},
"messages": {
"no_error_details_available": "No error details available",
"failed_to_load_error_details": "Failed to load error details",
"copied_to_clipboard": "Copied to clipboard",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard"
},
"features": {
"letzshop_sync": {
"name": "Synchronisation Lëtzshop",
"description": "Synchroniser les produits avec la marketplace Lëtzshop"
},
"api_access": {
"name": "Accès API",
"description": "Accès à l'API de la plateforme"
},
"webhooks": {
"name": "Webhooks",
"description": "Notifications d'événements en temps réel via webhooks"
},
"custom_integrations": {
"name": "Intégrations personnalisées",
"description": "Créer des intégrations personnalisées avec la plateforme"
}
},
"permissions": {
"view_integration": "Voir l'intégration",
"view_integration_desc": "Voir les paramètres d'intégration marketplace",
"manage_integration": "Gérer l'intégration",
"manage_integration_desc": "Configurer l'intégration marketplace",
"sync_products": "Synchroniser les produits",
"sync_products_desc": "Synchroniser les produits avec le marketplace"
} }
},
"messages": {
"no_error_details_available": "No error details available",
"failed_to_load_error_details": "Failed to load error details",
"copied_to_clipboard": "Copied to clipboard",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard"
},
"features": {
"letzshop_sync": {
"name": "Synchronisation Lëtzshop",
"description": "Synchroniser les produits avec la marketplace Lëtzshop"
},
"api_access": {
"name": "Accès API",
"description": "Accès à l'API de la plateforme"
},
"webhooks": {
"name": "Webhooks",
"description": "Notifications d'événements en temps réel via webhooks"
},
"custom_integrations": {
"name": "Intégrations personnalisées",
"description": "Créer des intégrations personnalisées avec la plateforme"
}
}
} }

View File

@@ -1,87 +1,95 @@
{ {
"menu": { "menu": {
"marketplace": "Marchéplaz", "marketplace": "Marchéplaz",
"letzshop": "Letzshop", "letzshop": "Letzshop",
"products_inventory": "Produkter & Inventar", "products_inventory": "Produkter & Inventar",
"marketplace_import": "Marchéplaz Import", "marketplace_import": "Marchéplaz Import",
"sales_orders": "Verkaf & Bestellungen", "sales_orders": "Verkaf & Bestellungen",
"letzshop_orders": "Letzshop Bestellungen" "letzshop_orders": "Letzshop Bestellungen"
}, },
"marketplace": { "marketplace": {
"title": "Marchéplaz", "title": "Marchéplaz",
"import": "Import", "import": "Import",
"export": "Export", "export": "Export",
"sync": "Synchroniséieren", "sync": "Synchroniséieren",
"source": "Quell", "source": "Quell",
"source_url": "Quell URL", "source_url": "Quell URL",
"import_products": "Produkter importéieren", "import_products": "Produkter importéieren",
"start_import": "Import starten", "start_import": "Import starten",
"importing": "Importéieren...", "importing": "Importéieren...",
"import_complete": "Import fäerdeg", "import_complete": "Import fäerdeg",
"import_failed": "Import feelgeschloen", "import_failed": "Import feelgeschloen",
"import_history": "Importgeschicht", "import_history": "Importgeschicht",
"job_id": "Job ID", "job_id": "Job ID",
"started_at": "Ugefaang um", "started_at": "Ugefaang um",
"completed_at": "Fäerdeg um", "completed_at": "Fäerdeg um",
"duration": "Dauer", "duration": "Dauer",
"imported_count": "Importéiert", "imported_count": "Importéiert",
"error_count": "Feeler", "error_count": "Feeler",
"total_processed": "Total veraarbecht", "total_processed": "Total veraarbecht",
"progress": "Fortschrëtt", "progress": "Fortschrëtt",
"no_import_jobs": "Nach keng Import Jobs", "no_import_jobs": "Nach keng Import Jobs",
"start_first_import": "Start Ären éischten Import mat der Form uewendriwwer" "start_first_import": "Start Ären éischten Import mat der Form uewendriwwer"
}, },
"letzshop": { "letzshop": {
"title": "Letzshop Integratioun", "title": "Letzshop Integratioun",
"connection": "Verbindung", "connection": "Verbindung",
"credentials": "Umeldungsdaten", "credentials": "Umeldungsdaten",
"api_key": "API Schlëssel", "api_key": "API Schlëssel",
"api_endpoint": "API Endpunkt", "api_endpoint": "API Endpunkt",
"auto_sync": "Automatesch Sync", "auto_sync": "Automatesch Sync",
"sync_interval": "Sync Intervall", "sync_interval": "Sync Intervall",
"every_hour": "All Stonn", "every_hour": "All Stonn",
"every_day": "All Dag", "every_day": "All Dag",
"test_connection": "Verbindung testen", "test_connection": "Verbindung testen",
"save_credentials": "Umeldungsdaten späicheren", "save_credentials": "Umeldungsdaten späicheren",
"connection_success": "Verbindung erfollegräich", "connection_success": "Verbindung erfollegräich",
"connection_failed": "Verbindung feelgeschloen", "connection_failed": "Verbindung feelgeschloen",
"last_sync": "Läschte Sync", "last_sync": "Läschte Sync",
"sync_status": "Sync Status", "sync_status": "Sync Status",
"import_orders": "Bestellungen importéieren", "import_orders": "Bestellungen importéieren",
"export_products": "Produkter exportéieren", "export_products": "Produkter exportéieren",
"no_credentials": "Konfiguréiert Ären API Schlëssel an den Astellungen fir unzefänken", "no_credentials": "Konfiguréiert Ären API Schlëssel an den Astellungen fir unzefänken",
"carriers": { "carriers": {
"dhl": "DHL", "dhl": "DHL",
"ups": "UPS", "ups": "UPS",
"fedex": "FedEx", "fedex": "FedEx",
"dpd": "DPD", "dpd": "DPD",
"gls": "GLS", "gls": "GLS",
"post_luxembourg": "Post Lëtzebuerg", "post_luxembourg": "Post Lëtzebuerg",
"other": "Anerer" "other": "Anerer"
}
},
"messages": {
"no_error_details_available": "No error details available",
"failed_to_load_error_details": "Failed to load error details",
"copied_to_clipboard": "Copied to clipboard",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard"
},
"features": {
"letzshop_sync": {
"name": "Lëtzshop-Synchronisatioun",
"description": "Produkter mam Lëtzshop-Marktplaz synchroniséieren"
},
"api_access": {
"name": "API-Zougang",
"description": "Zougang zur Plattform-API"
},
"webhooks": {
"name": "Webhooks",
"description": "Echtzäit-Evenement-Benoriichtegungen iwwer Webhooks"
},
"custom_integrations": {
"name": "Eegen Integratiounen",
"description": "Eegen Integratiounen mat der Plattform erstellen"
}
},
"permissions": {
"view_integration": "Integratioun kucken",
"view_integration_desc": "Marché-Integratiounsastellungen kucken",
"manage_integration": "Integratioun verwalten",
"manage_integration_desc": "Marché-Integratioun konfiguréieren",
"sync_products": "Produiten synchroniséieren",
"sync_products_desc": "Produiten mam Marché synchroniséieren"
} }
},
"messages": {
"no_error_details_available": "No error details available",
"failed_to_load_error_details": "Failed to load error details",
"copied_to_clipboard": "Copied to clipboard",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard"
},
"features": {
"letzshop_sync": {
"name": "Lëtzshop-Synchronisatioun",
"description": "Produkter mam Lëtzshop-Marktplaz synchroniséieren"
},
"api_access": {
"name": "API-Zougang",
"description": "Zougang zur Plattform-API"
},
"webhooks": {
"name": "Webhooks",
"description": "Echtzäit-Evenement-Benoriichtegungen iwwer Webhooks"
},
"custom_integrations": {
"name": "Eegen Integratiounen",
"description": "Eegen Integratiounen mat der Plattform erstellen"
}
}
} }

View File

@@ -60,5 +60,13 @@
"messages": "Nachrichten", "messages": "Nachrichten",
"notifications": "Benachrichtigungen", "notifications": "Benachrichtigungen",
"email_templates": "E-Mail-Vorlagen" "email_templates": "E-Mail-Vorlagen"
},
"permissions": {
"view_messages": "Nachrichten anzeigen",
"view_messages_desc": "Nachrichten und Konversationen anzeigen",
"send_messages": "Nachrichten senden",
"send_messages_desc": "Nachrichten an Kunden senden",
"manage_templates": "Vorlagen verwalten",
"manage_templates_desc": "Nachrichtenvorlagen erstellen und bearbeiten"
} }
} }

View File

@@ -10,6 +10,14 @@
"import_complete": "Import Complete", "import_complete": "Import Complete",
"import_failed": "Import Failed" "import_failed": "Import Failed"
}, },
"permissions": {
"view_messages": "View Messages",
"view_messages_desc": "View messages and conversations",
"send_messages": "Send Messages",
"send_messages_desc": "Send messages to customers",
"manage_templates": "Manage Templates",
"manage_templates_desc": "Create and edit message templates"
},
"messages": { "messages": {
"failed_to_load_template": "Failed to load template", "failed_to_load_template": "Failed to load template",
"template_saved_successfully": "Template saved successfully", "template_saved_successfully": "Template saved successfully",

View File

@@ -60,5 +60,13 @@
"messages": "Messages", "messages": "Messages",
"notifications": "Notifications", "notifications": "Notifications",
"email_templates": "Modèles d'e-mail" "email_templates": "Modèles d'e-mail"
},
"permissions": {
"view_messages": "Voir les messages",
"view_messages_desc": "Voir les messages et conversations",
"send_messages": "Envoyer des messages",
"send_messages_desc": "Envoyer des messages aux clients",
"manage_templates": "Gérer les modèles",
"manage_templates_desc": "Créer et modifier les modèles de messages"
} }
} }

View File

@@ -60,5 +60,13 @@
"messages": "Messagen", "messages": "Messagen",
"notifications": "Notifikatiounen", "notifications": "Notifikatiounen",
"email_templates": "E-Mail-Virlagen" "email_templates": "E-Mail-Virlagen"
},
"permissions": {
"view_messages": "Messagen kucken",
"view_messages_desc": "Messagen a Conversatiounen kucken",
"send_messages": "Messagen schécken",
"send_messages_desc": "Messagen u Clienten schécken",
"manage_templates": "Schablounen verwalten",
"manage_templates_desc": "Messageschablounen erstellen an änneren"
} }
} }

View File

@@ -79,5 +79,15 @@
"store_operations": "Shop-Betrieb", "store_operations": "Shop-Betrieb",
"sales_orders": "Verkäufe & Bestellungen", "sales_orders": "Verkäufe & Bestellungen",
"orders": "Bestellungen" "orders": "Bestellungen"
},
"permissions": {
"orders_view": "Bestellungen anzeigen",
"orders_view_desc": "Bestellliste und Details anzeigen",
"orders_edit": "Bestellungen bearbeiten",
"orders_edit_desc": "Bestellstatus und Details aktualisieren",
"orders_cancel": "Bestellungen stornieren",
"orders_cancel_desc": "Ausstehende oder in Bearbeitung befindliche Bestellungen stornieren",
"orders_refund": "Bestellungen erstatten",
"orders_refund_desc": "Rückerstattungen für Bestellungen verarbeiten"
} }
} }

View File

@@ -37,6 +37,16 @@
"set_tracking": "Set Tracking", "set_tracking": "Set Tracking",
"view_details": "View Details" "view_details": "View Details"
}, },
"permissions": {
"orders_view": "View Orders",
"orders_view_desc": "View order list and details",
"orders_edit": "Edit Orders",
"orders_edit_desc": "Update order status and details",
"orders_cancel": "Cancel Orders",
"orders_cancel_desc": "Cancel pending or processing orders",
"orders_refund": "Refund Orders",
"orders_refund_desc": "Process refunds for orders"
},
"messages": { "messages": {
"order_status_updated": "Order status updated", "order_status_updated": "Order status updated",
"item_shipped_successfully": "Item shipped successfully", "item_shipped_successfully": "Item shipped successfully",

View File

@@ -79,5 +79,15 @@
"store_operations": "Opérations du magasin", "store_operations": "Opérations du magasin",
"sales_orders": "Ventes et Commandes", "sales_orders": "Ventes et Commandes",
"orders": "Commandes" "orders": "Commandes"
},
"permissions": {
"orders_view": "Voir les commandes",
"orders_view_desc": "Voir la liste et les détails des commandes",
"orders_edit": "Modifier les commandes",
"orders_edit_desc": "Mettre à jour le statut et les détails des commandes",
"orders_cancel": "Annuler les commandes",
"orders_cancel_desc": "Annuler les commandes en attente ou en traitement",
"orders_refund": "Rembourser les commandes",
"orders_refund_desc": "Traiter les remboursements des commandes"
} }
} }

View File

@@ -79,5 +79,15 @@
"store_operations": "Buttek-Operatiounen", "store_operations": "Buttek-Operatiounen",
"sales_orders": "Verkaf & Bestellungen", "sales_orders": "Verkaf & Bestellungen",
"orders": "Bestellungen" "orders": "Bestellungen"
},
"permissions": {
"orders_view": "Bestellunge kucken",
"orders_view_desc": "Bestelllëscht an Detailer kucken",
"orders_edit": "Bestellungen änneren",
"orders_edit_desc": "Bestellstatus an Detailer aktualiséieren",
"orders_cancel": "Bestellungen stornéieren",
"orders_cancel_desc": "Aussteesend oder a Veraarbechtung Bestellunge stornéieren",
"orders_refund": "Bestellungen erstatten",
"orders_refund_desc": "Réckerstattunge fir Bestellunge veraarbechten"
} }
} }

View File

@@ -1,12 +1,20 @@
{ {
"menu": { "menu": {
"payments": "Zahlungen" "payments": "Zahlungen"
}, },
"payments": { "payments": {
"title": "Zahlungen" "title": "Zahlungen"
}, },
"messages": { "messages": {
"payment_successful": "Zahlung erfolgreich verarbeitet", "payment_successful": "Zahlung erfolgreich verarbeitet",
"payment_failed": "Zahlungsverarbeitung fehlgeschlagen" "payment_failed": "Zahlungsverarbeitung fehlgeschlagen"
} },
"permissions": {
"view_gateways": "Zahlungsgateways anzeigen",
"view_gateways_desc": "Konfigurationen der Zahlungsgateways anzeigen",
"manage_gateways": "Zahlungsgateways verwalten",
"manage_gateways_desc": "Zahlungsgateways konfigurieren und verwalten",
"view_transactions": "Transaktionen anzeigen",
"view_transactions_desc": "Zahlungstransaktionsverlauf anzeigen"
}
} }

View File

@@ -5,6 +5,14 @@
"payments": { "payments": {
"title": "Payments" "title": "Payments"
}, },
"permissions": {
"view_gateways": "View Gateways",
"view_gateways_desc": "View payment gateway configurations",
"manage_gateways": "Manage Gateways",
"manage_gateways_desc": "Configure and manage payment gateways",
"view_transactions": "View Transactions",
"view_transactions_desc": "View payment transaction history"
},
"messages": { "messages": {
"payment_successful": "Payment processed successfully", "payment_successful": "Payment processed successfully",
"payment_failed": "Payment processing failed" "payment_failed": "Payment processing failed"

View File

@@ -1,12 +1,20 @@
{ {
"menu": { "menu": {
"payments": "Paiements" "payments": "Paiements"
}, },
"payments": { "payments": {
"title": "Paiements" "title": "Paiements"
}, },
"messages": { "messages": {
"payment_successful": "Paiement traité avec succès", "payment_successful": "Paiement traité avec succès",
"payment_failed": "Échec du traitement du paiement" "payment_failed": "Échec du traitement du paiement"
} },
"permissions": {
"view_gateways": "Voir les passerelles",
"view_gateways_desc": "Voir les configurations des passerelles de paiement",
"manage_gateways": "Gérer les passerelles",
"manage_gateways_desc": "Configurer et gérer les passerelles de paiement",
"view_transactions": "Voir les transactions",
"view_transactions_desc": "Voir l'historique des transactions de paiement"
}
} }

View File

@@ -1,12 +1,20 @@
{ {
"menu": { "menu": {
"payments": "Bezuelungen" "payments": "Bezuelungen"
}, },
"payments": { "payments": {
"title": "Bezuelungen" "title": "Bezuelungen"
}, },
"messages": { "messages": {
"payment_successful": "Bezuelung erfollegräich veraarbecht", "payment_successful": "Bezuelung erfollegräich veraarbecht",
"payment_failed": "Bezuelungsveraarbechtung ass feelgeschloen" "payment_failed": "Bezuelungsveraarbechtung ass feelgeschloen"
} },
"permissions": {
"view_gateways": "Bezuelungsgateways kucken",
"view_gateways_desc": "Konfiguratiounen vun de Bezuelungsgateways kucken",
"manage_gateways": "Bezuelungsgateways verwalten",
"manage_gateways_desc": "Bezuelungsgateways konfiguréieren a verwalten",
"view_transactions": "Transaktiounen kucken",
"view_transactions_desc": "Bezuelungstransaktiounsverlaf kucken"
}
} }

View File

@@ -87,9 +87,11 @@ tenancy_module = ModuleDefinition(
"stores", "stores",
"admin-users", "admin-users",
"merchant-users", "merchant-users",
"store-roles",
], ],
FrontendType.STORE: [ FrontendType.STORE: [
"team", "team",
"roles",
], ],
FrontendType.MERCHANT: [ FrontendType.MERCHANT: [
"stores", "stores",
@@ -122,6 +124,13 @@ tenancy_module = ModuleDefinition(
order=20, order=20,
is_mandatory=True, is_mandatory=True,
), ),
MenuItemDefinition(
id="store-roles",
label_key="tenancy.menu.store_roles",
icon="shield-check",
route="/admin/store-roles",
order=30,
),
], ],
), ),
MenuSectionDefinition( MenuSectionDefinition(
@@ -202,6 +211,14 @@ tenancy_module = ModuleDefinition(
route="/store/{store_code}/team", route="/store/{store_code}/team",
order=5, order=5,
), ),
MenuItemDefinition(
id="roles",
label_key="tenancy.menu.roles",
icon="shield-check",
route="/store/{store_code}/team/roles",
order=10,
requires_permission="team.view",
),
], ],
), ),
], ],

View File

@@ -112,5 +112,37 @@
"name": "Audit-Protokoll", "name": "Audit-Protokoll",
"description": "Alle Benutzeraktionen und Änderungen nachverfolgen" "description": "Alle Benutzeraktionen und Änderungen nachverfolgen"
} }
},
"permissions": {
"category": {
"dashboard": "Dashboard",
"products": "Produkte",
"stock": "Inventar",
"orders": "Bestellungen",
"customers": "Kunden",
"marketing": "Marketing",
"reports": "Berichte",
"settings": "Einstellungen",
"team": "Team",
"imports": "Importe",
"general": "Allgemein",
"analytics": "Analytik",
"billing": "Abrechnung",
"cart": "Warenkorb",
"checkout": "Kasse",
"cms": "Inhalte",
"loyalty": "Treue",
"marketplace": "Marktplatz",
"messaging": "Nachrichten",
"payments": "Zahlungen"
},
"team_view": "Team anzeigen",
"team_view_desc": "Teammitglieder und ihre Rollen anzeigen",
"team_invite": "Mitglieder einladen",
"team_invite_desc": "Neue Mitglieder ins Team einladen",
"team_edit": "Mitglieder bearbeiten",
"team_edit_desc": "Rollen und Berechtigungen der Mitglieder bearbeiten",
"team_remove": "Mitglieder entfernen",
"team_remove_desc": "Mitglieder aus dem Team entfernen"
} }
} }

View File

@@ -97,6 +97,38 @@
"show_all_menu_items": "This will show all menu items. Continue?", "show_all_menu_items": "This will show all menu items. Continue?",
"hide_all_menu_items": "This will hide all menu items (except mandatory ones). You can then enable the ones you want. Continue?" "hide_all_menu_items": "This will hide all menu items (except mandatory ones). You can then enable the ones you want. Continue?"
}, },
"permissions": {
"category": {
"dashboard": "Dashboard",
"products": "Products",
"stock": "Inventory",
"orders": "Orders",
"customers": "Customers",
"marketing": "Marketing",
"reports": "Reports",
"settings": "Settings",
"team": "Team",
"imports": "Imports",
"general": "General",
"analytics": "Analytics",
"billing": "Billing",
"cart": "Cart",
"checkout": "Checkout",
"cms": "Content",
"loyalty": "Loyalty",
"marketplace": "Marketplace",
"messaging": "Messaging",
"payments": "Payments"
},
"team_view": "View Team",
"team_view_desc": "View team members and their roles",
"team_invite": "Invite Members",
"team_invite_desc": "Invite new members to the store team",
"team_edit": "Edit Members",
"team_edit_desc": "Edit team member roles and permissions",
"team_remove": "Remove Members",
"team_remove_desc": "Remove members from the store team"
},
"features": { "features": {
"team_members": { "team_members": {
"name": "Team Members", "name": "Team Members",

View File

@@ -112,5 +112,37 @@
"name": "Journal d'audit", "name": "Journal d'audit",
"description": "Suivre toutes les actions et modifications" "description": "Suivre toutes les actions et modifications"
} }
},
"permissions": {
"category": {
"dashboard": "Tableau de bord",
"products": "Produits",
"stock": "Inventaire",
"orders": "Commandes",
"customers": "Clients",
"marketing": "Marketing",
"reports": "Rapports",
"settings": "Paramètres",
"team": "Équipe",
"imports": "Importations",
"general": "Général",
"analytics": "Analytique",
"billing": "Facturation",
"cart": "Panier",
"checkout": "Paiement",
"cms": "Contenu",
"loyalty": "Fidélité",
"marketplace": "Marketplace",
"messaging": "Messagerie",
"payments": "Paiements"
},
"team_view": "Voir l'équipe",
"team_view_desc": "Voir les membres de l'équipe et leurs rôles",
"team_invite": "Inviter des membres",
"team_invite_desc": "Inviter de nouveaux membres dans l'équipe",
"team_edit": "Modifier les membres",
"team_edit_desc": "Modifier les rôles et permissions des membres",
"team_remove": "Supprimer des membres",
"team_remove_desc": "Retirer des membres de l'équipe"
} }
} }

View File

@@ -112,5 +112,37 @@
"name": "Audit-Protokoll", "name": "Audit-Protokoll",
"description": "All Benotzeraktiounen an Ännerungen nospueren" "description": "All Benotzeraktiounen an Ännerungen nospueren"
} }
},
"permissions": {
"category": {
"dashboard": "Dashboard",
"products": "Produiten",
"stock": "Inventar",
"orders": "Bestellungen",
"customers": "Clienten",
"marketing": "Marketing",
"reports": "Berichter",
"settings": "Astellungen",
"team": "Team",
"imports": "Importatiounen",
"general": "Allgemeng",
"analytics": "Analytik",
"billing": "Ofrechnung",
"cart": "Kuerf",
"checkout": "Keess",
"cms": "Inhalter",
"loyalty": "Treiheet",
"marketplace": "Marché",
"messaging": "Messagen",
"payments": "Bezuelungen"
},
"team_view": "Team kucken",
"team_view_desc": "Team-Memberen an hir Rollen kucken",
"team_invite": "Memberen invitéieren",
"team_invite_desc": "Nei Memberen an d'Team invitéieren",
"team_edit": "Memberen änneren",
"team_edit_desc": "Rollen a Rechter vun de Memberen änneren",
"team_remove": "Memberen ewechhuelen",
"team_remove_desc": "Memberen aus dem Team ewechhuelen"
} }
} }

View File

@@ -26,6 +26,7 @@ from .admin_modules import router as admin_modules_router
from .admin_platform_users import admin_platform_users_router from .admin_platform_users import admin_platform_users_router
from .admin_platforms import admin_platforms_router from .admin_platforms import admin_platforms_router
from .admin_store_domains import admin_store_domains_router from .admin_store_domains import admin_store_domains_router
from .admin_store_roles import admin_store_roles_router
from .admin_stores import admin_stores_router from .admin_stores import admin_stores_router
from .admin_users import admin_users_router from .admin_users import admin_users_router
@@ -39,6 +40,7 @@ admin_router.include_router(admin_merchants_router, tags=["admin-merchants"])
admin_router.include_router(admin_platforms_router, tags=["admin-platforms"]) admin_router.include_router(admin_platforms_router, tags=["admin-platforms"])
admin_router.include_router(admin_stores_router, tags=["admin-stores"]) admin_router.include_router(admin_stores_router, tags=["admin-stores"])
admin_router.include_router(admin_store_domains_router, tags=["admin-store-domains"]) admin_router.include_router(admin_store_domains_router, tags=["admin-store-domains"])
admin_router.include_router(admin_store_roles_router, tags=["admin-store-roles"])
admin_router.include_router(admin_merchant_domains_router, tags=["admin-merchant-domains"]) admin_router.include_router(admin_merchant_domains_router, tags=["admin-merchant-domains"])
admin_router.include_router(admin_modules_router, tags=["admin-modules"]) admin_router.include_router(admin_modules_router, tags=["admin-modules"])
admin_router.include_router(admin_module_config_router, tags=["admin-module-config"]) admin_router.include_router(admin_module_config_router, tags=["admin-module-config"])

View File

@@ -0,0 +1,181 @@
# app/modules/tenancy/routes/api/admin_store_roles.py
"""
Admin store role management endpoints.
Allows super admins and platform admins to manage roles for any store
they have access to. Platform admins are scoped to stores within their
assigned platforms.
Endpoints:
GET /admin/store-roles — List roles for a store
GET /admin/store-roles/permissions/catalog — Permission catalog
POST /admin/store-roles — Create a role
PUT /admin/store-roles/{role_id} — Update a role
DELETE /admin/store-roles/{role_id} — Delete a role
"""
import logging
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.modules.tenancy.schemas.team import (
PermissionCatalogResponse,
RoleCreate,
RoleListResponse,
RoleResponse,
RoleUpdate,
)
from app.modules.tenancy.services.permission_discovery_service import (
permission_discovery_service,
)
from app.modules.tenancy.services.store_team_service import store_team_service
from app.utils.i18n import translate
from models.schema.auth import UserContext
admin_store_roles_router = APIRouter(prefix="/store-roles")
logger = logging.getLogger(__name__)
@admin_store_roles_router.get(
"/permissions/catalog", response_model=PermissionCatalogResponse
)
def admin_get_permission_catalog(
request: Request,
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Get the full permission catalog grouped by category.
Available to all admin users. Returns all permission definitions
with labels and descriptions for the role editor UI.
"""
categories = permission_discovery_service.get_permissions_by_category()
lang = current_admin.preferred_language or getattr(
request.state, "language", "en"
)
def _t(key: str) -> str:
"""Translate key, falling back to readable version."""
translated = translate(key, language=lang)
if translated == key:
parts = key.split(".")
return parts[-1].replace("_", " ").title()
return translated
return PermissionCatalogResponse(
categories=[
{
"id": cat.id,
"label": _t(cat.label_key),
"permissions": [
{
"id": p.id,
"label": _t(p.label_key),
"description": _t(p.description_key),
"is_owner_only": p.is_owner_only,
}
for p in cat.permissions
],
}
for cat in categories
]
)
@admin_store_roles_router.get("", response_model=RoleListResponse)
def admin_list_store_roles(
store_id: int = Query(..., description="Store ID to list roles for"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
List all roles for a store.
Platform admins can only access stores within their assigned platforms.
Super admins can access any store.
"""
store_team_service.validate_admin_store_access(db, current_admin, store_id)
roles = store_team_service.get_store_roles(db=db, store_id=store_id)
db.commit() # Commit in case default roles were created
return RoleListResponse(roles=roles, total=len(roles))
@admin_store_roles_router.post("", response_model=RoleResponse, status_code=201)
def admin_create_store_role(
role_data: RoleCreate,
store_id: int = Query(..., description="Store ID to create role for"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Create a custom role for a store.
Platform admins can only manage stores within their assigned platforms.
"""
store_team_service.validate_admin_store_access(db, current_admin, store_id)
role = store_team_service.create_custom_role(
db=db,
store_id=store_id,
name=role_data.name,
permissions=role_data.permissions,
actor_user_id=current_admin.id,
)
db.commit()
return role
@admin_store_roles_router.put("/{role_id}", response_model=RoleResponse)
def admin_update_store_role(
role_id: int,
role_data: RoleUpdate,
store_id: int = Query(..., description="Store ID"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Update a role's name and/or permissions.
Platform admins can only manage stores within their assigned platforms.
"""
store_team_service.validate_admin_store_access(db, current_admin, store_id)
role = store_team_service.update_role(
db=db,
store_id=store_id,
role_id=role_id,
name=role_data.name,
permissions=role_data.permissions,
actor_user_id=current_admin.id,
)
db.commit()
return role
@admin_store_roles_router.delete("/{role_id}", status_code=204)
def admin_delete_store_role(
role_id: int,
store_id: int = Query(..., description="Store ID"),
db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api),
):
"""
Delete a custom role.
Preset roles cannot be deleted. Platform admins can only manage
stores within their assigned platforms.
"""
store_team_service.validate_admin_store_access(db, current_admin, store_id)
store_team_service.delete_role(
db=db,
store_id=store_id,
role_id=role_id,
actor_user_id=current_admin.id,
)
db.commit()

View File

@@ -86,6 +86,7 @@ def get_all_stores_admin(
search: str | None = Query(None, description="Search by name or store code"), search: str | None = Query(None, description="Search by name or store code"),
is_active: bool | None = Query(None), is_active: bool | None = Query(None),
is_verified: bool | None = Query(None), is_verified: bool | None = Query(None),
merchant_id: int | None = Query(None, description="Filter by merchant ID"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: UserContext = Depends(get_current_admin_api), current_admin: UserContext = Depends(get_current_admin_api),
): ):
@@ -97,6 +98,7 @@ def get_all_stores_admin(
search=search, search=search,
is_active=is_active, is_active=is_active,
is_verified=is_verified, is_verified=is_verified,
merchant_id=merchant_id,
) )
return StoreListResponse(stores=stores, total=total, skip=skip, limit=limit) return StoreListResponse(stores=stores, total=total, skip=skip, limit=limit)

View File

@@ -28,6 +28,7 @@ from app.modules.tenancy.schemas.team import (
InvitationAccept, InvitationAccept,
InvitationAcceptResponse, InvitationAcceptResponse,
InvitationResponse, InvitationResponse,
PermissionCatalogResponse,
RoleCreate, RoleCreate,
RoleListResponse, RoleListResponse,
RoleResponse, RoleResponse,
@@ -42,7 +43,11 @@ from app.modules.tenancy.schemas.team import (
# Permission IDs are now defined in module definition.py files # Permission IDs are now defined in module definition.py files
# and discovered by PermissionDiscoveryService # and discovered by PermissionDiscoveryService
from app.modules.tenancy.services.permission_discovery_service import (
permission_discovery_service,
)
from app.modules.tenancy.services.store_team_service import store_team_service from app.modules.tenancy.services.store_team_service import store_team_service
from app.utils.i18n import translate
from models.schema.auth import UserContext from models.schema.auth import UserContext
store_team_router = APIRouter(prefix="/team") store_team_router = APIRouter(prefix="/team")
@@ -480,6 +485,57 @@ def delete_role(
# ============================================================================ # ============================================================================
@store_team_router.get(
"/permissions/catalog", response_model=PermissionCatalogResponse
)
def get_permission_catalog(
request: Request,
current_user: UserContext = Depends(
require_store_permission("team.view")
),
):
"""
Get the full permission catalog grouped by category.
**Required Permission:** `team.view`
Returns all available permissions with labels and descriptions,
grouped by category. Used by the role editor UI for displaying
permission checkboxes with human-readable names and tooltips.
"""
categories = permission_discovery_service.get_permissions_by_category()
lang = current_user.preferred_language or getattr(
request.state, "language", "en"
)
def _t(key: str) -> str:
"""Translate key, falling back to readable version."""
translated = translate(key, language=lang)
if translated == key:
parts = key.split(".")
return parts[-1].replace("_", " ").title()
return translated
return PermissionCatalogResponse(
categories=[
{
"id": cat.id,
"label": _t(cat.label_key),
"permissions": [
{
"id": p.id,
"label": _t(p.label_key),
"description": _t(p.description_key),
"is_owner_only": p.is_owner_only,
}
for p in cat.permissions
],
}
for cat in categories
]
)
@store_team_router.get("/me/permissions", response_model=UserPermissionsResponse) @store_team_router.get("/me/permissions", response_model=UserPermissionsResponse)
def get_my_permissions( def get_my_permissions(
request: Request, request: Request,

View File

@@ -197,6 +197,34 @@ async def admin_store_domains_page(
) )
# ============================================================================
# STORE ROLES ROUTES
# ============================================================================
@router.get("/store-roles", response_class=HTMLResponse, include_in_schema=False)
async def admin_store_roles_page(
request: Request,
current_user: User = Depends(
require_menu_access("store-roles", FrontendType.ADMIN)
),
db: Session = Depends(get_db),
):
"""
Render store roles management page.
Allows admins to select a store and manage its roles and permissions.
Super admins see merchant → store cascading selection.
Platform admins see store selection scoped to their platforms.
"""
is_super_admin = current_user.role == "super_admin"
return templates.TemplateResponse(
"tenancy/admin/store-roles.html",
get_admin_context(
request, db, current_user, is_super_admin=is_super_admin
),
)
# ============================================================================ # ============================================================================
# STORE THEMES ROUTES # STORE THEMES ROUTES
# ============================================================================ # ============================================================================

View File

@@ -276,6 +276,34 @@ class UserPermissionsResponse(BaseModel):
role_name: str | None = None role_name: str | None = None
# ============================================================================
# Permission Catalog Schemas
# ============================================================================
class PermissionCatalogItem(BaseModel):
"""A single permission with its metadata for UI display."""
id: str
label: str
description: str
is_owner_only: bool = False
class PermissionCategoryResponse(BaseModel):
"""A category of related permissions."""
id: str
label: str
permissions: list[PermissionCatalogItem]
class PermissionCatalogResponse(BaseModel):
"""Complete permission catalog grouped by category."""
categories: list[PermissionCategoryResponse]
# ============================================================================ # ============================================================================
# Error Response Schema # Error Response Schema
# ============================================================================ # ============================================================================

View File

@@ -476,12 +476,17 @@ class AdminService:
search: str | None = None, search: str | None = None,
is_active: bool | None = None, is_active: bool | None = None,
is_verified: bool | None = None, is_verified: bool | None = None,
merchant_id: int | None = None,
) -> tuple[list[Store], int]: ) -> tuple[list[Store], int]:
"""Get paginated list of all stores with filtering.""" """Get paginated list of all stores with filtering."""
try: try:
# Eagerly load merchant relationship to avoid N+1 queries # Eagerly load merchant relationship to avoid N+1 queries
query = db.query(Store).options(joinedload(Store.merchant)) query = db.query(Store).options(joinedload(Store.merchant))
# Filter by merchant
if merchant_id is not None:
query = query.filter(Store.merchant_id == merchant_id)
# Apply search filter # Apply search filter
if search: if search:
search_term = f"%{search}%" search_term = f"%{search}%"
@@ -501,6 +506,8 @@ class AdminService:
# Get total count (without joinedload for performance) # Get total count (without joinedload for performance)
count_query = db.query(Store) count_query = db.query(Store)
if merchant_id is not None:
count_query = count_query.filter(Store.merchant_id == merchant_id)
if search: if search:
search_term = f"%{search}%" search_term = f"%{search}%"
count_query = count_query.filter( count_query = count_query.filter(

View File

@@ -35,7 +35,7 @@ from app.modules.tenancy.exceptions import (
TeamMemberAlreadyExistsException, TeamMemberAlreadyExistsException,
UserNotFoundException, UserNotFoundException,
) )
from app.modules.tenancy.models import Role, Store, StoreUser, User from app.modules.tenancy.models import Role, Store, StorePlatform, StoreUser, User
from middleware.auth import AuthManager from middleware.auth import AuthManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -724,6 +724,59 @@ class StoreTeamService:
details={"role_name": role_name, "store_id": store_id}, details={"role_name": role_name, "store_id": store_id},
) )
# ========================================================================
# Admin Access Validation
# ========================================================================
def validate_admin_store_access(
self,
db: Session,
user_context,
store_id: int,
) -> Store:
"""
Verify an admin user can access the given store.
Super admins can access any store. Platform admins can only access
stores that belong to one of their assigned platforms.
Args:
db: Database session
user_context: UserContext of the admin user
store_id: Store ID to validate access to
Returns:
The Store object if access is granted
Raises:
InvalidRoleException: If store not found or admin lacks access
"""
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
raise InvalidRoleException(f"Store {store_id} not found")
# Super admins (accessible_platform_ids is None) can access all stores
platform_ids = user_context.get_accessible_platform_ids()
if platform_ids is None:
return store
# Platform admins: store must belong to one of their platforms
store_in_platform = (
db.query(StorePlatform)
.filter(
StorePlatform.store_id == store_id,
StorePlatform.platform_id.in_(platform_ids),
StorePlatform.is_active == True,
)
.first()
)
if not store_in_platform:
raise InvalidRoleException(
"You do not have access to this store's roles"
)
return store
# Private helper methods # Private helper methods
def _generate_invitation_token(self) -> str: def _generate_invitation_token(self) -> str:

View File

@@ -0,0 +1,419 @@
// noqa: js-006 - async init pattern is safe, loadData has try/catch
// static/admin/js/store-roles.js
/**
* Admin store roles management page
*
* Super admins: merchant → store cascading selection.
* Platform admins: store selection scoped to their platforms.
*
* Uses Tom Select for selection and permission catalog API for
* displaying permissions with labels and descriptions.
*/
const storeRolesAdminLog = (window.LogConfig && window.LogConfig.createLogger)
? window.LogConfig.createLogger('adminStoreRoles', false)
: console;
storeRolesAdminLog.info('Loading...');
function adminStoreRoles() {
storeRolesAdminLog.info('adminStoreRoles() called');
const config = window._adminStoreRolesConfig || {};
const isSuperAdmin = config.isSuperAdmin || false;
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'store-roles',
// Selection state
isSuperAdmin,
selectedMerchant: null,
selectedStore: null,
merchantSelector: null,
storeSelector: null,
// Role state
loading: false,
roles: [],
rolesLoading: false,
saving: false,
showRoleModal: false,
editingRole: null,
roleForm: { name: '', permissions: [] },
permissionCategories: [],
presetRoles: ['manager', 'staff', 'support', 'viewer', 'marketing'],
async init() {
// Guard against multiple initialization
if (window._adminStoreRolesInitialized) {
return;
}
window._adminStoreRolesInitialized = true;
storeRolesAdminLog.info('Admin Store Roles init(), isSuperAdmin:', isSuperAdmin);
this.$nextTick(() => {
if (isSuperAdmin) {
this.initMerchantSelector();
} else {
this.initStoreSelector();
}
});
// Load permission catalog
await this.loadPermissionCatalog();
// Restore saved selection
const savedStoreId = localStorage.getItem('admin_store_roles_selected_store_id');
if (savedStoreId) {
storeRolesAdminLog.info('Restoring saved store:', savedStoreId);
setTimeout(async () => {
await this.restoreSavedStore(parseInt(savedStoreId));
}, 300);
}
storeRolesAdminLog.info('Admin Store Roles initialization complete');
},
// =====================================================================
// Permission Catalog
// =====================================================================
async loadPermissionCatalog() {
try {
const response = await apiClient.get('/admin/store-roles/permissions/catalog');
this.permissionCategories = response.categories || [];
storeRolesAdminLog.info('Loaded permission catalog:', this.permissionCategories.length, 'categories');
} catch (error) {
storeRolesAdminLog.warn('Failed to load permission catalog:', error);
}
},
// =====================================================================
// Merchant Selector (Super Admin only)
// =====================================================================
initMerchantSelector() {
const el = this.$refs.merchantSelect;
if (!el) {
storeRolesAdminLog.warn('Merchant select element not found');
return;
}
const self = this;
waitForTomSelect(() => {
self.merchantSelector = new TomSelect(el, {
valueField: 'id',
labelField: 'name',
searchField: ['name'],
maxOptions: 50,
placeholder: 'Search merchant by name...',
load: async function(query, callback) {
if (query.length < 2) { callback([]); return; }
try {
const response = await apiClient.get(
`/admin/merchants?search=${encodeURIComponent(query)}&limit=50`
);
const merchants = (response.merchants || []).map(m => ({
id: m.id,
name: m.name,
store_count: m.store_count || 0,
}));
callback(merchants);
} catch (error) {
storeRolesAdminLog.error('Merchant search failed:', error);
callback([]);
}
},
render: {
option: function(data, escape) {
return `<div class="flex justify-between items-center py-1">
<span class="font-medium">${escape(data.name)}</span>
<span class="text-xs text-gray-400 ml-2">${data.store_count} store(s)</span>
</div>`;
},
item: function(data, escape) {
return `<div>${escape(data.name)}</div>`;
},
no_results: function() {
return '<div class="no-results py-2 px-3 text-gray-500">No merchants found</div>';
},
loading: function() {
return '<div class="loading py-2 px-3 text-gray-500">Searching...</div>';
}
},
onChange: function(value) {
if (value) {
const selected = this.options[value];
if (selected) {
self.onMerchantSelected({
id: parseInt(value),
name: selected.name,
store_count: selected.store_count,
});
}
} else {
self.onMerchantCleared();
}
},
loadThrottle: 150,
closeAfterSelect: true,
persist: true,
create: false,
});
storeRolesAdminLog.info('Merchant selector initialized');
});
},
async onMerchantSelected(merchant) {
storeRolesAdminLog.info('Merchant selected:', merchant.name);
this.selectedMerchant = merchant;
this.selectedStore = null;
this.roles = [];
localStorage.removeItem('admin_store_roles_selected_store_id');
// Destroy previous store selector and reinit with merchant filter
if (this.storeSelector) {
if (typeof this.storeSelector.destroy === 'function') {
this.storeSelector.destroy();
}
this.storeSelector = null;
}
// Wait for DOM update (x-show toggles the store select container)
await this.$nextTick();
this.initStoreSelector(merchant.id);
},
onMerchantCleared() {
storeRolesAdminLog.info('Merchant cleared');
this.selectedMerchant = null;
this.selectedStore = null;
this.roles = [];
localStorage.removeItem('admin_store_roles_selected_store_id');
if (this.storeSelector) {
if (typeof this.storeSelector.destroy === 'function') {
this.storeSelector.destroy();
}
this.storeSelector = null;
}
},
// =====================================================================
// Store Selector
// =====================================================================
initStoreSelector(merchantId = null) {
const el = this.$refs.storeSelect;
if (!el) {
storeRolesAdminLog.warn('Store select element not found');
return;
}
const apiEndpoint = merchantId
? `/admin/stores?merchant_id=${merchantId}`
: '/admin/stores';
this.storeSelector = initStoreSelector(el, {
placeholder: merchantId ? 'Select store...' : 'Search store by name or code...',
apiEndpoint: apiEndpoint,
onSelect: async (store) => {
storeRolesAdminLog.info('Store selected:', store);
this.selectedStore = store;
localStorage.setItem('admin_store_roles_selected_store_id', store.id.toString());
await this.loadRoles();
},
onClear: () => {
storeRolesAdminLog.info('Store cleared');
this.selectedStore = null;
this.roles = [];
localStorage.removeItem('admin_store_roles_selected_store_id');
}
});
},
// =====================================================================
// Restore / Clear
// =====================================================================
async restoreSavedStore(storeId) {
try {
const store = await apiClient.get(`/admin/stores/${storeId}`);
if (!store) return;
if (isSuperAdmin && store.merchant_id) {
// For super admin, restore the merchant first
try {
const merchant = await apiClient.get(`/admin/merchants/${store.merchant_id}`);
if (merchant && this.merchantSelector) {
this.merchantSelector.addOption({
id: merchant.id,
name: merchant.name,
store_count: merchant.store_count || 0,
});
this.merchantSelector.setValue(merchant.id, true);
this.selectedMerchant = { id: merchant.id, name: merchant.name };
// Wait for DOM, then init store selector and set value
await this.$nextTick();
this.initStoreSelector(merchant.id);
setTimeout(() => {
if (this.storeSelector) {
this.storeSelector.setValue(store.id, store);
}
this.selectedStore = { id: store.id, name: store.name, store_code: store.store_code };
this.loadRoles();
}, 300);
}
} catch (error) {
storeRolesAdminLog.warn('Failed to restore merchant:', error);
localStorage.removeItem('admin_store_roles_selected_store_id');
}
} else {
// Platform admin: just restore the store
if (this.storeSelector) {
this.storeSelector.setValue(store.id, store);
}
this.selectedStore = { id: store.id, name: store.name, store_code: store.store_code };
await this.loadRoles();
}
storeRolesAdminLog.info('Restored store:', store.name);
} catch (error) {
storeRolesAdminLog.warn('Failed to restore saved store:', error);
localStorage.removeItem('admin_store_roles_selected_store_id');
}
},
clearSelection() {
if (isSuperAdmin) {
if (this.merchantSelector) {
this.merchantSelector.clear();
}
this.selectedMerchant = null;
}
if (this.storeSelector) {
if (typeof this.storeSelector.clear === 'function') {
this.storeSelector.clear();
}
}
this.selectedStore = null;
this.roles = [];
localStorage.removeItem('admin_store_roles_selected_store_id');
},
// =====================================================================
// Roles CRUD
// =====================================================================
async loadRoles() {
if (!this.selectedStore) return;
this.rolesLoading = true;
try {
const response = await apiClient.get(`/admin/store-roles?store_id=${this.selectedStore.id}`);
this.roles = response.roles || [];
storeRolesAdminLog.info('Loaded', this.roles.length, 'roles');
} catch (error) {
storeRolesAdminLog.error('Failed to load roles:', error);
Utils.showToast(error.message || 'Failed to load roles', 'error');
} finally {
this.rolesLoading = false;
}
},
isPresetRole(name) {
return this.presetRoles.includes(name.toLowerCase());
},
openCreateModal() {
this.editingRole = null;
this.roleForm = { name: '', permissions: [] };
this.showRoleModal = true;
},
openEditModal(role) {
this.editingRole = role;
this.roleForm = {
name: role.name,
permissions: [...(role.permissions || [])],
};
this.showRoleModal = true;
},
togglePermission(permId) {
const idx = this.roleForm.permissions.indexOf(permId);
if (idx >= 0) {
this.roleForm.permissions.splice(idx, 1);
} else {
this.roleForm.permissions.push(permId);
}
},
toggleCategory(category) {
const perms = category.permissions || [];
const permIds = perms.map(p => p.id);
const allSelected = permIds.every(id => this.roleForm.permissions.includes(id));
if (allSelected) {
this.roleForm.permissions = this.roleForm.permissions.filter(id => !permIds.includes(id));
} else {
for (const id of permIds) {
if (!this.roleForm.permissions.includes(id)) {
this.roleForm.permissions.push(id);
}
}
}
},
isCategoryFullySelected(category) {
const perms = category.permissions || [];
return perms.length > 0 && perms.every(p => this.roleForm.permissions.includes(p.id));
},
async saveRole() {
if (!this.selectedStore) return;
this.saving = true;
try {
const storeParam = `store_id=${this.selectedStore.id}`;
if (this.editingRole) {
await apiClient.put(`/admin/store-roles/${this.editingRole.id}?${storeParam}`, this.roleForm);
} else {
await apiClient.post(`/admin/store-roles?${storeParam}`, this.roleForm);
}
this.showRoleModal = false;
Utils.showToast('Role saved successfully', 'success');
await this.loadRoles();
} catch (error) {
storeRolesAdminLog.error('Error saving role:', error);
Utils.showToast(error.message || 'Failed to save role', 'error');
} finally {
this.saving = false;
}
},
async confirmDelete(role) {
if (!this.selectedStore) return;
if (!confirm(`Delete role "${role.name}"? This cannot be undone.`)) return;
try {
await apiClient.delete(`/admin/store-roles/${role.id}?store_id=${this.selectedStore.id}`);
Utils.showToast('Role deleted successfully', 'success');
await this.loadRoles();
} catch (error) {
storeRolesAdminLog.error('Error deleting role:', error);
Utils.showToast(error.message || 'Failed to delete role', 'error');
}
},
};
}
storeRolesAdminLog.info('Module loaded');

View File

@@ -27,7 +27,7 @@ function storeRoles() {
showRoleModal: false, showRoleModal: false,
editingRole: null, editingRole: null,
roleForm: { name: '', permissions: [] }, roleForm: { name: '', permissions: [] },
permissionsByCategory: {}, permissionCategories: [],
presetRoles: ['manager', 'staff', 'support', 'viewer', 'marketing'], presetRoles: ['manager', 'staff', 'support', 'viewer', 'marketing'],
async init() { async init() {
@@ -50,40 +50,14 @@ function storeRoles() {
async loadPermissions() { async loadPermissions() {
try { try {
// Group known permissions by category prefix const response = await apiClient.get('/store/team/permissions/catalog');
const allPerms = window.USER_PERMISSIONS || []; this.permissionCategories = response.categories || [];
this.permissionsByCategory = this.groupPermissions(allPerms); storeRolesLog.info('Loaded permission catalog:', this.permissionCategories.length, 'categories');
} catch (e) { } catch (e) {
storeRolesLog.warn('Could not load permission categories:', e); storeRolesLog.warn('Could not load permission catalog:', e);
} }
}, },
groupPermissions(permIds) {
// Known permission categories from the codebase
const knownPerms = [
'dashboard.view',
'settings.view', 'settings.edit', 'settings.theme', 'settings.domains',
'products.view', 'products.create', 'products.edit', 'products.delete', 'products.import', 'products.export',
'orders.view', 'orders.edit', 'orders.cancel', 'orders.refund',
'customers.view', 'customers.edit', 'customers.delete', 'customers.export',
'stock.view', 'stock.edit', 'stock.transfer',
'team.view', 'team.invite', 'team.edit', 'team.remove',
'analytics.view', 'analytics.export',
'messaging.view_messages', 'messaging.send_messages', 'messaging.manage_templates',
'billing.view_tiers', 'billing.manage_tiers', 'billing.view_subscriptions', 'billing.manage_subscriptions', 'billing.view_invoices',
'cms.view_pages', 'cms.manage_pages', 'cms.view_media', 'cms.manage_media', 'cms.manage_themes',
'loyalty.view_programs', 'loyalty.manage_programs', 'loyalty.view_rewards', 'loyalty.manage_rewards',
'cart.view', 'cart.manage',
];
const groups = {};
for (const perm of knownPerms) {
const cat = perm.split('.')[0];
if (!groups[cat]) groups[cat] = [];
groups[cat].push({ id: perm });
}
return groups;
},
async loadRoles() { async loadRoles() {
this.loading = true; this.loading = true;
this.error = false; this.error = false;
@@ -127,7 +101,7 @@ function storeRoles() {
}, },
toggleCategory(category) { toggleCategory(category) {
const perms = this.permissionsByCategory[category] || []; const perms = category.permissions || [];
const permIds = perms.map(p => p.id); const permIds = perms.map(p => p.id);
const allSelected = permIds.every(id => this.roleForm.permissions.includes(id)); const allSelected = permIds.every(id => this.roleForm.permissions.includes(id));
if (allSelected) { if (allSelected) {
@@ -142,7 +116,7 @@ function storeRoles() {
}, },
isCategoryFullySelected(category) { isCategoryFullySelected(category) {
const perms = this.permissionsByCategory[category] || []; const perms = category.permissions || [];
return perms.length > 0 && perms.every(p => this.roleForm.permissions.includes(p.id)); return perms.length > 0 && perms.every(p => this.roleForm.permissions.includes(p.id));
}, },

View File

@@ -0,0 +1,271 @@
{# app/templates/admin/store-roles.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% block title %}Store Roles{% endblock %}
{% block extra_head %}
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" rel="stylesheet">
<style>
.ts-wrapper { width: 100%; }
.ts-control {
background-color: rgb(249 250 251) !important;
border-color: rgb(209 213 219) !important;
border-radius: 0.5rem !important;
padding: 0.5rem 0.75rem !important;
}
.dark .ts-control {
background-color: rgb(55 65 81) !important;
border-color: rgb(75 85 99) !important;
color: rgb(229 231 235) !important;
}
.ts-dropdown {
border-radius: 0.5rem !important;
border-color: rgb(209 213 219) !important;
}
.dark .ts-dropdown {
background-color: rgb(55 65 81) !important;
border-color: rgb(75 85 99) !important;
}
.dark .ts-dropdown .option {
color: rgb(229 231 235) !important;
}
.dark .ts-dropdown .option.active {
background-color: rgb(75 85 99) !important;
}
</style>
{% endblock %}
{% block alpine_data %}adminStoreRoles(){% endblock %}
{% block content %}
{{ page_header('Store Roles', subtitle='Manage roles and permissions for any store') }}
<!-- Selection Panel -->
<div class="px-4 py-6 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
{% if is_super_admin %}
<!-- Super Admin: Merchant → Store cascading -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Merchant
</label>
<select x-ref="merchantSelect" placeholder="Search merchant by name..."></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Store
</label>
<div x-show="!selectedMerchant" class="px-3 py-2 text-sm text-gray-400 dark:text-gray-500 border rounded-lg dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
Select a merchant first
</div>
<div x-show="selectedMerchant" x-cloak>
<select x-ref="storeSelect" placeholder="Select store..."></select>
</div>
</div>
</div>
{% else %}
<!-- Platform Admin: Store only (scoped to their platforms) -->
<div class="max-w-md">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Store
</label>
<select x-ref="storeSelect" placeholder="Search store by name or code..."></select>
</div>
{% endif %}
</div>
<!-- Selected Store Info -->
<div x-show="selectedStore" x-cloak class="mb-6">
<div class="px-4 py-3 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span x-html="$icon('shield-check', 'w-6 h-6 text-purple-600')"></span>
<div>
<p class="text-sm font-medium text-purple-700 dark:text-purple-300">Managing Roles For</p>
<p class="text-lg font-semibold text-purple-900 dark:text-purple-100" x-text="selectedStore?.name"></p>
{% if is_super_admin %}
<p class="text-xs text-purple-600 dark:text-purple-400" x-text="selectedMerchant ? 'Merchant: ' + selectedMerchant.name : ''"></p>
{% endif %}
</div>
</div>
<div class="flex items-center gap-2">
<button
@click="openCreateModal()"
class="flex items-center px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
>
<span x-html="$icon('plus', 'w-4 h-4 mr-1')"></span>
Create Role
</button>
<button
@click="clearSelection()"
class="flex items-center gap-1 px-3 py-1.5 text-sm text-purple-700 dark:text-purple-300 bg-purple-100 dark:bg-purple-800 rounded-md hover:bg-purple-200 dark:hover:bg-purple-700 transition-colors"
>
<span x-html="$icon('x-mark', 'w-4 h-4')"></span>
Clear
</button>
</div>
</div>
</div>
</div>
<!-- Loading -->
<div x-show="rolesLoading" class="text-center py-12">
<div class="inline-block animate-spin w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full"></div>
<p class="mt-4 text-gray-500 dark:text-gray-400">Loading roles...</p>
</div>
<!-- Roles List -->
<div x-show="selectedStore && !rolesLoading" class="space-y-6">
<template x-for="role in roles" :key="role.id">
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="role.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span x-text="(role.permissions || []).length"></span> permissions
<template x-if="isPresetRole(role.name)">
<span class="ml-2 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full dark:bg-blue-900 dark:text-blue-200">Preset</span>
</template>
<template x-if="!isPresetRole(role.name)">
<span class="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 rounded-full dark:bg-green-900 dark:text-green-200">Custom</span>
</template>
</p>
</div>
<div class="flex items-center gap-2">
<button
@click="openEditModal(role)"
class="px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-400 dark:bg-purple-900/20 dark:hover:bg-purple-900/40"
>
<span x-html="$icon('pencil', 'w-4 h-4 inline mr-1')"></span>
Edit
</button>
<button
x-show="!isPresetRole(role.name)"
@click="confirmDelete(role)"
class="px-3 py-1.5 text-sm font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 dark:hover:bg-red-900/40"
>
<span x-html="$icon('trash', 'w-4 h-4 inline mr-1')"></span>
Delete
</button>
</div>
</div>
<!-- Permission tags -->
<div class="flex flex-wrap gap-1.5">
<template x-for="perm in (role.permissions || [])" :key="perm">
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded dark:bg-gray-700 dark:text-gray-300" x-text="perm"></span>
</template>
<template x-if="!role.permissions || role.permissions.length === 0">
<span class="text-sm text-gray-400 dark:text-gray-500">No permissions assigned</span>
</template>
</div>
</div>
</template>
<template x-if="roles.length === 0 && !rolesLoading">
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<span x-html="$icon('shield', 'w-12 h-12 mx-auto mb-4 opacity-50')"></span>
<p>No roles found for this store.</p>
</div>
</template>
</div>
<!-- No Store Selected -->
<div x-show="!selectedStore && !rolesLoading" class="text-center py-12">
<span x-html="$icon('shield-check', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
{% if is_super_admin %}
<p class="text-gray-600 dark:text-gray-400">Select a merchant and store above to manage roles</p>
{% else %}
<p class="text-gray-600 dark:text-gray-400">Select a store above to manage its roles</p>
{% endif %}
</div>
<!-- Create/Edit Role Modal -->
{% call modal_simple('roleModal', 'editingRole ? "Edit Role" : "Create Role"', 'showRoleModal') %}
<div class="space-y-4">
<!-- Role Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Role Name</label>
<input
type="text"
x-model="roleForm.name"
placeholder="e.g. Content Editor"
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<!-- Permission Matrix -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Permissions</label>
<div class="max-h-96 overflow-y-auto border rounded-lg dark:border-gray-600">
<template x-for="category in permissionCategories" :key="category.id">
<div class="border-b last:border-b-0 dark:border-gray-600">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700/50 flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 capitalize" x-text="category.label"></span>
<button
@click="toggleCategory(category)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
x-text="isCategoryFullySelected(category) ? 'Deselect All' : 'Select All'"
></button>
</div>
<div class="px-4 py-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
<template x-for="perm in category.permissions" :key="perm.id">
<label class="flex items-start gap-2 py-1.5 cursor-pointer">
<input
type="checkbox"
:value="perm.id"
:checked="roleForm.permissions.includes(perm.id)"
@change="togglePermission(perm.id)"
class="w-4 h-4 mt-0.5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<div class="flex flex-col">
<span class="flex items-center gap-1">
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="perm.label"></span>
<span
x-show="perm.description"
:title="perm.description"
x-html="$icon('information-circle', 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500 cursor-help')"
></span>
</span>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono" x-text="perm.id"></span>
</div>
<template x-if="perm.is_owner_only">
<span class="ml-auto px-1.5 py-0.5 text-xs bg-amber-100 text-amber-700 rounded dark:bg-amber-900/30 dark:text-amber-400">Owner</span>
</template>
</label>
</template>
</div>
</div>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<button
@click="showRoleModal = false"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:text-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600"
>Cancel</button>
<button
@click="saveRole()"
:disabled="saving || !roleForm.name.trim()"
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
<span x-show="saving" class="inline-block animate-spin mr-1">&#8635;</span>
<span x-text="editingRole ? 'Update Role' : 'Create Role'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script defer src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
<script>
window._adminStoreRolesConfig = { isSuperAdmin: {{ is_super_admin | tojson }} };
</script>
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-roles.js') }}"></script>
{% endblock %}

View File

@@ -101,10 +101,10 @@
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Permissions</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Permissions</label>
<div class="max-h-96 overflow-y-auto border rounded-lg dark:border-gray-600"> <div class="max-h-96 overflow-y-auto border rounded-lg dark:border-gray-600">
<template x-for="(perms, category) in permissionsByCategory" :key="category"> <template x-for="category in permissionCategories" :key="category.id">
<div class="border-b last:border-b-0 dark:border-gray-600"> <div class="border-b last:border-b-0 dark:border-gray-600">
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700/50 flex items-center justify-between"> <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700/50 flex items-center justify-between">
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 capitalize" x-text="category"></span> <span class="text-sm font-semibold text-gray-700 dark:text-gray-300 capitalize" x-text="category.label"></span>
<button <button
@click="toggleCategory(category)" @click="toggleCategory(category)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400" class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
@@ -112,16 +112,29 @@
></button> ></button>
</div> </div>
<div class="px-4 py-2 grid grid-cols-1 sm:grid-cols-2 gap-1"> <div class="px-4 py-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
<template x-for="perm in perms" :key="perm.id"> <template x-for="perm in category.permissions" :key="perm.id">
<label class="flex items-center gap-2 py-1 cursor-pointer"> <label class="flex items-start gap-2 py-1.5 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
:value="perm.id" :value="perm.id"
:checked="roleForm.permissions.includes(perm.id)" :checked="roleForm.permissions.includes(perm.id)"
@change="togglePermission(perm.id)" @change="togglePermission(perm.id)"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" class="w-4 h-4 mt-0.5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/> />
<span class="text-sm text-gray-600 dark:text-gray-400" x-text="perm.id"></span> <div class="flex flex-col">
<span class="flex items-center gap-1">
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="perm.label"></span>
<span
x-show="perm.description"
:title="perm.description"
x-html="$icon('information-circle', 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500 cursor-help')"
></span>
</span>
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono" x-text="perm.id"></span>
</div>
<template x-if="perm.is_owner_only">
<span class="ml-auto px-1.5 py-0.5 text-xs bg-amber-100 text-amber-700 rounded dark:bg-amber-900/30 dark:text-amber-400">Owner</span>
</template>
</label> </label>
</template> </template>
</div> </div>

View File

@@ -0,0 +1,276 @@
# app/modules/tenancy/tests/integration/test_admin_store_roles_api.py
"""
Integration tests for admin store role management API endpoints.
Tests the admin role management endpoints at:
/api/v1/admin/store-roles
Authentication: Uses super_admin_headers and platform_admin_headers
fixtures from tests/fixtures/auth_fixtures.py.
"""
import uuid
import pytest
from app.modules.tenancy.models import (
Merchant,
Platform,
Role,
Store,
StorePlatform,
User,
)
from middleware.auth import AuthManager
# ============================================================================
# Fixtures
# ============================================================================
BASE = "/api/v1/admin/store-roles"
@pytest.fixture
def admin_store_merchant(db):
"""Create a merchant for admin role tests."""
auth = AuthManager()
uid = uuid.uuid4().hex[:8]
owner = User(
email=f"admin_role_owner_{uid}@test.com",
username=f"admin_role_owner_{uid}",
hashed_password=auth.hash_password("ownerpass123"),
role="merchant_owner",
is_active=True,
)
db.add(owner)
db.flush()
merchant = Merchant(
name="Admin Role Test Merchant",
owner_user_id=owner.id,
contact_email=owner.email,
is_active=True,
is_verified=True,
)
db.add(merchant)
db.commit()
db.refresh(merchant)
return merchant
@pytest.fixture
def admin_role_store(db, admin_store_merchant):
"""Create a store for admin role tests."""
uid = uuid.uuid4().hex[:8]
store = Store(
merchant_id=admin_store_merchant.id,
store_code=f"ADMROLE_{uid.upper()}",
subdomain=f"admrole{uid}",
name=f"Admin Role Store {uid}",
is_active=True,
is_verified=True,
)
db.add(store)
db.commit()
db.refresh(store)
return store
@pytest.fixture
def admin_role_custom(db, admin_role_store):
"""Create a custom role for update/delete tests."""
role = Role(
store_id=admin_role_store.id,
name="admin_test_custom_role",
permissions=["products.view", "orders.view"],
)
db.add(role)
db.commit()
db.refresh(role)
return role
@pytest.fixture
def test_platform(db):
"""Create a platform for scoping tests."""
uid = uuid.uuid4().hex[:8]
platform = Platform(
code=f"test_{uid}",
name=f"Test Platform {uid}",
is_active=True,
)
db.add(platform)
db.commit()
db.refresh(platform)
return platform
@pytest.fixture
def store_on_platform(db, admin_role_store, test_platform):
"""Link the test store to a platform."""
sp = StorePlatform(
store_id=admin_role_store.id,
platform_id=test_platform.id,
is_active=True,
)
db.add(sp)
db.commit()
db.refresh(sp)
return sp
# ============================================================================
# Super Admin Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminListStoreRoles:
"""Tests for GET /api/v1/admin/store-roles."""
def test_list_roles_as_super_admin(
self, client, super_admin_headers, admin_role_store
):
"""Super admin can list roles for any store."""
response = client.get(
f"{BASE}?store_id={admin_role_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "roles" in data
assert "total" in data
# Default preset roles should be created
assert data["total"] >= 5
def test_list_roles_requires_store_id(self, client, super_admin_headers):
"""GET /store-roles without store_id returns 422."""
response = client.get(BASE, headers=super_admin_headers)
assert response.status_code == 422
def test_list_roles_unauthenticated(self, client, admin_role_store):
"""Unauthenticated request is rejected."""
response = client.get(f"{BASE}?store_id={admin_role_store.id}")
assert response.status_code in (401, 403)
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminCreateStoreRole:
"""Tests for POST /api/v1/admin/store-roles."""
def test_create_role_as_super_admin(
self, client, super_admin_headers, admin_role_store
):
"""Super admin can create a custom role for any store."""
response = client.post(
f"{BASE}?store_id={admin_role_store.id}",
headers=super_admin_headers,
json={
"name": "admin_created_role",
"permissions": ["products.view", "orders.view"],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "admin_created_role"
assert "products.view" in data["permissions"]
def test_create_preset_name_rejected(
self, client, super_admin_headers, admin_role_store
):
"""Cannot create a role with a preset name."""
response = client.post(
f"{BASE}?store_id={admin_role_store.id}",
headers=super_admin_headers,
json={
"name": "manager",
"permissions": ["products.view"],
},
)
assert response.status_code == 422
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminUpdateStoreRole:
"""Tests for PUT /api/v1/admin/store-roles/{role_id}."""
def test_update_role_as_super_admin(
self, client, super_admin_headers, admin_role_store, admin_role_custom
):
"""Super admin can update a custom role."""
response = client.put(
f"{BASE}/{admin_role_custom.id}?store_id={admin_role_store.id}",
headers=super_admin_headers,
json={
"name": "renamed_admin_role",
"permissions": ["products.view", "products.edit"],
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "renamed_admin_role"
assert "products.edit" in data["permissions"]
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminDeleteStoreRole:
"""Tests for DELETE /api/v1/admin/store-roles/{role_id}."""
def test_delete_role_as_super_admin(
self, client, super_admin_headers, admin_role_store, admin_role_custom
):
"""Super admin can delete a custom role."""
response = client.delete(
f"{BASE}/{admin_role_custom.id}?store_id={admin_role_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 204
def test_delete_nonexistent_role(
self, client, super_admin_headers, admin_role_store
):
"""Deleting nonexistent role returns 422."""
response = client.delete(
f"{BASE}/99999?store_id={admin_role_store.id}",
headers=super_admin_headers,
)
assert response.status_code == 422
# ============================================================================
# Permission Catalog Tests
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestAdminPermissionCatalog:
"""Tests for GET /api/v1/admin/store-roles/permissions/catalog."""
def test_catalog_returns_categories(self, client, super_admin_headers):
"""GET /permissions/catalog returns categories with permissions."""
response = client.get(
f"{BASE}/permissions/catalog",
headers=super_admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "categories" in data
assert len(data["categories"]) > 0
def test_catalog_permission_has_metadata(self, client, super_admin_headers):
"""Each permission has id, label, description, and is_owner_only."""
response = client.get(
f"{BASE}/permissions/catalog",
headers=super_admin_headers,
)
data = response.json()
perm = data["categories"][0]["permissions"][0]
assert "id" in perm
assert "label" in perm
assert "description" in perm
assert "is_owner_only" in perm

View File

@@ -353,3 +353,54 @@ class TestDeleteRole:
) )
assert response.status_code == 422 assert response.status_code == 422
assert "preset" in response.json()["message"].lower() assert "preset" in response.json()["message"].lower()
# ============================================================================
# GET /team/permissions/catalog
# ============================================================================
@pytest.mark.integration
@pytest.mark.tenancy
class TestPermissionCatalog:
"""Tests for GET /api/v1/store/team/permissions/catalog."""
def test_catalog_returns_categories(self, client, role_auth):
"""GET /permissions/catalog returns categories with permissions."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
assert response.status_code == 200
data = response.json()
assert "categories" in data
assert len(data["categories"]) > 0
def test_catalog_category_has_permissions(self, client, role_auth):
"""Each category contains permission items."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
data = response.json()
for category in data["categories"]:
assert "id" in category
assert "label" in category
assert "permissions" in category
assert len(category["permissions"]) > 0
def test_catalog_permission_has_metadata(self, client, role_auth):
"""Each permission has id, label, description, and is_owner_only."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
data = response.json()
perm = data["categories"][0]["permissions"][0]
assert "id" in perm
assert "label" in perm
assert "description" in perm
assert "is_owner_only" in perm
def test_catalog_includes_team_permissions(self, client, role_auth):
"""Catalog includes team permissions from tenancy module."""
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
data = response.json()
all_perm_ids = {
p["id"]
for cat in data["categories"]
for p in cat["permissions"]
}
assert "team.view" in all_perm_ids
assert "team.edit" in all_perm_ids

View File

@@ -12,7 +12,14 @@ from app.modules.tenancy.exceptions import (
InvalidRoleException, InvalidRoleException,
UserNotFoundException, UserNotFoundException,
) )
from app.modules.tenancy.models import Role, Store, StoreUser, User from app.modules.tenancy.models import (
Platform,
Role,
Store,
StorePlatform,
StoreUser,
User,
)
from app.modules.tenancy.services.store_team_service import store_team_service from app.modules.tenancy.services.store_team_service import store_team_service
# ============================================================================= # =============================================================================
@@ -728,3 +735,80 @@ class TestStoreTeamServiceDeleteRole:
store_id=team_store.id, store_id=team_store.id,
role_id=role.id, role_id=role.id,
) )
# =============================================================================
# ADMIN STORE ACCESS VALIDATION
# =============================================================================
@pytest.mark.unit
@pytest.mark.tenancy
class TestValidateAdminStoreAccess:
"""Tests for validate_admin_store_access()."""
def test_super_admin_can_access_any_store(self, db, team_store):
"""Super admin (accessible_platform_ids=None) can access any store."""
from unittest.mock import MagicMock
user_ctx = MagicMock()
user_ctx.get_accessible_platform_ids.return_value = None
store = store_team_service.validate_admin_store_access(
db, user_ctx, team_store.id
)
assert store.id == team_store.id
def test_platform_admin_can_access_store_in_their_platform(self, db, team_store):
"""Platform admin can access stores in their assigned platform."""
from unittest.mock import MagicMock
# Create a platform and link the store
platform = Platform(
code=f"test_plat_{uuid.uuid4().hex[:6]}",
name="Test Platform",
is_active=True,
)
db.add(platform)
db.flush()
sp = StorePlatform(
store_id=team_store.id,
platform_id=platform.id,
is_active=True,
)
db.add(sp)
db.flush()
user_ctx = MagicMock()
user_ctx.get_accessible_platform_ids.return_value = [platform.id]
store = store_team_service.validate_admin_store_access(
db, user_ctx, team_store.id
)
assert store.id == team_store.id
def test_platform_admin_cannot_access_store_outside_platform(self, db, team_store):
"""Platform admin cannot access stores outside their platform."""
from unittest.mock import MagicMock
user_ctx = MagicMock()
# Platform ID 99999 does not have the test store
user_ctx.get_accessible_platform_ids.return_value = [99999]
with pytest.raises(InvalidRoleException, match="do not have access"):
store_team_service.validate_admin_store_access(
db, user_ctx, team_store.id
)
def test_nonexistent_store_raises_error(self, db):
"""Accessing a nonexistent store raises InvalidRoleException."""
from unittest.mock import MagicMock
user_ctx = MagicMock()
user_ctx.get_accessible_platform_ids.return_value = None
with pytest.raises(InvalidRoleException, match="not found"):
store_team_service.validate_admin_store_access(
db, user_ctx, 99999
)

View File

@@ -203,7 +203,19 @@ The system provides 5 preset roles with predefined permission sets:
| `viewer` | Read-only access | ~6 | | `viewer` | Read-only access | ~6 |
| `marketing` | Marketing and customer data | ~7 | | `marketing` | Marketing and customer data | ~7 |
Preset roles are created automatically on first access. Store owners can also create custom roles with any combination of the ~75 available permissions. Preset roles are created automatically on first access. Store owners can also create custom roles with any combination of the available permissions via the role editor UI at `/store/{store_code}/team/roles`.
### Custom Role Management
Store owners can create, edit, and delete custom roles via:
- **Store UI:** `/store/{store_code}/team/roles` (Alpine.js permission matrix)
- **Store API:** `POST/PUT/DELETE /api/v1/store/team/roles`
- **Admin UI:** `/admin/store-roles` (with Tom Select store picker)
- **Admin API:** `GET/POST/PUT/DELETE /api/v1/admin/store-roles?store_id=X`
The **Permission Catalog API** (`GET /api/v1/store/team/permissions/catalog`) returns all permissions grouped by category with labels and descriptions for the UI.
Admin access is scoped: **super admins** can manage any store, while **platform admins** can only manage stores within their assigned platforms (validated via `StorePlatform` table).
### Enforcement Points ### Enforcement Points
@@ -255,6 +267,7 @@ A user can have **different roles in different stores**. For example, a user mig
## Related Documentation ## Related Documentation
- [Authentication & RBAC](auth-rbac.md) — JWT auth, user roles, enforcement methods - [Authentication & RBAC](auth-rbac.md) — JWT auth, user roles, enforcement methods
- [Store RBAC](../backend/store-rbac.md) — Custom role CRUD, permission catalog API, admin role management
- [Menu Management](menu-management.md) — Menu discovery, visibility config, AdminMenuConfig - [Menu Management](menu-management.md) — Menu discovery, visibility config, AdminMenuConfig
- [Module System](module-system.md) — Module architecture, auto-discovery, classification - [Module System](module-system.md) — Module architecture, auto-discovery, classification
- [Feature Gating](../implementation/feature-gating-system.md) — Tier-based feature limits - [Feature Gating](../implementation/feature-gating-system.md) — Tier-based feature limits

View File

@@ -121,6 +121,52 @@ async def update_merchant_endpoint(merchant_id: int, data: MerchantUpdate, db: S
**Status:** 📝 **ACCEPTED** - Inline styles OK for admin pages **Status:** 📝 **ACCEPTED** - Inline styles OK for admin pages
### Category 6: Cross-Module Model Imports (HIGH Priority)
**Violation:** MOD-025 - Modules importing and querying models from other modules
**Date Added:** 2026-02-26
**Total Violations:** ~84 (services and route files, excluding tests and type-hints)
**Subcategories:**
| Cat | Description | Count | Priority |
|-----|-------------|-------|----------|
| 1 | Direct queries on another module's models | ~47 | URGENT |
| 2 | Creating instances of another module's models | ~15 | URGENT |
| 3 | Aggregation/count queries across module boundaries | ~11 | URGENT |
| 4 | Join queries involving another module's models | ~4 | URGENT |
| 5 | UserContext legacy import path (74 files) | 74 | URGENT |
**Top Violating Module Pairs:**
- `billing → tenancy`: 31 violations
- `loyalty → tenancy`: 23 violations
- `marketplace → tenancy`: 18 violations
- `core → tenancy`: 11 violations
- `cms → tenancy`: 8 violations
- `analytics → tenancy/catalog/orders`: 8 violations
- `inventory → catalog`: 3 violations
- `marketplace → catalog/orders`: 5 violations
**Resolution:** Migrate all cross-module model imports to service calls. See [Cross-Module Migration Plan](cross-module-migration-plan.md).
**Status:** :construction: **IN PROGRESS** - Migration plan created, executing per-module
### Category 7: Provider Pattern Gaps (MEDIUM Priority — Incremental)
**Violation:** Modules with data that should be exposed via providers but aren't
**Date Added:** 2026-02-26
| Provider | Implementing | Should Add |
|----------|-------------|------------|
| MetricsProvider | 8 modules | loyalty, payments, analytics |
| WidgetProvider | 2 modules (marketplace, tenancy) | orders, billing, catalog, inventory, loyalty |
| AuditProvider | 1 module (monitoring) | OK — single backend is the design |
**Status:** :memo: **PLANNED** - Will add incrementally as we work on each module
## Architecture Validation Philosophy ## Architecture Validation Philosophy
### What We Enforce Strictly: ### What We Enforce Strictly:
@@ -149,6 +195,9 @@ async def update_merchant_endpoint(merchant_id: int, data: MerchantUpdate, db: S
| Service patterns | ~50 | Medium | 📝 Incremental | | Service patterns | ~50 | Medium | 📝 Incremental |
| Simple queries in endpoints | ~10 | Low | 📝 Case-by-case | | Simple queries in endpoints | ~10 | Low | 📝 Case-by-case |
| Template inline styles | ~110 | Low | ✅ Accepted | | Template inline styles | ~110 | Low | ✅ Accepted |
| **Cross-module model imports** | **~84** | **High** | **🔄 Migrating** |
| **UserContext legacy path** | **74** | **High** | **🔄 Migrating** |
| **Provider pattern gaps** | **~8** | **Medium** | **📝 Incremental** |
## Validation Command ## Validation Command
@@ -164,7 +213,14 @@ python scripts/validate/validate_architecture.py
- [x] Add comments to intentional violations - [x] Add comments to intentional violations
### Short Term (Next Sprint) ### Short Term (Next Sprint)
- [ ] Move UserContext to tenancy.schemas, update 74 imports (Cat 5)
- [ ] Add missing service methods to tenancy for cross-module consumers (Cat 1)
- [ ] Migrate direct model queries to service calls (Cat 1-4)
- [ ] Create Pydantic response models for top 10 endpoints - [ ] Create Pydantic response models for top 10 endpoints
### Medium Term
- [ ] Add widget providers to orders, billing, catalog, inventory, loyalty (P5)
- [ ] Add metrics providers to loyalty, payments (P5)
- [ ] Refactor 2-3 services to use dependency injection - [ ] Refactor 2-3 services to use dependency injection
- [ ] Move complex queries to service layer - [ ] Move complex queries to service layer

View File

@@ -2,15 +2,29 @@
This document defines the strict import rules that ensure the module system remains decoupled, testable, and resilient. These rules are critical for maintaining a truly modular architecture. This document defines the strict import rules that ensure the module system remains decoupled, testable, and resilient. These rules are critical for maintaining a truly modular architecture.
## Core Principle ## Core Principles
**Core modules NEVER import from optional modules.** ### Principle 1: Core modules NEVER import from optional modules
This is the fundamental rule that enables optional modules to be truly optional. When a core module imports from an optional module: This is the fundamental rule that enables optional modules to be truly optional. When a core module imports from an optional module:
- The app crashes if that module is disabled - The app crashes if that module is disabled
- You can't test core functionality in isolation - You can't test core functionality in isolation
- You create a hidden dependency that violates the architecture - You create a hidden dependency that violates the architecture
### Principle 2: Services over models — NEVER import another module's models
**If module A needs data from module B, it MUST call module B's service methods.**
Modules must NEVER import and query another module's SQLAlchemy models directly. This applies to ALL cross-module interactions — core-to-core, optional-to-core, and optional-to-optional.
When a module imports another module's models, it:
- Couples to the internal schema (column names, relationships, table structure)
- Bypasses business logic, validation, and access control in the owning service
- Makes refactoring the model owner's schema a breaking change for all consumers
- Scatters query logic across multiple modules instead of centralizing it
**The owning module's service is the ONLY authorized gateway to its data.**
## Module Classification ## Module Classification
### Core Modules (Always Enabled) ### Core Modules (Always Enabled)
@@ -35,30 +49,70 @@ This is the fundamental rule that enables optional modules to be truly optional.
## Import Rules Matrix ## Import Rules Matrix
| From \ To | Core | Optional | Contracts | | From \ To | Core Services | Core Models | Optional Services | Optional Models | Contracts |
|-----------|------|----------|-----------| |-----------|--------------|-------------|-------------------|-----------------|-----------|
| **Core** | :white_check_mark: | :x: FORBIDDEN | :white_check_mark: | | **Core** | :white_check_mark: | :x: Use services | :x: FORBIDDEN | :x: FORBIDDEN | :white_check_mark: |
| **Optional** | :white_check_mark: | :warning: With care | :white_check_mark: | | **Optional** | :white_check_mark: | :x: Use services | :warning: With care | :x: Use services | :white_check_mark: |
| **Contracts** | :x: | :x: | :white_check_mark: | | **Contracts** | :x: | :x: | :x: | :x: | :white_check_mark: |
### Explanation ### Explanation
1. **Core → Core**: Allowed. Core modules can import from each other (e.g., billing imports from tenancy) 1. **Any → Any Services**: Allowed (with core→optional restriction). Import the service, call its methods.
2. **Core → Optional**: **FORBIDDEN**. This is the most important rule. Core modules must never have direct imports from optional modules. 2. **Any → Any Models**: **FORBIDDEN**. Never import another module's SQLAlchemy models. Use that module's service instead.
3. **Core → Contracts**: Allowed. Contracts define shared protocols and data structures. 3. **Core → Optional**: **FORBIDDEN** (both services and models). Use provider protocols instead.
4. **Optional → Core**: Allowed. Optional modules can use core functionality. 4. **Optional → Core Services**: Allowed. Optional modules can call core service methods.
5. **Optional → Optional**: Allowed with care. Check dependencies in `definition.py` to ensure proper ordering. 5. **Optional → Optional Services**: Allowed with care. Declare dependency in `definition.py`.
6. **Optional → Contracts**: Allowed. This is how optional modules implement protocols. 6. **Any → Contracts**: Allowed. Contracts define shared protocols and data structures.
7. **Contracts → Anything**: Contracts should only depend on stdlib/typing/Protocol. No module imports. 7. **Contracts → Anything**: Contracts should only depend on stdlib/typing/Protocol. No module imports.
## Anti-Patterns (DO NOT DO) ## Anti-Patterns (DO NOT DO)
### Cross-Module Model Import (MOD-025)
```python
# app/modules/orders/services/order_service.py
# BAD: Importing and querying another module's models
from app.modules.catalog.models import Product
class OrderService:
def get_order_with_products(self, db, order_id):
order = db.query(Order).filter_by(id=order_id).first()
# BAD: Direct query on catalog's model
products = db.query(Product).filter(Product.id.in_(product_ids)).all()
return order, products
```
```python
# GOOD: Call the owning module's service
from app.modules.catalog.services import product_service
class OrderService:
def get_order_with_products(self, db, order_id):
order = db.query(Order).filter_by(id=order_id).first()
# GOOD: Catalog service owns Product data access
products = product_service.get_products_by_ids(db, product_ids)
return order, products
```
### Cross-Module Aggregation Query
```python
# BAD: Counting another module's models directly
from app.modules.orders.models import Order
count = db.query(func.count(Order.id)).filter_by(store_id=store_id).scalar()
# GOOD: Ask the owning service
from app.modules.orders.services import order_service
count = order_service.get_order_count(db, store_id=store_id)
```
### Direct Import from Optional Module ### Direct Import from Optional Module
```python ```python
@@ -97,6 +151,36 @@ def process_import(job: MarketplaceImportJob) -> None: # Crashes if disabled
## Approved Patterns ## Approved Patterns
### 0. Cross-Module Service Calls (Primary Pattern)
The default way to access another module's data is through its service layer:
```python
# app/modules/inventory/services/inventory_service.py
# GOOD: Import the service, not the model
from app.modules.catalog.services import product_service
class InventoryService:
def get_stock_for_product(self, db, product_id):
# Verify product exists via catalog service
product = product_service.get_product_by_id(db, product_id)
if not product:
raise InventoryError("Product not found")
# Query own models
return db.query(StockLevel).filter_by(product_id=product_id).first()
```
Each module should expose these standard service methods for external consumers:
| Method Pattern | Purpose |
|---------------|---------|
| `get_{entity}_by_id(db, id)` | Single entity lookup |
| `list_{entities}(db, **filters)` | Filtered list |
| `get_{entity}_count(db, **filters)` | Count query |
| `search_{entities}(db, query, **filters)` | Text search |
| `get_{entities}_by_ids(db, ids)` | Batch lookup |
### 1. Provider Protocol Pattern (Metrics & Widgets) ### 1. Provider Protocol Pattern (Metrics & Widgets)
Use the provider protocol pattern for cross-module data: Use the provider protocol pattern for cross-module data:
@@ -190,6 +274,8 @@ The architecture validator (`scripts/validate/validate_architecture.py`) include
| IMPORT-001 | ERROR | Core module imports from optional module | | IMPORT-001 | ERROR | Core module imports from optional module |
| IMPORT-002 | WARNING | Optional module imports from unrelated optional module | | IMPORT-002 | WARNING | Optional module imports from unrelated optional module |
| IMPORT-003 | INFO | Consider using protocol pattern instead of direct import | | IMPORT-003 | INFO | Consider using protocol pattern instead of direct import |
| MOD-025 | ERROR | Module imports models from another module (use services) |
| MOD-026 | ERROR | Cross-module data access not going through service layer |
Run validation: Run validation:
```bash ```bash
@@ -291,7 +377,9 @@ grep -r "from app.modules.orders" app/modules/core/ && exit 1
| Rule | Enforcement | | Rule | Enforcement |
|------|-------------| |------|-------------|
| Core → Optional = FORBIDDEN | Architecture validation, CI checks | | Core → Optional = FORBIDDEN | Architecture validation, CI checks |
| Use Protocol pattern | Code review, documentation | | Cross-module model imports = FORBIDDEN | MOD-025 rule, code review |
| Use services for cross-module data | MOD-026 rule, code review |
| Use Protocol pattern for core→optional | Code review, documentation |
| Lazy factory functions | Required for definition.py | | Lazy factory functions | Required for definition.py |
| TYPE_CHECKING imports | Required for type hints across modules | | TYPE_CHECKING imports | Required for type hints across modules |
| Registry-based discovery | Required for all cross-module access | | Registry-based discovery | Required for all cross-module access |
@@ -301,6 +389,8 @@ Following these rules ensures:
- Testing can be done in isolation - Testing can be done in isolation
- New modules can be added without modifying core - New modules can be added without modifying core
- The app remains stable when modules fail - The app remains stable when modules fail
- Module schemas can evolve independently
- Data access logic is centralized in the owning service
## Related Documentation ## Related Documentation
@@ -308,3 +398,4 @@ Following these rules ensures:
- [Metrics Provider Pattern](metrics-provider-pattern.md) - Numeric statistics architecture - [Metrics Provider Pattern](metrics-provider-pattern.md) - Numeric statistics architecture
- [Widget Provider Pattern](widget-provider-pattern.md) - Dashboard widgets architecture - [Widget Provider Pattern](widget-provider-pattern.md) - Dashboard widgets architecture
- [Architecture Violations Status](architecture-violations-status.md) - Current violation tracking - [Architecture Violations Status](architecture-violations-status.md) - Current violation tracking
- [Cross-Module Migration Plan](cross-module-migration-plan.md) - Migration plan for resolving all cross-module violations

View File

@@ -0,0 +1,464 @@
# Cross-Module Import Migration Plan
**Created:** 2026-02-26
**Status:** In Progress
**Rules:** MOD-025, MOD-026
This document tracks the migration of all cross-module model imports to proper service-based access patterns.
## Overview
| Category | Description | Files | Priority | Status |
|----------|-------------|-------|----------|--------|
| Cat 5 | UserContext legacy import path | 74 | URGENT | Pending |
| Cat 1 | Direct queries on another module's models | ~47 | URGENT | Pending |
| Cat 2 | Creating instances across module boundaries | ~15 | URGENT | Pending |
| Cat 3 | Aggregation/count queries across boundaries | ~11 | URGENT | Pending |
| Cat 4 | Join queries involving another module's models | ~4 | URGENT | Pending |
| P5 | Provider pattern gaps (widgets, metrics) | ~8 modules | Incremental | Pending |
| P6 | Route variable naming standardization | ~109 files | Low | Deferred |
---
## Cat 5: Move UserContext to Tenancy Module (74 files)
**Priority:** URGENT — mechanical, low risk, high impact
**Approach:** Move definition, update all imports in one batch
### What
`UserContext` is defined in `models/schema/auth.py` (legacy location). Per MOD-019, schemas belong in their module. UserContext is a tenancy concern (user identity, platform/store context).
### Steps
1. **Move `UserContext` class** from `models/schema/auth.py` to `app/modules/tenancy/schemas/auth.py`
- Keep all properties and methods intact
- Also move related schemas used alongside it: `UserLogin`, `LogoutResponse`, `StoreUserResponse`
2. **Add re-export in legacy location** (temporary backwards compat):
```python
# models/schema/auth.py
from app.modules.tenancy.schemas.auth import UserContext # noqa: F401
```
3. **Update all 74 import sites** from:
```python
from models.schema.auth import UserContext
```
to:
```python
from app.modules.tenancy.schemas.auth import UserContext
```
4. **Remove legacy re-export** once all imports are updated
### Files to Update (by module)
**app/api/** (1 file)
- `app/api/deps.py`
**app/modules/tenancy/** (15 files)
- `routes/api/admin_merchants.py`
- `routes/api/admin_module_config.py`
- `routes/api/admin_merchant_domains.py`
- `routes/api/admin_modules.py`
- `routes/api/admin_platforms.py`
- `routes/api/admin_store_domains.py`
- `routes/api/admin_stores.py`
- `routes/api/admin_users.py`
- `routes/api/merchant.py`
- `routes/api/store_auth.py` (also imports `LogoutResponse`, `StoreUserResponse`, `UserLogin`)
- `routes/api/store_profile.py`
- `routes/api/store_team.py`
- `routes/pages/merchant.py`
- `services/admin_platform_service.py`
- `tests/integration/test_merchant_routes.py`
**app/modules/core/** (12 files)
- `routes/api/admin_dashboard.py`
- `routes/api/admin_menu_config.py`
- `routes/api/admin_settings.py`
- `routes/api/merchant_menu.py`
- `routes/api/store_dashboard.py`
- `routes/api/store_menu.py`
- `routes/api/store_settings.py`
- `routes/pages/merchant.py`
- `tests/integration/test_merchant_dashboard_routes.py`
- `tests/integration/test_merchant_menu_routes.py`
- `tests/integration/test_store_dashboard_routes.py`
**app/modules/billing/** (9 files)
- `routes/api/admin.py`
- `routes/api/admin_features.py`
- `routes/api/store.py`
- `routes/api/store_addons.py`
- `routes/api/store_checkout.py`
- `routes/api/store_features.py`
- `routes/api/store_usage.py`
- `routes/pages/merchant.py`
- `tests/integration/test_merchant_routes.py`
**app/modules/marketplace/** (8 files)
- `routes/api/admin_letzshop.py`
- `routes/api/admin_marketplace.py`
- `routes/api/admin_products.py`
- `routes/api/store_letzshop.py`
- `routes/api/store_marketplace.py`
- `routes/api/store_onboarding.py`
- `tests/integration/test_store_page_routes.py`
- `tests/unit/test_store_page_routes.py`
**app/modules/messaging/** (7 files)
- `routes/api/admin_email_templates.py`
- `routes/api/admin_messages.py`
- `routes/api/admin_notifications.py`
- `routes/api/store_email_settings.py`
- `routes/api/store_email_templates.py`
- `routes/api/store_messages.py`
- `routes/api/store_notifications.py`
**app/modules/orders/** (6 files)
- `routes/api/admin.py`
- `routes/api/admin_exceptions.py`
- `routes/api/store.py`
- `routes/api/store_customer_orders.py`
- `routes/api/store_exceptions.py`
- `routes/api/store_invoices.py`
**app/modules/monitoring/** (6 files)
- `routes/api/admin_audit.py`
- `routes/api/admin_code_quality.py`
- `routes/api/admin_logs.py`
- `routes/api/admin_platform_health.py`
- `routes/api/admin_tasks.py`
- `routes/api/admin_tests.py`
**app/modules/cms/** (4 files)
- `routes/api/admin_images.py`
- `routes/api/admin_media.py`
- `routes/api/admin_store_themes.py`
- `routes/api/store_media.py`
**app/modules/catalog/** (2 files)
- `routes/api/admin.py`
- `routes/api/store.py`
**app/modules/customers/** (2 files)
- `routes/api/admin.py`
- `routes/api/store.py`
**app/modules/inventory/** (2 files)
- `routes/api/admin.py`
- `routes/api/store.py`
**app/modules/loyalty/** (2 files)
- `routes/pages/merchant.py`
- `tests/conftest.py`
**app/modules/payments/** (1 file)
- `routes/api/store.py`
**tests/** (1 file)
- `tests/unit/api/test_deps.py`
**docs/** (2 files — update code examples)
- `docs/architecture/user-context-pattern.md`
- `docs/proposals/decouple-modules.md`
---
## Cat 1: Direct Queries → Service Methods (~47 violations)
**Priority:** URGENT — requires creating new service methods first
**Approach:** Per-module, add service methods then migrate consumers
### Required New Service Methods
#### Tenancy Module (most consumed)
The tenancy module needs these public service methods for cross-module consumers:
```python
# app/modules/tenancy/services/merchant_service.py (new or extend existing)
class MerchantService:
def get_merchant_by_id(self, db, merchant_id) -> Merchant | None
def get_merchant_by_owner_id(self, db, owner_user_id) -> Merchant | None
def get_merchants_for_platform(self, db, platform_id, **filters) -> list[Merchant]
def get_merchant_count(self, db, platform_id=None) -> int
def search_merchants(self, db, query, platform_id=None) -> list[Merchant]
# app/modules/tenancy/services/store_service.py (extend existing)
class StoreService:
def get_store_by_id(self, db, store_id) -> Store | None
def get_stores_for_merchant(self, db, merchant_id) -> list[Store]
def get_stores_for_platform(self, db, platform_id, **filters) -> list[Store]
def get_store_count(self, db, merchant_id=None, platform_id=None) -> int
def get_active_store_count(self, db, platform_id) -> int
# app/modules/tenancy/services/platform_service.py (extend existing)
class PlatformService:
def get_platform_by_id(self, db, platform_id) -> Platform | None
def list_platforms(self, db) -> list[Platform]
# app/modules/tenancy/services/user_service.py (extend existing)
class UserService:
def get_user_by_id(self, db, user_id) -> User | None
def get_user_by_email(self, db, email) -> User | None
def get_store_users(self, db, store_id) -> list[StoreUser]
```
#### Catalog Module
```python
# app/modules/catalog/services/product_service.py (extend existing)
class ProductService:
def get_product_by_id(self, db, product_id) -> Product | None
def get_products_by_ids(self, db, product_ids) -> list[Product]
def get_product_count(self, db, store_id=None) -> int
```
#### Orders Module
```python
# app/modules/orders/services/order_service.py (extend existing)
class OrderService:
def get_order_by_id(self, db, order_id) -> Order | None
def get_order_count(self, db, store_id=None, **filters) -> int
def get_orders_for_store(self, db, store_id, **filters) -> list[Order]
```
### Migration Per Consuming Module
#### billing → tenancy (13 direct queries)
| File | What it queries | Replace with |
|------|----------------|--------------|
| `services/admin_subscription_service.py:279` | `db.query(Platform)` | `platform_service.get_platform_by_id()` |
| `services/admin_subscription_service.py:285` | `db.query(Store)` | `store_service.get_store_by_id()` |
| `services/admin_subscription_service.py:362` | `db.query(Store).filter()` | `store_service.get_stores_for_merchant()` |
| `services/billing_service.py:158` | `db.query(Store)` | `store_service.get_store_by_id()` |
| `services/billing_service.py:497` | `db.query(Merchant)` | `merchant_service.get_merchant_by_id()` |
| `services/feature_service.py:118,145` | `db.query(StorePlatform)` | `store_service.get_store_platform()` |
| `services/store_platform_sync_service.py:14` | `db.query(StorePlatform)` | `store_service` methods |
| `services/stripe_service.py:26,297,316` | `db.query(Merchant/Store)` | `merchant_service/store_service` |
| `services/subscription_service.py:56,74,178,191` | `db.query(Platform/Store)` | service methods |
| `services/usage_service.py:22` | `db.query(Store)` | `store_service` |
#### loyalty → tenancy (10 direct queries)
| File | What it queries | Replace with |
|------|----------------|--------------|
| `services/card_service.py` | `db.query(Store/User)` | `store_service/user_service` |
| `services/program_service.py` | `db.query(Merchant)` | `merchant_service` |
| `services/admin_loyalty_service.py` | `db.query(Merchant)` | `merchant_service` |
#### marketplace → tenancy (5 direct queries)
| File | What it queries | Replace with |
|------|----------------|--------------|
| `services/letzshop/order_service.py:76` | `db.query(Store)` | `store_service.get_store_by_id()` |
| `services/letzshop/order_service.py:100,110` | `db.query(Order)` | `order_service.get_order_count()` |
| `services/marketplace_metrics.py:203` | `db.query(StorePlatform)` | `store_service.get_store_count()` |
#### core → tenancy (3 direct queries)
| File | What it queries | Replace with |
|------|----------------|--------------|
| `services/auth_service.py:156` | `db.query(Merchant)` | `merchant_service.get_merchant_by_owner_id()` |
| `services/auth_service.py:25` | `db.query(Store)` | `store_service.get_store_by_id()` |
| `services/menu_service.py:351` | `db.query(Store).join()` | `store_service.get_stores_for_merchant()` |
#### analytics → tenancy/catalog (4 direct queries)
| File | What it queries | Replace with |
|------|----------------|--------------|
| `services/stats_service.py:69` | `db.query(Product)` | `product_service.get_product_count()` |
| `services/stats_service.py:75` | `db.query(Product)` | `product_service` methods |
| `services/stats_service.py:88-92` | `db.query(MarketplaceProduct)` | marketplace service |
| `services/stats_service.py:229` | `db.query(Product)` aggregation | `product_service` |
#### Other modules (various)
| Module | File | Replace with |
|--------|------|--------------|
| `inventory` | `services/inventory_transaction_service.py:236` | `order_service.get_order_by_id()` |
| `cms` | `services/cms_features.py:160` | `store_service.get_store_count()` |
| `customers` | `services/admin_customer_service.py:16` | `store_service.get_store_by_id()` |
---
## Cat 2: Model Creation Across Boundaries (~15 violations)
**Priority:** URGENT
**Approach:** Add create/factory methods to owning services
Most of these are in **test fixtures** (acceptable exception) but a few are in production code:
### Production Code Violations
| File | Creates | Replace with |
|------|---------|--------------|
| `contracts/audit.py:117` | `AdminAuditLog()` | Use audit provider `log_action()` |
| `billing/services/store_platform_sync_service.py` | `StorePlatform()` | `store_service.create_store_platform()` |
| `marketplace/services/letzshop/order_service.py` | `Order/OrderItem()` | `order_service.create_order()` |
### Test Fixtures (Acceptable — Document as Exception)
Test files creating models from other modules for integration test setup is acceptable but should be documented:
```python
# ACCEPTABLE in tests — document with comment:
# Test fixture: creates tenancy models for integration test setup
merchant = Merchant(name="Test", ...)
```
---
## Cat 3: Aggregation/Count Queries (~11 violations)
**Priority:** URGENT
**Approach:** Add count/stats methods to owning services
| File | Query | Replace with |
|------|-------|--------------|
| `marketplace/services/letzshop/order_service.py:100` | `func.count(Order.id)` | `order_service.get_order_count()` |
| `marketplace/services/letzshop/order_service.py:110` | `func.count(Order.id)` | `order_service.get_order_count()` |
| `catalog/services/catalog_features.py:92` | `StorePlatform` count | `store_service.get_active_store_count()` |
| `cms/services/cms_features.py:160` | `StorePlatform` count | `store_service.get_active_store_count()` |
| `marketplace/services/marketplace_metrics.py:203` | `StorePlatform` count | `store_service.get_active_store_count()` |
| `customers/services/customer_features.py` | Store count | `store_service.get_store_count()` |
| `inventory/services/inventory_features.py` | Store count | `store_service.get_store_count()` |
| `orders/services/order_features.py` | Store count | `store_service.get_store_count()` |
---
## Cat 4: Join Queries (~4 violations)
**Priority:** URGENT
**Approach:** Decompose into service calls or add service methods
| File | Join | Resolution |
|------|------|------------|
| `catalog/services/store_product_service.py:19` | `.join(Store)` | Use `store_service.get_store_by_id()` + own query |
| `core/services/menu_service.py:351` | `.join(Store)` | `store_service.get_stores_for_merchant()` |
| `customers/services/admin_customer_service.py:16` | `.join(Store)` | `store_service.get_store_by_id()` + own query |
| `messaging/services/messaging_service.py:653` | `.join(StoreUser)` | `user_service.get_store_users()` + filter |
---
## P5: Provider Pattern Gaps (Incremental)
**Priority:** MEDIUM — implement as each module is touched
### Widget Providers to Add
Currently only 2 modules (marketplace, tenancy) provide dashboard widgets. These modules have valuable dashboard data:
| Module | Widget Ideas | Implementation |
|--------|-------------|----------------|
| **orders** | Recent orders list, order status breakdown | `services/order_widgets.py` |
| **billing** | Subscription status, revenue trend | `services/billing_widgets.py` |
| **catalog** | Product summary, category breakdown | `services/catalog_widgets.py` |
| **inventory** | Stock summary, low-stock alerts | `services/inventory_widgets.py` |
| **loyalty** | Program stats, member engagement | `services/loyalty_widgets.py` |
| **customers** | Customer growth, segments | `services/customer_widgets.py` |
### Metrics Providers to Add
| Module | Metric Ideas | Implementation |
|--------|-------------|----------------|
| **loyalty** | Active programs, total enrollments, points issued | `services/loyalty_metrics.py` |
| **payments** | Transaction volume, success rate, gateway stats | `services/payment_metrics.py` |
| **analytics** | Report count, active dashboards | `services/analytics_metrics.py` |
### Implementation Template
```python
# app/modules/{module}/services/{module}_widgets.py
from app.modules.contracts.widgets import (
DashboardWidgetProviderProtocol,
ListWidget,
BreakdownWidget,
)
class {Module}WidgetProvider:
@property
def widget_category(self) -> str:
return "{module}"
def get_store_widgets(self, db, store_id, context=None):
# Return list of ListWidget/BreakdownWidget
...
def get_platform_widgets(self, db, platform_id, context=None):
...
{module}_widget_provider = {Module}WidgetProvider()
```
Register in `definition.py`:
```python
def _get_widget_provider():
from app.modules.{module}.services.{module}_widgets import {module}_widget_provider
return {module}_widget_provider
{module}_module = ModuleDefinition(
...
widget_provider=_get_widget_provider,
)
```
---
## P6: Route Variable Naming (Deferred)
**Priority:** LOW — cosmetic, no functional impact
**Count:** ~109 files use `admin_router`/`store_router` instead of `router`
Per MOD-010, route files should export a `router` variable. Many files use `admin_router` or `store_router` instead. The route discovery system currently handles both patterns.
**Decision:** Defer to a future cleanup sprint. This is purely naming consistency and has no architectural impact.
---
## Execution Order
### Phase 1: Foundation (Do First)
1. **Cat 5**: Move UserContext to `tenancy.schemas.auth` — mechanical, enables clean imports
2. **Add service methods to tenancy** — most modules depend on tenancy, need methods first
### Phase 2: High-Impact Migrations (URGENT)
3. **Cat 1 - billing→tenancy**: 13 violations, highest count
4. **Cat 1 - loyalty→tenancy**: 10 violations
5. **Cat 1 - marketplace→tenancy/catalog/orders**: 10 violations
6. **Cat 1 - core→tenancy**: 3 violations
7. **Cat 1 - analytics→tenancy/catalog**: 4 violations
### Phase 3: Remaining Migrations (URGENT)
8. **Cat 2**: Model creation violations (3 production files)
9. **Cat 3**: All aggregation queries (11 files)
10. **Cat 4**: All join queries (4 files)
11. **Cat 1**: Remaining modules (cms, customers, inventory, messaging, monitoring)
### Phase 4: Provider Enrichment (Incremental)
12. **P5**: Add widget providers to orders, billing, catalog (highest value)
13. **P5**: Add metrics providers to loyalty, payments
14. **P5**: Add remaining widget providers as modules are touched
### Phase 5: Cleanup (Deferred)
15. **P6**: Route variable naming standardization
---
## Testing Strategy
For each migration:
1. Add/verify service method has a unit test
2. Run existing integration tests to confirm no regressions
3. Run `python scripts/validate/validate_architecture.py` to verify violation count decreases
## Related Documentation
- [Cross-Module Import Rules](cross-module-import-rules.md) — The rules being enforced
- [Architecture Violations Status](architecture-violations-status.md) — Violation tracking
- [Module System Architecture](module-system.md) — Module structure reference

View File

@@ -699,4 +699,146 @@ Marketing (7 permissions, specialized)
--- ---
## Custom Role Management
### Overview
Store owners can create, edit, and delete custom roles with granular permission selection. Preset roles (manager, staff, support, viewer, marketing) cannot be deleted but can be edited.
### Store Role CRUD API
All endpoints require **store owner** authentication.
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/store/team/roles` | List all roles (creates defaults if none exist) |
| `POST` | `/api/v1/store/team/roles` | Create a custom role |
| `PUT` | `/api/v1/store/team/roles/{id}` | Update a role's name/permissions |
| `DELETE` | `/api/v1/store/team/roles/{id}` | Delete a custom role |
**Validation rules:**
- Cannot create/rename a role to a preset name (manager, staff, etc.)
- Cannot delete preset roles
- Cannot delete a role with assigned team members
- All permission IDs are validated against the module-discovered permission catalog
### Permission Catalog API
Returns all available permissions grouped by category, with human-readable labels and descriptions.
```
GET /api/v1/store/team/permissions/catalog
```
**Required Permission:** `team.view`
**Response:**
```json
{
"categories": [
{
"id": "team",
"label": "tenancy.permissions.category.team",
"permissions": [
{
"id": "team.view",
"label": "tenancy.permissions.team_view",
"description": "tenancy.permissions.team_view_desc",
"is_owner_only": false
}
]
}
]
}
```
Permissions are discovered from all module `definition.py` files via `PermissionDiscoveryService.get_permissions_by_category()`.
### Role Editor UI
The role editor page at `/store/{store_code}/team/roles`:
- Lists all roles (preset + custom) with permission counts
- Modal for creating/editing roles with a permission matrix
- Permissions displayed with labels, IDs, and hover descriptions
- Category-level "Select All / Deselect All" toggles
- Owner-only permissions marked with an "Owner" badge
**Alpine.js component:** `storeRoles()` in `app/modules/tenancy/static/store/js/roles.js`
**Menu location:** Account section > Roles (requires `team.view` permission)
---
## Admin Store Roles Management
### Overview
Super admins and platform admins can manage roles for any store via the admin panel. Platform admins are scoped to stores within their assigned platforms.
### Admin Role CRUD API
All endpoints require **admin authentication** (`get_current_admin_api`).
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/store-roles?store_id=X` | List roles for a store |
| `GET` | `/api/v1/admin/store-roles/permissions/catalog` | Permission catalog |
| `POST` | `/api/v1/admin/store-roles?store_id=X` | Create a role |
| `PUT` | `/api/v1/admin/store-roles/{id}?store_id=X` | Update a role |
| `DELETE` | `/api/v1/admin/store-roles/{id}?store_id=X` | Delete a role |
### Platform Admin Scoping
Platform admins can only access stores that belong to one of their assigned platforms:
```python
# In StoreTeamService.validate_admin_store_access():
# 1. Super admin (accessible_platform_ids is None) → access all stores
# 2. Platform admin → store must exist in StorePlatform where
# platform_id is in the admin's accessible_platform_ids
```
The scoping is enforced at the service layer via `validate_admin_store_access()`, called by every admin endpoint before performing operations.
### Admin UI
Page at `/admin/store-roles`:
- Tom Select store search/selector (shared `initStoreSelector()` component)
- Platform admins see only stores in their assigned platforms
- Same role CRUD and permission matrix as the store-side UI
- Located in the "User Management" admin menu section
**Alpine.js component:** `adminStoreRoles()` in `app/modules/tenancy/static/admin/js/store-roles.js`
### Audit Trail
All role operations are logged via `AuditAggregatorService`:
| Action | Description |
|--------|-------------|
| `role.create` | Custom role created |
| `role.update` | Role name or permissions modified |
| `role.delete` | Custom role deleted |
| `member.role_change` | Team member assigned a different role |
| `member.invite` | Team member invited |
| `member.remove` | Team member removed |
---
## Key Files
| File | Purpose |
|------|---------|
| `app/modules/tenancy/services/store_team_service.py` | Role CRUD, platform scoping, audit trail |
| `app/modules/tenancy/services/permission_discovery_service.py` | Permission catalog, role presets |
| `app/modules/tenancy/routes/api/store_team.py` | Store team & role API endpoints |
| `app/modules/tenancy/routes/api/admin_store_roles.py` | Admin store role API endpoints |
| `app/modules/tenancy/schemas/team.py` | Request/response schemas |
| `app/modules/tenancy/static/store/js/roles.js` | Store role editor Alpine.js component |
| `app/modules/tenancy/static/admin/js/store-roles.js` | Admin role editor Alpine.js component |
| `app/modules/tenancy/templates/tenancy/store/roles.html` | Store role editor template |
| `app/modules/tenancy/templates/tenancy/admin/store-roles.html` | Admin role editor template |
---
This RBAC system provides flexible, secure access control for store dashboards with clear separation between owners and team members. This RBAC system provides flexible, secure access control for store dashboards with clear separation between owners and team members.