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

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

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

View File

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

View File

@@ -14,6 +14,14 @@
"loading": "Loading analytics...",
"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": {
"analytics": "Analytics"
}

View File

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

View File

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

View File

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

View File

@@ -81,6 +81,18 @@
"current": "Current Plan",
"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": {
"subscription_updated": "Subscription updated successfully",
"tier_created": "Tier created successfully",

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,12 @@
"max_quantity": "Maximum quantity is {max}",
"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": {
"item_added": "Item added to cart",
"item_updated": "Cart updated",

View File

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

View File

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

View File

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

View File

@@ -46,6 +46,20 @@
"sort_name_az": "Name: A-Z",
"sort_name_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": {
"product_deleted_successfully": "Product deleted successfully",
"product_created_successfully": "Product created successfully",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -200,6 +200,18 @@
"cta_final_note": "No credit card required. Setup in 5 minutes. Full Professional features during trial."
}
},
"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": {
"failed_to_delete_page": "Failed to delete page: {error}",
"media_updated_successfully": "Media updated successfully",

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,18 @@
"save_profile": "Save Profile",
"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": {
"failed_to_load_dashboard_data": "Failed to load dashboard data",
"dashboard_refreshed": "Dashboard refreshed",

View File

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

View File

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

View File

@@ -166,8 +166,9 @@ function initStoreSelector(selectElement, options = {}) {
}
try {
const sep = config.apiEndpoint.includes('?') ? '&' : '?';
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 => ({

View File

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

View File

@@ -18,6 +18,16 @@
"no_customers": "No customers found",
"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": {
"failed_to_toggle_customer_status": "Failed to toggle customer status",
"failed_to_load_customer_details": "Failed to load customer details",

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,14 @@
"low_stock_alert": "Low Stock Alert",
"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": {
"stock_adjusted_successfully": "Stock adjusted successfully",
"quantity_set_successfully": "Quantity set successfully",

View File

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

View File

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

View File

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

View File

@@ -69,6 +69,16 @@
"pin_locked": "PIN locked due to too many failed attempts"
}
},
"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": {
"loyalty": "Loyalty",
"loyalty_programs": "Loyalty Programs",

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,14 @@
"sales_orders": "Sales & Orders",
"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": {
"title": "Marketplace",
"import": "Import",

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,14 @@
"import_complete": "Import Complete",
"import_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": {
"failed_to_load_template": "Failed to load template",
"template_saved_successfully": "Template saved successfully",

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,16 @@
"set_tracking": "Set Tracking",
"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": {
"order_status_updated": "Order status updated",
"item_shipped_successfully": "Item shipped successfully",

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,14 @@
"payments": {
"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": {
"payment_successful": "Payment processed successfully",
"payment_failed": "Payment processing failed"

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,6 +97,38 @@
"show_all_menu_items": "This will show all menu items. Continue?",
"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": {
"team_members": {
"name": "Team Members",

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ from .admin_modules import router as admin_modules_router
from .admin_platform_users import admin_platform_users_router
from .admin_platforms import admin_platforms_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_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_stores_router, tags=["admin-stores"])
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_modules_router, tags=["admin-modules"])
admin_router.include_router(admin_module_config_router, tags=["admin-module-config"])

View File

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

View File

@@ -86,6 +86,7 @@ def get_all_stores_admin(
search: str | None = Query(None, description="Search by name or store code"),
is_active: 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),
current_admin: UserContext = Depends(get_current_admin_api),
):
@@ -97,6 +98,7 @@ def get_all_stores_admin(
search=search,
is_active=is_active,
is_verified=is_verified,
merchant_id=merchant_id,
)
return StoreListResponse(stores=stores, total=total, skip=skip, limit=limit)

View File

@@ -28,6 +28,7 @@ from app.modules.tenancy.schemas.team import (
InvitationAccept,
InvitationAcceptResponse,
InvitationResponse,
PermissionCatalogResponse,
RoleCreate,
RoleListResponse,
RoleResponse,
@@ -42,7 +43,11 @@ from app.modules.tenancy.schemas.team import (
# Permission IDs are now defined in module definition.py files
# 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.utils.i18n import translate
from models.schema.auth import UserContext
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)
def get_my_permissions(
request: Request,

View File

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

View File

@@ -276,6 +276,34 @@ class UserPermissionsResponse(BaseModel):
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
# ============================================================================

View File

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

View File

@@ -35,7 +35,7 @@ from app.modules.tenancy.exceptions import (
TeamMemberAlreadyExistsException,
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
logger = logging.getLogger(__name__)
@@ -724,6 +724,59 @@ class StoreTeamService:
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
def _generate_invitation_token(self) -> str:

View File

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

View File

@@ -27,7 +27,7 @@ function storeRoles() {
showRoleModal: false,
editingRole: null,
roleForm: { name: '', permissions: [] },
permissionsByCategory: {},
permissionCategories: [],
presetRoles: ['manager', 'staff', 'support', 'viewer', 'marketing'],
async init() {
@@ -50,40 +50,14 @@ function storeRoles() {
async loadPermissions() {
try {
// Group known permissions by category prefix
const allPerms = window.USER_PERMISSIONS || [];
this.permissionsByCategory = this.groupPermissions(allPerms);
const response = await apiClient.get('/store/team/permissions/catalog');
this.permissionCategories = response.categories || [];
storeRolesLog.info('Loaded permission catalog:', this.permissionCategories.length, 'categories');
} 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() {
this.loading = true;
this.error = false;
@@ -127,7 +101,7 @@ function storeRoles() {
},
toggleCategory(category) {
const perms = this.permissionsByCategory[category] || [];
const perms = category.permissions || [];
const permIds = perms.map(p => p.id);
const allSelected = permIds.every(id => this.roleForm.permissions.includes(id));
if (allSelected) {
@@ -142,7 +116,7 @@ function storeRoles() {
},
isCategoryFullySelected(category) {
const perms = this.permissionsByCategory[category] || [];
const perms = category.permissions || [];
return perms.length > 0 && perms.every(p => this.roleForm.permissions.includes(p.id));
},

View File

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

View File

@@ -101,10 +101,10 @@
<div>
<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="(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="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
@click="toggleCategory(category)"
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
@@ -112,16 +112,29 @@
></button>
</div>
<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">
<label class="flex items-center gap-2 py-1 cursor-pointer">
<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 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>
</template>
</div>

View File

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

View File

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

View File

@@ -12,7 +12,14 @@ from app.modules.tenancy.exceptions import (
InvalidRoleException,
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
# =============================================================================
@@ -728,3 +735,80 @@ class TestStoreTeamServiceDeleteRole:
store_id=team_store.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
)