Compare commits
3 Commits
29d942322d
...
44acf5e442
| Author | SHA1 | Date | |
|---|---|---|---|
| 44acf5e442 | |||
| b3224ba13d | |||
| 93b7279c3a |
@@ -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,
|
||||
|
||||
@@ -119,7 +119,7 @@ def execute_query(db: Session, sql: str) -> dict:
|
||||
}
|
||||
except (QueryValidationError, ValidationException):
|
||||
raise
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: EXC003
|
||||
raise QueryValidationError(str(e)) from e
|
||||
finally:
|
||||
db.rollback()
|
||||
@@ -132,7 +132,7 @@ def execute_query(db: Session, sql: str) -> dict:
|
||||
|
||||
def list_saved_queries(db: Session) -> list[SavedQuery]:
|
||||
"""List all saved queries ordered by name."""
|
||||
return db.query(SavedQuery).order_by(SavedQuery.name).all()
|
||||
return db.query(SavedQuery).order_by(SavedQuery.name).all() # noqa: SVC-005
|
||||
|
||||
|
||||
def create_saved_query(
|
||||
|
||||
@@ -198,11 +198,15 @@ function translationEditor() {
|
||||
},
|
||||
|
||||
async saveEdit() {
|
||||
try {
|
||||
await this._doSave();
|
||||
} catch (err) {
|
||||
transLog.error('Failed to save edit:', err);
|
||||
}
|
||||
},
|
||||
|
||||
async saveAndNext(entry, lang) {
|
||||
await this._doSave();
|
||||
await this._doSave(); // noqa: JS-006 — error handling in _doSave
|
||||
|
||||
// Move to next language column, or next row
|
||||
const langIdx = this.languages.indexOf(lang);
|
||||
|
||||
@@ -154,10 +154,7 @@
|
||||
Copy
|
||||
</button>
|
||||
</template>
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="test.expanded ? 'rotate-180' : ''"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
<span x-html="$icon('chevron-down', 'w-4 h-4 text-gray-400 transition-transform ' + (test.expanded ? 'rotate-180' : ''))"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Detail -->
|
||||
|
||||
55
app/modules/dev_tools/tests/unit/test_sql_query_service.py
Normal file
55
app/modules/dev_tools/tests/unit/test_sql_query_service.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Unit tests for sql_query_service."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.dev_tools.services.sql_query_service import (
|
||||
QueryValidationError,
|
||||
validate_query,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.dev
|
||||
class TestValidateQuery:
|
||||
"""Tests for the validate_query function."""
|
||||
|
||||
def test_select_allowed(self):
|
||||
validate_query("SELECT * FROM users")
|
||||
|
||||
def test_empty_query_rejected(self):
|
||||
with pytest.raises(QueryValidationError, match="empty"):
|
||||
validate_query("")
|
||||
|
||||
def test_insert_rejected(self):
|
||||
with pytest.raises(QueryValidationError, match="INSERT"):
|
||||
validate_query("INSERT INTO users (email) VALUES ('a@b.com')")
|
||||
|
||||
def test_update_rejected(self):
|
||||
with pytest.raises(QueryValidationError, match="UPDATE"):
|
||||
validate_query("UPDATE users SET email = 'x' WHERE id = 1")
|
||||
|
||||
def test_delete_rejected(self):
|
||||
with pytest.raises(QueryValidationError, match="DELETE"):
|
||||
validate_query("DELETE FROM users WHERE id = 1")
|
||||
|
||||
def test_drop_rejected(self):
|
||||
with pytest.raises(QueryValidationError, match="DROP"):
|
||||
validate_query("DROP TABLE users")
|
||||
|
||||
def test_alter_rejected(self):
|
||||
with pytest.raises(QueryValidationError, match="ALTER"):
|
||||
validate_query("ALTER TABLE users ADD COLUMN foo TEXT")
|
||||
|
||||
def test_truncate_rejected(self):
|
||||
with pytest.raises(QueryValidationError, match="TRUNCATE"):
|
||||
validate_query("TRUNCATE users")
|
||||
|
||||
def test_comment_hiding_rejected(self):
|
||||
"""Forbidden keywords hidden inside comments are stripped first."""
|
||||
validate_query("SELECT 1 -- DROP TABLE users")
|
||||
|
||||
def test_block_comment_hiding_rejected(self):
|
||||
validate_query("SELECT /* DELETE FROM users */ 1")
|
||||
|
||||
def test_select_with_where(self):
|
||||
validate_query("SELECT id, email FROM users WHERE is_active = true")
|
||||
@@ -60,7 +60,7 @@ def _get_feature_provider():
|
||||
|
||||
def _get_onboarding_provider():
|
||||
"""Lazy import of onboarding provider to avoid circular imports."""
|
||||
from app.modules.loyalty.services.loyalty_onboarding import (
|
||||
from app.modules.loyalty.services.loyalty_onboarding_service import (
|
||||
loyalty_onboarding_provider,
|
||||
)
|
||||
|
||||
|
||||
@@ -417,9 +417,9 @@ class AppleWalletService:
|
||||
size = 29 * scale
|
||||
|
||||
if program.logo_url:
|
||||
try:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.get(program.logo_url, timeout=10, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
@@ -428,7 +428,7 @@ class AppleWalletService:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
except (httpx.HTTPError, OSError, ValueError):
|
||||
logger.warning("Failed to fetch logo for icon, using fallback")
|
||||
|
||||
# Fallback: colored square with initial
|
||||
@@ -463,9 +463,9 @@ class AppleWalletService:
|
||||
width, height = 160 * scale, 50 * scale
|
||||
|
||||
if program.logo_url:
|
||||
try:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.get(program.logo_url, timeout=10, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
@@ -480,7 +480,7 @@ class AppleWalletService:
|
||||
buf = io.BytesIO()
|
||||
canvas.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
except (httpx.HTTPError, OSError, ValueError):
|
||||
logger.warning("Failed to fetch logo for pass logo, using fallback")
|
||||
|
||||
# Fallback: colored rectangle with initial
|
||||
@@ -719,7 +719,7 @@ class AppleWalletService:
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except (httpx.HTTPError, ssl.SSLError, OSError) as exc:
|
||||
logger.error("APNs push error for token %s...: %s", push_token[:8], exc)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -141,7 +142,7 @@ class GoogleWalletService:
|
||||
|
||||
except json.JSONDecodeError as exc:
|
||||
result["errors"].append(f"Invalid JSON in service account file: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except (OSError, ValueError) as exc:
|
||||
result["errors"].append(f"Failed to load credentials: {exc}")
|
||||
|
||||
return result
|
||||
@@ -182,9 +183,9 @@ class GoogleWalletService:
|
||||
settings.loyalty_google_service_account_json,
|
||||
)
|
||||
return self._signer
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except (ValueError, OSError, KeyError) as exc:
|
||||
logger.error("Failed to create RSA signer: %s", exc)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
raise WalletIntegrationException("google", str(exc)) from exc
|
||||
|
||||
def _get_http_client(self):
|
||||
"""Get authenticated HTTP client."""
|
||||
@@ -197,9 +198,9 @@ class GoogleWalletService:
|
||||
credentials = self._get_credentials()
|
||||
self._http_client = AuthorizedSession(credentials)
|
||||
return self._http_client
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
logger.error("Failed to create Google HTTP client: %s", exc)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
raise WalletIntegrationException("google", str(exc)) from exc
|
||||
|
||||
# =========================================================================
|
||||
# LoyaltyClass Operations (Program-level)
|
||||
@@ -283,9 +284,9 @@ class GoogleWalletService:
|
||||
)
|
||||
except WalletIntegrationException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except (requests.RequestException, ValueError, AttributeError) as exc:
|
||||
logger.error("Failed to create Google Wallet class: %s", exc)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
raise WalletIntegrationException("google", str(exc)) from exc
|
||||
|
||||
@_retry_on_failure
|
||||
def update_class(self, db: Session, program: LoyaltyProgram) -> None:
|
||||
@@ -317,7 +318,7 @@ class GoogleWalletService:
|
||||
program.google_class_id,
|
||||
response.status_code,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except (requests.RequestException, ValueError, AttributeError) as exc:
|
||||
logger.error("Failed to update Google Wallet class: %s", exc)
|
||||
|
||||
# =========================================================================
|
||||
@@ -375,9 +376,9 @@ class GoogleWalletService:
|
||||
)
|
||||
except WalletIntegrationException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except (requests.RequestException, ValueError, AttributeError) as exc:
|
||||
logger.error("Failed to create Google Wallet object: %s", exc)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
raise WalletIntegrationException("google", str(exc)) from exc
|
||||
|
||||
@_retry_on_failure
|
||||
def update_object(self, db: Session, card: LoyaltyCard) -> None:
|
||||
@@ -405,7 +406,7 @@ class GoogleWalletService:
|
||||
card.google_object_id,
|
||||
response.status_code,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except (requests.RequestException, ValueError, AttributeError) as exc:
|
||||
logger.error("Failed to update Google Wallet object: %s", exc)
|
||||
|
||||
def _build_object_data(
|
||||
@@ -506,11 +507,11 @@ class GoogleWalletService:
|
||||
db.commit()
|
||||
|
||||
return f"https://pay.google.com/gp/v/save/{token}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except (AttributeError, ValueError, KeyError) as exc:
|
||||
logger.error(
|
||||
"Failed to generate Google Wallet save URL: %s", exc
|
||||
)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
raise WalletIntegrationException("google", str(exc)) from exc
|
||||
|
||||
# =========================================================================
|
||||
# Class Approval
|
||||
@@ -544,7 +545,7 @@ class GoogleWalletService:
|
||||
"program_name": data.get("programName"),
|
||||
}
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except (requests.RequestException, ValueError, AttributeError) as exc:
|
||||
logger.error(
|
||||
"Failed to get Google Wallet class status: %s", exc
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# app/modules/loyalty/services/loyalty_onboarding.py
|
||||
# app/modules/loyalty/services/loyalty_onboarding_service.py
|
||||
"""
|
||||
Onboarding provider for the loyalty module.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# app/modules/loyalty/tests/unit/test_loyalty_onboarding.py
|
||||
# app/modules/loyalty/tests/unit/test_loyalty_onboarding_service.py
|
||||
"""Unit tests for LoyaltyOnboardingProvider."""
|
||||
|
||||
import uuid
|
||||
@@ -6,7 +6,9 @@ import uuid
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyProgram, LoyaltyType
|
||||
from app.modules.loyalty.services.loyalty_onboarding import LoyaltyOnboardingProvider
|
||||
from app.modules.loyalty.services.loyalty_onboarding_service import (
|
||||
LoyaltyOnboardingProvider,
|
||||
)
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
78
app/modules/tenancy/routes/api/user_account.py
Normal file
78
app/modules/tenancy/routes/api/user_account.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# 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 (
|
||||
PasswordChangeResponse,
|
||||
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", response_model=PasswordChangeResponse)
|
||||
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 PasswordChangeResponse(message="Password changed successfully")
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
64
app/modules/tenancy/schemas/user_account.py
Normal file
64
app/modules/tenancy/schemas/user_account.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
|
||||
|
||||
class PasswordChangeResponse(BaseModel):
|
||||
"""Response for a successful password change."""
|
||||
|
||||
message: str
|
||||
100
app/modules/tenancy/services/user_account_service.py
Normal file
100
app/modules/tenancy/services/user_account_service.py
Normal file
@@ -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()
|
||||
294
app/modules/tenancy/templates/tenancy/admin/my-account.html
Normal file
294
app/modules/tenancy/templates/tenancy/admin/my-account.html
Normal file
@@ -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 %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='My Account', subtitle='Manage your personal account information') %}
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading account...') }}
|
||||
{{ error_state('Error loading account') }}
|
||||
|
||||
<!-- Account Form -->
|
||||
<div x-show="!loading && !error" class="w-full mb-8">
|
||||
<!-- Personal Information -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Personal Information</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your name and email address</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- First Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.first_name"
|
||||
@input="profileChanged = true"
|
||||
:class="{'border-red-500': errors.first_name}"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
<p x-show="errors.first_name" class="mt-1 text-xs text-red-500" x-text="errors.first_name"></p>
|
||||
</div>
|
||||
|
||||
<!-- Last Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.last_name"
|
||||
@input="profileChanged = true"
|
||||
:class="{'border-red-500': errors.last_name}"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
<p x-show="errors.last_name" class="mt-1 text-xs text-red-500" x-text="errors.last_name"></p>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
x-model="form.email"
|
||||
@input="profileChanged = true"
|
||||
:class="{'border-red-500': errors.email}"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
<p x-show="errors.email" class="mt-1 text-xs text-red-500" x-text="errors.email"></p>
|
||||
</div>
|
||||
|
||||
<!-- Preferred Language -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Preferred Language</label>
|
||||
<select
|
||||
x-model="form.preferred_language"
|
||||
@change="profileChanged = true"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
>
|
||||
<option value="">-- Default --</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="lb">Luxembourgish</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
@click="resetProfile()"
|
||||
x-show="profileChanged"
|
||||
class="mr-3 px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>Reset</button>
|
||||
<button
|
||||
@click="saveProfile()"
|
||||
:disabled="savingProfile || !profileChanged"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!savingProfile">Save Changes</span>
|
||||
<span x-show="savingProfile">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Change Password</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your login password</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
x-model="passwordForm.current_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
x-model="passwordForm.new_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">Minimum 8 characters, must include a letter and a digit</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
x-model="passwordForm.confirm_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
@click="changePassword()"
|
||||
:disabled="changingPassword"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50"
|
||||
>
|
||||
<span x-show="!changingPassword">Change Password</span>
|
||||
<span x-show="changingPassword">Changing...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Info (Read Only) -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Account Information</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Read-only account metadata</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Username</label>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="account?.username"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Role</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="account?.role"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Email Verified</label>
|
||||
<span
|
||||
:class="account?.is_email_verified
|
||||
? 'px-2 py-1 text-xs font-semibold text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100'
|
||||
: 'px-2 py-1 text-xs font-semibold text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="account?.is_email_verified ? 'Verified' : 'Not verified'"
|
||||
></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Last Login</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(account?.last_login)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Account Created</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(account?.created_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function myAccountPage() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'my_account',
|
||||
|
||||
loading: true,
|
||||
error: null,
|
||||
account: null,
|
||||
|
||||
form: { first_name: '', last_name: '', email: '', preferred_language: '' },
|
||||
errors: {},
|
||||
profileChanged: false,
|
||||
savingProfile: false,
|
||||
|
||||
passwordForm: { current_password: '', new_password: '', confirm_password: '' },
|
||||
changingPassword: false,
|
||||
|
||||
async init() {
|
||||
const parentInit = data().init;
|
||||
if (parentInit) await parentInit.call(this);
|
||||
this.currentPage = 'my_account';
|
||||
await this.loadAccount();
|
||||
},
|
||||
|
||||
async loadAccount() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const data = await apiClient.get('/admin/account/me');
|
||||
this.account = data;
|
||||
this.form = {
|
||||
first_name: data.first_name || '',
|
||||
last_name: data.last_name || '',
|
||||
email: data.email || '',
|
||||
preferred_language: data.preferred_language || '',
|
||||
};
|
||||
this.profileChanged = false;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to load account';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetProfile() {
|
||||
if (this.account) {
|
||||
this.form = {
|
||||
first_name: this.account.first_name || '',
|
||||
last_name: this.account.last_name || '',
|
||||
email: this.account.email || '',
|
||||
preferred_language: this.account.preferred_language || '',
|
||||
};
|
||||
}
|
||||
this.profileChanged = false;
|
||||
this.errors = {};
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
this.errors = {};
|
||||
this.savingProfile = true;
|
||||
try {
|
||||
const resp = await apiClient.put('/admin/account/me', this.form);
|
||||
this.account = resp;
|
||||
this.profileChanged = false;
|
||||
Utils.showToast('Profile updated successfully', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast(err.message || 'Failed to save', 'error');
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (!this.passwordForm.current_password || !this.passwordForm.new_password) {
|
||||
Utils.showToast('Please fill in all password fields', 'error');
|
||||
return;
|
||||
}
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
Utils.showToast('New password and confirmation do not match', 'error');
|
||||
return;
|
||||
}
|
||||
this.changingPassword = true;
|
||||
try {
|
||||
await apiClient.put('/admin/account/me/password', this.passwordForm);
|
||||
this.passwordForm = { current_password: '', new_password: '', confirm_password: '' };
|
||||
Utils.showToast('Password changed successfully', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast(err.message || 'Failed to change password', 'error');
|
||||
} finally {
|
||||
this.changingPassword = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return dateString; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
253
app/modules/tenancy/templates/tenancy/merchant/my-account.html
Normal file
253
app/modules/tenancy/templates/tenancy/merchant/my-account.html
Normal file
@@ -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 %}
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">My Account</h2>
|
||||
<p class="mt-1 text-gray-500 dark:text-gray-400">Manage your personal account information.</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg dark:bg-red-900/20 dark:border-red-800">
|
||||
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Success -->
|
||||
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg dark:bg-green-900/20 dark:border-green-800">
|
||||
<p class="text-sm text-green-800 dark:text-green-200" x-text="successMessage"></p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="text-center py-12 text-gray-500">
|
||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Loading account...
|
||||
</div>
|
||||
|
||||
<!-- Personal Information -->
|
||||
<div x-show="!loading" class="mb-8 bg-white rounded-lg shadow-sm border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Personal Information</h3>
|
||||
</div>
|
||||
<form @submit.prevent="saveProfile()" class="p-6 space-y-6">
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name</label>
|
||||
<input type="text" x-model="form.first_name"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name</label>
|
||||
<input type="text" x-model="form.last_name"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
|
||||
<input type="email" x-model="form.email"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Preferred Language</label>
|
||||
<select x-model="form.preferred_language"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors">
|
||||
<option value="">-- Default --</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="lb">Luxembourgish</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="submit" :disabled="savingProfile"
|
||||
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50">
|
||||
<span x-show="!savingProfile">Save Changes</span>
|
||||
<span x-show="savingProfile" class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div x-show="!loading" class="mb-8 bg-white rounded-lg shadow-sm border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Change Password</h3>
|
||||
</div>
|
||||
<form @submit.prevent="changePassword()" class="p-6 space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
|
||||
<input type="password" x-model="passwordForm.current_password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
|
||||
<input type="password" x-model="passwordForm.new_password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
<p class="mt-1 text-xs text-gray-400">Minimum 8 characters, must include a letter and a digit</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
|
||||
<input type="password" x-model="passwordForm.confirm_password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 dark:text-gray-100 dark:bg-gray-700 dark:border-gray-600 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="submit" :disabled="changingPassword"
|
||||
class="inline-flex items-center px-5 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50">
|
||||
<span x-show="!changingPassword">Change Password</span>
|
||||
<span x-show="changingPassword" class="inline-flex items-center">
|
||||
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Changing...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Account Info (Read Only) -->
|
||||
<div x-show="!loading" class="bg-white rounded-lg shadow-sm border border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Account Information</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Read-only account metadata</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Username</label>
|
||||
<p class="text-sm font-mono text-gray-900 dark:text-gray-100" x-text="account?.username"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Role</label>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="account?.role"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Email Verified</label>
|
||||
<span :class="account?.is_email_verified
|
||||
? 'px-2 py-1 text-xs font-semibold text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100'
|
||||
: 'px-2 py-1 text-xs font-semibold text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="account?.is_email_verified ? 'Verified' : 'Not verified'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Last Login</label>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="formatDate(account?.last_login)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Account Created</label>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="formatDate(account?.created_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function myAccountPage() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'my_account',
|
||||
|
||||
loading: true,
|
||||
error: null,
|
||||
successMessage: null,
|
||||
account: null,
|
||||
|
||||
form: { first_name: '', last_name: '', email: '', preferred_language: '' },
|
||||
savingProfile: false,
|
||||
|
||||
passwordForm: { current_password: '', new_password: '', confirm_password: '' },
|
||||
changingPassword: false,
|
||||
|
||||
async init() {
|
||||
const parentInit = data().init;
|
||||
if (parentInit) await parentInit.call(this);
|
||||
this.currentPage = 'my_account';
|
||||
await this.loadAccount();
|
||||
},
|
||||
|
||||
async loadAccount() {
|
||||
try {
|
||||
const resp = await apiClient.get('/merchants/account/me');
|
||||
this.account = resp;
|
||||
this.form = {
|
||||
first_name: resp.first_name || '',
|
||||
last_name: resp.last_name || '',
|
||||
email: resp.email || '',
|
||||
preferred_language: resp.preferred_language || '',
|
||||
};
|
||||
} catch (err) {
|
||||
this.error = 'Failed to load account. Please try again.';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
this.savingProfile = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
try {
|
||||
const resp = await apiClient.put('/merchants/account/me', this.form);
|
||||
this.account = resp;
|
||||
this.successMessage = 'Profile updated successfully.';
|
||||
setTimeout(() => { this.successMessage = null; }, 3000);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (!this.passwordForm.current_password || !this.passwordForm.new_password) {
|
||||
this.error = 'Please fill in all password fields.';
|
||||
return;
|
||||
}
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
this.error = 'New password and confirmation do not match.';
|
||||
return;
|
||||
}
|
||||
this.changingPassword = true;
|
||||
this.error = null;
|
||||
this.successMessage = null;
|
||||
try {
|
||||
await apiClient.put('/merchants/account/me/password', this.passwordForm);
|
||||
this.passwordForm = { current_password: '', new_password: '', confirm_password: '' };
|
||||
this.successMessage = 'Password changed successfully.';
|
||||
setTimeout(() => { this.successMessage = null; }, 3000);
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
} finally {
|
||||
this.changingPassword = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return dateString; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
243
app/modules/tenancy/templates/tenancy/store/my-account.html
Normal file
243
app/modules/tenancy/templates/tenancy/store/my-account.html
Normal file
@@ -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 %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='My Account', subtitle='Manage your personal account information') %}
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading account...') }}
|
||||
{{ error_state('Error loading account') }}
|
||||
|
||||
<!-- Account Form -->
|
||||
<div x-show="!loading && !error" class="w-full mb-8">
|
||||
<!-- Personal Information -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Personal Information</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your name and email address</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">First Name</label>
|
||||
<input type="text" x-model="form.first_name" @input="profileChanged = true"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Name</label>
|
||||
<input type="text" x-model="form.last_name" @input="profileChanged = true"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email</label>
|
||||
<input type="email" x-model="form.email" @input="profileChanged = true"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Preferred Language</label>
|
||||
<select x-model="form.preferred_language" @change="profileChanged = true"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400">
|
||||
<option value="">-- Default --</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">French</option>
|
||||
<option value="de">German</option>
|
||||
<option value="lb">Luxembourgish</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-6">
|
||||
<button @click="resetProfile()" x-show="profileChanged"
|
||||
class="mr-3 px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Reset</button>
|
||||
<button @click="saveProfile()" :disabled="savingProfile || !profileChanged"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
|
||||
<span x-show="!savingProfile">Save Changes</span>
|
||||
<span x-show="savingProfile">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Change Password</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Update your login password</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Current Password</label>
|
||||
<input type="password" x-model="passwordForm.current_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Password</label>
|
||||
<input type="password" x-model="passwordForm.new_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
<p class="mt-1 text-xs text-gray-400">Minimum 8 characters, must include a letter and a digit</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Confirm New Password</label>
|
||||
<input type="password" x-model="passwordForm.confirm_password"
|
||||
class="w-full px-4 py-2 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg dark:text-gray-300 dark:bg-gray-700 dark:border-gray-600 focus:border-purple-400 focus:outline-none focus:ring-1 focus:ring-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-6">
|
||||
<button @click="changePassword()" :disabled="changingPassword"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50">
|
||||
<span x-show="!changingPassword">Change Password</span>
|
||||
<span x-show="changingPassword">Changing...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Info (Read Only) -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">Account Information</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Read-only account metadata</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Username</label>
|
||||
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="account?.username"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Role</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="account?.role"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Email Verified</label>
|
||||
<span :class="account?.is_email_verified
|
||||
? 'px-2 py-1 text-xs font-semibold text-green-700 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-100'
|
||||
: 'px-2 py-1 text-xs font-semibold text-gray-700 bg-gray-100 rounded-full dark:bg-gray-700 dark:text-gray-100'"
|
||||
x-text="account?.is_email_verified ? 'Verified' : 'Not verified'"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Last Login</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(account?.last_login)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Account Created</label>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(account?.created_at)"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function myAccountPage() {
|
||||
return {
|
||||
...data(),
|
||||
currentPage: 'my_account',
|
||||
|
||||
loading: true,
|
||||
error: null,
|
||||
account: null,
|
||||
|
||||
form: { first_name: '', last_name: '', email: '', preferred_language: '' },
|
||||
errors: {},
|
||||
profileChanged: false,
|
||||
savingProfile: false,
|
||||
|
||||
passwordForm: { current_password: '', new_password: '', confirm_password: '' },
|
||||
changingPassword: false,
|
||||
|
||||
async init() {
|
||||
const parentInit = data().init;
|
||||
if (parentInit) await parentInit.call(this);
|
||||
this.currentPage = 'my_account';
|
||||
await this.loadAccount();
|
||||
},
|
||||
|
||||
async loadAccount() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const data = await apiClient.get('/store/account/me');
|
||||
this.account = data;
|
||||
this.form = {
|
||||
first_name: data.first_name || '',
|
||||
last_name: data.last_name || '',
|
||||
email: data.email || '',
|
||||
preferred_language: data.preferred_language || '',
|
||||
};
|
||||
this.profileChanged = false;
|
||||
} catch (err) {
|
||||
this.error = err.message || 'Failed to load account';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
resetProfile() {
|
||||
if (this.account) {
|
||||
this.form = {
|
||||
first_name: this.account.first_name || '',
|
||||
last_name: this.account.last_name || '',
|
||||
email: this.account.email || '',
|
||||
preferred_language: this.account.preferred_language || '',
|
||||
};
|
||||
}
|
||||
this.profileChanged = false;
|
||||
this.errors = {};
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
this.savingProfile = true;
|
||||
try {
|
||||
const resp = await apiClient.put('/store/account/me', this.form);
|
||||
this.account = resp;
|
||||
this.profileChanged = false;
|
||||
Utils.showToast('Profile updated successfully', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast(err.message || 'Failed to save', 'error');
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
}
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
if (!this.passwordForm.current_password || !this.passwordForm.new_password) {
|
||||
Utils.showToast('Please fill in all password fields', 'error');
|
||||
return;
|
||||
}
|
||||
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
|
||||
Utils.showToast('New password and confirmation do not match', 'error');
|
||||
return;
|
||||
}
|
||||
this.changingPassword = true;
|
||||
try {
|
||||
await apiClient.put('/store/account/me/password', this.passwordForm);
|
||||
this.passwordForm = { current_password: '', new_password: '', confirm_password: '' };
|
||||
Utils.showToast('Password changed successfully', 'success');
|
||||
} catch (err) {
|
||||
Utils.showToast(err.message || 'Failed to change password', 'error');
|
||||
} finally {
|
||||
this.changingPassword = false;
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return dateString; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
323
app/modules/tenancy/tests/integration/test_user_account_api.py
Normal file
323
app/modules/tenancy/tests/integration/test_user_account_api.py
Normal file
@@ -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
|
||||
91
app/modules/tenancy/tests/unit/test_user_account_schema.py
Normal file
91
app/modules/tenancy/tests/unit/test_user_account_schema.py
Normal file
@@ -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
|
||||
146
app/modules/tenancy/tests/unit/test_user_account_service.py
Normal file
146
app/modules/tenancy/tests/unit/test_user_account_service.py
Normal file
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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 '<div style="display:flex;justify-content:space-between;padding:1px 0">' +
|
||||
return '<div class="_dev_row" style="display:flex;justify-content:space-between;padding:1px 0">' +
|
||||
'<span style="color:' + C.subtext + '">' + escapeHtml(label) + '</span>' +
|
||||
'<span style="color:' + color + ';font-weight:bold">' + escapeHtml(String(value)) + '</span></div>';
|
||||
}
|
||||
@@ -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 = '<div style="width:40px;height:2px;background:' + C.surface1 +
|
||||
dragHandle.innerHTML = '<div style="width:40px;height:2px;background:' + C.surface1 + // noqa: SEC015
|
||||
';border-radius:1px"></div>';
|
||||
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 + '<span id="_dev_badge_' + tab.id + '" style="margin-left:4px;font-size:9px;color:' + C.subtext + '"></span>';
|
||||
btn.innerHTML = tab.label + '<span id="_dev_badge_' + tab.id + '" style="margin-left:4px;font-size:9px;color:' + C.subtext + '"></span>'; // 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('<span style="color:' + C.red + '">' + errCount + 'E</span>');
|
||||
if (warnCount > 0) parts.push('<span style="color:' + C.yellow + '">' + warnCount + 'W</span>');
|
||||
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 += '<div style="margin:2px 0"><span style="display:inline-block;padding:2px 10px;border-radius:3px;' +
|
||||
'font-weight:bold;font-size:11px;background:' + badgeColor + ';color:' + C.base + '">' +
|
||||
escapeHtml(frontend) + '</span></div>';
|
||||
|
||||
// 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 += '<div style="color:' + C.subtext + '">No JWT to decode</div>';
|
||||
}
|
||||
|
||||
// 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 += '<div style="color:' + C.subtext + '">No exp claim</div>';
|
||||
}
|
||||
|
||||
// 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 += '<div id="_dev_auth_me_section"></div>';
|
||||
|
||||
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
|
||||
'<div style="color:' + C.subtext + '">Loading...</div>';
|
||||
|
||||
fetchFrontendAuthMe(frontend).then(function (me) {
|
||||
if (activeTab !== 'auth') return;
|
||||
var meHtml = sectionHeader('Server Auth (' + getAuthMeEndpoint(frontend) + ')');
|
||||
if (me.error) {
|
||||
meHtml += '<div style="color:' + C.red + '">' + escapeHtml(me.error) + '</div>';
|
||||
} 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 += '<div style="color:' + C.red + ';font-weight:bold">MISMATCH: ' + escapeHtml(msg) + '</div>';
|
||||
});
|
||||
} else {
|
||||
meHtml += '<div style="color:' + C.green + '">All checked fields consistent</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
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 += '<div style="color:' + C.green + '">All sources consistent</div>';
|
||||
}
|
||||
|
||||
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 += '<div style="border-bottom:1px solid ' + C.surface0 + ';cursor:pointer" data-api-row="' + call.id + '">';
|
||||
html += '<div class="_dev_row" style="border-bottom:1px solid ' + C.surface0 + ';cursor:pointer" data-api-row="' + call.id + '">';
|
||||
html += '<div style="display:flex;padding:2px 4px;align-items:center" data-api-toggle="' + call.id + '">';
|
||||
html += '<span style="width:50px;color:' + methodColor + ';font-weight:bold;font-size:10px">' + call.method + '</span>';
|
||||
html += '<span style="flex:1;padding:0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(call.url) + '</span>';
|
||||
@@ -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 += '<div style="color:' + C.subtext + '">LogConfig not defined</div>';
|
||||
}
|
||||
|
||||
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 += '<div style="display:flex;gap:6px;padding:2px 0;border-bottom:1px solid ' + C.surface0 + ';align-items:flex-start">';
|
||||
html += '<div class="_dev_row" style="display:flex;gap:6px;padding:2px 0;border-bottom:1px solid ' + C.surface0 + ';align-items:flex-start">';
|
||||
html += '<span style="flex-shrink:0;color:' + C.subtext + ';font-size:10px;width:75px">' + formatTime(entry.timestamp) + '</span>';
|
||||
html += '<span style="flex-shrink:0">' + levelBadge(entry.level) + '</span>';
|
||||
html += '<span style="flex:1;white-space:pre-wrap;word-break:break-all;color:' + levelColor(entry.level) + '">';
|
||||
@@ -740,7 +954,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
contentEl.innerHTML = html;
|
||||
contentEl.innerHTML = html; // noqa: SEC015
|
||||
|
||||
// Attach filter listeners
|
||||
contentEl.querySelectorAll('._dev_console_filter').forEach(function (btn) {
|
||||
|
||||
Reference in New Issue
Block a user