feat(tenancy): add profile editing in merchant team edit modal
Some checks failed
CI / ruff (push) Successful in 16s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / pytest (push) Has been cancelled

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:
2026-03-29 13:31:23 +02:00
parent 211c46ebbc
commit 157b4c6ec3
9 changed files with 147 additions and 15 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,

View File

@@ -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

View File

@@ -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.

View File

@@ -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
*/

View File

@@ -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">