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",
|
||||
"title": "Team",
|
||||
"total_members": "Mitglieder gesamt",
|
||||
"personal_info": "Persönliche Informationen",
|
||||
"save_profile": "Profil speichern",
|
||||
"view_member": "Mitglied anzeigen",
|
||||
"account_information": "Kontoinformationen",
|
||||
"username": "Benutzername",
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
"stores_and_roles": "Stores & Roles",
|
||||
"title": "Team",
|
||||
"total_members": "Total Members",
|
||||
"personal_info": "Personal Information",
|
||||
"save_profile": "Save Profile",
|
||||
"view_member": "View Member",
|
||||
"account_information": "Account Information",
|
||||
"username": "Username",
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
"stores_and_roles": "Magasins et rôles",
|
||||
"title": "Équipe",
|
||||
"total_members": "Membres totaux",
|
||||
"personal_info": "Informations personnelles",
|
||||
"save_profile": "Enregistrer le profil",
|
||||
"view_member": "Voir le membre",
|
||||
"account_information": "Informations du compte",
|
||||
"username": "Nom d'utilisateur",
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
"stores_and_roles": "Geschäfter & Rollen",
|
||||
"title": "Team",
|
||||
"total_members": "Memberen total",
|
||||
"personal_info": "Perséinlech Informatiounen",
|
||||
"save_profile": "Profil späicheren",
|
||||
"view_member": "Member kucken",
|
||||
"account_information": "Konto Informatiounen",
|
||||
"username": "Benotzernumm",
|
||||
|
||||
@@ -25,7 +25,10 @@ from app.modules.tenancy.schemas import (
|
||||
MerchantStoreUpdate,
|
||||
)
|
||||
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_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}")
|
||||
async def merchant_team_update_role(
|
||||
store_id: int,
|
||||
|
||||
@@ -401,3 +401,11 @@ class MerchantTeamInviteResponse(BaseModel):
|
||||
message: str
|
||||
email: EmailStr
|
||||
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.store import Role, Store
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from app.modules.tenancy.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -456,6 +457,58 @@ class MerchantStoreService:
|
||||
raise StoreNotFoundException(store_id, identifier_type="id")
|
||||
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:
|
||||
"""
|
||||
Get team members across all merchant stores in a member-centric view.
|
||||
|
||||
@@ -201,6 +201,31 @@ function merchantTeam() {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -271,26 +271,41 @@
|
||||
<!-- ==================== EDIT MODAL ==================== -->
|
||||
{% call modal('editModal', _('tenancy.team.edit_member'), 'showEditModal', size='md', show_footer=false) %}
|
||||
<div x-show="selectedMember" class="space-y-4">
|
||||
<!-- Member info (read-only) -->
|
||||
<div class="flex items-center gap-3 pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="w-10 h-10 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center">
|
||||
<span class="text-sm font-semibold text-purple-600 dark:text-purple-300"
|
||||
x-text="selectedMember ? getInitials(selectedMember) : ''"></span>
|
||||
<!-- Profile fields -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('tenancy.team.personal_info') }}</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<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>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
<span x-text="selectedMember?.first_name || ''"></span>
|
||||
<span x-text="selectedMember?.last_name || ''"></span>
|
||||
<span x-show="!selectedMember?.first_name && !selectedMember?.last_name"
|
||||
x-text="selectedMember?.email"></span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="selectedMember?.email"
|
||||
x-show="selectedMember?.first_name || selectedMember?.last_name"></p>
|
||||
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">{{ _('tenancy.team.email') }}</label>
|
||||
<input type="email" x-model="selectedMember.email"
|
||||
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.email') }}">
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button @click="updateMemberProfile(selectedMember.user_id, selectedMember.first_name, selectedMember.last_name, selectedMember.email)"
|
||||
: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>
|
||||
|
||||
<!-- 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>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user