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
+
+
+
+
+
+
+
+
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) {