feat(roles): add admin store roles page, permission i18n, and menu integration
Some checks failed
Some checks failed
- 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:
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => ({
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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é"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
181
app/modules/tenancy/routes/api/admin_store_roles.py
Normal file
181
app/modules/tenancy/routes/api/admin_store_roles.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
419
app/modules/tenancy/static/admin/js/store-roles.js
Normal file
419
app/modules/tenancy/static/admin/js/store-roles.js
Normal 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');
|
||||||
@@ -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));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
271
app/modules/tenancy/templates/tenancy/admin/store-roles.html
Normal file
271
app/modules/tenancy/templates/tenancy/admin/store-roles.html
Normal 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">↻</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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
464
docs/architecture/cross-module-migration-plan.md
Normal file
464
docs/architecture/cross-module-migration-plan.md
Normal 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
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user