feat(roles): add admin store roles page, permission i18n, and menu integration
Some checks failed
Some checks failed
- Add admin store roles page with merchant→store cascading for superadmin and store-only selection for platform admin - Add permission catalog API with translated labels/descriptions (en/fr/de/lb) - Add permission translations to all 15 module locale files (60 files total) - Add info icon tooltips for permission descriptions in role editor - Add store roles menu item and admin menu item in module definition - Fix store-selector.js URL construction bug when apiEndpoint has query params - Add admin store roles API (CRUD + platform scoping) - Add integration tests for admin store roles and permission catalog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
181
app/modules/tenancy/routes/api/admin_store_roles.py
Normal file
181
app/modules/tenancy/routes/api/admin_store_roles.py
Normal file
@@ -0,0 +1,181 @@
|
||||
# app/modules/tenancy/routes/api/admin_store_roles.py
|
||||
"""
|
||||
Admin store role management endpoints.
|
||||
|
||||
Allows super admins and platform admins to manage roles for any store
|
||||
they have access to. Platform admins are scoped to stores within their
|
||||
assigned platforms.
|
||||
|
||||
Endpoints:
|
||||
GET /admin/store-roles — List roles for a store
|
||||
GET /admin/store-roles/permissions/catalog — Permission catalog
|
||||
POST /admin/store-roles — Create a role
|
||||
PUT /admin/store-roles/{role_id} — Update a role
|
||||
DELETE /admin/store-roles/{role_id} — Delete a role
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.modules.tenancy.schemas.team import (
|
||||
PermissionCatalogResponse,
|
||||
RoleCreate,
|
||||
RoleListResponse,
|
||||
RoleResponse,
|
||||
RoleUpdate,
|
||||
)
|
||||
from app.modules.tenancy.services.permission_discovery_service import (
|
||||
permission_discovery_service,
|
||||
)
|
||||
from app.modules.tenancy.services.store_team_service import store_team_service
|
||||
from app.utils.i18n import translate
|
||||
from models.schema.auth import UserContext
|
||||
|
||||
admin_store_roles_router = APIRouter(prefix="/store-roles")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin_store_roles_router.get(
|
||||
"/permissions/catalog", response_model=PermissionCatalogResponse
|
||||
)
|
||||
def admin_get_permission_catalog(
|
||||
request: Request,
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get the full permission catalog grouped by category.
|
||||
|
||||
Available to all admin users. Returns all permission definitions
|
||||
with labels and descriptions for the role editor UI.
|
||||
"""
|
||||
categories = permission_discovery_service.get_permissions_by_category()
|
||||
lang = current_admin.preferred_language or getattr(
|
||||
request.state, "language", "en"
|
||||
)
|
||||
|
||||
def _t(key: str) -> str:
|
||||
"""Translate key, falling back to readable version."""
|
||||
translated = translate(key, language=lang)
|
||||
if translated == key:
|
||||
parts = key.split(".")
|
||||
return parts[-1].replace("_", " ").title()
|
||||
return translated
|
||||
|
||||
return PermissionCatalogResponse(
|
||||
categories=[
|
||||
{
|
||||
"id": cat.id,
|
||||
"label": _t(cat.label_key),
|
||||
"permissions": [
|
||||
{
|
||||
"id": p.id,
|
||||
"label": _t(p.label_key),
|
||||
"description": _t(p.description_key),
|
||||
"is_owner_only": p.is_owner_only,
|
||||
}
|
||||
for p in cat.permissions
|
||||
],
|
||||
}
|
||||
for cat in categories
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@admin_store_roles_router.get("", response_model=RoleListResponse)
|
||||
def admin_list_store_roles(
|
||||
store_id: int = Query(..., description="Store ID to list roles for"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all roles for a store.
|
||||
|
||||
Platform admins can only access stores within their assigned platforms.
|
||||
Super admins can access any store.
|
||||
"""
|
||||
store_team_service.validate_admin_store_access(db, current_admin, store_id)
|
||||
|
||||
roles = store_team_service.get_store_roles(db=db, store_id=store_id)
|
||||
db.commit() # Commit in case default roles were created
|
||||
|
||||
return RoleListResponse(roles=roles, total=len(roles))
|
||||
|
||||
|
||||
@admin_store_roles_router.post("", response_model=RoleResponse, status_code=201)
|
||||
def admin_create_store_role(
|
||||
role_data: RoleCreate,
|
||||
store_id: int = Query(..., description="Store ID to create role for"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Create a custom role for a store.
|
||||
|
||||
Platform admins can only manage stores within their assigned platforms.
|
||||
"""
|
||||
store_team_service.validate_admin_store_access(db, current_admin, store_id)
|
||||
|
||||
role = store_team_service.create_custom_role(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
name=role_data.name,
|
||||
permissions=role_data.permissions,
|
||||
actor_user_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
return role
|
||||
|
||||
|
||||
@admin_store_roles_router.put("/{role_id}", response_model=RoleResponse)
|
||||
def admin_update_store_role(
|
||||
role_id: int,
|
||||
role_data: RoleUpdate,
|
||||
store_id: int = Query(..., description="Store ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update a role's name and/or permissions.
|
||||
|
||||
Platform admins can only manage stores within their assigned platforms.
|
||||
"""
|
||||
store_team_service.validate_admin_store_access(db, current_admin, store_id)
|
||||
|
||||
role = store_team_service.update_role(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
role_id=role_id,
|
||||
name=role_data.name,
|
||||
permissions=role_data.permissions,
|
||||
actor_user_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
return role
|
||||
|
||||
|
||||
@admin_store_roles_router.delete("/{role_id}", status_code=204)
|
||||
def admin_delete_store_role(
|
||||
role_id: int,
|
||||
store_id: int = Query(..., description="Store ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: UserContext = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete a custom role.
|
||||
|
||||
Preset roles cannot be deleted. Platform admins can only manage
|
||||
stores within their assigned platforms.
|
||||
"""
|
||||
store_team_service.validate_admin_store_access(db, current_admin, store_id)
|
||||
|
||||
store_team_service.delete_role(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
role_id=role_id,
|
||||
actor_user_id=current_admin.id,
|
||||
)
|
||||
db.commit()
|
||||
@@ -86,6 +86,7 @@ def get_all_stores_admin(
|
||||
search: str | None = Query(None, description="Search by name or store code"),
|
||||
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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
419
app/modules/tenancy/static/admin/js/store-roles.js
Normal file
419
app/modules/tenancy/static/admin/js/store-roles.js
Normal file
@@ -0,0 +1,419 @@
|
||||
// noqa: js-006 - async init pattern is safe, loadData has try/catch
|
||||
// static/admin/js/store-roles.js
|
||||
/**
|
||||
* Admin store roles management page
|
||||
*
|
||||
* Super admins: merchant → store cascading selection.
|
||||
* Platform admins: store selection scoped to their platforms.
|
||||
*
|
||||
* Uses Tom Select for selection and permission catalog API for
|
||||
* displaying permissions with labels and descriptions.
|
||||
*/
|
||||
|
||||
const storeRolesAdminLog = (window.LogConfig && window.LogConfig.createLogger)
|
||||
? window.LogConfig.createLogger('adminStoreRoles', false)
|
||||
: console;
|
||||
|
||||
storeRolesAdminLog.info('Loading...');
|
||||
|
||||
function adminStoreRoles() {
|
||||
storeRolesAdminLog.info('adminStoreRoles() called');
|
||||
|
||||
const config = window._adminStoreRolesConfig || {};
|
||||
const isSuperAdmin = config.isSuperAdmin || false;
|
||||
|
||||
return {
|
||||
// Inherit base layout state
|
||||
...data(),
|
||||
|
||||
// Set page identifier
|
||||
currentPage: 'store-roles',
|
||||
|
||||
// Selection state
|
||||
isSuperAdmin,
|
||||
selectedMerchant: null,
|
||||
selectedStore: null,
|
||||
merchantSelector: null,
|
||||
storeSelector: null,
|
||||
|
||||
// Role state
|
||||
loading: false,
|
||||
roles: [],
|
||||
rolesLoading: false,
|
||||
saving: false,
|
||||
showRoleModal: false,
|
||||
editingRole: null,
|
||||
roleForm: { name: '', permissions: [] },
|
||||
permissionCategories: [],
|
||||
presetRoles: ['manager', 'staff', 'support', 'viewer', 'marketing'],
|
||||
|
||||
async init() {
|
||||
// Guard against multiple initialization
|
||||
if (window._adminStoreRolesInitialized) {
|
||||
return;
|
||||
}
|
||||
window._adminStoreRolesInitialized = true;
|
||||
|
||||
storeRolesAdminLog.info('Admin Store Roles init(), isSuperAdmin:', isSuperAdmin);
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (isSuperAdmin) {
|
||||
this.initMerchantSelector();
|
||||
} else {
|
||||
this.initStoreSelector();
|
||||
}
|
||||
});
|
||||
|
||||
// Load permission catalog
|
||||
await this.loadPermissionCatalog();
|
||||
|
||||
// Restore saved selection
|
||||
const savedStoreId = localStorage.getItem('admin_store_roles_selected_store_id');
|
||||
if (savedStoreId) {
|
||||
storeRolesAdminLog.info('Restoring saved store:', savedStoreId);
|
||||
setTimeout(async () => {
|
||||
await this.restoreSavedStore(parseInt(savedStoreId));
|
||||
}, 300);
|
||||
}
|
||||
|
||||
storeRolesAdminLog.info('Admin Store Roles initialization complete');
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Permission Catalog
|
||||
// =====================================================================
|
||||
|
||||
async loadPermissionCatalog() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/store-roles/permissions/catalog');
|
||||
this.permissionCategories = response.categories || [];
|
||||
storeRolesAdminLog.info('Loaded permission catalog:', this.permissionCategories.length, 'categories');
|
||||
} catch (error) {
|
||||
storeRolesAdminLog.warn('Failed to load permission catalog:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Merchant Selector (Super Admin only)
|
||||
// =====================================================================
|
||||
|
||||
initMerchantSelector() {
|
||||
const el = this.$refs.merchantSelect;
|
||||
if (!el) {
|
||||
storeRolesAdminLog.warn('Merchant select element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
|
||||
waitForTomSelect(() => {
|
||||
self.merchantSelector = new TomSelect(el, {
|
||||
valueField: 'id',
|
||||
labelField: 'name',
|
||||
searchField: ['name'],
|
||||
maxOptions: 50,
|
||||
placeholder: 'Search merchant by name...',
|
||||
load: async function(query, callback) {
|
||||
if (query.length < 2) { callback([]); return; }
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/admin/merchants?search=${encodeURIComponent(query)}&limit=50`
|
||||
);
|
||||
const merchants = (response.merchants || []).map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
store_count: m.store_count || 0,
|
||||
}));
|
||||
callback(merchants);
|
||||
} catch (error) {
|
||||
storeRolesAdminLog.error('Merchant search failed:', error);
|
||||
callback([]);
|
||||
}
|
||||
},
|
||||
render: {
|
||||
option: function(data, escape) {
|
||||
return `<div class="flex justify-between items-center py-1">
|
||||
<span class="font-medium">${escape(data.name)}</span>
|
||||
<span class="text-xs text-gray-400 ml-2">${data.store_count} store(s)</span>
|
||||
</div>`;
|
||||
},
|
||||
item: function(data, escape) {
|
||||
return `<div>${escape(data.name)}</div>`;
|
||||
},
|
||||
no_results: function() {
|
||||
return '<div class="no-results py-2 px-3 text-gray-500">No merchants found</div>';
|
||||
},
|
||||
loading: function() {
|
||||
return '<div class="loading py-2 px-3 text-gray-500">Searching...</div>';
|
||||
}
|
||||
},
|
||||
onChange: function(value) {
|
||||
if (value) {
|
||||
const selected = this.options[value];
|
||||
if (selected) {
|
||||
self.onMerchantSelected({
|
||||
id: parseInt(value),
|
||||
name: selected.name,
|
||||
store_count: selected.store_count,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.onMerchantCleared();
|
||||
}
|
||||
},
|
||||
loadThrottle: 150,
|
||||
closeAfterSelect: true,
|
||||
persist: true,
|
||||
create: false,
|
||||
});
|
||||
|
||||
storeRolesAdminLog.info('Merchant selector initialized');
|
||||
});
|
||||
},
|
||||
|
||||
async onMerchantSelected(merchant) {
|
||||
storeRolesAdminLog.info('Merchant selected:', merchant.name);
|
||||
this.selectedMerchant = merchant;
|
||||
this.selectedStore = null;
|
||||
this.roles = [];
|
||||
localStorage.removeItem('admin_store_roles_selected_store_id');
|
||||
|
||||
// Destroy previous store selector and reinit with merchant filter
|
||||
if (this.storeSelector) {
|
||||
if (typeof this.storeSelector.destroy === 'function') {
|
||||
this.storeSelector.destroy();
|
||||
}
|
||||
this.storeSelector = null;
|
||||
}
|
||||
|
||||
// Wait for DOM update (x-show toggles the store select container)
|
||||
await this.$nextTick();
|
||||
|
||||
this.initStoreSelector(merchant.id);
|
||||
},
|
||||
|
||||
onMerchantCleared() {
|
||||
storeRolesAdminLog.info('Merchant cleared');
|
||||
this.selectedMerchant = null;
|
||||
this.selectedStore = null;
|
||||
this.roles = [];
|
||||
localStorage.removeItem('admin_store_roles_selected_store_id');
|
||||
|
||||
if (this.storeSelector) {
|
||||
if (typeof this.storeSelector.destroy === 'function') {
|
||||
this.storeSelector.destroy();
|
||||
}
|
||||
this.storeSelector = null;
|
||||
}
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Store Selector
|
||||
// =====================================================================
|
||||
|
||||
initStoreSelector(merchantId = null) {
|
||||
const el = this.$refs.storeSelect;
|
||||
if (!el) {
|
||||
storeRolesAdminLog.warn('Store select element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const apiEndpoint = merchantId
|
||||
? `/admin/stores?merchant_id=${merchantId}`
|
||||
: '/admin/stores';
|
||||
|
||||
this.storeSelector = initStoreSelector(el, {
|
||||
placeholder: merchantId ? 'Select store...' : 'Search store by name or code...',
|
||||
apiEndpoint: apiEndpoint,
|
||||
onSelect: async (store) => {
|
||||
storeRolesAdminLog.info('Store selected:', store);
|
||||
this.selectedStore = store;
|
||||
localStorage.setItem('admin_store_roles_selected_store_id', store.id.toString());
|
||||
await this.loadRoles();
|
||||
},
|
||||
onClear: () => {
|
||||
storeRolesAdminLog.info('Store cleared');
|
||||
this.selectedStore = null;
|
||||
this.roles = [];
|
||||
localStorage.removeItem('admin_store_roles_selected_store_id');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Restore / Clear
|
||||
// =====================================================================
|
||||
|
||||
async restoreSavedStore(storeId) {
|
||||
try {
|
||||
const store = await apiClient.get(`/admin/stores/${storeId}`);
|
||||
if (!store) return;
|
||||
|
||||
if (isSuperAdmin && store.merchant_id) {
|
||||
// For super admin, restore the merchant first
|
||||
try {
|
||||
const merchant = await apiClient.get(`/admin/merchants/${store.merchant_id}`);
|
||||
if (merchant && this.merchantSelector) {
|
||||
this.merchantSelector.addOption({
|
||||
id: merchant.id,
|
||||
name: merchant.name,
|
||||
store_count: merchant.store_count || 0,
|
||||
});
|
||||
this.merchantSelector.setValue(merchant.id, true);
|
||||
this.selectedMerchant = { id: merchant.id, name: merchant.name };
|
||||
|
||||
// Wait for DOM, then init store selector and set value
|
||||
await this.$nextTick();
|
||||
this.initStoreSelector(merchant.id);
|
||||
setTimeout(() => {
|
||||
if (this.storeSelector) {
|
||||
this.storeSelector.setValue(store.id, store);
|
||||
}
|
||||
this.selectedStore = { id: store.id, name: store.name, store_code: store.store_code };
|
||||
this.loadRoles();
|
||||
}, 300);
|
||||
}
|
||||
} catch (error) {
|
||||
storeRolesAdminLog.warn('Failed to restore merchant:', error);
|
||||
localStorage.removeItem('admin_store_roles_selected_store_id');
|
||||
}
|
||||
} else {
|
||||
// Platform admin: just restore the store
|
||||
if (this.storeSelector) {
|
||||
this.storeSelector.setValue(store.id, store);
|
||||
}
|
||||
this.selectedStore = { id: store.id, name: store.name, store_code: store.store_code };
|
||||
await this.loadRoles();
|
||||
}
|
||||
storeRolesAdminLog.info('Restored store:', store.name);
|
||||
} catch (error) {
|
||||
storeRolesAdminLog.warn('Failed to restore saved store:', error);
|
||||
localStorage.removeItem('admin_store_roles_selected_store_id');
|
||||
}
|
||||
},
|
||||
|
||||
clearSelection() {
|
||||
if (isSuperAdmin) {
|
||||
if (this.merchantSelector) {
|
||||
this.merchantSelector.clear();
|
||||
}
|
||||
this.selectedMerchant = null;
|
||||
}
|
||||
if (this.storeSelector) {
|
||||
if (typeof this.storeSelector.clear === 'function') {
|
||||
this.storeSelector.clear();
|
||||
}
|
||||
}
|
||||
this.selectedStore = null;
|
||||
this.roles = [];
|
||||
localStorage.removeItem('admin_store_roles_selected_store_id');
|
||||
},
|
||||
|
||||
// =====================================================================
|
||||
// Roles CRUD
|
||||
// =====================================================================
|
||||
|
||||
async loadRoles() {
|
||||
if (!this.selectedStore) return;
|
||||
|
||||
this.rolesLoading = true;
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/store-roles?store_id=${this.selectedStore.id}`);
|
||||
this.roles = response.roles || [];
|
||||
storeRolesAdminLog.info('Loaded', this.roles.length, 'roles');
|
||||
} catch (error) {
|
||||
storeRolesAdminLog.error('Failed to load roles:', error);
|
||||
Utils.showToast(error.message || 'Failed to load roles', 'error');
|
||||
} finally {
|
||||
this.rolesLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
isPresetRole(name) {
|
||||
return this.presetRoles.includes(name.toLowerCase());
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.editingRole = null;
|
||||
this.roleForm = { name: '', permissions: [] };
|
||||
this.showRoleModal = true;
|
||||
},
|
||||
|
||||
openEditModal(role) {
|
||||
this.editingRole = role;
|
||||
this.roleForm = {
|
||||
name: role.name,
|
||||
permissions: [...(role.permissions || [])],
|
||||
};
|
||||
this.showRoleModal = true;
|
||||
},
|
||||
|
||||
togglePermission(permId) {
|
||||
const idx = this.roleForm.permissions.indexOf(permId);
|
||||
if (idx >= 0) {
|
||||
this.roleForm.permissions.splice(idx, 1);
|
||||
} else {
|
||||
this.roleForm.permissions.push(permId);
|
||||
}
|
||||
},
|
||||
|
||||
toggleCategory(category) {
|
||||
const perms = category.permissions || [];
|
||||
const permIds = perms.map(p => p.id);
|
||||
const allSelected = permIds.every(id => this.roleForm.permissions.includes(id));
|
||||
if (allSelected) {
|
||||
this.roleForm.permissions = this.roleForm.permissions.filter(id => !permIds.includes(id));
|
||||
} else {
|
||||
for (const id of permIds) {
|
||||
if (!this.roleForm.permissions.includes(id)) {
|
||||
this.roleForm.permissions.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
isCategoryFullySelected(category) {
|
||||
const perms = category.permissions || [];
|
||||
return perms.length > 0 && perms.every(p => this.roleForm.permissions.includes(p.id));
|
||||
},
|
||||
|
||||
async saveRole() {
|
||||
if (!this.selectedStore) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const storeParam = `store_id=${this.selectedStore.id}`;
|
||||
if (this.editingRole) {
|
||||
await apiClient.put(`/admin/store-roles/${this.editingRole.id}?${storeParam}`, this.roleForm);
|
||||
} else {
|
||||
await apiClient.post(`/admin/store-roles?${storeParam}`, this.roleForm);
|
||||
}
|
||||
|
||||
this.showRoleModal = false;
|
||||
Utils.showToast('Role saved successfully', 'success');
|
||||
await this.loadRoles();
|
||||
} catch (error) {
|
||||
storeRolesAdminLog.error('Error saving role:', error);
|
||||
Utils.showToast(error.message || 'Failed to save role', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async confirmDelete(role) {
|
||||
if (!this.selectedStore) return;
|
||||
if (!confirm(`Delete role "${role.name}"? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/admin/store-roles/${role.id}?store_id=${this.selectedStore.id}`);
|
||||
Utils.showToast('Role deleted successfully', 'success');
|
||||
await this.loadRoles();
|
||||
} catch (error) {
|
||||
storeRolesAdminLog.error('Error deleting role:', error);
|
||||
Utils.showToast(error.message || 'Failed to delete role', 'error');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
storeRolesAdminLog.info('Module loaded');
|
||||
@@ -27,7 +27,7 @@ function storeRoles() {
|
||||
showRoleModal: false,
|
||||
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));
|
||||
},
|
||||
|
||||
|
||||
271
app/modules/tenancy/templates/tenancy/admin/store-roles.html
Normal file
271
app/modules/tenancy/templates/tenancy/admin/store-roles.html
Normal file
@@ -0,0 +1,271 @@
|
||||
{# app/templates/admin/store-roles.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
{% from 'shared/macros/modals.html' import modal_simple %}
|
||||
|
||||
{% block title %}Store Roles{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" rel="stylesheet">
|
||||
<style>
|
||||
.ts-wrapper { width: 100%; }
|
||||
.ts-control {
|
||||
background-color: rgb(249 250 251) !important;
|
||||
border-color: rgb(209 213 219) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
}
|
||||
.dark .ts-control {
|
||||
background-color: rgb(55 65 81) !important;
|
||||
border-color: rgb(75 85 99) !important;
|
||||
color: rgb(229 231 235) !important;
|
||||
}
|
||||
.ts-dropdown {
|
||||
border-radius: 0.5rem !important;
|
||||
border-color: rgb(209 213 219) !important;
|
||||
}
|
||||
.dark .ts-dropdown {
|
||||
background-color: rgb(55 65 81) !important;
|
||||
border-color: rgb(75 85 99) !important;
|
||||
}
|
||||
.dark .ts-dropdown .option {
|
||||
color: rgb(229 231 235) !important;
|
||||
}
|
||||
.dark .ts-dropdown .option.active {
|
||||
background-color: rgb(75 85 99) !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminStoreRoles(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ page_header('Store Roles', subtitle='Manage roles and permissions for any store') }}
|
||||
|
||||
<!-- Selection Panel -->
|
||||
<div class="px-4 py-6 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
{% if is_super_admin %}
|
||||
<!-- Super Admin: Merchant → Store cascading -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Merchant
|
||||
</label>
|
||||
<select x-ref="merchantSelect" placeholder="Search merchant by name..."></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Store
|
||||
</label>
|
||||
<div x-show="!selectedMerchant" class="px-3 py-2 text-sm text-gray-400 dark:text-gray-500 border rounded-lg dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
|
||||
Select a merchant first
|
||||
</div>
|
||||
<div x-show="selectedMerchant" x-cloak>
|
||||
<select x-ref="storeSelect" placeholder="Select store..."></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Platform Admin: Store only (scoped to their platforms) -->
|
||||
<div class="max-w-md">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Store
|
||||
</label>
|
||||
<select x-ref="storeSelect" placeholder="Search store by name or code..."></select>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Selected Store Info -->
|
||||
<div x-show="selectedStore" x-cloak class="mb-6">
|
||||
<div class="px-4 py-3 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span x-html="$icon('shield-check', 'w-6 h-6 text-purple-600')"></span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-purple-700 dark:text-purple-300">Managing Roles For</p>
|
||||
<p class="text-lg font-semibold text-purple-900 dark:text-purple-100" x-text="selectedStore?.name"></p>
|
||||
{% if is_super_admin %}
|
||||
<p class="text-xs text-purple-600 dark:text-purple-400" x-text="selectedMerchant ? 'Merchant: ' + selectedMerchant.name : ''"></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="openCreateModal()"
|
||||
class="flex items-center px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
<span x-html="$icon('plus', 'w-4 h-4 mr-1')"></span>
|
||||
Create Role
|
||||
</button>
|
||||
<button
|
||||
@click="clearSelection()"
|
||||
class="flex items-center gap-1 px-3 py-1.5 text-sm text-purple-700 dark:text-purple-300 bg-purple-100 dark:bg-purple-800 rounded-md hover:bg-purple-200 dark:hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<span x-html="$icon('x-mark', 'w-4 h-4')"></span>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="rolesLoading" class="text-center py-12">
|
||||
<div class="inline-block animate-spin w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full"></div>
|
||||
<p class="mt-4 text-gray-500 dark:text-gray-400">Loading roles...</p>
|
||||
</div>
|
||||
|
||||
<!-- Roles List -->
|
||||
<div x-show="selectedStore && !rolesLoading" class="space-y-6">
|
||||
<template x-for="role in roles" :key="role.id">
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="role.name"></h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="(role.permissions || []).length"></span> permissions
|
||||
<template x-if="isPresetRole(role.name)">
|
||||
<span class="ml-2 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full dark:bg-blue-900 dark:text-blue-200">Preset</span>
|
||||
</template>
|
||||
<template x-if="!isPresetRole(role.name)">
|
||||
<span class="ml-2 px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 rounded-full dark:bg-green-900 dark:text-green-200">Custom</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="openEditModal(role)"
|
||||
class="px-3 py-1.5 text-sm font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-400 dark:bg-purple-900/20 dark:hover:bg-purple-900/40"
|
||||
>
|
||||
<span x-html="$icon('pencil', 'w-4 h-4 inline mr-1')"></span>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
x-show="!isPresetRole(role.name)"
|
||||
@click="confirmDelete(role)"
|
||||
class="px-3 py-1.5 text-sm font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 dark:hover:bg-red-900/40"
|
||||
>
|
||||
<span x-html="$icon('trash', 'w-4 h-4 inline mr-1')"></span>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permission tags -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<template x-for="perm in (role.permissions || [])" :key="perm">
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded dark:bg-gray-700 dark:text-gray-300" x-text="perm"></span>
|
||||
</template>
|
||||
<template x-if="!role.permissions || role.permissions.length === 0">
|
||||
<span class="text-sm text-gray-400 dark:text-gray-500">No permissions assigned</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="roles.length === 0 && !rolesLoading">
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('shield', 'w-12 h-12 mx-auto mb-4 opacity-50')"></span>
|
||||
<p>No roles found for this store.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- No Store Selected -->
|
||||
<div x-show="!selectedStore && !rolesLoading" class="text-center py-12">
|
||||
<span x-html="$icon('shield-check', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
{% if is_super_admin %}
|
||||
<p class="text-gray-600 dark:text-gray-400">Select a merchant and store above to manage roles</p>
|
||||
{% else %}
|
||||
<p class="text-gray-600 dark:text-gray-400">Select a store above to manage its roles</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Role Modal -->
|
||||
{% call modal_simple('roleModal', 'editingRole ? "Edit Role" : "Create Role"', 'showRoleModal') %}
|
||||
<div class="space-y-4">
|
||||
<!-- Role Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Role Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="roleForm.name"
|
||||
placeholder="e.g. Content Editor"
|
||||
class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Permission Matrix -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Permissions</label>
|
||||
<div class="max-h-96 overflow-y-auto border rounded-lg dark:border-gray-600">
|
||||
<template x-for="category in permissionCategories" :key="category.id">
|
||||
<div class="border-b last:border-b-0 dark:border-gray-600">
|
||||
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-700/50 flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300 capitalize" x-text="category.label"></span>
|
||||
<button
|
||||
@click="toggleCategory(category)"
|
||||
class="text-xs text-purple-600 hover:text-purple-800 dark:text-purple-400"
|
||||
x-text="isCategoryFullySelected(category) ? 'Deselect All' : 'Select All'"
|
||||
></button>
|
||||
</div>
|
||||
<div class="px-4 py-2 grid grid-cols-1 sm:grid-cols-2 gap-1">
|
||||
<template x-for="perm in category.permissions" :key="perm.id">
|
||||
<label class="flex items-start gap-2 py-1.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="perm.id"
|
||||
:checked="roleForm.permissions.includes(perm.id)"
|
||||
@change="togglePermission(perm.id)"
|
||||
class="w-4 h-4 mt-0.5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="perm.label"></span>
|
||||
<span
|
||||
x-show="perm.description"
|
||||
:title="perm.description"
|
||||
x-html="$icon('information-circle', 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500 cursor-help')"
|
||||
></span>
|
||||
</span>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 font-mono" x-text="perm.id"></span>
|
||||
</div>
|
||||
<template x-if="perm.is_owner_only">
|
||||
<span class="ml-auto px-1.5 py-0.5 text-xs bg-amber-100 text-amber-700 rounded dark:bg-amber-900/30 dark:text-amber-400">Owner</span>
|
||||
</template>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
@click="showRoleModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 dark:text-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600"
|
||||
>Cancel</button>
|
||||
<button
|
||||
@click="saveRole()"
|
||||
:disabled="saving || !roleForm.name.trim()"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
<span x-show="saving" class="inline-block animate-spin mr-1">↻</span>
|
||||
<span x-text="editingRole ? 'Update Role' : 'Create Role'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
|
||||
<script>
|
||||
window._adminStoreRolesConfig = { isSuperAdmin: {{ is_super_admin | tojson }} };
|
||||
</script>
|
||||
<script defer src="{{ url_for('tenancy_static', path='admin/js/store-roles.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -101,10 +101,10 @@
|
||||
<div>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# app/modules/tenancy/tests/integration/test_admin_store_roles_api.py
|
||||
"""
|
||||
Integration tests for admin store role management API endpoints.
|
||||
|
||||
Tests the admin role management endpoints at:
|
||||
/api/v1/admin/store-roles
|
||||
|
||||
Authentication: Uses super_admin_headers and platform_admin_headers
|
||||
fixtures from tests/fixtures/auth_fixtures.py.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.tenancy.models import (
|
||||
Merchant,
|
||||
Platform,
|
||||
Role,
|
||||
Store,
|
||||
StorePlatform,
|
||||
User,
|
||||
)
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
BASE = "/api/v1/admin/store-roles"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_store_merchant(db):
|
||||
"""Create a merchant for admin role tests."""
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
owner = User(
|
||||
email=f"admin_role_owner_{uid}@test.com",
|
||||
username=f"admin_role_owner_{uid}",
|
||||
hashed_password=auth.hash_password("ownerpass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.flush()
|
||||
|
||||
merchant = Merchant(
|
||||
name="Admin Role Test Merchant",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
return merchant
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_role_store(db, admin_store_merchant):
|
||||
"""Create a store for admin role tests."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
store = Store(
|
||||
merchant_id=admin_store_merchant.id,
|
||||
store_code=f"ADMROLE_{uid.upper()}",
|
||||
subdomain=f"admrole{uid}",
|
||||
name=f"Admin Role Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_role_custom(db, admin_role_store):
|
||||
"""Create a custom role for update/delete tests."""
|
||||
role = Role(
|
||||
store_id=admin_role_store.id,
|
||||
name="admin_test_custom_role",
|
||||
permissions=["products.view", "orders.view"],
|
||||
)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(role)
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_platform(db):
|
||||
"""Create a platform for scoping tests."""
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
platform = Platform(
|
||||
code=f"test_{uid}",
|
||||
name=f"Test Platform {uid}",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(platform)
|
||||
db.commit()
|
||||
db.refresh(platform)
|
||||
return platform
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_on_platform(db, admin_role_store, test_platform):
|
||||
"""Link the test store to a platform."""
|
||||
sp = StorePlatform(
|
||||
store_id=admin_role_store.id,
|
||||
platform_id=test_platform.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
db.refresh(sp)
|
||||
return sp
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Super Admin Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestAdminListStoreRoles:
|
||||
"""Tests for GET /api/v1/admin/store-roles."""
|
||||
|
||||
def test_list_roles_as_super_admin(
|
||||
self, client, super_admin_headers, admin_role_store
|
||||
):
|
||||
"""Super admin can list roles for any store."""
|
||||
response = client.get(
|
||||
f"{BASE}?store_id={admin_role_store.id}",
|
||||
headers=super_admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "roles" in data
|
||||
assert "total" in data
|
||||
# Default preset roles should be created
|
||||
assert data["total"] >= 5
|
||||
|
||||
def test_list_roles_requires_store_id(self, client, super_admin_headers):
|
||||
"""GET /store-roles without store_id returns 422."""
|
||||
response = client.get(BASE, headers=super_admin_headers)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_list_roles_unauthenticated(self, client, admin_role_store):
|
||||
"""Unauthenticated request is rejected."""
|
||||
response = client.get(f"{BASE}?store_id={admin_role_store.id}")
|
||||
assert response.status_code in (401, 403)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestAdminCreateStoreRole:
|
||||
"""Tests for POST /api/v1/admin/store-roles."""
|
||||
|
||||
def test_create_role_as_super_admin(
|
||||
self, client, super_admin_headers, admin_role_store
|
||||
):
|
||||
"""Super admin can create a custom role for any store."""
|
||||
response = client.post(
|
||||
f"{BASE}?store_id={admin_role_store.id}",
|
||||
headers=super_admin_headers,
|
||||
json={
|
||||
"name": "admin_created_role",
|
||||
"permissions": ["products.view", "orders.view"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "admin_created_role"
|
||||
assert "products.view" in data["permissions"]
|
||||
|
||||
def test_create_preset_name_rejected(
|
||||
self, client, super_admin_headers, admin_role_store
|
||||
):
|
||||
"""Cannot create a role with a preset name."""
|
||||
response = client.post(
|
||||
f"{BASE}?store_id={admin_role_store.id}",
|
||||
headers=super_admin_headers,
|
||||
json={
|
||||
"name": "manager",
|
||||
"permissions": ["products.view"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestAdminUpdateStoreRole:
|
||||
"""Tests for PUT /api/v1/admin/store-roles/{role_id}."""
|
||||
|
||||
def test_update_role_as_super_admin(
|
||||
self, client, super_admin_headers, admin_role_store, admin_role_custom
|
||||
):
|
||||
"""Super admin can update a custom role."""
|
||||
response = client.put(
|
||||
f"{BASE}/{admin_role_custom.id}?store_id={admin_role_store.id}",
|
||||
headers=super_admin_headers,
|
||||
json={
|
||||
"name": "renamed_admin_role",
|
||||
"permissions": ["products.view", "products.edit"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "renamed_admin_role"
|
||||
assert "products.edit" in data["permissions"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestAdminDeleteStoreRole:
|
||||
"""Tests for DELETE /api/v1/admin/store-roles/{role_id}."""
|
||||
|
||||
def test_delete_role_as_super_admin(
|
||||
self, client, super_admin_headers, admin_role_store, admin_role_custom
|
||||
):
|
||||
"""Super admin can delete a custom role."""
|
||||
response = client.delete(
|
||||
f"{BASE}/{admin_role_custom.id}?store_id={admin_role_store.id}",
|
||||
headers=super_admin_headers,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
def test_delete_nonexistent_role(
|
||||
self, client, super_admin_headers, admin_role_store
|
||||
):
|
||||
"""Deleting nonexistent role returns 422."""
|
||||
response = client.delete(
|
||||
f"{BASE}/99999?store_id={admin_role_store.id}",
|
||||
headers=super_admin_headers,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Permission Catalog Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestAdminPermissionCatalog:
|
||||
"""Tests for GET /api/v1/admin/store-roles/permissions/catalog."""
|
||||
|
||||
def test_catalog_returns_categories(self, client, super_admin_headers):
|
||||
"""GET /permissions/catalog returns categories with permissions."""
|
||||
response = client.get(
|
||||
f"{BASE}/permissions/catalog",
|
||||
headers=super_admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "categories" in data
|
||||
assert len(data["categories"]) > 0
|
||||
|
||||
def test_catalog_permission_has_metadata(self, client, super_admin_headers):
|
||||
"""Each permission has id, label, description, and is_owner_only."""
|
||||
response = client.get(
|
||||
f"{BASE}/permissions/catalog",
|
||||
headers=super_admin_headers,
|
||||
)
|
||||
data = response.json()
|
||||
perm = data["categories"][0]["permissions"][0]
|
||||
assert "id" in perm
|
||||
assert "label" in perm
|
||||
assert "description" in perm
|
||||
assert "is_owner_only" in perm
|
||||
@@ -353,3 +353,54 @@ class TestDeleteRole:
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert "preset" in response.json()["message"].lower()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GET /team/permissions/catalog
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.tenancy
|
||||
class TestPermissionCatalog:
|
||||
"""Tests for GET /api/v1/store/team/permissions/catalog."""
|
||||
|
||||
def test_catalog_returns_categories(self, client, role_auth):
|
||||
"""GET /permissions/catalog returns categories with permissions."""
|
||||
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "categories" in data
|
||||
assert len(data["categories"]) > 0
|
||||
|
||||
def test_catalog_category_has_permissions(self, client, role_auth):
|
||||
"""Each category contains permission items."""
|
||||
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
|
||||
data = response.json()
|
||||
for category in data["categories"]:
|
||||
assert "id" in category
|
||||
assert "label" in category
|
||||
assert "permissions" in category
|
||||
assert len(category["permissions"]) > 0
|
||||
|
||||
def test_catalog_permission_has_metadata(self, client, role_auth):
|
||||
"""Each permission has id, label, description, and is_owner_only."""
|
||||
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
|
||||
data = response.json()
|
||||
perm = data["categories"][0]["permissions"][0]
|
||||
assert "id" in perm
|
||||
assert "label" in perm
|
||||
assert "description" in perm
|
||||
assert "is_owner_only" in perm
|
||||
|
||||
def test_catalog_includes_team_permissions(self, client, role_auth):
|
||||
"""Catalog includes team permissions from tenancy module."""
|
||||
response = client.get(f"{BASE}/permissions/catalog", headers=role_auth)
|
||||
data = response.json()
|
||||
all_perm_ids = {
|
||||
p["id"]
|
||||
for cat in data["categories"]
|
||||
for p in cat["permissions"]
|
||||
}
|
||||
assert "team.view" in all_perm_ids
|
||||
assert "team.edit" in all_perm_ids
|
||||
|
||||
@@ -12,7 +12,14 @@ from app.modules.tenancy.exceptions import (
|
||||
InvalidRoleException,
|
||||
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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user