From 47ae304ae6a96ef05fa303501d26d66b14ab71e4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 00:43:31 +0000 Subject: [PATCH] Implement Custom RBAC with Roles, Permissions, and Frontend Management --- back/app/inventory_routes.py | 107 ++++++--- back/app/main.py | 212 +++++++++++++----- back/app/models.py | 61 +++++ back/app/permissions.py | 64 ++++++ back/app/roles_routes.py | 162 +++++++++++++ back/app/security.py | 18 ++ back/app/seeds/roles.py | 128 +++++++++++ back/app/users_routes.py | 86 +++++++ .../20260119002916_add_rbac_tables.sql | 25 +++ front/src/app/auth/permission.guard.ts | 24 ++ front/src/app/services/api.service.ts | 46 ++++ front/src/app/settings/roles.component.ts | 181 +++++++++++++++ front/src/app/settings/settings.component.ts | 52 ++++- front/src/app/settings/users.component.ts | 172 ++++++++++++++ .../app/shared/has-permission.directive.ts | 45 ++++ front/src/app/shared/sidebar.component.ts | 21 +- 16 files changed, 1316 insertions(+), 88 deletions(-) create mode 100644 back/app/permissions.py create mode 100644 back/app/roles_routes.py create mode 100644 back/app/seeds/roles.py create mode 100644 back/app/users_routes.py create mode 100644 back/migrations/20260119002916_add_rbac_tables.sql create mode 100644 front/src/app/auth/permission.guard.ts create mode 100644 front/src/app/settings/roles.component.ts create mode 100644 front/src/app/settings/users.component.ts create mode 100644 front/src/app/shared/has-permission.directive.ts diff --git a/back/app/inventory_routes.py b/back/app/inventory_routes.py index 2f20dc58..52d9fdd7 100644 --- a/back/app/inventory_routes.py +++ b/back/app/inventory_routes.py @@ -19,7 +19,8 @@ from sqlmodel import Session, select from .db import get_session -from .security import get_current_user +from .security import get_current_user, PermissionChecker +from .permissions import Permissions from . import models from .inventory_models import ( InventoryBatch, @@ -66,7 +67,9 @@ @router.get("/items", response_model=list[InventoryItemResponse]) def list_inventory_items( - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), category: InventoryCategory | None = None, active_only: bool = True, @@ -122,7 +125,9 @@ def list_inventory_items( @router.post("/items", response_model=InventoryItemResponse) def create_inventory_item( item_create: InventoryItemCreate, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Create a new inventory item""" @@ -167,7 +172,9 @@ def create_inventory_item( @router.get("/items/{item_id}", response_model=InventoryItemResponse) def get_inventory_item( item_id: int, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), ): """Get a single inventory item with details""" @@ -199,7 +206,9 @@ def get_inventory_item( def update_inventory_item( item_id: int, item_update: InventoryItemUpdate, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Update an inventory item""" @@ -253,7 +262,9 @@ def update_inventory_item( @router.delete("/items/{item_id}") def delete_inventory_item( item_id: int, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Soft delete an inventory item""" @@ -275,7 +286,9 @@ def delete_inventory_item( def adjust_inventory_stock( item_id: int, adjustment: StockAdjustment, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Manual stock adjustment (add, subtract, or waste)""" @@ -321,7 +334,9 @@ def adjust_inventory_stock( @router.get("/suppliers", response_model=list[Supplier]) def list_suppliers( - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), active_only: bool = True, ): @@ -342,7 +357,9 @@ def list_suppliers( @router.post("/suppliers", response_model=Supplier) def create_supplier( supplier_create: SupplierCreate, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Create a new supplier""" @@ -359,7 +376,9 @@ def create_supplier( @router.get("/suppliers/{supplier_id}", response_model=Supplier) def get_supplier( supplier_id: int, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), ): """Get a single supplier""" @@ -375,7 +394,9 @@ def get_supplier( def update_supplier( supplier_id: int, supplier_update: SupplierUpdate, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Update a supplier""" @@ -398,7 +419,9 @@ def update_supplier( @router.delete("/suppliers/{supplier_id}") def delete_supplier( supplier_id: int, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Soft delete a supplier""" @@ -420,7 +443,9 @@ def delete_supplier( @router.get("/purchase-orders") def list_purchase_orders( - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), status: PurchaseOrderStatus | None = None, supplier_id: int | None = None, @@ -470,7 +495,9 @@ def list_purchase_orders( @router.post("/purchase-orders") def create_purchase_order( po_create: PurchaseOrderCreate, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Create a new purchase order""" @@ -536,7 +563,9 @@ def create_purchase_order( @router.get("/purchase-orders/{po_id}") def get_purchase_order( po_id: int, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), ): """Get a purchase order with full details""" @@ -589,7 +618,9 @@ def get_purchase_order( def update_purchase_order( po_id: int, po_update: PurchaseOrderUpdate, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Update a purchase order (only while in draft status)""" @@ -620,7 +651,9 @@ def update_purchase_order( def update_purchase_order_status( po_id: int, new_status: PurchaseOrderStatus, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Change purchase order status""" @@ -657,7 +690,9 @@ def update_purchase_order_status( def receive_purchase_order( po_id: int, receive_input: ReceiveGoodsInput, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Receive goods against a purchase order (GRN)""" @@ -703,7 +738,9 @@ def receive_purchase_order( @router.delete("/purchase-orders/{po_id}") def cancel_purchase_order( po_id: int, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Cancel a purchase order (only if not yet received)""" @@ -729,7 +766,9 @@ def cancel_purchase_order( @router.get("/purchase-orders/{po_id}/pdf") def get_purchase_order_pdf( po_id: int, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), ): """Generate a professional PDF for a purchase order""" @@ -815,7 +854,9 @@ def get_purchase_order_pdf( @router.get("/recipes/product/{product_id}") def get_product_recipe( product_id: int, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), ): """Get recipe (BOM) for a product""" @@ -858,7 +899,9 @@ def get_product_recipe( def update_product_recipe( product_id: int, recipe_update: ProductRecipeUpdate, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ): """Replace entire recipe for a product""" @@ -906,7 +949,9 @@ def update_product_recipe( @router.get("/recipes/product/{product_id}/cost", response_model=ProductCostResponse) def get_product_cost( product_id: int, - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), ): """Calculate theoretical cost for a product based on its recipe""" @@ -930,7 +975,9 @@ def get_product_cost( @router.get("/stock-levels", response_model=list[StockLevelResponse]) def get_stock_levels( - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), category: InventoryCategory | None = None, ): @@ -970,7 +1017,9 @@ def get_stock_levels( @router.get("/low-stock") def get_low_stock_alerts( - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), ): """Get items at or below reorder level""" @@ -994,7 +1043,9 @@ def get_low_stock_alerts( @router.get("/valuation", response_model=InventoryValuationResponse) def get_inventory_valuation( - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), ): """Get FIFO inventory valuation report""" @@ -1009,7 +1060,9 @@ def get_inventory_valuation( @router.get("/transactions") def get_inventory_transactions( - current_user: Annotated[models.User, Depends(get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), item_id: int | None = None, transaction_type: TransactionType | None = None, diff --git a/back/app/main.py b/back/app/main.py index 5c7f26b2..f969f5d0 100644 --- a/back/app/main.py +++ b/back/app/main.py @@ -18,13 +18,18 @@ from sqlmodel import Session, select from . import models, security -from .db import check_db_connection, create_db_and_tables, get_session +from .db import check_db_connection, create_db_and_tables, get_session, engine from .settings import settings from .inventory_routes import router as inventory_router from .inventory_service import deduct_inventory_for_order from . import inventory_models from .translation_service import TranslationService from .messages import get_message +from .permissions import PermissionService, Permissions +from .seeds.roles import seed_roles_for_tenant, seed_all_tenants +from .roles_routes import router as roles_router +from .users_routes import router as users_router +from .security import PermissionChecker # Configure logging logging.basicConfig( @@ -175,6 +180,8 @@ def _get_requested_language( # Register Inventory API router app.include_router(inventory_router, prefix="/inventory", tags=["Inventory"]) +app.include_router(roles_router, tags=["Roles"]) +app.include_router(users_router, tags=["Users"]) # ============ IMAGE OPTIMIZATION ============ @@ -344,6 +351,13 @@ def on_startup() -> None: # Log but don't fail startup - migrations can be run manually logger.warning(f"Migration check failed: {e}", exc_info=True) + # Seed roles + try: + with Session(engine) as session: + seed_all_tenants(session) + except Exception as e: + logger.warning(f"Role seeding failed: {e}", exc_info=True) + @app.get("/health") def health() -> dict: @@ -397,12 +411,17 @@ def register( session.commit() session.refresh(tenant) + # Seed roles for new tenant + roles = seed_roles_for_tenant(session, tenant.id) + admin_role = roles.get("Admin") + hashed_password = security.get_password_hash(password) user = models.User( email=email, hashed_password=hashed_password, full_name=full_name, tenant_id=tenant.id, + role_id=admin_role.id if admin_role else None, ) session.add(user) session.commit() @@ -455,11 +474,22 @@ def logout(): return response -@app.get("/users/me") +@app.get("/users/me", response_model=models.UserReadWithPermissions) def read_users_me( current_user: Annotated[models.User, Depends(security.get_current_user)], -) -> models.User: - return current_user + session: Session = Depends(get_session), +) -> models.UserReadWithPermissions: + permissions = PermissionService.get_user_permissions(session, current_user) + + # Get role name + role_name = current_user.role.name if current_user.role else None + + # Create response + user_dict = current_user.model_dump() + user_dict["permissions"] = list(permissions) + user_dict["role_name"] = role_name + + return models.UserReadWithPermissions(**user_dict) # ============ TRANSLATIONS ============ @@ -469,7 +499,9 @@ def read_users_me( def get_translations_for_entity( entity_type: str, entity_id: int, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.SETTINGS_READ)) + ], session: Session = Depends(get_session), ) -> dict: """Get all translations for a specific entity.""" @@ -517,7 +549,9 @@ def update_translations_for_entity( entity_type: str, entity_id: int, translations: Dict[str, Dict[str, str]], # {field: {lang: text}} - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.SETTINGS_MANAGE)) + ], session: Session = Depends(get_session), ) -> dict: """Update translations for a specific entity.""" @@ -577,7 +611,9 @@ def update_translations_for_entity( @app.get("/tenant/settings") def get_tenant_settings( - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.SETTINGS_READ)) + ], session: Session = Depends(get_session), ) -> dict: """Get tenant/business profile settings.""" @@ -614,7 +650,9 @@ def get_tenant_settings( @app.put("/tenant/settings") def update_tenant_settings( tenant_update: models.TenantUpdate, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.SETTINGS_MANAGE)) + ], session: Session = Depends(get_session), ) -> dict: """Update tenant/business profile settings.""" @@ -744,7 +782,9 @@ def update_tenant_settings( @app.post("/tenant/logo") async def upload_tenant_logo( file: Annotated[UploadFile, File()], - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.SETTINGS_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.Tenant: """Upload a logo for the tenant/business.""" @@ -813,7 +853,9 @@ async def upload_tenant_logo( @app.get("/products") def list_products( - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_READ)) + ], session: Session = Depends(get_session), ) -> list[models.Product]: """List all products for the tenant. @@ -917,7 +959,9 @@ def list_products( @app.post("/products") def create_product( product: models.Product, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.Product: product.tenant_id = current_user.tenant_id @@ -931,7 +975,9 @@ def create_product( def update_product( product_id: int, product_update: models.ProductUpdate, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.Product: product = session.exec( @@ -964,7 +1010,9 @@ def update_product( @app.delete("/products/{product_id}") def delete_product( product_id: int, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_MANAGE)) + ], session: Session = Depends(get_session), ) -> dict: product = session.exec( @@ -986,7 +1034,9 @@ def delete_product( async def upload_product_image( product_id: int, file: Annotated[UploadFile, File()], - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.Product: """Upload an image for a product. Validates file type and size.""" @@ -1058,7 +1108,9 @@ async def upload_product_image( @app.get("/providers") def list_providers( - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), active_only: bool = True, ) -> list[models.Provider]: @@ -1072,7 +1124,9 @@ def list_providers( @app.get("/providers/{provider_id}") def get_provider( provider_id: int, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), ) -> models.Provider: """Get a specific provider.""" @@ -1087,7 +1141,9 @@ def get_provider( @app.post("/providers") def create_provider( provider: models.Provider, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.Provider: """Create a new provider (admin function).""" @@ -1102,7 +1158,9 @@ def create_provider( @app.get("/catalog") async def list_catalog( - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_READ)) + ], session: Session = Depends(get_session), category: str | None = None, subcategory: str | None = None, @@ -1236,7 +1294,9 @@ async def list_catalog( @app.get("/catalog/categories") async def get_catalog_categories( - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_READ)) + ], session: Session = Depends(get_session), ) -> dict: """Get all categories and subcategories from catalog.""" @@ -1256,7 +1316,9 @@ async def get_catalog_categories( @app.get("/catalog/{catalog_id}") async def get_catalog_item( catalog_id: int, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_READ)) + ], session: Session = Depends(get_session), ) -> dict: """Get a specific catalog item with price comparison.""" @@ -1367,7 +1429,9 @@ async def get_catalog_item( @app.get("/providers/{provider_id}/products") def list_provider_products( provider_id: int, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.INVENTORY_READ)) + ], session: Session = Depends(get_session), ) -> list[models.ProviderProduct]: """List all products from a specific provider.""" @@ -1389,7 +1453,9 @@ def list_provider_products( @app.get("/tenant-products") def list_tenant_products( - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_READ)) + ], session: Session = Depends(get_session), active_only: bool = True, ) -> list[dict]: @@ -1454,7 +1520,9 @@ def list_tenant_products( @app.post("/tenant-products") def create_tenant_product( product_data: models.TenantProductCreate, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.TenantProduct: """Add a product from catalog to tenant's menu. @@ -1635,7 +1703,9 @@ def create_tenant_product( def update_tenant_product( tenant_product_id: int, product_update: models.TenantProductUpdate, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.TenantProduct: """Update a tenant product.""" @@ -1665,7 +1735,9 @@ def update_tenant_product( @app.delete("/tenant-products/{tenant_product_id}") def delete_tenant_product( tenant_product_id: int, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.PRODUCTS_MANAGE)) + ], session: Session = Depends(get_session), ) -> dict: """Delete a tenant product.""" @@ -1689,7 +1761,9 @@ def delete_tenant_product( @app.get("/floors") def list_floors( - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.TABLES_READ)) + ], session: Session = Depends(get_session), ) -> list[models.Floor]: """List all floors for this tenant.""" @@ -1703,7 +1777,9 @@ def list_floors( @app.post("/floors") def create_floor( floor_data: models.FloorCreate, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.TABLES_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.Floor: """Create a new floor/zone.""" @@ -1730,7 +1806,9 @@ def create_floor( def update_floor( floor_id: int, floor_update: models.FloorUpdate, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.TABLES_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.Floor: """Update a floor.""" @@ -1758,7 +1836,9 @@ def update_floor( @app.delete("/floors/{floor_id}") def delete_floor( floor_id: int, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.TABLES_MANAGE)) + ], session: Session = Depends(get_session), ) -> dict: """Delete a floor. Tables on this floor will have floor_id set to null.""" @@ -1782,7 +1862,9 @@ def delete_floor( @app.get("/tables") def list_tables( - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.TABLES_READ)) + ], session: Session = Depends(get_session), ) -> list[models.Table]: return session.exec( @@ -1792,7 +1874,9 @@ def list_tables( @app.get("/tables/with-status") def list_tables_with_status( - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.TABLES_READ)) + ], session: Session = Depends(get_session), ) -> list[dict]: """List tables with computed status based on active orders.""" @@ -1836,7 +1920,9 @@ def list_tables_with_status( @app.post("/tables") def create_table( table_data: models.TableCreate, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.TABLES_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.Table: table = models.Table( @@ -1854,7 +1940,9 @@ def create_table( def update_table( table_id: int, table_update: models.TableUpdate, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.TABLES_MANAGE)) + ], session: Session = Depends(get_session), ) -> models.Table: """Update table properties including canvas layout.""" @@ -1897,7 +1985,9 @@ def update_table( @app.delete("/tables/{table_id}") def delete_table( table_id: int, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.TABLES_MANAGE)) + ], session: Session = Depends(get_session), ) -> dict: table = session.exec( @@ -2712,9 +2802,13 @@ def compute_order_status_from_items(items: list[models.OrderItem]) -> models.Ord @app.get("/orders") def list_orders( - current_user: Annotated[models.User, Depends(security.get_current_user)], - include_removed: bool = Query(False, description="Include removed items in response"), - session: Session = Depends(get_session) + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.ORDERS_READ)) + ], + include_removed: bool = Query( + False, description="Include removed items in response" + ), + session: Session = Depends(get_session), ) -> list[dict]: orders = session.exec( select(models.Order) @@ -2791,7 +2885,9 @@ def list_orders( def update_order_status( order_id: int, status_update: models.OrderStatusUpdate, - current_user: Annotated[models.User, Depends(security.get_current_user)], + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.ORDERS_UPDATE)) + ], session: Session = Depends(get_session), ) -> dict: order = session.exec( @@ -2853,8 +2949,10 @@ def update_order_status( def mark_order_paid( order_id: int, payment_data: models.OrderMarkPaid, - current_user: Annotated[models.User, Depends(security.get_current_user)], - session: Session = Depends(get_session) + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.ORDERS_PAY)) + ], + session: Session = Depends(get_session), ) -> dict: """Mark order as paid manually (for cash/terminal payments).""" order = session.exec( @@ -2905,8 +3003,10 @@ def update_order_item_status( order_id: int, item_id: int, status_update: models.OrderItemStatusUpdate, - current_user: Annotated[models.User, Depends(security.get_current_user)], - session: Session = Depends(get_session) + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.ORDERS_UPDATE)) + ], + session: Session = Depends(get_session), ) -> dict: """Update individual order item status (restaurant staff).""" order = session.exec( @@ -2975,8 +3075,10 @@ def update_order_item_status( def reset_item_status( order_id: int, item_id: int, - current_user: Annotated[models.User, Depends(security.get_current_user)], - session: Session = Depends(get_session) + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.ORDERS_UPDATE)) + ], + session: Session = Depends(get_session), ) -> dict: """Reset item status from preparing to pending (restaurant staff only).""" order = session.exec( @@ -3049,8 +3151,10 @@ def cancel_order_item_staff( order_id: int, item_id: int, cancel_data: models.OrderItemCancel, - current_user: Annotated[models.User, Depends(security.get_current_user)], - session: Session = Depends(get_session) + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.ORDERS_CANCEL)) + ], + session: Session = Depends(get_session), ) -> dict: """Cancel order item (restaurant staff) - requires reason if item is ready.""" order = session.exec( @@ -3130,8 +3234,10 @@ def update_order_item_staff( order_id: int, item_id: int, item_update: models.OrderItemStaffUpdate, - current_user: Annotated[models.User, Depends(security.get_current_user)], - session: Session = Depends(get_session) + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.ORDERS_UPDATE)) + ], + session: Session = Depends(get_session), ) -> dict: """Update order item (restaurant staff) - can modify any item except delivered.""" order = session.exec( @@ -3214,9 +3320,13 @@ def update_order_item_staff( def remove_order_item_staff( order_id: int, item_id: int, - current_user: Annotated[models.User, Depends(security.get_current_user)], - reason: str | None = Query(None, description="Required reason when removing ready items"), - session: Session = Depends(get_session) + current_user: Annotated[ + models.User, Depends(PermissionChecker(Permissions.ORDERS_CANCEL)) + ], + reason: str | None = Query( + None, description="Required reason when removing ready items" + ), + session: Session = Depends(get_session), ) -> dict: """Remove order item (restaurant staff) - requires reason if item is ready.""" order = session.exec( diff --git a/back/app/models.py b/back/app/models.py index e16ba422..f7fefea8 100644 --- a/back/app/models.py +++ b/back/app/models.py @@ -68,6 +68,28 @@ class Tenant(SQLModel, table=True): # ) # Enable auto-deduction on orders users: list["User"] = Relationship(back_populates="tenant") + roles: list["Role"] = Relationship(back_populates="tenant") + + +class Role(SQLModel, table=True): + id: int | None = Field(default=None, primary_key=True) + tenant_id: int = Field(foreign_key="tenant.id", index=True) + name: str = Field(index=True) + description: str | None = None + is_default: bool = Field( + default=False + ) # If true, it's a system default role (e.g. Admin, Manager) + + tenant: Tenant = Relationship(back_populates="roles") + users: list["User"] = Relationship(back_populates="role") + permissions: list["RolePermission"] = Relationship(back_populates="role") + + +class RolePermission(SQLModel, table=True): + role_id: int = Field(foreign_key="role.id", primary_key=True) + permission: str = Field(primary_key=True, index=True) + + role: Role = Relationship(back_populates="permissions") class User(SQLModel, table=True): @@ -79,6 +101,9 @@ class User(SQLModel, table=True): tenant_id: int | None = Field(default=None, foreign_key="tenant.id") tenant: Tenant | None = Relationship(back_populates="users") + role_id: int | None = Field(default=None, foreign_key="role.id") + role: Role | None = Relationship(back_populates="users") + class TenantMixin(SQLModel): tenant_id: int = Field(foreign_key="tenant.id") @@ -291,6 +316,42 @@ class UserRegister(SQLModel): full_name: str | None = None +class UserReadWithPermissions(SQLModel): + id: int + email: str + full_name: str | None = None + tenant_id: int + role_id: int | None = None + role_name: str | None = None + permissions: list[str] = [] + + +class RoleBase(SQLModel): + name: str + description: str | None = None + + +class RoleCreate(RoleBase): + permissions: list[str] = [] + + +class RoleUpdate(RoleBase): + name: str | None = None + permissions: list[str] | None = None + + +class RoleResponse(RoleBase): + id: int + is_default: bool + permissions: list[str] + + +class UserUpdate(SQLModel): + full_name: str | None = None + role_id: int | None = None + password: str | None = None # Optional password reset + + class ProductUpdate(SQLModel): name: str | None = None price_cents: int | None = None diff --git a/back/app/permissions.py b/back/app/permissions.py new file mode 100644 index 00000000..382f05f5 --- /dev/null +++ b/back/app/permissions.py @@ -0,0 +1,64 @@ +from enum import Enum +from typing import List, Set + +from sqlmodel import Session, select + +from .models import Role, RolePermission, User + + +class Permissions(str, Enum): + # Orders + ORDERS_READ = "orders:read" + ORDERS_CREATE = "orders:create" + ORDERS_UPDATE = "orders:update" + ORDERS_CANCEL = "orders:cancel" + ORDERS_PAY = "orders:pay" + + # Products / Menu + PRODUCTS_READ = "products:read" + PRODUCTS_MANAGE = "products:manage" # Create, Update, Delete + + # Inventory + INVENTORY_READ = "inventory:read" + INVENTORY_MANAGE = "inventory:manage" + + # Settings (Business Profile, Stripe) + SETTINGS_READ = "settings:read" + SETTINGS_MANAGE = "settings:manage" + + # Users & Roles + USERS_READ = "users:read" + USERS_MANAGE = "users:manage" + ROLES_MANAGE = "roles:manage" + + # Floors & Tables + TABLES_READ = "tables:read" + TABLES_MANAGE = "tables:manage" + + +class PermissionService: + @staticmethod + def get_user_permissions(session: Session, user: User) -> Set[str]: + """Get all permissions for a user based on their role.""" + if not user.role_id: + return set() + + # Check if role is loaded, if not load it + if not user.role: + user.role = session.get(Role, user.role_id) + + if not user.role: + return set() + + # Get permissions + # If the role relations are not loaded, we might need to query + statement = select(RolePermission.permission).where(RolePermission.role_id == user.role_id) + permissions = session.exec(statement).all() + + return set(permissions) + + @staticmethod + def has_permission(session: Session, user: User, required_permission: str) -> bool: + """Check if user has specific permission.""" + perms = PermissionService.get_user_permissions(session, user) + return required_permission in perms diff --git a/back/app/roles_routes.py b/back/app/roles_routes.py new file mode 100644 index 00000000..48085c30 --- /dev/null +++ b/back/app/roles_routes.py @@ -0,0 +1,162 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from . import models, security +from .db import get_session +from .permissions import Permissions, PermissionService +from .security import PermissionChecker +from .models import Role, RolePermission, User + +router = APIRouter() + + +@router.get("/roles", response_model=list[models.RoleResponse]) +def list_roles( + current_user: Annotated[User, Depends(security.get_current_user)], + session: Session = Depends(get_session), +): + """List all roles for the tenant.""" + # Allow if user has ROLES_MANAGE or USERS_MANAGE + perms = PermissionService.get_user_permissions(session, current_user) + if not (Permissions.ROLES_MANAGE in perms or Permissions.USERS_MANAGE in perms): + raise HTTPException(status_code=403, detail="Not authorized") + + roles = session.exec( + select(Role).where(Role.tenant_id == current_user.tenant_id) + ).all() + + result = [] + for role in roles: + # Get permissions for response + role_perms = session.exec( + select(RolePermission.permission).where(RolePermission.role_id == role.id) + ).all() + result.append( + models.RoleResponse( + id=role.id, + name=role.name, + description=role.description, + is_default=role.is_default, + permissions=role_perms, + ) + ) + return result + + +@router.post("/roles", response_model=models.RoleResponse) +def create_role( + role_create: models.RoleCreate, + current_user: Annotated[User, Depends(PermissionChecker(Permissions.ROLES_MANAGE))], + session: Session = Depends(get_session), +): + """Create a new custom role.""" + role = Role( + tenant_id=current_user.tenant_id, + name=role_create.name, + description=role_create.description, + is_default=False, + ) + session.add(role) + session.commit() + session.refresh(role) + + # Add permissions + for perm in role_create.permissions: + rp = RolePermission(role_id=role.id, permission=perm) + session.add(rp) + session.commit() + + return models.RoleResponse( + id=role.id, + name=role.name, + description=role.description, + is_default=role.is_default, + permissions=role_create.permissions, + ) + + +@router.put("/roles/{role_id}", response_model=models.RoleResponse) +def update_role( + role_id: int, + role_update: models.RoleUpdate, + current_user: Annotated[User, Depends(PermissionChecker(Permissions.ROLES_MANAGE))], + session: Session = Depends(get_session), +): + """Update a custom role.""" + role = session.exec( + select(Role).where(Role.id == role_id, Role.tenant_id == current_user.tenant_id) + ).first() + + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + if role.is_default: + raise HTTPException(status_code=400, detail="Cannot edit default roles") + + if role_update.name is not None: + role.name = role_update.name + if role_update.description is not None: + role.description = role_update.description + + session.add(role) + + if role_update.permissions is not None: + # Clear existing + existing = session.exec( + select(RolePermission).where(RolePermission.role_id == role.id) + ).all() + for rp in existing: + session.delete(rp) + + # Add new + for perm in role_update.permissions: + rp = RolePermission(role_id=role.id, permission=perm) + session.add(rp) + + session.commit() + session.refresh(role) + + # Get permissions + role_perms = session.exec( + select(RolePermission.permission).where(RolePermission.role_id == role.id) + ).all() + + return models.RoleResponse( + id=role.id, + name=role.name, + description=role.description, + is_default=role.is_default, + permissions=role_perms, + ) + + +@router.delete("/roles/{role_id}") +def delete_role( + role_id: int, + current_user: Annotated[User, Depends(PermissionChecker(Permissions.ROLES_MANAGE))], + session: Session = Depends(get_session), +): + """Delete a custom role.""" + role = session.exec( + select(Role).where(Role.id == role_id, Role.tenant_id == current_user.tenant_id) + ).first() + + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + if role.is_default: + raise HTTPException(status_code=400, detail="Cannot delete default roles") + + session.delete(role) + session.commit() + return {"status": "deleted"} + + +@router.get("/permissions") +def list_permissions( + current_user: Annotated[User, Depends(security.get_current_user)], +): + """List all available permissions.""" + return [p.value for p in Permissions] diff --git a/back/app/security.py b/back/app/security.py index a4eda2ea..edcadc24 100644 --- a/back/app/security.py +++ b/back/app/security.py @@ -12,6 +12,7 @@ from .db import get_session from .models import User, Tenant from .settings import settings +from .permissions import PermissionService # Context variable to store the current tenant_id for the request _tenant_id_ctx = ContextVar("tenant_id", default=None) @@ -102,3 +103,20 @@ async def get_current_user( raise credentials_exception return user + + +class PermissionChecker: + def __init__(self, required_permission: str): + self.required_permission = required_permission + + def __call__( + self, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[Session, Depends(get_session)], + ) -> User: + if not PermissionService.has_permission(session, user, self.required_permission): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Operation not permitted. Required: {self.required_permission}", + ) + return user diff --git a/back/app/seeds/roles.py b/back/app/seeds/roles.py new file mode 100644 index 00000000..b3d2194b --- /dev/null +++ b/back/app/seeds/roles.py @@ -0,0 +1,128 @@ +from sqlmodel import Session, select + +from ..models import Role, RolePermission, Tenant, User +from ..permissions import Permissions + + +DEFAULT_ROLES = { + "Admin": { + "description": "Full access to all features", + "permissions": [p.value for p in Permissions], + "is_default": True, + }, + "Manager": { + "description": "Access to day-to-day operations and management", + "permissions": [ + Permissions.ORDERS_READ, + Permissions.ORDERS_CREATE, + Permissions.ORDERS_UPDATE, + Permissions.ORDERS_CANCEL, + Permissions.ORDERS_PAY, + Permissions.PRODUCTS_READ, + Permissions.PRODUCTS_MANAGE, + Permissions.INVENTORY_READ, + Permissions.INVENTORY_MANAGE, + Permissions.SETTINGS_READ, + Permissions.USERS_READ, + Permissions.USERS_MANAGE, + Permissions.TABLES_READ, + Permissions.TABLES_MANAGE, + ], + "is_default": True, + }, + "Kitchen": { + "description": "View orders and products", + "permissions": [ + Permissions.ORDERS_READ, + Permissions.ORDERS_UPDATE, + Permissions.PRODUCTS_READ, + ], + "is_default": True, + }, + "Waiter": { + "description": "Manage orders and tables", + "permissions": [ + Permissions.ORDERS_READ, + Permissions.ORDERS_CREATE, + Permissions.ORDERS_UPDATE, + Permissions.TABLES_READ, + Permissions.TABLES_MANAGE, + Permissions.PRODUCTS_READ, + ], + "is_default": True, + }, +} + + +def seed_roles_for_tenant(session: Session, tenant_id: int): + """Create default roles for a tenant if they don't exist.""" + + created_roles = {} + + for role_name, role_data in DEFAULT_ROLES.items(): + # Check if role exists + role = session.exec( + select(Role).where( + Role.tenant_id == tenant_id, + Role.name == role_name, + Role.is_default == True + ) + ).first() + + if not role: + role = Role( + tenant_id=tenant_id, + name=role_name, + description=role_data["description"], + is_default=True + ) + session.add(role) + session.commit() + session.refresh(role) + + # Add permissions + for perm in role_data["permissions"]: + rp = RolePermission(role_id=role.id, permission=perm) + session.add(rp) + session.commit() + + created_roles[role_name] = role + + return created_roles + + +def assign_admin_role_to_existing_users(session: Session, tenant_id: int): + """Assign Admin role to users who don't have a role.""" + # Get Admin role + admin_role = session.exec( + select(Role).where( + Role.tenant_id == tenant_id, + Role.name == "Admin", + Role.is_default == True + ) + ).first() + + if not admin_role: + return + + # Get users without role + users = session.exec( + select(User).where( + User.tenant_id == tenant_id, + User.role_id == None + ) + ).all() + + for user in users: + user.role_id = admin_role.id + session.add(user) + + session.commit() + + +def seed_all_tenants(session: Session): + """Seed roles for all existing tenants and fix user roles.""" + tenants = session.exec(select(Tenant)).all() + for tenant in tenants: + seed_roles_for_tenant(session, tenant.id) + assign_admin_role_to_existing_users(session, tenant.id) diff --git a/back/app/users_routes.py b/back/app/users_routes.py new file mode 100644 index 00000000..65814035 --- /dev/null +++ b/back/app/users_routes.py @@ -0,0 +1,86 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select + +from . import models, security +from .db import get_session +from .permissions import Permissions, PermissionService +from .security import PermissionChecker +from .models import User, Role + +router = APIRouter() + + +@router.get("/users", response_model=list[models.UserReadWithPermissions]) +def list_users( + current_user: Annotated[User, Depends(PermissionChecker(Permissions.USERS_READ))], + session: Session = Depends(get_session), +): + """List all users for the tenant.""" + users = session.exec( + select(User).where(User.tenant_id == current_user.tenant_id) + ).all() + + result = [] + for user in users: + # Get permissions and role name + perms = PermissionService.get_user_permissions(session, user) + role_name = user.role.name if user.role else None + + user_dict = user.model_dump() + user_dict["permissions"] = list(perms) + user_dict["role_name"] = role_name + result.append(models.UserReadWithPermissions(**user_dict)) + + return result + + +@router.put("/users/{user_id}", response_model=models.UserReadWithPermissions) +def update_user( + user_id: int, + user_update: models.UserUpdate, + current_user: Annotated[User, Depends(PermissionChecker(Permissions.USERS_MANAGE))], + session: Session = Depends(get_session), +): + """Update a user (including role assignment).""" + user = session.exec( + select(User).where(User.id == user_id, User.tenant_id == current_user.tenant_id) + ).first() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user_update.full_name is not None: + user.full_name = user_update.full_name + + if user_update.role_id is not None: + # Verify role exists and belongs to tenant + role = session.exec( + select(Role).where( + Role.id == user_update.role_id, Role.tenant_id == current_user.tenant_id + ) + ).first() + if not role: + raise HTTPException(status_code=400, detail="Invalid role") + user.role_id = user_update.role_id + + if user_update.password: + user.hashed_password = security.get_password_hash(user_update.password) + + session.add(user) + session.commit() + session.refresh(user) + + # Reload role for response + if user.role_id: + user.role = session.get(Role, user.role_id) + + perms = PermissionService.get_user_permissions(session, user) + role_name = user.role.name if user.role else None + + user_dict = user.model_dump() + user_dict["permissions"] = list(perms) + user_dict["role_name"] = role_name + + return models.UserReadWithPermissions(**user_dict) diff --git a/back/migrations/20260119002916_add_rbac_tables.sql b/back/migrations/20260119002916_add_rbac_tables.sql new file mode 100644 index 00000000..309ab74f --- /dev/null +++ b/back/migrations/20260119002916_add_rbac_tables.sql @@ -0,0 +1,25 @@ +-- Migration 20260119002916: add rbac tables +-- Description: add rbac tables +-- Date: 2026-01-19 00:29:16 + +CREATE TABLE IF NOT EXISTS role ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenant(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + is_default BOOLEAN DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS ix_role_tenant_id ON role(tenant_id); +CREATE INDEX IF NOT EXISTS ix_role_name ON role(name); + +CREATE TABLE IF NOT EXISTS rolepermission ( + role_id INTEGER NOT NULL REFERENCES role(id) ON DELETE CASCADE, + permission VARCHAR(255) NOT NULL, + PRIMARY KEY (role_id, permission) +); + +CREATE INDEX IF NOT EXISTS ix_rolepermission_permission ON rolepermission(permission); + +-- Add role_id to user table +ALTER TABLE "user" ADD COLUMN IF NOT EXISTS role_id INTEGER REFERENCES role(id) ON DELETE SET NULL; diff --git a/front/src/app/auth/permission.guard.ts b/front/src/app/auth/permission.guard.ts new file mode 100644 index 00000000..276c35e7 --- /dev/null +++ b/front/src/app/auth/permission.guard.ts @@ -0,0 +1,24 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { ApiService } from '../services/api.service'; +import { map } from 'rxjs'; + +export const permissionGuard = (requiredPermission: string): CanActivateFn => { + return () => { + const api = inject(ApiService); + const router = inject(Router); + + return api.user$.pipe( + map(user => { + if (!user) { + return router.createUrlTree(['/login']); + } + if (user.permissions?.includes(requiredPermission)) { + return true; + } + // Redirect to dashboard if authorized but no permission + return router.createUrlTree(['/']); + }) + ); + }; +}; diff --git a/front/src/app/services/api.service.ts b/front/src/app/services/api.service.ts index f0304d10..f71f416c 100644 --- a/front/src/app/services/api.service.ts +++ b/front/src/app/services/api.service.ts @@ -5,8 +5,13 @@ import { environment } from '../../environments/environment'; // Interfaces export interface User { + id: number; email: string; tenant_id: number; + full_name?: string | null; + role_id?: number | null; + role_name?: string | null; + permissions?: string[]; } export interface AuthResponse { @@ -275,6 +280,17 @@ export class ApiService { return this.userSubject.value; } + hasPermission(permission: string): boolean { + const user = this.getCurrentUser(); + return user?.permissions?.includes(permission) ?? false; + } + + hasAnyPermission(permissions: string[]): boolean { + const user = this.getCurrentUser(); + if (!user || !user.permissions) return false; + return permissions.some((p) => user.permissions!.includes(p)); + } + // Auth register(data: any): Observable { let params = new HttpParams(); @@ -680,4 +696,34 @@ export class ApiService { updateTranslations(entityType: string, entityId: number, translations: any): Observable<{ message: string; updated: string[] }> { return this.http.put<{ message: string; updated: string[] }>(`${this.apiUrl}/i18n/${entityType}/${entityId}`, translations); } + + // Roles + getRoles(): Observable { + return this.http.get(`${this.apiUrl}/roles`); + } + + createRole(role: { name: string; description?: string; permissions: string[] }): Observable { + return this.http.post(`${this.apiUrl}/roles`, role); + } + + updateRole(id: number, role: { name?: string; description?: string; permissions?: string[] }): Observable { + return this.http.put(`${this.apiUrl}/roles/${id}`, role); + } + + deleteRole(id: number): Observable { + return this.http.delete(`${this.apiUrl}/roles/${id}`); + } + + getPermissions(): Observable { + return this.http.get(`${this.apiUrl}/permissions`); + } + + // Users + getUsers(): Observable { + return this.http.get(`${this.apiUrl}/users`); + } + + updateUser(id: number, data: { full_name?: string; role_id?: number; password?: string }): Observable { + return this.http.put(`${this.apiUrl}/users/${id}`, data); + } } diff --git a/front/src/app/settings/roles.component.ts b/front/src/app/settings/roles.component.ts new file mode 100644 index 00000000..c7bc9938 --- /dev/null +++ b/front/src/app/settings/roles.component.ts @@ -0,0 +1,181 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ApiService } from '../services/api.service'; +import { TranslateModule } from '@ngx-translate/core'; + +@Component({ + selector: 'app-roles-settings', + standalone: true, + imports: [CommonModule, FormsModule, TranslateModule], + template: ` +
+
+

Roles & Permissions

+ +
+ +
+ @for (role of roles(); track role.id) { +
+
+ {{ role.name }} + @if (role.is_default) { + Default + } +
+

{{ role.description }}

+
+ + @if (!role.is_default) { + + } +
+
+ } +
+ + + @if (showModal()) { + + } +
+ `, + styles: [` + .roles-container { padding: 1rem; } + .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; } + .roles-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; } + .role-card { border: 1px solid #e2e8f0; border-radius: 0.5rem; padding: 1rem; background: white; } + .role-header { display: flex; justify-content: space-between; margin-bottom: 0.5rem; font-weight: 600; } + .role-desc { color: #64748b; font-size: 0.875rem; margin-bottom: 1rem; } + .role-actions { display: flex; gap: 0.5rem; } + .badge.default { background: #e2e8f0; color: #475569; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; } + + .modal-backdrop { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } + .modal { background: white; border-radius: 0.5rem; width: 100%; max-width: 600px; max-height: 90vh; display: flex; flex-direction: column; } + .modal-header { padding: 1rem; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; } + .modal-body { padding: 1rem; overflow-y: auto; } + .modal-footer { padding: 1rem; border-top: 1px solid #e2e8f0; display: flex; justify-content: flex-end; gap: 0.5rem; } + + .permissions-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-top: 0.5rem; } + .permission-item { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; cursor: pointer; } + + .btn { padding: 0.5rem 1rem; border-radius: 0.375rem; border: none; cursor: pointer; font-weight: 500; } + .btn-primary { background: #2563eb; color: white; } + .btn-secondary { background: #f1f5f9; color: #0f172a; } + .btn-danger { background: #ef4444; color: white; } + .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; } + .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; } + + .form-group { margin-bottom: 1rem; } + .form-group label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; } + .form-group input { width: 100%; padding: 0.5rem; border: 1px solid #cbd5e1; border-radius: 0.375rem; } + `] +}) +export class RolesComponent implements OnInit { + private api = inject(ApiService); + + roles = signal([]); + availablePermissions = signal([]); + showModal = signal(false); + editingRole = signal(null); + + formData = { + name: '', + description: '', + permissions: [] as string[] + }; + + ngOnInit() { + this.loadRoles(); + this.api.getPermissions().subscribe(perms => this.availablePermissions.set(perms)); + } + + loadRoles() { + this.api.getRoles().subscribe(roles => this.roles.set(roles)); + } + + openCreateModal() { + this.editingRole.set(null); + this.formData = { name: '', description: '', permissions: [] }; + this.showModal.set(true); + } + + editRole(role: any) { + this.editingRole.set(role); + this.formData = { + name: role.name, + description: role.description, + permissions: [...role.permissions] + }; + this.showModal.set(true); + } + + closeModal() { + this.showModal.set(false); + } + + togglePermission(perm: string) { + const index = this.formData.permissions.indexOf(perm); + if (index === -1) { + this.formData.permissions.push(perm); + } else { + this.formData.permissions.splice(index, 1); + } + } + + saveRole() { + if (this.editingRole()) { + this.api.updateRole(this.editingRole().id, this.formData).subscribe(() => { + this.loadRoles(); + this.closeModal(); + }); + } else { + this.api.createRole(this.formData).subscribe(() => { + this.loadRoles(); + this.closeModal(); + }); + } + } + + deleteRole(role: any) { + if (confirm('Are you sure you want to delete this role?')) { + this.api.deleteRole(role.id).subscribe(() => this.loadRoles()); + } + } +} diff --git a/front/src/app/settings/settings.component.ts b/front/src/app/settings/settings.component.ts index 3f82bbe0..36a3ff78 100644 --- a/front/src/app/settings/settings.component.ts +++ b/front/src/app/settings/settings.component.ts @@ -6,11 +6,23 @@ import { ApiService, TenantSettings } from '../services/api.service'; import { SidebarComponent } from '../shared/sidebar.component'; import { TranslationsComponent } from '../translations/translations.component'; import { TranslateModule } from '@ngx-translate/core'; +import { RolesComponent } from './roles.component'; +import { UsersComponent } from './users.component'; +import { HasPermissionDirective } from '../shared/has-permission.directive'; @Component({ selector: 'app-settings', standalone: true, - imports: [CommonModule, FormsModule, SidebarComponent, TranslateModule, TranslationsComponent], + imports: [ + CommonModule, + FormsModule, + SidebarComponent, + TranslateModule, + TranslationsComponent, + RolesComponent, + UsersComponent, + HasPermissionDirective, + ], template: ` @@ -100,7 +139,14 @@ import { TranslateModule } from '@ngx-translate/core'; - } @else { + } + @else if (activeSection() === 'users') { + + } + @else if (activeSection() === 'roles') { + + } + @else {
@@ -1117,7 +1163,7 @@ export class SettingsComponent implements OnInit { private router = inject(Router); settings = signal(null); - activeSection = signal<'general' | 'contact' | 'hours' | 'payments' | 'translations'>('general'); + activeSection = signal<'general' | 'contact' | 'hours' | 'payments' | 'translations' | 'users' | 'roles'>('general'); loading = signal(false); saving = signal(false); error = signal(null); diff --git a/front/src/app/settings/users.component.ts b/front/src/app/settings/users.component.ts new file mode 100644 index 00000000..0335a01d --- /dev/null +++ b/front/src/app/settings/users.component.ts @@ -0,0 +1,172 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ApiService, User } from '../services/api.service'; +import { TranslateModule } from '@ngx-translate/core'; + +@Component({ + selector: 'app-users-settings', + standalone: true, + imports: [CommonModule, FormsModule, TranslateModule], + template: ` +
+
+

Users

+ +
+ +
+ + + + + + + + + + + @for (user of users(); track user.id) { + + + + + + + } + +
EmailFull NameRoleActions
{{ user.email }}{{ user.full_name || '-' }} + @if (user.role_name) { + {{ user.role_name }} + } @else { + No Role + } + + +
+
+ + + @if (showModal()) { + + } +
+ `, + styles: [` + .users-container { padding: 1rem; } + .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; } + + .users-table { width: 100%; border-collapse: collapse; } + .users-table th, .users-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #e2e8f0; } + .users-table th { font-weight: 600; color: #475569; } + + .badge.role { background: #dbeafe; color: #1e40af; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; font-weight: 500; } + .text-muted { color: #94a3b8; font-style: italic; } + + .modal-backdrop { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } + .modal { background: white; border-radius: 0.5rem; width: 100%; max-width: 500px; display: flex; flex-direction: column; } + .modal-header { padding: 1rem; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; } + .modal-body { padding: 1rem; } + .modal-footer { padding: 1rem; border-top: 1px solid #e2e8f0; display: flex; justify-content: flex-end; gap: 0.5rem; } + + .btn { padding: 0.5rem 1rem; border-radius: 0.375rem; border: none; cursor: pointer; font-weight: 500; } + .btn-primary { background: #2563eb; color: white; } + .btn-secondary { background: #f1f5f9; color: #0f172a; } + .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; } + .close-btn { background: none; border: none; font-size: 1.5rem; cursor: pointer; } + + .form-group { margin-bottom: 1rem; } + .form-group label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; } + .form-group input, .form-group select { width: 100%; padding: 0.5rem; border: 1px solid #cbd5e1; border-radius: 0.375rem; } + `] +}) +export class UsersComponent implements OnInit { + private api = inject(ApiService); + + users = signal([]); + roles = signal([]); + showModal = signal(false); + editingUser = signal(null); + + formData = { + full_name: '', + role_id: null as number | null, + password: '' + }; + + ngOnInit() { + this.loadUsers(); + this.loadRoles(); + } + + loadUsers() { + this.api.getUsers().subscribe(users => this.users.set(users)); + } + + loadRoles() { + this.api.getRoles().subscribe(roles => this.roles.set(roles)); + } + + editUser(user: User) { + this.editingUser.set(user); + this.formData = { + full_name: user.full_name || '', + role_id: user.role_id || null, + password: '' + }; + this.showModal.set(true); + } + + closeModal() { + this.showModal.set(false); + } + + saveUser() { + if (this.editingUser()) { + const updateData: any = { + full_name: this.formData.full_name, + role_id: this.formData.role_id + }; + if (this.formData.password) { + updateData.password = this.formData.password; + } + + this.api.updateUser(this.editingUser()!.id, updateData).subscribe(() => { + this.loadUsers(); + this.closeModal(); + }); + } + } +} diff --git a/front/src/app/shared/has-permission.directive.ts b/front/src/app/shared/has-permission.directive.ts new file mode 100644 index 00000000..4ee86e50 --- /dev/null +++ b/front/src/app/shared/has-permission.directive.ts @@ -0,0 +1,45 @@ +import { Directive, Input, TemplateRef, ViewContainerRef, inject, OnDestroy } from '@angular/core'; +import { ApiService, User } from '../services/api.service'; +import { Subscription } from 'rxjs'; + +@Directive({ + selector: '[appHasPermission]', + standalone: true +}) +export class HasPermissionDirective implements OnDestroy { + private api = inject(ApiService); + private templateRef = inject(TemplateRef); + private viewContainer = inject(ViewContainerRef); + + private permissions: string[] = []; + private subscription: Subscription; + + constructor() { + this.subscription = this.api.user$.subscribe((user) => { + this.updateView(user); + }); + } + + @Input() set appHasPermission(permission: string | string[]) { + this.permissions = Array.isArray(permission) ? permission : [permission]; + this.updateView(this.api.getCurrentUser()); + } + + private updateView(user: User | null) { + const hasPermission = + user?.permissions && + this.permissions.some((p) => user.permissions!.includes(p)); + + if (hasPermission) { + if (this.viewContainer.length === 0) { + this.viewContainer.createEmbeddedView(this.templateRef); + } + } else { + this.viewContainer.clear(); + } + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/front/src/app/shared/sidebar.component.ts b/front/src/app/shared/sidebar.component.ts index d6f76078..1990bbfb 100644 --- a/front/src/app/shared/sidebar.component.ts +++ b/front/src/app/shared/sidebar.component.ts @@ -5,11 +5,18 @@ import { ApiService, User } from '../services/api.service'; import { environment } from '../../environments/environment'; import { LanguagePickerComponent } from './language-picker.component'; import { TranslateModule } from '@ngx-translate/core'; +import { HasPermissionDirective } from './has-permission.directive'; @Component({ selector: 'app-sidebar', standalone: true, - imports: [RouterLink, RouterLinkActive, LanguagePickerComponent, TranslateModule], + imports: [ + RouterLink, + RouterLinkActive, + LanguagePickerComponent, + TranslateModule, + HasPermissionDirective, + ], template: `