feat(tenancy): add profile editing in merchant team edit modal
Some checks failed
Some checks failed
Edit modal now has editable first_name, last_name, email fields with
a "Save Profile" button, alongside the existing per-store role management.
New:
- PUT /merchants/account/team/members/{user_id}/profile endpoint
- MerchantTeamProfileUpdate schema
- update_team_member_profile() service method with ownership validation
- 2 new i18n keys across 4 locales (personal_info, save_profile)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,8 @@
|
|||||||
"stores_and_roles": "Filialen & Rollen",
|
"stores_and_roles": "Filialen & Rollen",
|
||||||
"title": "Team",
|
"title": "Team",
|
||||||
"total_members": "Mitglieder gesamt",
|
"total_members": "Mitglieder gesamt",
|
||||||
|
"personal_info": "Persönliche Informationen",
|
||||||
|
"save_profile": "Profil speichern",
|
||||||
"view_member": "Mitglied anzeigen",
|
"view_member": "Mitglied anzeigen",
|
||||||
"account_information": "Kontoinformationen",
|
"account_information": "Kontoinformationen",
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
"stores_and_roles": "Stores & Roles",
|
"stores_and_roles": "Stores & Roles",
|
||||||
"title": "Team",
|
"title": "Team",
|
||||||
"total_members": "Total Members",
|
"total_members": "Total Members",
|
||||||
|
"personal_info": "Personal Information",
|
||||||
|
"save_profile": "Save Profile",
|
||||||
"view_member": "View Member",
|
"view_member": "View Member",
|
||||||
"account_information": "Account Information",
|
"account_information": "Account Information",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
"stores_and_roles": "Magasins et rôles",
|
"stores_and_roles": "Magasins et rôles",
|
||||||
"title": "Équipe",
|
"title": "Équipe",
|
||||||
"total_members": "Membres totaux",
|
"total_members": "Membres totaux",
|
||||||
|
"personal_info": "Informations personnelles",
|
||||||
|
"save_profile": "Enregistrer le profil",
|
||||||
"view_member": "Voir le membre",
|
"view_member": "Voir le membre",
|
||||||
"account_information": "Informations du compte",
|
"account_information": "Informations du compte",
|
||||||
"username": "Nom d'utilisateur",
|
"username": "Nom d'utilisateur",
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
"stores_and_roles": "Geschäfter & Rollen",
|
"stores_and_roles": "Geschäfter & Rollen",
|
||||||
"title": "Team",
|
"title": "Team",
|
||||||
"total_members": "Memberen total",
|
"total_members": "Memberen total",
|
||||||
|
"personal_info": "Perséinlech Informatiounen",
|
||||||
|
"save_profile": "Profil späicheren",
|
||||||
"view_member": "Member kucken",
|
"view_member": "Member kucken",
|
||||||
"account_information": "Konto Informatiounen",
|
"account_information": "Konto Informatiounen",
|
||||||
"username": "Benotzernumm",
|
"username": "Benotzernumm",
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ from app.modules.tenancy.schemas import (
|
|||||||
MerchantStoreUpdate,
|
MerchantStoreUpdate,
|
||||||
)
|
)
|
||||||
from app.modules.tenancy.schemas.auth import UserContext
|
from app.modules.tenancy.schemas.auth import UserContext
|
||||||
from app.modules.tenancy.schemas.team import MerchantTeamInvite
|
from app.modules.tenancy.schemas.team import (
|
||||||
|
MerchantTeamInvite,
|
||||||
|
MerchantTeamProfileUpdate,
|
||||||
|
)
|
||||||
from app.modules.tenancy.services.merchant_service import merchant_service
|
from app.modules.tenancy.services.merchant_service import merchant_service
|
||||||
from app.modules.tenancy.services.merchant_store_service import merchant_store_service
|
from app.modules.tenancy.services.merchant_store_service import merchant_store_service
|
||||||
|
|
||||||
@@ -252,6 +255,26 @@ async def merchant_team_invite(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_account_router.put("/team/members/{user_id}/profile")
|
||||||
|
async def merchant_team_update_profile(
|
||||||
|
user_id: int,
|
||||||
|
data: MerchantTeamProfileUpdate,
|
||||||
|
current_user: UserContext = Depends(get_current_merchant_api),
|
||||||
|
merchant=Depends(get_merchant_for_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a team member's profile (first name, last name, email)."""
|
||||||
|
merchant_store_service.update_team_member_profile(
|
||||||
|
db, merchant.id, user_id, data.model_dump(exclude_unset=True)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"Merchant {merchant.id} updated profile for user {user_id} "
|
||||||
|
f"by {current_user.username}"
|
||||||
|
)
|
||||||
|
return {"message": "Profile updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
@_account_router.put("/team/stores/{store_id}/members/{user_id}")
|
@_account_router.put("/team/stores/{store_id}/members/{user_id}")
|
||||||
async def merchant_team_update_role(
|
async def merchant_team_update_role(
|
||||||
store_id: int,
|
store_id: int,
|
||||||
|
|||||||
@@ -401,3 +401,11 @@ class MerchantTeamInviteResponse(BaseModel):
|
|||||||
message: str
|
message: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
results: list[MerchantTeamInviteResult]
|
results: list[MerchantTeamInviteResult]
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantTeamProfileUpdate(BaseModel):
|
||||||
|
"""Schema for updating a team member's profile."""
|
||||||
|
|
||||||
|
first_name: str | None = Field(None, max_length=100)
|
||||||
|
last_name: str | None = Field(None, max_length=100)
|
||||||
|
email: EmailStr | None = None
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from app.modules.tenancy.models.merchant import Merchant
|
|||||||
from app.modules.tenancy.models.platform import Platform
|
from app.modules.tenancy.models.platform import Platform
|
||||||
from app.modules.tenancy.models.store import Role, Store
|
from app.modules.tenancy.models.store import Role, Store
|
||||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||||
|
from app.modules.tenancy.models.user import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -456,6 +457,58 @@ class MerchantStoreService:
|
|||||||
raise StoreNotFoundException(store_id, identifier_type="id")
|
raise StoreNotFoundException(store_id, identifier_type="id")
|
||||||
return store
|
return store
|
||||||
|
|
||||||
|
def update_team_member_profile(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
merchant_id: int,
|
||||||
|
user_id: int,
|
||||||
|
update_data: dict,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update a team member's profile (first_name, last_name, email).
|
||||||
|
|
||||||
|
Validates that the user is a team member of one of the merchant's stores.
|
||||||
|
"""
|
||||||
|
from app.modules.tenancy.models.store import StoreUser
|
||||||
|
|
||||||
|
# Verify user is a team member in at least one of the merchant's stores
|
||||||
|
stores = (
|
||||||
|
db.query(Store)
|
||||||
|
.filter(Store.merchant_id == merchant_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
store_ids = [s.id for s in stores]
|
||||||
|
|
||||||
|
membership = (
|
||||||
|
db.query(StoreUser)
|
||||||
|
.filter(
|
||||||
|
StoreUser.store_id.in_(store_ids),
|
||||||
|
StoreUser.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not membership:
|
||||||
|
# Also allow updating the owner
|
||||||
|
merchant = db.query(Merchant).filter(Merchant.id == merchant_id).first()
|
||||||
|
if not merchant or merchant.owner_user_id != user_id:
|
||||||
|
from app.modules.tenancy.exceptions import UserNotFoundException
|
||||||
|
raise UserNotFoundException(str(user_id))
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
from app.modules.tenancy.exceptions import UserNotFoundException
|
||||||
|
raise UserNotFoundException(str(user_id))
|
||||||
|
|
||||||
|
if "first_name" in update_data:
|
||||||
|
user.first_name = update_data["first_name"]
|
||||||
|
if "last_name" in update_data:
|
||||||
|
user.last_name = update_data["last_name"]
|
||||||
|
if "email" in update_data and update_data["email"]:
|
||||||
|
user.email = update_data["email"]
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
def get_merchant_team_members(self, db: Session, merchant_id: int) -> dict:
|
def get_merchant_team_members(self, db: Session, merchant_id: int) -> dict:
|
||||||
"""
|
"""
|
||||||
Get team members across all merchant stores in a member-centric view.
|
Get team members across all merchant stores in a member-centric view.
|
||||||
|
|||||||
@@ -201,6 +201,31 @@ function merchantTeam() {
|
|||||||
this.showEditModal = true;
|
this.showEditModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update member profile (first name, last name, email)
|
||||||
|
*/
|
||||||
|
async updateMemberProfile(userId, firstName, lastName, email) {
|
||||||
|
this.saving = true;
|
||||||
|
try {
|
||||||
|
await apiClient.put(
|
||||||
|
`/merchants/account/team/members/${userId}/profile`,
|
||||||
|
{ first_name: firstName, last_name: lastName, email: email }
|
||||||
|
);
|
||||||
|
|
||||||
|
Utils.showToast(I18n.t('tenancy.messages.team_member_updated'), 'success');
|
||||||
|
merchantTeamLog.info('Updated member profile:', userId);
|
||||||
|
|
||||||
|
this.showEditModal = false;
|
||||||
|
this.selectedMember = null;
|
||||||
|
await this.loadTeamData();
|
||||||
|
} catch (error) {
|
||||||
|
merchantTeamLog.error('Failed to update member profile:', error);
|
||||||
|
Utils.showToast(error.message || 'Failed to update member profile', 'error');
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update member role for a specific store
|
* Update member role for a specific store
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -271,26 +271,41 @@
|
|||||||
<!-- ==================== EDIT MODAL ==================== -->
|
<!-- ==================== EDIT MODAL ==================== -->
|
||||||
{% call modal('editModal', _('tenancy.team.edit_member'), 'showEditModal', size='md', show_footer=false) %}
|
{% call modal('editModal', _('tenancy.team.edit_member'), 'showEditModal', size='md', show_footer=false) %}
|
||||||
<div x-show="selectedMember" class="space-y-4">
|
<div x-show="selectedMember" class="space-y-4">
|
||||||
<!-- Member info (read-only) -->
|
<!-- Profile fields -->
|
||||||
<div class="flex items-center gap-3 pb-4 border-b border-gray-200 dark:border-gray-700">
|
<div class="space-y-3">
|
||||||
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.personal_info') }}</h4>
|
||||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300"
|
<div class="grid grid-cols-2 gap-3">
|
||||||
x-text="selectedMember ? getInitials(selectedMember) : ''"></span>
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{{ _('tenancy.team.first_name') }}</label>
|
||||||
|
<input type="text" x-model="selectedMember.first_name"
|
||||||
|
class="w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600"
|
||||||
|
placeholder="{{ _('tenancy.team.first_name') }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{{ _('tenancy.team.last_name') }}</label>
|
||||||
|
<input type="text" x-model="selectedMember.last_name"
|
||||||
|
class="w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600"
|
||||||
|
placeholder="{{ _('tenancy.team.last_name') }}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{{ _('tenancy.team.email') }}</label>
|
||||||
<span x-text="selectedMember?.first_name || ''"></span>
|
<input type="email" x-model="selectedMember.email"
|
||||||
<span x-text="selectedMember?.last_name || ''"></span>
|
class="w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none focus:ring focus:ring-purple-300 dark:focus:ring-purple-600"
|
||||||
<span x-show="!selectedMember?.first_name && !selectedMember?.last_name"
|
placeholder="{{ _('tenancy.team.email') }}">
|
||||||
x-text="selectedMember?.email"></span>
|
</div>
|
||||||
</p>
|
<div class="flex justify-end">
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedMember?.email"
|
<button @click="updateMemberProfile(selectedMember.user_id, selectedMember.first_name, selectedMember.last_name, selectedMember.email)"
|
||||||
x-show="selectedMember?.first_name || selectedMember?.last_name"></p>
|
:disabled="saving"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||||
|
<span x-show="saving" x-html="$icon('spinner', 'w-3 h-3 mr-1')"></span>
|
||||||
|
{{ _('tenancy.team.save_profile') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Per-store role management -->
|
<!-- Per-store role management -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.store_roles') }}</h4>
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.store_roles') }}</h4>
|
||||||
<template x-for="store in selectedMember?.stores || []" :key="store.store_id">
|
<template x-for="store in selectedMember?.stores || []" :key="store.store_id">
|
||||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
|||||||
Reference in New Issue
Block a user