diff --git a/app/api/deps.py b/app/api/deps.py index a9760d14..f913abe9 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -1557,6 +1557,55 @@ def get_user_permissions( return [] +# ============================================================================ +# PAGE-LEVEL PERMISSION GUARDS (For Store Page Routes) +# ============================================================================ + + +def require_store_page_permission(permission: str): + """ + Dependency factory to require a specific store permission for page routes. + + Same as require_store_permission but raises InsufficientStorePermissionsException + which the exception handler intercepts for HTML requests (redirecting to login). + + Usage: + @router.get("/products", response_class=HTMLResponse) + def store_products_page( + request: Request, + store_code: str = Depends(get_resolved_store_code), + current_user: User = Depends(require_store_page_permission("products.view")), + db: Session = Depends(get_db), + ): + ... + """ + + def permission_checker( + request: Request, + db: Session = Depends(get_db), + current_user: UserContext = Depends(get_current_store_from_cookie_or_header), + ) -> UserContext: + if not current_user.token_store_id: + raise InvalidTokenException( + "Token missing store information. Please login again." + ) + + store_id = current_user.token_store_id + store = store_service.get_store_by_id(db, store_id) + request.state.store = store + + user_model = _get_user_model(current_user, db) + if not user_model.has_store_permission(store.id, permission): + raise InsufficientStorePermissionsException( + required_permission=permission, + store_code=store.store_code, + ) + + return current_user + + return permission_checker + + # ============================================================================ # OPTIONAL AUTHENTICATION (For Login Page Redirects) # ============================================================================ diff --git a/app/modules/analytics/routes/pages/store.py b/app/modules/analytics/routes/pages/store.py index ba96233d..2b37e4e0 100644 --- a/app/modules/analytics/routes/pages/store.py +++ b/app/modules/analytics/routes/pages/store.py @@ -12,9 +12,9 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import ( - get_current_store_from_cookie_or_header, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.services.platform_settings_service import ( platform_settings_service, # MOD-004 - shared platform service @@ -82,7 +82,7 @@ def get_store_context( async def store_analytics_page( request: Request, store_code: str = Depends(get_resolved_store_code), - current_user: User = Depends(get_current_store_from_cookie_or_header), + current_user: User = Depends(require_store_page_permission("analytics.view")), db: Session = Depends(get_db), ): """ diff --git a/app/modules/billing/definition.py b/app/modules/billing/definition.py index 5cb26951..a7afbce7 100644 --- a/app/modules/billing/definition.py +++ b/app/modules/billing/definition.py @@ -241,6 +241,7 @@ billing_module = ModuleDefinition( icon="currency-euro", route="/store/{store_code}/invoices", order=30, + requires_permission="billing.view_invoices", ), ], ), @@ -256,6 +257,7 @@ billing_module = ModuleDefinition( icon="credit-card", route="/store/{store_code}/billing", order=30, + requires_permission="billing.view_subscriptions", ), ], ), diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py index 670c2cd7..e41a7c86 100644 --- a/app/modules/catalog/definition.py +++ b/app/modules/catalog/definition.py @@ -121,6 +121,7 @@ catalog_module = ModuleDefinition( route="/store/{store_code}/products", order=10, is_mandatory=True, + requires_permission="products.view", ), ], ), diff --git a/app/modules/catalog/routes/pages/store.py b/app/modules/catalog/routes/pages/store.py index 8b911a56..9e571dbb 100644 --- a/app/modules/catalog/routes/pages/store.py +++ b/app/modules/catalog/routes/pages/store.py @@ -12,9 +12,9 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import ( - get_current_store_from_cookie_or_header, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.utils.page_context import get_store_context from app.modules.tenancy.models import User @@ -34,7 +34,7 @@ router = APIRouter() async def store_products_page( request: Request, store_code: str = Depends(get_resolved_store_code), - current_user: User = Depends(get_current_store_from_cookie_or_header), + current_user: User = Depends(require_store_page_permission("products.view")), db: Session = Depends(get_db), ): """ @@ -55,7 +55,7 @@ async def store_products_page( async def store_product_create_page( request: Request, store_code: str = Depends(get_resolved_store_code), - current_user: User = Depends(get_current_store_from_cookie_or_header), + current_user: User = Depends(require_store_page_permission("products.create")), db: Session = Depends(get_db), ): """ diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py index 8328b813..16d21280 100644 --- a/app/modules/cms/definition.py +++ b/app/modules/cms/definition.py @@ -240,6 +240,7 @@ cms_module = ModuleDefinition( icon="document-text", route="/store/{store_code}/content-pages", order=10, + requires_permission="cms.view_pages", ), MenuItemDefinition( id="media", @@ -247,6 +248,7 @@ cms_module = ModuleDefinition( icon="photograph", route="/store/{store_code}/media", order=20, + requires_permission="cms.view_media", ), ], ), diff --git a/app/modules/core/definition.py b/app/modules/core/definition.py index 12944a6b..eebddf3d 100644 --- a/app/modules/core/definition.py +++ b/app/modules/core/definition.py @@ -158,6 +158,7 @@ core_module = ModuleDefinition( route="/store/{store_code}/dashboard", order=10, is_mandatory=True, + requires_permission="dashboard.view", ), ], ), @@ -181,6 +182,7 @@ core_module = ModuleDefinition( route="/store/{store_code}/settings", order=20, is_mandatory=True, + requires_permission="settings.view", ), ], ), diff --git a/app/modules/core/routes/api/store_menu.py b/app/modules/core/routes/api/store_menu.py index 22bf5053..033b8edd 100644 --- a/app/modules/core/routes/api/store_menu.py +++ b/app/modules/core/routes/api/store_menu.py @@ -17,7 +17,7 @@ from fastapi import APIRouter, Depends, Request from pydantic import BaseModel from sqlalchemy.orm import Session -from app.api.deps import get_current_store_api +from app.api.deps import get_current_store_api, get_user_permissions from app.core.database import get_db from app.modules.core.services.menu_service import menu_service from app.modules.enums import FrontendType @@ -81,6 +81,7 @@ async def get_rendered_store_menu( request: Request, db: Session = Depends(get_db), current_user: UserContext = Depends(get_current_store_api), + user_perms: list = Depends(get_user_permissions), ): """ Get the rendered store menu for the current user. @@ -89,6 +90,7 @@ async def get_rendered_store_menu( - Modules enabled on the store's platform - AdminMenuConfig visibility for the platform - Store code for URL placeholder replacement + - User permissions (items with requires_permission are hidden if user lacks it) Used by the store frontend to render the sidebar dynamically. """ @@ -100,12 +102,13 @@ async def get_rendered_store_menu( if platform_id is None: platform_id = menu_service.get_store_primary_platform_id(db, store.id) - # Get filtered menu with platform visibility and store_code interpolation + # Get filtered menu with platform visibility, store_code, and permission filtering menu = menu_service.get_menu_for_rendering( db=db, frontend_type=FrontendType.STORE, platform_id=platform_id, store_code=store.subdomain, + user_permissions=user_perms, ) # Resolve language diff --git a/app/modules/core/services/menu_discovery_service.py b/app/modules/core/services/menu_discovery_service.py index 85b353a1..afd02a4c 100644 --- a/app/modules/core/services/menu_discovery_service.py +++ b/app/modules/core/services/menu_discovery_service.py @@ -212,6 +212,7 @@ class MenuDiscoveryService: is_super_admin: bool = False, store_code: str | None = None, enabled_module_codes: set[str] | None = None, + user_permissions: list[str] | None = None, ) -> list[DiscoveredMenuSection]: """ Get filtered menu structure for frontend rendering. @@ -220,7 +221,7 @@ class MenuDiscoveryService: 1. Module enablement (disabled modules = hidden items) 2. Visibility configuration (AdminMenuConfig preferences) 3. Super admin status (hides super_admin_only items for non-super-admins) - 4. Permission requirements (future: filter by user permissions) + 4. Permission requirements (filter by user permissions) Args: db: Database session @@ -231,6 +232,9 @@ class MenuDiscoveryService: store_code: Store code for route placeholder replacement enabled_module_codes: If provided, overrides single-platform lookup for module enablement. Passed through to get_menu_sections_for_frontend. + user_permissions: List of permission IDs the user has. If provided, + items with requires_permission set will be hidden unless the + permission is in this list. None = no permission filtering. Returns: List of DiscoveredMenuSection with filtered and sorted items @@ -268,6 +272,14 @@ class MenuDiscoveryService: if item.id not in visible_item_ids: continue + # Apply permission filtering + if ( + user_permissions is not None + and item.requires_permission + and item.requires_permission not in user_permissions + ): + continue + # Resolve route placeholders if store_code and "{store_code}" in item.route: item.route = item.route.replace("{store_code}", store_code) diff --git a/app/modules/core/services/menu_service.py b/app/modules/core/services/menu_service.py index a13c91a4..d31b90eb 100644 --- a/app/modules/core/services/menu_service.py +++ b/app/modules/core/services/menu_service.py @@ -229,6 +229,7 @@ class MenuService: is_super_admin: bool = False, store_code: str | None = None, enabled_module_codes: set[str] | None = None, + user_permissions: list[str] | None = None, ) -> list: """ Get filtered menu structure for frontend rendering. @@ -239,6 +240,7 @@ class MenuService: 1. Module enablement (items from disabled modules are removed) 2. Visibility configuration 3. Super admin status + 4. User permissions (items with requires_permission hidden if user lacks it) Args: db: Database session @@ -250,6 +252,9 @@ class MenuService: enabled_module_codes: If provided, overrides single-platform lookup for module enablement. Used by merchant portal where a merchant may have subscriptions across multiple platforms. + user_permissions: List of permission IDs the user has. If provided, + items with requires_permission will be hidden unless the user + has the permission. None = no permission filtering. Returns: List of DiscoveredMenuSection ready for rendering @@ -262,6 +267,7 @@ class MenuService: is_super_admin=is_super_admin, store_code=store_code, enabled_module_codes=enabled_module_codes, + user_permissions=user_permissions, ) # ========================================================================= diff --git a/app/modules/core/tests/unit/test_menu_discovery_service.py b/app/modules/core/tests/unit/test_menu_discovery_service.py index ac206e58..33f3cf34 100644 --- a/app/modules/core/tests/unit/test_menu_discovery_service.py +++ b/app/modules/core/tests/unit/test_menu_discovery_service.py @@ -179,3 +179,82 @@ class TestMenuDiscoveryServiceEnabledModuleCodes: assert "billing" in section_ids assert "loyalty" in section_ids assert "account" in section_ids + + +@pytest.mark.unit +@pytest.mark.core +class TestMenuDiscoveryPermissionFiltering: + """Test user_permissions parameter on get_menu_for_frontend.""" + + def setup_method(self): + self.service = MenuDiscoveryService() + + def test_no_permission_filter_shows_all_items(self, db): + """When user_permissions is None, no permission filtering occurs.""" + enabled = {"core", "catalog", "orders", "tenancy", "billing", "loyalty"} + sections = self.service.get_menu_for_frontend( + db, FrontendType.STORE, enabled_module_codes=enabled, + user_permissions=None, + ) + # Should have items regardless of requires_permission + all_items = [item for s in sections for item in s.items] + assert len(all_items) > 0 + + def test_permission_filter_hides_unpermitted_items(self, db): + """Items with requires_permission not in user_permissions are hidden.""" + enabled = {"core", "catalog", "orders", "tenancy", "billing", "loyalty"} + # Only allow dashboard.view — should hide products, orders, etc. + sections = self.service.get_menu_for_frontend( + db, FrontendType.STORE, enabled_module_codes=enabled, + user_permissions=["dashboard.view"], + ) + all_item_ids = {item.id for s in sections for item in s.items} + # Dashboard should be visible (has requires_permission="dashboard.view") + assert "dashboard" in all_item_ids + # Products requires "products.view" — should be hidden + assert "products" not in all_item_ids + + def test_permission_filter_shows_permitted_items(self, db): + """Items with matching permission are shown.""" + enabled = {"core", "catalog", "orders", "tenancy"} + sections = self.service.get_menu_for_frontend( + db, FrontendType.STORE, enabled_module_codes=enabled, + user_permissions=["dashboard.view", "products.view", "orders.view", "team.view"], + ) + all_item_ids = {item.id for s in sections for item in s.items} + assert "dashboard" in all_item_ids + assert "products" in all_item_ids + assert "orders" in all_item_ids + + def test_empty_permissions_list_hides_permission_required_items(self, db): + """Empty permissions list hides all items that require a permission.""" + enabled = {"core", "catalog"} + sections = self.service.get_menu_for_frontend( + db, FrontendType.STORE, enabled_module_codes=enabled, + user_permissions=[], + ) + all_item_ids = {item.id for s in sections for item in s.items} + # Items without requires_permission should still show + # Items with requires_permission should be hidden + assert "products" not in all_item_ids + assert "dashboard" not in all_item_ids + + def test_items_without_requires_permission_always_visible(self, db): + """Items that have no requires_permission are shown regardless of user_permissions.""" + enabled = {"core", "catalog", "tenancy"} + # Get all store items to check which have no requires_permission + all_items_raw = self.service.get_all_menu_items(FrontendType.STORE) + items_without_perm = [ + item for item in all_items_raw if item.requires_permission is None + ] + + if items_without_perm: + sections = self.service.get_menu_for_frontend( + db, FrontendType.STORE, enabled_module_codes=enabled, + user_permissions=["some.random.permission"], + ) + all_item_ids = {item.id for s in sections for item in s.items} + # Items without requires_permission should still be visible + for item in items_without_perm: + if item.module_code in enabled: + assert item.id in all_item_ids diff --git a/app/modules/core/utils/page_context.py b/app/modules/core/utils/page_context.py index 66f8e92b..60fdb0a6 100644 --- a/app/modules/core/utils/page_context.py +++ b/app/modules/core/utils/page_context.py @@ -290,18 +290,61 @@ def get_store_context( **extra_context: Additional variables for template Returns: - Context dict for store pages + Context dict for store pages (includes user_permissions list) """ + # Resolve user permissions for the current store + user_permissions = _resolve_store_user_permissions(db, current_user) + return get_context_for_frontend( FrontendType.STORE, request, db, user=current_user, store_code=store_code, + user_permissions=user_permissions, **extra_context, ) +def _resolve_store_user_permissions(db: Session, current_user: Any) -> list[str]: + """ + Resolve the permission list for a store user. + + Returns all permission IDs for the current user in their current store. + Owners get all permissions. Members get permissions from their role. + """ + from app.modules.tenancy.models import User + + store_id = getattr(current_user, "token_store_id", None) + if not store_id: + return [] + + user_id = getattr(current_user, "user_id", None) or getattr( + current_user, "id", None + ) + if not user_id: + return [] + + user_model = db.query(User).filter(User.id == user_id).first() + if not user_model: + return [] + + # Owners get all permissions + if user_model.is_owner_of(store_id): + from app.modules.tenancy.services.permission_discovery_service import ( + permission_discovery_service, + ) + + return list(permission_discovery_service.get_all_permission_ids()) + + # Members get permissions from their store role + for membership in user_model.store_memberships: + if membership.store_id == store_id and membership.is_active: + return membership.get_all_permissions() + + return [] + + def get_storefront_context( request: Request, db: Session | None = None, diff --git a/app/modules/customers/definition.py b/app/modules/customers/definition.py index 07330311..032ce8cf 100644 --- a/app/modules/customers/definition.py +++ b/app/modules/customers/definition.py @@ -128,6 +128,7 @@ customers_module = ModuleDefinition( icon="user-group", route="/store/{store_code}/customers", order=10, + requires_permission="customers.view", ), ], ), diff --git a/app/modules/customers/routes/pages/store.py b/app/modules/customers/routes/pages/store.py index e5776738..e1ab6e15 100644 --- a/app/modules/customers/routes/pages/store.py +++ b/app/modules/customers/routes/pages/store.py @@ -11,9 +11,9 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import ( - get_current_store_from_cookie_or_header, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.utils.page_context import get_store_context from app.modules.tenancy.models import User @@ -33,7 +33,7 @@ router = APIRouter() async def store_customers_page( request: Request, store_code: str = Depends(get_resolved_store_code), - current_user: User = Depends(get_current_store_from_cookie_or_header), + current_user: User = Depends(require_store_page_permission("customers.view")), db: Session = Depends(get_db), ): """ diff --git a/app/modules/inventory/definition.py b/app/modules/inventory/definition.py index 994627cf..7279d997 100644 --- a/app/modules/inventory/definition.py +++ b/app/modules/inventory/definition.py @@ -136,6 +136,7 @@ inventory_module = ModuleDefinition( icon="clipboard-list", route="/store/{store_code}/inventory", order=20, + requires_permission="stock.view", ), ], ), diff --git a/app/modules/inventory/routes/pages/store.py b/app/modules/inventory/routes/pages/store.py index 1efbb791..4d38312f 100644 --- a/app/modules/inventory/routes/pages/store.py +++ b/app/modules/inventory/routes/pages/store.py @@ -11,9 +11,9 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import ( - get_current_store_from_cookie_or_header, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.utils.page_context import get_store_context from app.modules.tenancy.models import User @@ -33,7 +33,7 @@ router = APIRouter() async def store_inventory_page( request: Request, store_code: str = Depends(get_resolved_store_code), - current_user: User = Depends(get_current_store_from_cookie_or_header), + current_user: User = Depends(require_store_page_permission("stock.view")), db: Session = Depends(get_db), ): """ diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index d1ef845f..54eaf86d 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -168,6 +168,7 @@ loyalty_module = ModuleDefinition( icon="gift", route="/store/{store_code}/loyalty/terminal", order=10, + requires_permission="loyalty.view_programs", ), MenuItemDefinition( id="cards", @@ -175,6 +176,7 @@ loyalty_module = ModuleDefinition( icon="identification", route="/store/{store_code}/loyalty/cards", order=20, + requires_permission="loyalty.view_programs", ), MenuItemDefinition( id="stats", @@ -182,6 +184,7 @@ loyalty_module = ModuleDefinition( icon="chart-bar", route="/store/{store_code}/loyalty/stats", order=30, + requires_permission="loyalty.view_programs", ), ], ), diff --git a/app/modules/messaging/definition.py b/app/modules/messaging/definition.py index a3716f48..76cd15d9 100644 --- a/app/modules/messaging/definition.py +++ b/app/modules/messaging/definition.py @@ -147,6 +147,7 @@ messaging_module = ModuleDefinition( icon="chat-bubble-left-right", route="/store/{store_code}/messages", order=20, + requires_permission="messaging.view_messages", ), MenuItemDefinition( id="notifications", @@ -154,6 +155,7 @@ messaging_module = ModuleDefinition( icon="bell", route="/store/{store_code}/notifications", order=30, + requires_permission="messaging.view_messages", ), ], ), @@ -169,6 +171,7 @@ messaging_module = ModuleDefinition( icon="mail", route="/store/{store_code}/email-templates", order=40, + requires_permission="messaging.manage_templates", ), ], ), diff --git a/app/modules/orders/definition.py b/app/modules/orders/definition.py index 4d9f8afa..aa570167 100644 --- a/app/modules/orders/definition.py +++ b/app/modules/orders/definition.py @@ -133,6 +133,7 @@ orders_module = ModuleDefinition( route="/store/{store_code}/orders", order=10, is_mandatory=True, + requires_permission="orders.view", ), ], ), diff --git a/app/modules/orders/routes/pages/store.py b/app/modules/orders/routes/pages/store.py index 76862cc4..d29865eb 100644 --- a/app/modules/orders/routes/pages/store.py +++ b/app/modules/orders/routes/pages/store.py @@ -12,9 +12,9 @@ from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.api.deps import ( - get_current_store_from_cookie_or_header, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.utils.page_context import get_store_context from app.modules.tenancy.models import User @@ -34,7 +34,7 @@ router = APIRouter() async def store_orders_page( request: Request, store_code: str = Depends(get_resolved_store_code), - current_user: User = Depends(get_current_store_from_cookie_or_header), + current_user: User = Depends(require_store_page_permission("orders.view")), db: Session = Depends(get_db), ): """ @@ -56,7 +56,7 @@ async def store_order_detail_page( request: Request, store_code: str = Depends(get_resolved_store_code), order_id: int = Path(..., description="Order ID"), - current_user: User = Depends(get_current_store_from_cookie_or_header), + current_user: User = Depends(require_store_page_permission("orders.view")), db: Session = Depends(get_db), ): """ diff --git a/app/modules/tenancy/routes/api/store_team.py b/app/modules/tenancy/routes/api/store_team.py index 60138545..336c35de 100644 --- a/app/modules/tenancy/routes/api/store_team.py +++ b/app/modules/tenancy/routes/api/store_team.py @@ -28,7 +28,10 @@ from app.modules.tenancy.schemas.team import ( InvitationAccept, InvitationAcceptResponse, InvitationResponse, + RoleCreate, RoleListResponse, + RoleResponse, + RoleUpdate, TeamMemberInvite, TeamMemberListResponse, TeamMemberResponse, @@ -392,6 +395,86 @@ def list_roles( return RoleListResponse(roles=roles, total=len(roles)) +@store_team_router.post("/roles", response_model=RoleResponse, status_code=201) +def create_role( + request: Request, + role_data: RoleCreate, + db: Session = Depends(get_db), + current_user: UserContext = Depends(require_store_owner), +): + """ + Create a custom role for the store. + + **Required:** Store owner only. + + Preset role names (manager, staff, support, viewer, marketing) cannot be used. + """ + store = request.state.store + + 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_user.id, + ) + db.commit() + return role + + +@store_team_router.put("/roles/{role_id}", response_model=RoleResponse) +def update_role( + request: Request, + role_id: int, + role_data: RoleUpdate, + db: Session = Depends(get_db), + current_user: UserContext = Depends(require_store_owner), +): + """ + Update a role's name and/or permissions. + + **Required:** Store owner only. + """ + store = request.state.store + + 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_user.id, + ) + db.commit() + return role + + +@store_team_router.delete("/roles/{role_id}", status_code=204) +def delete_role( + request: Request, + role_id: int, + db: Session = Depends(get_db), + current_user: UserContext = Depends(require_store_owner), +): + """ + Delete a custom role. + + **Required:** Store owner only. + + Preset roles cannot be deleted. + Roles with assigned team members cannot be deleted. + """ + store = request.state.store + + store_team_service.delete_role( + db=db, + store_id=store.id, + role_id=role_id, + actor_user_id=current_user.id, + ) + db.commit() + + # ============================================================================ # Permission Routes # ============================================================================ diff --git a/app/modules/tenancy/routes/pages/store.py b/app/modules/tenancy/routes/pages/store.py index bbc04eaf..991df930 100644 --- a/app/modules/tenancy/routes/pages/store.py +++ b/app/modules/tenancy/routes/pages/store.py @@ -19,6 +19,7 @@ from app.api.deps import ( get_current_store_optional, get_db, get_resolved_store_code, + require_store_page_permission, ) from app.modules.core.utils.page_context import get_store_context from app.modules.tenancy.models import User @@ -90,7 +91,7 @@ async def store_login_page( async def store_team_page( request: Request, store_code: str = Depends(get_resolved_store_code), - current_user: User = Depends(get_current_store_from_cookie_or_header), + current_user: User = Depends(require_store_page_permission("team.view")), db: Session = Depends(get_db), ): """ @@ -103,6 +104,26 @@ async def store_team_page( ) +@router.get( + "/team/roles", response_class=HTMLResponse, include_in_schema=False +) +async def store_roles_page( + request: Request, + store_code: str = Depends(get_resolved_store_code), + current_user: User = Depends(require_store_page_permission("team.view")), + db: Session = Depends(get_db), +): + """ + Render role editor page. + Store owners can create, edit, and delete custom roles + with a permission matrix UI. + """ + return templates.TemplateResponse( + "tenancy/store/roles.html", + get_store_context(request, db, current_user, store_code), + ) + + @router.get( "/profile", response_class=HTMLResponse, include_in_schema=False ) diff --git a/app/modules/tenancy/services/store_team_service.py b/app/modules/tenancy/services/store_team_service.py index 3e4ff120..26c1978a 100644 --- a/app/modules/tenancy/services/store_team_service.py +++ b/app/modules/tenancy/services/store_team_service.py @@ -26,9 +26,11 @@ def get_preset_permissions(preset_name: str) -> set[str]: """Get permissions for a preset role.""" return permission_discovery_service.get_preset_permissions(preset_name) from app.modules.billing.exceptions import TierLimitExceededException +from app.modules.core.services.audit_aggregator import audit_aggregator from app.modules.tenancy.exceptions import ( CannotRemoveOwnerException, InvalidInvitationTokenException, + InvalidRoleException, TeamInvitationAlreadyAcceptedException, TeamMemberAlreadyExistsException, UserNotFoundException, @@ -174,6 +176,20 @@ class StoreTeamService: # TODO: Send invitation email # self._send_invitation_email(email, store, invitation_token) + audit_aggregator.log( + db=db, + admin_user_id=inviter.id, + action="member.invite", + target_type="store_user", + target_id=str(store_user.id), + details={ + "email": email, + "role": role_name, + "store_id": store.id, + "store_code": store.store_code, + }, + ) + return { "invitation_token": invitation_token, "email": email, @@ -274,6 +290,7 @@ class StoreTeamService: db: Session, store: Store, user_id: int, + actor_user_id: int | None = None, ) -> bool: """ Remove a team member from a store. @@ -309,6 +326,21 @@ class StoreTeamService: store_user.is_active = False logger.info(f"Removed user {user_id} from store {store.store_code}") + + if actor_user_id is not None: + audit_aggregator.log( + db=db, + admin_user_id=actor_user_id, + action="member.remove", + target_type="store_user", + target_id=str(store_user.id), + details={ + "user_id": user_id, + "store_id": store.id, + "store_code": store.store_code, + }, + ) + return True except (UserNotFoundException, CannotRemoveOwnerException): @@ -324,6 +356,7 @@ class StoreTeamService: user_id: int, new_role_name: str, custom_permissions: list[str] | None = None, + actor_user_id: int | None = None, ) -> StoreUser: """ Update a team member's role. @@ -363,14 +396,31 @@ class StoreTeamService: custom_permissions=custom_permissions, ) + old_role_name = store_user.role.name if store_user.role else "none" store_user.role_id = new_role.id db.flush() + db.refresh(store_user) logger.info( f"Updated role for user {user_id} in store {store.store_code} " f"to {new_role_name}" ) + if actor_user_id is not None: + audit_aggregator.log( + db=db, + admin_user_id=actor_user_id, + action="member.role_change", + target_type="store_user", + target_id=str(store_user.id), + details={ + "user_id": user_id, + "store_id": store.id, + "old_role": old_role_name, + "new_role": new_role_name, + }, + ) + return store_user except (UserNotFoundException, CannotRemoveOwnerException): @@ -470,6 +520,210 @@ class StoreTeamService: for role in roles ] + # ======================================================================== + # Role CRUD + # ======================================================================== + + PRESET_ROLE_NAMES = {"manager", "staff", "support", "viewer", "marketing"} + + def create_custom_role( + self, + db: Session, + store_id: int, + name: str, + permissions: list[str], + actor_user_id: int | None = None, + ) -> Role: + """ + Create a custom role for a store. + + Args: + db: Database session + store_id: Store ID + name: Role name + permissions: List of permission IDs + actor_user_id: ID of user performing the action (for audit) + + Returns: + Created Role object + + Raises: + InvalidRoleException: If role name conflicts with a preset + """ + if name.lower() in self.PRESET_ROLE_NAMES: + raise InvalidRoleException(f"Cannot create role with preset name: {name}") + + # Check for duplicate name + existing = ( + db.query(Role) + .filter(Role.store_id == store_id, Role.name == name) + .first() + ) + if existing: + raise InvalidRoleException(f"Role '{name}' already exists for this store") + + # Validate permissions exist + valid_ids = permission_discovery_service.get_all_permission_ids() + invalid = set(permissions) - valid_ids + if invalid: + raise InvalidRoleException(f"Invalid permission IDs: {', '.join(sorted(invalid))}") + + role = Role( + store_id=store_id, + name=name, + permissions=permissions, + ) + db.add(role) + db.flush() + + audit_aggregator.log( + db=db, + admin_user_id=actor_user_id, + action="role.create", + target_type="role", + target_id=str(role.id), + details={"name": name, "permissions_count": len(permissions), "store_id": store_id}, + ) + + return role + + def update_role( + self, + db: Session, + store_id: int, + role_id: int, + name: str | None = None, + permissions: list[str] | None = None, + actor_user_id: int | None = None, + ) -> Role: + """ + Update a role's name and/or permissions. + + Args: + db: Database session + store_id: Store ID (for ownership check) + role_id: Role ID to update + name: New role name (optional) + permissions: New permission list (optional) + + Returns: + Updated Role object + + Raises: + InvalidRoleException: If role not found or name conflicts + """ + role = ( + db.query(Role) + .filter(Role.id == role_id, Role.store_id == store_id) + .first() + ) + if not role: + raise InvalidRoleException(f"Role {role_id} not found for this store") + + if name is not None: + if name.lower() in self.PRESET_ROLE_NAMES and role.name.lower() != name.lower(): + raise InvalidRoleException(f"Cannot rename to preset name: {name}") + # Check duplicate + duplicate = ( + db.query(Role) + .filter( + Role.store_id == store_id, + Role.name == name, + Role.id != role_id, + ) + .first() + ) + if duplicate: + raise InvalidRoleException(f"Role '{name}' already exists for this store") + role.name = name + + if permissions is not None: + valid_ids = permission_discovery_service.get_all_permission_ids() + invalid = set(permissions) - valid_ids + if invalid: + raise InvalidRoleException( + f"Invalid permission IDs: {', '.join(sorted(invalid))}" + ) + old_permissions = role.permissions or [] + role.permissions = permissions + + db.flush() + + details = {"role_name": role.name, "store_id": store_id} + if name is not None: + details["new_name"] = name + if permissions is not None: + added = set(permissions) - set(old_permissions) + removed = set(old_permissions) - set(permissions) + if added: + details["permissions_added"] = sorted(added) + if removed: + details["permissions_removed"] = sorted(removed) + + audit_aggregator.log( + db=db, + admin_user_id=actor_user_id, + action="role.update", + target_type="role", + target_id=str(role.id), + details=details, + ) + + return role + + def delete_role( + self, + db: Session, + store_id: int, + role_id: int, + actor_user_id: int | None = None, + ) -> None: + """ + Delete a custom role. Preset roles cannot be deleted. + + Args: + db: Database session + store_id: Store ID (for ownership check) + role_id: Role ID to delete + + Raises: + InvalidRoleException: If role not found or is a preset role + """ + role = ( + db.query(Role) + .filter(Role.id == role_id, Role.store_id == store_id) + .first() + ) + if not role: + raise InvalidRoleException(f"Role {role_id} not found for this store") + + if role.name.lower() in self.PRESET_ROLE_NAMES: + raise InvalidRoleException(f"Cannot delete preset role: {role.name}") + + # Check if any team members use this role + members_with_role = ( + db.query(StoreUser) + .filter(StoreUser.store_id == store_id, StoreUser.role_id == role_id) + .count() + ) + if members_with_role > 0: + raise InvalidRoleException( + f"Cannot delete role: {members_with_role} team member(s) still assigned" + ) + + role_name = role.name + db.delete(role) + db.flush() + + audit_aggregator.log( + db=db, + admin_user_id=actor_user_id, + action="role.delete", + target_type="role", + target_id=str(role_id), + details={"role_name": role_name, "store_id": store_id}, + ) + # Private helper methods def _generate_invitation_token(self) -> str: diff --git a/app/modules/tenancy/templates/tenancy/store/roles.html b/app/modules/tenancy/templates/tenancy/store/roles.html new file mode 100644 index 00000000..1a65dac6 --- /dev/null +++ b/app/modules/tenancy/templates/tenancy/store/roles.html @@ -0,0 +1,338 @@ +{# app/templates/store/roles.html #} +{% extends "store/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex, refresh_button %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/modals.html' import modal_simple %} + +{% block title %}Role Management{% endblock %} + +{% block alpine_data %}storeRoles(){% endblock %} + +{% block content %} + +{% call page_header_flex(title='Role Management', subtitle='Create and manage custom roles with granular permissions') %} +
+ permissions + + Preset + + + Custom + +
+No roles found. Create a custom role to get started.
+