From 641711322ec4ed3adacba8871503bda545383e52 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 30 Jun 2026 13:55:17 +0200 Subject: [PATCH] feat(trips): transfer trip ownership to a member (#973) Add POST /api/trips/:id/transfer so the owner can hand a trip to one of its existing members. The swap runs in a transaction: the new owner takes trips.user_id and the former owner is kept on as a regular member, so nobody loses access. The endpoint is owner-only, writes a trip.transfer_ownership audit entry and broadcasts the refreshed trip. The members modal gains a "Make owner" action, shown only to the current owner. --- client/src/api/client.ts | 3 +- .../src/components/Trips/TripMembersModal.tsx | 28 +++++++++++ server/src/nest/trips/trips.controller.ts | 35 ++++++++++++++ server/src/nest/trips/trips.service.ts | 4 ++ server/src/services/tripService.ts | 42 ++++++++++++++++ server/tests/integration/trips.test.ts | 48 +++++++++++++++++++ .../tests/unit/nest/trips.controller.test.ts | 35 ++++++++++++++ server/tests/unit/nest/trips.service.test.ts | 2 + .../tests/unit/services/tripService.test.ts | 46 +++++++++++++++++- shared/src/i18n/ar/members.ts | 3 ++ shared/src/i18n/br/members.ts | 3 ++ shared/src/i18n/cs/members.ts | 3 ++ shared/src/i18n/de/members.ts | 3 ++ shared/src/i18n/en/members.ts | 3 ++ shared/src/i18n/es/members.ts | 3 ++ shared/src/i18n/fr/members.ts | 3 ++ shared/src/i18n/gr/members.ts | 3 ++ shared/src/i18n/hu/members.ts | 3 ++ shared/src/i18n/id/members.ts | 3 ++ shared/src/i18n/it/members.ts | 3 ++ shared/src/i18n/ja/members.ts | 3 ++ shared/src/i18n/ko/members.ts | 3 ++ shared/src/i18n/nl/members.ts | 3 ++ shared/src/i18n/pl/members.ts | 3 ++ shared/src/i18n/ru/members.ts | 3 ++ shared/src/i18n/sv/members.ts | 3 ++ shared/src/i18n/tr/members.ts | 3 ++ shared/src/i18n/uk/members.ts | 3 ++ shared/src/i18n/vi/members.ts | 3 ++ shared/src/i18n/zh-TW/members.ts | 3 ++ shared/src/i18n/zh/members.ts | 3 ++ shared/src/trip/trip.schema.ts | 6 +++ 32 files changed, 312 insertions(+), 3 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 2b98c6f2a..f72fad943 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -15,7 +15,7 @@ import { type RegisterRequest, type LoginRequest, type ForgotPasswordRequest, type ResetPasswordRequest, type ChangePasswordRequest, type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest, - type TripAddMemberRequest, type AssignmentReorderRequest, + type TripAddMemberRequest, type TripTransferOwnershipRequest, type AssignmentReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest, @@ -340,6 +340,7 @@ export const tripsApi = { getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data), addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier } satisfies TripAddMemberRequest).then(r => r.data), removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), + transferOwnership: (id: number | string, newOwnerId: number) => apiClient.post(`/trips/${id}/transfer`, { newOwnerId } satisfies TripTransferOwnershipRequest).then(r => r.data), copy: (id: number | string, data?: TripCopyRequest) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data), } diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx index 2862a548f..a167baa96 100644 --- a/client/src/components/Trips/TripMembersModal.tsx +++ b/client/src/components/Trips/TripMembersModal.tsx @@ -177,6 +177,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: const [selectedUserId, setSelectedUserId] = useState('') const [adding, setAdding] = useState(false) const [removingId, setRemovingId] = useState(null) + const [transferringId, setTransferringId] = useState(null) const toast = useToast() const { user } = useAuthStore() const { t } = useTranslation() @@ -227,6 +228,21 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: } } + const handleTransfer = async (newOwnerId, username) => { + if (!confirm(t('members.confirmTransfer', { name: username }))) return + setTransferringId(newOwnerId) + try { + await tripsApi.transferOwnership(tripId, newOwnerId) + // The current user just dropped from owner to member — reload so the trip + // state and permissions everywhere reflect the new ownership. + onClose() + window.location.reload() + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('members.transferError'))) + setTransferringId(null) + } + } + const handleRemove = async (userId, isSelf) => { const msg = isSelf ? t('members.confirmLeave') @@ -347,6 +363,18 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: )} + {isCurrentOwner && member.role !== 'owner' && ( + + )} {canRemove && (