diff --git a/app/modules/core/definition.py b/app/modules/core/definition.py index 2af30a51..e6fd612b 100644 --- a/app/modules/core/definition.py +++ b/app/modules/core/definition.py @@ -64,11 +64,13 @@ core_module = ModuleDefinition( menu_items={ FrontendType.ADMIN: [ "dashboard", + "my_account", "settings", "email-templates", ], FrontendType.STORE: [ "dashboard", + "my_account", "profile", "settings", "email-templates", @@ -97,6 +99,22 @@ core_module = ModuleDefinition( ), ], ), + MenuSectionDefinition( + id="account", + label_key="core.menu.account", + icon="user", + order=890, + items=[ + MenuItemDefinition( + id="my_account", + label_key="core.menu.my_account", + icon="user-circle", + route="/admin/my-account", + order=5, + is_mandatory=True, + ), + ], + ), MenuSectionDefinition( id="settings", label_key="core.menu.platform_settings", @@ -158,9 +176,16 @@ core_module = ModuleDefinition( icon="user", order=900, items=[ + MenuItemDefinition( + id="my_account", + label_key="core.menu.my_account", + icon="user-circle", + route="/store/{store_code}/my-account", + order=5, + ), MenuItemDefinition( id="profile", - label_key="core.menu.profile", + label_key="core.menu.store_settings", icon="user", route="/store/{store_code}/profile", order=10, diff --git a/app/modules/loyalty/services/loyalty_features.py b/app/modules/loyalty/services/loyalty_features.py index b0c0c06f..1bd7ece8 100644 --- a/app/modules/loyalty/services/loyalty_features.py +++ b/app/modules/loyalty/services/loyalty_features.py @@ -164,6 +164,9 @@ class LoyaltyFeatureProvider: db: Session, store_id: int, ) -> list[FeatureUsage]: + if db is None: + return [] + from sqlalchemy import func from app.modules.loyalty.models import LoyaltyCard, StaffPin @@ -199,6 +202,9 @@ class LoyaltyFeatureProvider: merchant_id: int, platform_id: int, ) -> list[FeatureUsage]: + if db is None: + return [] + from sqlalchemy import func from app.modules.loyalty.models import ( diff --git a/app/modules/tenancy/definition.py b/app/modules/tenancy/definition.py index ea2e7160..42a3ba4e 100644 --- a/app/modules/tenancy/definition.py +++ b/app/modules/tenancy/definition.py @@ -103,6 +103,7 @@ tenancy_module = ModuleDefinition( "roles", ], FrontendType.MERCHANT: [ + "my_account", "stores", "profile", ], @@ -181,6 +182,13 @@ tenancy_module = ModuleDefinition( icon="cog", order=900, items=[ + MenuItemDefinition( + id="my_account", + label_key="tenancy.menu.my_account", + icon="user-circle", + route="/merchants/account/my-account", + order=5, + ), MenuItemDefinition( id="stores", label_key="tenancy.menu.stores", @@ -197,7 +205,7 @@ tenancy_module = ModuleDefinition( ), MenuItemDefinition( id="profile", - label_key="tenancy.menu.profile", + label_key="tenancy.menu.business_profile", icon="user", route="/merchants/account/profile", order=20, diff --git a/app/modules/tenancy/routes/api/admin.py b/app/modules/tenancy/routes/api/admin.py index d0b6b4ee..5097d67f 100644 --- a/app/modules/tenancy/routes/api/admin.py +++ b/app/modules/tenancy/routes/api/admin.py @@ -29,10 +29,12 @@ from .admin_store_domains import admin_store_domains_router from .admin_store_roles import admin_store_roles_router from .admin_stores import admin_stores_router from .admin_users import admin_users_router +from .user_account import admin_account_router router = APIRouter() # Aggregate all tenancy admin routes +router.include_router(admin_account_router, tags=["admin-account"]) router.include_router(admin_auth_router, tags=["admin-auth"]) router.include_router(admin_users_router, tags=["admin-admin-users"]) router.include_router(admin_platform_users_router, tags=["admin-users"]) diff --git a/app/modules/tenancy/routes/api/merchant.py b/app/modules/tenancy/routes/api/merchant.py index 8a0be907..ae7a672e 100644 --- a/app/modules/tenancy/routes/api/merchant.py +++ b/app/modules/tenancy/routes/api/merchant.py @@ -30,6 +30,7 @@ from app.modules.tenancy.services.merchant_store_service import merchant_store_s from .email_verification import email_verification_api_router from .merchant_auth import merchant_auth_router +from .user_account import merchant_account_router logger = logging.getLogger(__name__) @@ -219,3 +220,6 @@ async def update_merchant_profile( # Include account routes in main router router.include_router(_account_router, tags=["merchant-account"]) + +# Include self-service user account routes +router.include_router(merchant_account_router, tags=["merchant-user-account"]) diff --git a/app/modules/tenancy/routes/api/store.py b/app/modules/tenancy/routes/api/store.py index 416b3f87..aa5c3cdd 100644 --- a/app/modules/tenancy/routes/api/store.py +++ b/app/modules/tenancy/routes/api/store.py @@ -92,7 +92,9 @@ def get_store_info( from .store_auth import store_auth_router from .store_profile import store_profile_router from .store_team import store_team_router +from .user_account import store_account_router +router.include_router(store_account_router, tags=["store-account"]) router.include_router(store_auth_router, tags=["store-auth"]) router.include_router(store_profile_router, tags=["store-profile"]) router.include_router(store_team_router, tags=["store-team"]) diff --git a/app/modules/tenancy/routes/api/user_account.py b/app/modules/tenancy/routes/api/user_account.py new file mode 100644 index 00000000..fb9b7383 --- /dev/null +++ b/app/modules/tenancy/routes/api/user_account.py @@ -0,0 +1,77 @@ +# app/modules/tenancy/routes/api/user_account.py +""" +Self-service account API routes. + +Provides GET/PUT /account/me and PUT /account/me/password +for admin, store, and merchant frontends. +""" + +import logging +from collections.abc import Callable + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.modules.tenancy.schemas.auth import UserContext +from app.modules.tenancy.schemas.user_account import ( + UserAccountResponse, + UserAccountUpdate, + UserPasswordChange, +) +from app.modules.tenancy.services.user_account_service import user_account_service + +logger = logging.getLogger(__name__) + + +def create_account_router(auth_dependency: Callable) -> APIRouter: + """Create an account/me router bound to the given auth dependency.""" + router = APIRouter(prefix="/account/me") + + @router.get("", response_model=UserAccountResponse) + async def get_my_account( + current_user: UserContext = Depends(auth_dependency), + db: Session = Depends(get_db), + ): + """Get the logged-in user's account info.""" + return user_account_service.get_account(db, current_user.id) + + @router.put("", response_model=UserAccountResponse) + async def update_my_account( + update_data: UserAccountUpdate, + current_user: UserContext = Depends(auth_dependency), + db: Session = Depends(get_db), + ): + """Update the logged-in user's account info.""" + result = user_account_service.update_account( + db, current_user.id, update_data.model_dump(exclude_unset=True) + ) + db.commit() + return result + + @router.put("/password") + async def change_my_password( + password_data: UserPasswordChange, + current_user: UserContext = Depends(auth_dependency), + db: Session = Depends(get_db), + ): + """Change the logged-in user's password.""" + user_account_service.change_password( + db, current_user.id, password_data.model_dump() + ) + db.commit() + return {"message": "Password changed successfully"} # noqa: API001 + + return router + + +# Create routers for each frontend +from app.api.deps import ( + get_current_admin_api, + get_current_merchant_api, + get_current_store_api, +) + +admin_account_router = create_account_router(get_current_admin_api) +store_account_router = create_account_router(get_current_store_api) +merchant_account_router = create_account_router(get_current_merchant_api) diff --git a/app/modules/tenancy/routes/pages/admin.py b/app/modules/tenancy/routes/pages/admin.py index 8815ffca..1ac6a178 100644 --- a/app/modules/tenancy/routes/pages/admin.py +++ b/app/modules/tenancy/routes/pages/admin.py @@ -24,6 +24,24 @@ from app.templates_config import templates router = APIRouter() +# ============================================================================ +# MY ACCOUNT (Self-Service) +# ============================================================================ + + +@router.get("/my-account", response_class=HTMLResponse, include_in_schema=False) +async def admin_my_account_page( + request: Request, + current_user: User = Depends(require_menu_access("my_account", FrontendType.ADMIN)), + db: Session = Depends(get_db), +): + """Render the admin user's personal account page.""" + return templates.TemplateResponse( + "tenancy/admin/my-account.html", + get_admin_context(request, db, current_user), + ) + + # ============================================================================ # MERCHANT MANAGEMENT ROUTES # ============================================================================ diff --git a/app/modules/tenancy/routes/pages/merchant.py b/app/modules/tenancy/routes/pages/merchant.py index c4d060f1..ce42284e 100644 --- a/app/modules/tenancy/routes/pages/merchant.py +++ b/app/modules/tenancy/routes/pages/merchant.py @@ -99,6 +99,25 @@ async def merchant_team_page( ) +@router.get("/my-account", response_class=HTMLResponse, include_in_schema=False) +async def merchant_my_account_page( + request: Request, + current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header), + db: Session = Depends(get_db), +): + """Render the merchant user's personal account page.""" + context = get_context_for_frontend( + FrontendType.MERCHANT, + request, + db, + user=current_user, + ) + return templates.TemplateResponse( + "tenancy/merchant/my-account.html", + context, + ) + + @router.get("/profile", response_class=HTMLResponse, include_in_schema=False) async def merchant_profile_page( request: Request, diff --git a/app/modules/tenancy/routes/pages/store.py b/app/modules/tenancy/routes/pages/store.py index 2d3416b4..2644dca2 100644 --- a/app/modules/tenancy/routes/pages/store.py +++ b/app/modules/tenancy/routes/pages/store.py @@ -143,6 +143,22 @@ async def store_roles_page( ) +@router.get( + "/my-account", response_class=HTMLResponse, include_in_schema=False +) +async def store_my_account_page( + request: Request, + store_code: str = Depends(get_resolved_store_code), + current_user: User = Depends(get_current_store_from_cookie_or_header), + db: Session = Depends(get_db), +): + """Render the store user's personal account page.""" + return templates.TemplateResponse( + "tenancy/store/my-account.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/schemas/__init__.py b/app/modules/tenancy/schemas/__init__.py index 00fb4820..873f25a6 100644 --- a/app/modules/tenancy/schemas/__init__.py +++ b/app/modules/tenancy/schemas/__init__.py @@ -135,6 +135,13 @@ from app.modules.tenancy.schemas.team import ( UserPermissionsResponse, ) +# User account (self-service) schemas +from app.modules.tenancy.schemas.user_account import ( + UserAccountResponse, + UserAccountUpdate, + UserPasswordChange, +) + __all__ = [ # Auth "LoginResponse", @@ -243,6 +250,10 @@ __all__ = [ "TeamMemberUpdate", "TeamStatistics", "UserPermissionsResponse", + # User Account + "UserAccountResponse", + "UserAccountUpdate", + "UserPasswordChange", # Store Domain "DomainDeletionResponse", "DomainVerificationInstructions", diff --git a/app/modules/tenancy/schemas/user_account.py b/app/modules/tenancy/schemas/user_account.py new file mode 100644 index 00000000..9f74267f --- /dev/null +++ b/app/modules/tenancy/schemas/user_account.py @@ -0,0 +1,58 @@ +# app/modules/tenancy/schemas/user_account.py +""" +Self-service account schemas for logged-in users. + +Used by admin, store, and merchant frontends to let users +manage their own identity (name, email, password). +""" + +from datetime import datetime + +from pydantic import BaseModel, EmailStr, Field, field_validator + + +class UserAccountResponse(BaseModel): + """Self-service account info returned to the logged-in user.""" + + id: int + email: str + username: str + first_name: str | None = None + last_name: str | None = None + role: str + preferred_language: str | None = None + is_email_verified: bool = False + last_login: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class UserAccountUpdate(BaseModel): + """Fields the user can edit about themselves.""" + + first_name: str | None = Field(None, max_length=100) + last_name: str | None = Field(None, max_length=100) + email: EmailStr | None = None + preferred_language: str | None = Field(None, pattern=r"^(en|fr|de|lb)$") + + +class UserPasswordChange(BaseModel): + """Password change with current-password verification.""" + + current_password: str = Field(..., description="Current password") + new_password: str = Field( + ..., min_length=8, description="New password (minimum 8 characters)" + ) + confirm_password: str = Field(..., description="Confirm new password") + + @field_validator("new_password") + @classmethod + def password_strength(cls, v: str) -> str: + """Validate password strength.""" + if not any(char.isdigit() for char in v): + raise ValueError("Password must contain at least one digit") + if not any(char.isalpha() for char in v): + raise ValueError("Password must contain at least one letter") + return v diff --git a/app/modules/tenancy/services/user_account_service.py b/app/modules/tenancy/services/user_account_service.py new file mode 100644 index 00000000..eb03c50b --- /dev/null +++ b/app/modules/tenancy/services/user_account_service.py @@ -0,0 +1,100 @@ +# app/modules/tenancy/services/user_account_service.py +""" +Self-service account management for logged-in users. + +Allows users to view/update their own profile and change password. +Used by admin, store, and merchant frontends. +""" + +import logging + +from sqlalchemy.orm import Session + +from app.modules.tenancy.exceptions import ( + InvalidCredentialsException, + UserAlreadyExistsException, + UserNotFoundException, +) +from app.modules.tenancy.models import User +from middleware.auth import AuthManager + +logger = logging.getLogger(__name__) + + +class UserAccountService: + """Service for self-service account operations.""" + + def __init__(self): + self.auth_manager = AuthManager() + + def get_account(self, db: Session, user_id: int) -> User: + """Get the logged-in user's account info.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise UserNotFoundException(str(user_id)) + return user + + def update_account(self, db: Session, user_id: int, update_data: dict) -> User: + """Update the logged-in user's account info.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise UserNotFoundException(str(user_id)) + + # Check email uniqueness if email is being changed + new_email = update_data.get("email") + if new_email and new_email != user.email: + existing = ( + db.query(User) + .filter(User.email == new_email, User.id != user_id) + .first() + ) + if existing: + raise UserAlreadyExistsException( + "Email already registered", field="email" + ) + + # Apply updates (only provided fields) + for field, value in update_data.items(): + if value is not None: + setattr(user, field, value) + + db.flush() + db.refresh(user) + + logger.info(f"User {user.username} updated account: {list(update_data.keys())}") + return user + + def change_password(self, db: Session, user_id: int, password_data: dict) -> None: + """Change the logged-in user's password.""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise UserNotFoundException(str(user_id)) + + current_password = password_data["current_password"] + new_password = password_data["new_password"] + confirm_password = password_data["confirm_password"] + + # Verify current password + if not self.auth_manager.verify_password(current_password, user.hashed_password): + raise InvalidCredentialsException("Current password is incorrect") + + # Validate new != current + if self.auth_manager.verify_password(new_password, user.hashed_password): + raise InvalidCredentialsException( + "New password must be different from current password" + ) + + # Validate confirmation match + if new_password != confirm_password: + raise InvalidCredentialsException( + "New password and confirmation do not match" + ) + + # Hash and save + user.hashed_password = self.auth_manager.hash_password(new_password) + db.flush() + + logger.info(f"User {user.username} changed password") # noqa: SEC021 + + +user_account_service = UserAccountService() diff --git a/app/modules/tenancy/templates/tenancy/admin/my-account.html b/app/modules/tenancy/templates/tenancy/admin/my-account.html new file mode 100644 index 00000000..aaf71a31 --- /dev/null +++ b/app/modules/tenancy/templates/tenancy/admin/my-account.html @@ -0,0 +1,294 @@ +{# app/modules/tenancy/templates/tenancy/admin/my-account.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}My Account{% endblock %} + +{% block alpine_data %}myAccountPage(){% endblock %} + +{% block content %} + +{% call page_header_flex(title='My Account', subtitle='Manage your personal account information') %} +{% endcall %} + +{{ loading_state('Loading account...') }} +{{ error_state('Error loading account') }} + + +
+ +
+
+

Personal Information

+

Update your name and email address

+
+
+
+ +
+ + +

+
+ + +
+ + +

+
+ + +
+ + +

+
+ + +
+ + +
+
+ + +
+ + +
+
+
+ + +
+
+

Change Password

+

Update your login password

+
+
+
+
+ + +
+
+
+ + +

Minimum 8 characters, must include a letter and a digit

+
+
+ + +
+
+
+ +
+
+
+ + +
+
+

Account Information

+

Read-only account metadata

+
+
+
+
+ +

+
+
+ +

+
+
+ + +
+
+ +

+
+
+ +

+
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/tenancy/templates/tenancy/merchant/my-account.html b/app/modules/tenancy/templates/tenancy/merchant/my-account.html new file mode 100644 index 00000000..9c36d33e --- /dev/null +++ b/app/modules/tenancy/templates/tenancy/merchant/my-account.html @@ -0,0 +1,253 @@ +{# app/modules/tenancy/templates/tenancy/merchant/my-account.html #} +{% extends "merchant/base.html" %} + +{% block title %}My Account{% endblock %} + +{% block alpine_data %}myAccountPage(){% endblock %} + +{% block content %} + +
+

My Account

+

Manage your personal account information.

+
+ + +
+

+
+ + +
+

+
+ + +
+ + + + + Loading account... +
+ + +
+
+

Personal Information

+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+ + +
+
+

Change Password

+
+
+
+ + +
+
+
+ + +

Minimum 8 characters, must include a letter and a digit

+
+
+ + +
+
+
+ +
+
+
+ + +
+
+

Account Information

+

Read-only account metadata

+
+
+
+
+ +

+
+
+ +

+
+
+ + +
+
+ +

+
+
+ +

+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/tenancy/templates/tenancy/store/my-account.html b/app/modules/tenancy/templates/tenancy/store/my-account.html new file mode 100644 index 00000000..caf035ff --- /dev/null +++ b/app/modules/tenancy/templates/tenancy/store/my-account.html @@ -0,0 +1,243 @@ +{# app/modules/tenancy/templates/tenancy/store/my-account.html #} +{% extends "store/base.html" %} +{% from 'shared/macros/headers.html' import page_header_flex %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} + +{% block title %}My Account{% endblock %} + +{% block alpine_data %}myAccountPage(){% endblock %} + +{% block content %} + +{% call page_header_flex(title='My Account', subtitle='Manage your personal account information') %} +{% endcall %} + +{{ loading_state('Loading account...') }} +{{ error_state('Error loading account') }} + + +
+ +
+
+

Personal Information

+

Update your name and email address

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+

Change Password

+

Update your login password

+
+
+
+
+ + +
+
+
+ + +

Minimum 8 characters, must include a letter and a digit

+
+
+ + +
+
+
+ +
+
+
+ + +
+
+

Account Information

+

Read-only account metadata

+
+
+
+
+ +

+
+
+ +

+
+
+ + +
+
+ +

+
+
+ +

+
+
+
+
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/modules/tenancy/tests/integration/test_user_account_api.py b/app/modules/tenancy/tests/integration/test_user_account_api.py new file mode 100644 index 00000000..8c8f4fab --- /dev/null +++ b/app/modules/tenancy/tests/integration/test_user_account_api.py @@ -0,0 +1,323 @@ +# app/modules/tenancy/tests/integration/test_user_account_api.py +""" +Integration tests for self-service user account API endpoints. + +Tests the /account/me endpoints for admin, store, and merchant frontends. +""" + +import uuid + +import pytest + +from app.api.deps import get_current_merchant_api +from app.modules.tenancy.models import Merchant, Store, StoreUser, User +from app.modules.tenancy.schemas.auth import UserContext +from main import app +from middleware.auth import AuthManager + +# ============================================================================ +# Fixtures +# ============================================================================ + +ADMIN_BASE = "/api/v1/admin/account/me" +STORE_BASE = "/api/v1/store/account/me" +MERCHANT_BASE = "/api/v1/merchants/account/me" + + +@pytest.fixture +def ua_admin(db): + """Create an admin user for account tests.""" + auth = AuthManager() + uid = uuid.uuid4().hex[:8] + user = User( + email=f"ua_admin_{uid}@test.com", + username=f"ua_admin_{uid}", + hashed_password=auth.hash_password("adminpass123"), + first_name="Admin", + last_name="User", + role="super_admin", + is_active=True, + is_email_verified=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture +def ua_admin_headers(client, ua_admin): + """Get admin auth headers.""" + response = client.post( + "/api/v1/admin/auth/login", + json={"email_or_username": ua_admin.username, "password": "adminpass123"}, + ) + assert response.status_code == 200 + token = response.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def ua_store_user(db): + """Create a store user for account tests.""" + auth = AuthManager() + uid = uuid.uuid4().hex[:8] + user = User( + email=f"ua_store_{uid}@test.com", + username=f"ua_store_{uid}", + hashed_password=auth.hash_password("storepass123"), + first_name="Store", + last_name="User", + role="merchant_owner", + is_active=True, + is_email_verified=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture +def ua_store_with_user(db, ua_store_user): + """Create a store owned by ua_store_user with StoreUser association.""" + uid = uuid.uuid4().hex[:8].upper() + merchant = Merchant( + name=f"UA Merchant {uid}", + owner_user_id=ua_store_user.id, + contact_email=ua_store_user.email, + is_active=True, + is_verified=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + + store = Store( + merchant_id=merchant.id, + store_code=f"UASTORE_{uid}", + subdomain=f"uastore{uid.lower()}", + name=f"UA Store {uid}", + is_active=True, + is_verified=True, + ) + db.add(store) + db.commit() + db.refresh(store) + + store_user = StoreUser( + store_id=store.id, + user_id=ua_store_user.id, + is_active=True, + ) + db.add(store_user) + db.commit() + return store + + +@pytest.fixture +def ua_store_headers(client, ua_store_user, ua_store_with_user): + """Get store user auth headers.""" + response = client.post( + "/api/v1/store/auth/login", + json={ + "email_or_username": ua_store_user.username, + "password": "storepass123", + }, + ) + assert response.status_code == 200 + token = response.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def ua_merchant_owner(db): + """Create a merchant owner user for account tests.""" + auth = AuthManager() + uid = uuid.uuid4().hex[:8] + user = User( + email=f"ua_merch_{uid}@test.com", + username=f"ua_merch_{uid}", + hashed_password=auth.hash_password("merchpass123"), + first_name="Merchant", + last_name="Owner", + role="merchant_owner", + is_active=True, + is_email_verified=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.fixture +def ua_merchant(db, ua_merchant_owner): + """Create a merchant owned by ua_merchant_owner.""" + merchant = Merchant( + name="UA Test Merchant", + owner_user_id=ua_merchant_owner.id, + contact_email=ua_merchant_owner.email, + is_active=True, + is_verified=True, + ) + db.add(merchant) + db.commit() + db.refresh(merchant) + return merchant + + +@pytest.fixture +def ua_merchant_override(ua_merchant_owner): + """Override merchant API auth to return the test user.""" + user_context = UserContext.from_user(ua_merchant_owner, include_store_context=False) + + app.dependency_overrides[get_current_merchant_api] = lambda: user_context + yield + app.dependency_overrides.pop(get_current_merchant_api, None) + + +# ============================================================================ +# Admin Account Tests +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.tenancy +class TestAdminAccountAPI: + """Tests for admin /account/me endpoints.""" + + def test_get_account_me(self, client, ua_admin_headers, ua_admin): + response = client.get(ADMIN_BASE, headers=ua_admin_headers) + assert response.status_code == 200 + data = response.json() + assert data["id"] == ua_admin.id + assert data["email"] == ua_admin.email + assert data["username"] == ua_admin.username + assert data["first_name"] == "Admin" + assert "role" in data + + def test_update_account_me(self, client, ua_admin_headers): + response = client.put( + ADMIN_BASE, + headers=ua_admin_headers, + json={"first_name": "Updated", "last_name": "Admin"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["first_name"] == "Updated" + assert data["last_name"] == "Admin" + + def test_change_password(self, client, ua_admin_headers): + response = client.put( + f"{ADMIN_BASE}/password", + headers=ua_admin_headers, + json={ + "current_password": "adminpass123", + "new_password": "newadmin456", + "confirm_password": "newadmin456", + }, + ) + assert response.status_code == 200 + assert "message" in response.json() + + def test_change_password_wrong_current(self, client, ua_admin_headers): + response = client.put( + f"{ADMIN_BASE}/password", + headers=ua_admin_headers, + json={ + "current_password": "wrongpass", + "new_password": "newadmin456", + "confirm_password": "newadmin456", + }, + ) + assert response.status_code in (400, 401, 422) + + def test_unauthenticated(self, client): + response = client.get(ADMIN_BASE) + assert response.status_code in (401, 403) + + +# ============================================================================ +# Store Account Tests +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.tenancy +class TestStoreAccountAPI: + """Tests for store /account/me endpoints.""" + + def test_get_account_me(self, client, ua_store_headers, ua_store_user): + response = client.get(STORE_BASE, headers=ua_store_headers) + assert response.status_code == 200 + data = response.json() + assert data["id"] == ua_store_user.id + assert data["email"] == ua_store_user.email + + def test_update_account_me(self, client, ua_store_headers): + response = client.put( + STORE_BASE, + headers=ua_store_headers, + json={"first_name": "StoreUpdated"}, + ) + assert response.status_code == 200 + assert response.json()["first_name"] == "StoreUpdated" + + def test_change_password(self, client, ua_store_headers): + response = client.put( + f"{STORE_BASE}/password", + headers=ua_store_headers, + json={ + "current_password": "storepass123", + "new_password": "newstore456", + "confirm_password": "newstore456", + }, + ) + assert response.status_code == 200 + + +# ============================================================================ +# Merchant Account Tests +# ============================================================================ + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.tenancy +class TestMerchantAccountAPI: + """Tests for merchant /account/me endpoints.""" + + def test_get_account_me( + self, client, ua_merchant_owner, ua_merchant, ua_merchant_override + ): + response = client.get(MERCHANT_BASE) + assert response.status_code == 200 + data = response.json() + assert data["id"] == ua_merchant_owner.id + assert data["email"] == ua_merchant_owner.email + + def test_update_account_me( + self, client, ua_merchant_owner, ua_merchant, ua_merchant_override + ): + response = client.put( + MERCHANT_BASE, + json={"first_name": "MerchUpdated", "last_name": "OwnerUpdated"}, + ) + assert response.status_code == 200 + assert response.json()["first_name"] == "MerchUpdated" + + def test_change_password( + self, client, ua_merchant_owner, ua_merchant, ua_merchant_override + ): + response = client.put( + f"{MERCHANT_BASE}/password", + json={ + "current_password": "merchpass123", + "new_password": "newmerch456", + "confirm_password": "newmerch456", + }, + ) + assert response.status_code == 200 diff --git a/app/modules/tenancy/tests/unit/test_user_account_schema.py b/app/modules/tenancy/tests/unit/test_user_account_schema.py new file mode 100644 index 00000000..f6d6cfe9 --- /dev/null +++ b/app/modules/tenancy/tests/unit/test_user_account_schema.py @@ -0,0 +1,91 @@ +# app/modules/tenancy/tests/unit/test_user_account_schema.py +""" +Unit tests for user account schemas. + +Tests validation rules for UserPasswordChange and UserAccountUpdate. +""" + +import pytest +from pydantic import ValidationError + +from app.modules.tenancy.schemas.user_account import ( + UserAccountUpdate, + UserPasswordChange, +) + + +@pytest.mark.unit +@pytest.mark.schema +class TestUserPasswordChangeSchema: + """Tests for UserPasswordChange validation.""" + + def test_valid_password(self): + schema = UserPasswordChange( + current_password="oldpass123", # noqa: SEC001 + new_password="newpass456", # noqa: SEC001 + confirm_password="newpass456", # noqa: SEC001 + ) + assert schema.new_password == "newpass456" + + def test_password_too_short(self): + with pytest.raises(ValidationError) as exc_info: + UserPasswordChange( + current_password="old", # noqa: SEC001 + new_password="short1", # noqa: SEC001 + confirm_password="short1", # noqa: SEC001 + ) + assert "at least 8" in str(exc_info.value).lower() or "min_length" in str( + exc_info.value + ).lower() + + def test_password_no_digit(self): + with pytest.raises(ValidationError) as exc_info: + UserPasswordChange( + current_password="oldpass123", # noqa: SEC001 + new_password="nodigitss", # noqa: SEC001 + confirm_password="nodigitss", # noqa: SEC001 + ) + assert "digit" in str(exc_info.value).lower() + + def test_password_no_letter(self): + with pytest.raises(ValidationError) as exc_info: + UserPasswordChange( + current_password="oldpass123", # noqa: SEC001 + new_password="12345678", # noqa: SEC001 + confirm_password="12345678", # noqa: SEC001 + ) + assert "letter" in str(exc_info.value).lower() + + +@pytest.mark.unit +@pytest.mark.schema +class TestUserAccountUpdateSchema: + """Tests for UserAccountUpdate validation.""" + + def test_valid_update(self): + schema = UserAccountUpdate( + first_name="John", + last_name="Doe", + email="john@example.com", + preferred_language="en", + ) + assert schema.first_name == "John" + + def test_partial_update(self): + schema = UserAccountUpdate(first_name="John") + assert schema.first_name == "John" + assert schema.last_name is None + assert schema.email is None + + def test_email_validation(self): + with pytest.raises(ValidationError): + UserAccountUpdate(email="not-an-email") + + def test_language_validation(self): + with pytest.raises(ValidationError): + UserAccountUpdate(preferred_language="xx") + + def test_valid_languages(self): + for lang in ("en", "fr", "de", "lb"): + schema = UserAccountUpdate(preferred_language=lang) + assert schema.preferred_language == lang diff --git a/app/modules/tenancy/tests/unit/test_user_account_service.py b/app/modules/tenancy/tests/unit/test_user_account_service.py new file mode 100644 index 00000000..51714a71 --- /dev/null +++ b/app/modules/tenancy/tests/unit/test_user_account_service.py @@ -0,0 +1,146 @@ +# app/modules/tenancy/tests/unit/test_user_account_service.py +""" +Unit tests for UserAccountService. + +Tests self-service account operations: get, update, change password. +""" + +import uuid + +import pytest + +from app.modules.tenancy.exceptions import ( + InvalidCredentialsException, + UserAlreadyExistsException, + UserNotFoundException, +) +from app.modules.tenancy.models import User +from app.modules.tenancy.services.user_account_service import UserAccountService +from middleware.auth import AuthManager + + +@pytest.fixture +def auth_mgr(): + return AuthManager() + + +@pytest.fixture +def account_service(): + return UserAccountService() + + +@pytest.fixture +def acct_user(db, auth_mgr): + """Create a user for account service tests.""" + uid = uuid.uuid4().hex[:8] + user = User( + email=f"acct_{uid}@test.com", + username=f"acct_{uid}", + hashed_password=auth_mgr.hash_password("oldpass123"), + first_name="Test", + last_name="User", + role="super_admin", + is_active=True, + is_email_verified=True, + preferred_language="en", + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@pytest.mark.unit +@pytest.mark.tenancy +class TestUserAccountService: + """Tests for UserAccountService.""" + + def test_get_account_returns_user(self, db, account_service, acct_user): + result = account_service.get_account(db, acct_user.id) + assert result.id == acct_user.id + assert result.email == acct_user.email + + def test_get_account_not_found_raises(self, db, account_service): + with pytest.raises(UserNotFoundException): + account_service.get_account(db, 999999) + + def test_update_account_first_last_name(self, db, account_service, acct_user): + result = account_service.update_account( + db, acct_user.id, {"first_name": "New", "last_name": "Name"} + ) + assert result.first_name == "New" + assert result.last_name == "Name" + + def test_update_account_email_uniqueness_conflict( + self, db, account_service, acct_user, auth_mgr + ): + uid2 = uuid.uuid4().hex[:8] + other = User( + email=f"other_{uid2}@test.com", + username=f"other_{uid2}", + hashed_password=auth_mgr.hash_password("pass1234"), + role="store_member", + is_active=True, + ) + db.add(other) + db.commit() + + with pytest.raises(UserAlreadyExistsException): + account_service.update_account( + db, acct_user.id, {"email": other.email} + ) + + def test_update_account_preferred_language(self, db, account_service, acct_user): + result = account_service.update_account( + db, acct_user.id, {"preferred_language": "fr"} + ) + assert result.preferred_language == "fr" + + def test_change_password_success(self, db, account_service, acct_user, auth_mgr): + account_service.change_password( + db, + acct_user.id, + { + "current_password": "oldpass123", + "new_password": "newpass456", + "confirm_password": "newpass456", + }, + ) + db.refresh(acct_user) + assert auth_mgr.verify_password("newpass456", acct_user.hashed_password) + + def test_change_password_wrong_current(self, db, account_service, acct_user): + with pytest.raises(InvalidCredentialsException): + account_service.change_password( + db, + acct_user.id, + { + "current_password": "wrongpass", + "new_password": "newpass456", + "confirm_password": "newpass456", + }, + ) + + def test_change_password_mismatch_confirm(self, db, account_service, acct_user): + with pytest.raises(InvalidCredentialsException): + account_service.change_password( + db, + acct_user.id, + { + "current_password": "oldpass123", + "new_password": "newpass456", + "confirm_password": "different789", + }, + ) + + def test_change_password_same_as_current(self, db, account_service, acct_user): + with pytest.raises(InvalidCredentialsException): + account_service.change_password( + db, + acct_user.id, + { + "current_password": "oldpass123", + "new_password": "oldpass123", + "confirm_password": "oldpass123", + }, + ) diff --git a/static/shared/js/dev-toolbar.js b/static/shared/js/dev-toolbar.js index f8bc791b..663383bf 100644 --- a/static/shared/js/dev-toolbar.js +++ b/static/shared/js/dev-toolbar.js @@ -1,4 +1,5 @@ // static/shared/js/dev-toolbar.js +// noqa: SEC015 - dev-only toolbar, innerHTML used with trusted/constructed content only /** * Dev-Mode Debug Toolbar * @@ -133,7 +134,7 @@ function row(label, value, highlightFn) { var color = highlightFn ? highlightFn(value) : C.text; - return '
' + + return '
' + '' + escapeHtml(label) + '' + '' + escapeHtml(String(value)) + '
'; } @@ -276,6 +277,8 @@ tokensPresent: { store_token: !!localStorage.getItem('store_token'), admin_token: !!localStorage.getItem('admin_token'), + merchant_token: !!localStorage.getItem('merchant_token'), + customer_token: !!localStorage.getItem('customer_token'), }, }; } @@ -284,6 +287,8 @@ var path = window.location.pathname; if (path.startsWith('/store/') || path === '/store') return 'store'; if (path.startsWith('/admin/') || path === '/admin') return 'admin'; + if (path.indexOf('/merchants/') !== -1) return 'merchant'; + if (path.indexOf('/storefront/') !== -1) return 'storefront'; if (path.startsWith('/api/')) return 'api'; return 'unknown'; } @@ -323,7 +328,7 @@ height: '6px', cursor: 'ns-resize', background: C.surface0, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0', }); - dragHandle.innerHTML = '
'; setupDragResize(dragHandle); toolbarEl.appendChild(dragHandle); @@ -338,6 +343,7 @@ var tabs = [ { id: 'platform', label: 'Platform' }, + { id: 'auth', label: 'Auth' }, { id: 'api', label: 'API' }, { id: 'request', label: 'Request' }, { id: 'console', label: 'Console' }, @@ -352,7 +358,7 @@ fontSize: '11px', fontFamily: 'inherit', background: 'transparent', color: C.subtext, borderBottom: '2px solid transparent', transition: 'all 0.15s', }); - btn.innerHTML = tab.label + ''; + btn.innerHTML = tab.label + ''; // noqa: SEC015 btn.addEventListener('click', function () { switchTab(tab.id); }); tabBar.appendChild(btn); }); @@ -362,6 +368,17 @@ spacer.style.flex = '1'; tabBar.appendChild(spacer); + var copyBtn = document.createElement('button'); + copyBtn.id = '_dev_copy_btn'; + Object.assign(copyBtn.style, { + padding: '2px 8px', border: 'none', cursor: 'pointer', fontSize: '11px', + fontFamily: 'inherit', background: C.teal, color: C.base, + borderRadius: '3px', fontWeight: 'bold', margin: '2px 4px 2px 0', + }); + copyBtn.textContent = '\u2398 Copy'; + copyBtn.addEventListener('click', copyTabContent); + tabBar.appendChild(copyBtn); + var closeBtn = document.createElement('button'); Object.assign(closeBtn.style, { padding: '2px 8px', border: 'none', cursor: 'pointer', fontSize: '11px', @@ -384,6 +401,11 @@ document.body.appendChild(toolbarEl); + // Inject hover style for rows and content lines + var style = document.createElement('style'); + style.textContent = '._dev_row:hover, #_dev_toolbar_content > div:hover { background: ' + C.surface1 + ' }'; + document.head.appendChild(style); + updateTabStyles(); updateBadges(); } @@ -424,6 +446,43 @@ } } + function copyTabContent() { + if (!contentEl) return; + var text = contentEl.innerText || contentEl.textContent || ''; + navigator.clipboard.writeText(text).then(function () { + var btn = document.getElementById('_dev_copy_btn'); + if (btn) { + var orig = btn.textContent; + btn.textContent = '\u2714 Copied!'; + btn.style.background = C.green; + setTimeout(function () { + btn.textContent = orig; + btn.style.background = C.teal; + }, 1500); + } + }).catch(function () { + // Fallback for older browsers + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + var btn = document.getElementById('_dev_copy_btn'); + if (btn) { + var orig = btn.textContent; + btn.textContent = '\u2714 Copied!'; + btn.style.background = C.green; + setTimeout(function () { + btn.textContent = orig; + btn.style.background = C.teal; + }, 1500); + } + }); + } + function switchTab(tabId) { activeTab = tabId; localStorage.setItem(STORAGE_TAB_KEY, tabId); @@ -432,7 +491,7 @@ } function updateTabStyles() { - var tabs = ['platform', 'api', 'request', 'console']; + var tabs = ['platform', 'auth', 'api', 'request', 'console']; tabs.forEach(function (id) { var btn = document.getElementById('_dev_tab_' + id); if (!btn) return; @@ -461,7 +520,159 @@ if (errCount > 0) parts.push('' + errCount + 'E'); if (warnCount > 0) parts.push('' + warnCount + 'W'); if (parts.length === 0 && consoleLogs.length > 0) parts.push(consoleLogs.length.toString()); - consoleBadge.innerHTML = parts.length > 0 ? '(' + parts.join('/') + ')' : ''; + consoleBadge.innerHTML = parts.length > 0 ? '(' + parts.join('/') + ')' : ''; // noqa: SEC015 + } + } + + // ── Auth Tab Helpers ── + var TOKEN_MAP = { + admin: { key: 'admin_token', endpoint: '/api/v1/admin/auth/me' }, + store: { key: 'store_token', endpoint: '/api/v1/store/auth/me' }, + merchant: { key: 'merchant_token', endpoint: '/api/v1/merchants/auth/me' }, + storefront: { key: 'customer_token', endpoint: '/api/v1/storefront/profile' }, + }; + + function getTokenForFrontend(frontend) { + var info = TOKEN_MAP[frontend]; + return info ? localStorage.getItem(info.key) : null; + } + + function getAuthMeEndpoint(frontend) { + var info = TOKEN_MAP[frontend]; + return info ? info.endpoint : null; + } + + function fetchFrontendAuthMe(frontend) { + var token = getTokenForFrontend(frontend); + var endpoint = getAuthMeEndpoint(frontend); + if (!token || !endpoint) return Promise.resolve({ error: 'No token or endpoint for ' + frontend }); + return _originalFetch.call(window, endpoint, { + headers: { 'Authorization': 'Bearer ' + token }, + }).then(function (resp) { + if (!resp.ok) return { error: resp.status + ' ' + resp.statusText }; + return resp.json(); + }).catch(function (e) { + return { error: e.message }; + }); + } + + function renderAuthTab() { + var frontend = detectFrontend(); + var token = getTokenForFrontend(frontend); + var jwt = token ? decodeJwtPayload(token) : null; + var html = ''; + + // 1. Detected Frontend badge + var frontendColors = { admin: C.red, store: C.green, merchant: C.peach, storefront: C.blue }; + var badgeColor = frontendColors[frontend] || C.subtext; + html += sectionHeader('Detected Frontend'); + html += '
' + + escapeHtml(frontend) + '
'; + + // 2. Active Token + var tokenInfo = TOKEN_MAP[frontend]; + html += sectionHeader('Active Token (' + (tokenInfo ? tokenInfo.key : 'n/a') + ')'); + if (token) { + html += row('Status', 'present', function () { return C.green; }); + html += row('Last 20 chars', '...' + token.slice(-20)); + } else { + html += row('Status', 'absent', function () { return C.red; }); + } + + // 3. JWT Decoded + html += sectionHeader('JWT Decoded'); + if (jwt) { + Object.keys(jwt).forEach(function (key) { + var val = jwt[key]; + if (key === 'exp' || key === 'iat') { + val = new Date(val * 1000).toLocaleString() + ' (' + val + ')'; + } + html += row(key, val ?? '(null)'); + }); + } else { + html += '
No JWT to decode
'; + } + + // 4. Token Expiry countdown + html += sectionHeader('Token Expiry'); + if (jwt && jwt.exp) { + var now = Math.floor(Date.now() / 1000); + var remaining = jwt.exp - now; + var expiryColor; + var expiryText; + if (remaining <= 0) { + expiryColor = C.red; + expiryText = 'EXPIRED (' + Math.abs(remaining) + 's ago)'; + } else if (remaining < 300) { + expiryColor = C.yellow; + var m = Math.floor(remaining / 60); + var s = remaining % 60; + expiryText = m + 'm ' + s + 's remaining'; + } else { + expiryColor = C.green; + var m2 = Math.floor(remaining / 60); + var s2 = remaining % 60; + expiryText = m2 + 'm ' + s2 + 's remaining'; + } + html += row('Expires', expiryText, function () { return expiryColor; }); + } else { + html += '
No exp claim
'; + } + + // 5. All Tokens Overview + html += sectionHeader('All Tokens Overview'); + Object.keys(TOKEN_MAP).forEach(function (fe) { + var present = !!localStorage.getItem(TOKEN_MAP[fe].key); + var color = present ? C.green : C.red; + var label = present ? 'present' : 'absent'; + html += row(TOKEN_MAP[fe].key, label, function () { return color; }); + }); + + // 6. Server Auth (/auth/me) — placeholder, filled async + html += '
'; + + contentEl.innerHTML = html; // noqa: SEC015 // noqa: SEC015 + + // Async fetch + if (token && getAuthMeEndpoint(frontend)) { + var meSection = document.getElementById('_dev_auth_me_section'); + if (meSection) meSection.innerHTML = sectionHeader('Server Auth (' + getAuthMeEndpoint(frontend) + ')') + // noqa: SEC015 + '
Loading...
'; + + fetchFrontendAuthMe(frontend).then(function (me) { + if (activeTab !== 'auth') return; + var meHtml = sectionHeader('Server Auth (' + getAuthMeEndpoint(frontend) + ')'); + if (me.error) { + meHtml += '
' + escapeHtml(me.error) + '
'; + } else { + Object.keys(me).forEach(function (key) { + meHtml += row(key, me[key] ?? '(null)'); + }); + + // 7. Consistency Check + if (jwt) { + meHtml += sectionHeader('Consistency Check (JWT vs /auth/me)'); + var mismatches = []; + var checkFields = ['platform_code', 'store_code', 'username', 'user_id', 'role']; + checkFields.forEach(function (field) { + if (jwt[field] !== undefined && me[field] !== undefined && + String(jwt[field]) !== String(me[field])) { + mismatches.push(field + ': JWT=' + jwt[field] + ' vs server=' + me[field]); + } + }); + if (mismatches.length > 0) { + mismatches.forEach(function (msg) { + meHtml += '
MISMATCH: ' + escapeHtml(msg) + '
'; + }); + } else { + meHtml += '
All checked fields consistent
'; + } + } + } + var section = document.getElementById('_dev_auth_me_section'); + if (section) section.innerHTML = meHtml; // noqa: SEC015 + }); } } @@ -470,6 +681,7 @@ if (!contentEl) return; switch (activeTab) { case 'platform': renderPlatformTab(); break; + case 'auth': renderAuthTab(); break; case 'api': renderApiCallsTab(); break; case 'request': renderRequestInfoTab(); break; case 'console': renderConsoleTab(); break; @@ -523,7 +735,7 @@ html += '
All sources consistent
'; } - contentEl.innerHTML = html; + contentEl.innerHTML = html; // noqa: SEC015 // Async /auth/me fetchAuthMe().then(function (me) { @@ -575,7 +787,7 @@ var methodColor = call.method === 'GET' ? C.green : call.method === 'POST' ? C.blue : call.method === 'PUT' ? C.peach : call.method === 'DELETE' ? C.red : C.text; - html += '
'; + html += '
'; html += '
'; html += '' + call.method + ''; html += '' + escapeHtml(call.url) + ''; @@ -612,7 +824,7 @@ } } - contentEl.innerHTML = html; + contentEl.innerHTML = html; // noqa: SEC015 // Attach event listeners var clearBtn = document.getElementById('_dev_api_clear'); @@ -685,6 +897,8 @@ html += sectionHeader('Tokens'); html += row('store_token', info.tokensPresent.store_token ? 'present' : 'absent'); html += row('admin_token', info.tokensPresent.admin_token ? 'present' : 'absent'); + html += row('merchant_token', info.tokensPresent.merchant_token ? 'present' : 'absent'); + html += row('customer_token', info.tokensPresent.customer_token ? 'present' : 'absent'); html += sectionHeader('Log Config'); if (info.logConfig) { @@ -698,7 +912,7 @@ html += '
LogConfig not defined
'; } - contentEl.innerHTML = html; + contentEl.innerHTML = html; // noqa: SEC015 } function renderConsoleTab() { @@ -731,7 +945,7 @@ } else { for (var i = filtered.length - 1; i >= 0; i--) { var entry = filtered[i]; - html += '
'; + html += '
'; html += '' + formatTime(entry.timestamp) + ''; html += '' + levelBadge(entry.level) + ''; html += ''; @@ -740,7 +954,7 @@ } } - contentEl.innerHTML = html; + contentEl.innerHTML = html; // noqa: SEC015 // Attach filter listeners contentEl.querySelectorAll('._dev_console_filter').forEach(function (btn) {