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.
This commit is contained in:
Maurice
2026-06-30 13:55:17 +02:00
committed by Maurice
parent fac2393388
commit 641711322e
32 changed files with 312 additions and 3 deletions
+2 -1
View File
@@ -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),
}
@@ -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 }:
)}
</div>
</div>
{isCurrentOwner && member.role !== 'owner' && (
<button
onClick={() => handleTransfer(member.id, member.username)}
disabled={transferringId === member.id}
title={t('members.makeOwner')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: transferringId === member.id ? 0.4 : 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#d97706'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
>
<Crown size={14} />
</button>
)}
{canRemove && (
<button
onClick={() => handleRemove(member.id, isSelf)}
+35
View File
@@ -279,6 +279,41 @@ export class TripsController {
return { success: true };
}
@Post(':id/transfer')
transferOwnership(
@CurrentUser() user: User,
@Param('id') id: string,
@Body('newOwnerId') newOwnerId: unknown,
@Req() req: Request,
@Headers('x-socket-id') socketId?: string,
) {
const access = this.trips.canAccessTrip(id, user.id);
if (!access) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
// Owner-only: handing over a trip is reserved for its actual owner, not just
// anyone who can manage members.
if (access.user_id !== user.id) {
throw new HttpException({ error: 'Only the owner can transfer ownership' }, 403);
}
if (typeof newOwnerId !== 'number') {
throw new HttpException({ error: 'newOwnerId is required' }, 400);
}
try {
const result = this.trips.transferOwnership(id, newOwnerId, user.id);
writeAudit({ userId: user.id, action: 'trip.transfer_ownership', ip: getClientIp(req), details: { tripId: Number(id), trip: result.tripTitle, from: result.fromEmail, to: result.toEmail } });
// Nudge everyone viewing the trip to re-read it so the new ownership and the
// recomputed permissions take effect live.
const updatedTrip = this.trips.get(id, user.id);
this.trips.broadcast(id, 'trip:updated', { trip: updatedTrip }, socketId);
return { success: true };
} catch (e: unknown) {
if (e instanceof NotFoundError) throw new HttpException({ error: e.message }, 404);
if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400);
throw e;
}
}
@Get(':id/bundle')
bundle(@CurrentUser() user: User, @Param('id') id: string) {
const trip = this.trips.get(id, user.id) as { user_id: number } | undefined;
+4
View File
@@ -95,6 +95,10 @@ export class TripsService {
tripSvc.removeMember(tripId, targetId);
}
transferOwnership(tripId: string, newOwnerId: number, currentOwnerId: number) {
return tripSvc.transferOwnership(tripId, newOwnerId, currentOwnerId);
}
exportICS(tripId: string) {
return tripSvc.exportICS(tripId);
}
+42
View File
@@ -403,6 +403,48 @@ export function removeMember(tripId: string | number, targetUserId: number) {
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, targetUserId);
}
export interface TransferOwnershipResult {
tripTitle: string;
fromEmail: string;
toEmail: string;
}
/**
* Hand a trip over to one of its existing members (#973). The new owner must
* already be a member; afterwards they hold `trips.user_id` and the former owner
* becomes a regular member, so nobody loses access. Runs in a transaction so the
* owner pointer and the membership rows never diverge.
*/
export function transferOwnership(
tripId: string | number,
newOwnerId: number,
currentOwnerId: number,
): TransferOwnershipResult {
const trip = db.prepare('SELECT id, title, user_id FROM trips WHERE id = ?').get(tripId) as { id: number; title: string; user_id: number } | undefined;
if (!trip) throw new NotFoundError('Trip not found');
if (trip.user_id !== currentOwnerId) throw new ValidationError('Only the owner can transfer ownership');
if (newOwnerId === currentOwnerId) throw new ValidationError('You already own this trip');
const newOwner = db.prepare('SELECT id, email FROM users WHERE id = ?').get(newOwnerId) as { id: number; email: string } | undefined;
if (!newOwner) throw new NotFoundError('User not found');
const isMember = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, newOwnerId);
if (!isMember) throw new ValidationError('New owner must be a trip member');
const fromEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(currentOwnerId) as { email: string } | undefined)?.email || '';
const run = db.transaction(() => {
db.prepare('UPDATE trips SET user_id = ? WHERE id = ?').run(newOwnerId, tripId);
// The new owner is no longer a plain member…
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, newOwnerId);
// …and the former owner keeps access as a member.
db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, currentOwnerId, newOwnerId);
});
run();
return { tripTitle: trip.title, fromEmail, toEmail: newOwner.email };
}
// ── ICS export ────────────────────────────────────────────────────────────
// RFC 5545 §3.1: content lines longer than 75 octets must be folded with a CRLF
+48
View File
@@ -736,6 +736,54 @@ describe('Trip members', () => {
expect(res.body.success).toBe(true);
});
it('TRIP-TRANSFER-001 — owner hands the trip to a member; roles swap (#973)', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/transfer`)
.set('Cookie', authCookie(owner.id))
.send({ newOwnerId: member.id });
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
const row = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number };
expect(row.user_id).toBe(member.id);
const memberRows = (testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[]).map(r => r.user_id);
expect(memberRows).toContain(owner.id);
expect(memberRows).not.toContain(member.id);
});
it('TRIP-TRANSFER-002 — a non-owner cannot transfer ownership → 403', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
addTripMember(testDb, trip.id, member.id);
const res = await request(app)
.post(`/api/trips/${trip.id}/transfer`)
.set('Cookie', authCookie(member.id))
.send({ newOwnerId: member.id });
expect(res.status).toBe(403);
});
it('TRIP-TRANSFER-003 — cannot transfer to a non-member → 400', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Team Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/transfer`)
.set('Cookie', authCookie(owner.id))
.send({ newOwnerId: stranger.id });
expect(res.status).toBe(400);
});
it('TRIP-013 — Non-owner member cannot add other members when member_manage is trip_owner', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
@@ -256,6 +256,41 @@ describe('TripsController (parity with the legacy /api/trips route)', () => {
});
});
describe('POST /:id/transfer (#973)', () => {
it('404 without trip access', () => {
const s = svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) });
expect(thrown(() => new TripsController(s).transferOwnership(user, '9', 2, req))).toEqual({ status: 404, body: { error: 'Trip not found' } });
});
it('403 when the requester is not the owner', () => {
// access.user_id (5) differs from the requesting user (1)
const s = svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) });
expect(thrown(() => new TripsController(s).transferOwnership(user, '9', 2, req))).toEqual({ status: 403, body: { error: 'Only the owner can transfer ownership' } });
});
it('400 when newOwnerId is not a number', () => {
const s = svc();
expect(thrown(() => new TripsController(s).transferOwnership(user, '9', 'nope' as unknown as number, req))).toEqual({ status: 400, body: { error: 'newOwnerId is required' } });
});
it('transfers, audits and broadcasts the refreshed trip', () => {
const transferOwnership = vi.fn().mockReturnValue({ tripTitle: 'Roadtrip', fromEmail: 'a@x.y', toEmail: 'b@x.y' });
const get = vi.fn().mockReturnValue({ id: 9, user_id: 2 });
const broadcast = vi.fn();
const s = svc({ transferOwnership, get, broadcast } as Partial<TripsService>);
expect(new TripsController(s).transferOwnership(user, '9', 2, req, 'sock')).toEqual({ success: true });
expect(transferOwnership).toHaveBeenCalledWith('9', 2, user.id);
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { trip: { id: 9, user_id: 2 } }, 'sock');
});
it('maps NotFoundError to 404 and ValidationError to 400', () => {
const nf = svc({ transferOwnership: vi.fn().mockImplementation(() => { throw new NotFoundError('User not found'); }) } as Partial<TripsService>);
expect(thrown(() => new TripsController(nf).transferOwnership(user, '9', 2, req))).toEqual({ status: 404, body: { error: 'User not found' } });
const ve = svc({ transferOwnership: vi.fn().mockImplementation(() => { throw new ValidationError('New owner must be a trip member'); }) } as Partial<TripsService>);
expect(thrown(() => new TripsController(ve).transferOwnership(user, '9', 2, req))).toEqual({ status: 400, body: { error: 'New owner must be a trip member' } });
});
});
it('GET /:id/bundle 404 then aggregates', () => {
expect(thrown(() => new TripsController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<TripsService>)).bundle(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
const bundle = vi.fn().mockReturnValue({ trip: { id: 9 }, days: [] });
@@ -17,6 +17,7 @@ const { tripSvc } = vi.hoisted(() => ({
listTrips: vi.fn(), createTrip: vi.fn(), getTrip: vi.fn(), updateTrip: vi.fn(), deleteTrip: vi.fn(),
getTripRaw: vi.fn(), getTripOwner: vi.fn(), deleteOldCover: vi.fn(), updateCoverImage: vi.fn(),
listMembers: vi.fn(() => ({ owner: { id: 1 }, members: [] })), addMember: vi.fn(), removeMember: vi.fn(),
transferOwnership: vi.fn(),
exportICS: vi.fn(), copyTripById: vi.fn(), TRIP_SELECT: 'SELECT * FROM trips t',
},
}));
@@ -50,6 +51,7 @@ describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () =>
s.listMembers('9', 1); expect(tripSvc.listMembers).toHaveBeenCalledWith('9', 1);
s.addMember('9', 'b@x.y', 1, 1); expect(tripSvc.addMember).toHaveBeenCalledWith('9', 'b@x.y', 1, 1);
s.removeMember('9', 2); expect(tripSvc.removeMember).toHaveBeenCalledWith('9', 2);
s.transferOwnership('9', 2, 1); expect(tripSvc.transferOwnership).toHaveBeenCalledWith('9', 2, 1);
s.exportICS('9'); expect(tripSvc.exportICS).toHaveBeenCalledWith('9');
});
+44 -2
View File
@@ -33,8 +33,8 @@ vi.mock('../../../src/config', () => ({
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote } from '../../helpers/factories';
import { exportICS, generateDays, deleteOldCover, updateTrip } from '../../../src/services/tripService';
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories';
import { exportICS, generateDays, deleteOldCover, updateTrip, transferOwnership } from '../../../src/services/tripService';
import fs from 'fs';
beforeAll(() => {
@@ -507,3 +507,45 @@ describe('resyncReservationDays (#1288)', () => {
expect(res.day_id).toBe(origDayId);
});
});
describe('transferOwnership (#973)', () => {
it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const result = transferOwnership(trip.id, member.id, owner.id);
expect(result.toEmail).toBe(member.email);
const updated = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number };
expect(updated.user_id).toBe(member.id);
// New owner no longer sits in trip_members, former owner now does.
const memberIds = (testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[]).map(r => r.user_id);
expect(memberIds).toContain(owner.id);
expect(memberIds).not.toContain(member.id);
});
it('TRIP-SVC-021: rejects a transfer from a non-owner', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// member (not the owner) attempts the transfer
expect(() => transferOwnership(trip.id, member.id, member.id)).toThrow();
});
it('TRIP-SVC-022: rejects a transfer to someone who is not a member', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => transferOwnership(trip.id, stranger.id, owner.id)).toThrow('New owner must be a trip member');
});
it('TRIP-SVC-023: rejects transferring to yourself', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => transferOwnership(trip.id, owner.id, owner.id)).toThrow('You already own this trip');
});
});
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'فشلت الإضافة',
'members.removed': 'تمت إزالة العضو',
'members.removeError': 'فشلت الإزالة',
'members.makeOwner': 'تعيين كمالك',
'members.confirmTransfer': 'نقل الملكية إلى {name}؟ ستصبح عضوًا عاديًا.',
'members.transferError': 'فشل نقل الملكية',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Falha ao adicionar',
'members.removed': 'Membro removido',
'members.removeError': 'Falha ao remover',
'members.makeOwner': 'Tornar proprietário',
'members.confirmTransfer': 'Transferir a propriedade para {name}? Você se tornará um membro comum.',
'members.transferError': 'Falha ao transferir a propriedade',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Nepodařilo se přidat',
'members.removed': 'Člen odebrán',
'members.removeError': 'Nepodařilo se odebrat',
'members.makeOwner': 'Nastavit jako vlastníka',
'members.confirmTransfer': 'Převést vlastnictví na {name}? Stanete se běžným členem.',
'members.transferError': 'Převod vlastnictví se nezdařil',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Fehler beim Hinzufügen',
'members.removed': 'Mitglied entfernt',
'members.removeError': 'Fehler beim Entfernen',
'members.makeOwner': 'Zum Eigentümer machen',
'members.confirmTransfer': 'Eigentümerschaft an {name} übertragen? Du wirst zu einem normalen Mitglied.',
'members.transferError': 'Übertragung fehlgeschlagen',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Failed to add',
'members.removed': 'Member removed',
'members.removeError': 'Failed to remove',
'members.makeOwner': 'Make owner',
'members.confirmTransfer': 'Transfer ownership to {name}? You will become a regular member.',
'members.transferError': 'Failed to transfer ownership',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'No se pudo añadir',
'members.removed': 'Miembro eliminado',
'members.removeError': 'No se pudo eliminar',
'members.makeOwner': 'Hacer propietario',
'members.confirmTransfer': '¿Transferir la propiedad a {name}? Pasarás a ser un miembro normal.',
'members.transferError': 'Error al transferir la propiedad',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': "Échec de l'ajout",
'members.removed': 'Membre retiré',
'members.removeError': 'Échec de la suppression',
'members.makeOwner': 'Définir comme propriétaire',
'members.confirmTransfer': 'Transférer la propriété à {name} ? Vous deviendrez un membre ordinaire.',
'members.transferError': 'Échec du transfert de propriété',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Αποτυχία προσθήκης',
'members.removed': 'Το μέλος αφαιρέθηκε',
'members.removeError': 'Αποτυχία αφαίρεσης',
'members.makeOwner': 'Ορισμός ως κάτοχο',
'members.confirmTransfer': 'Μεταβίβαση ιδιοκτησίας στον/στην {name}; Θα γίνετε απλό μέλος.',
'members.transferError': 'Αποτυχία μεταβίβασης ιδιοκτησίας',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Nem sikerült hozzáadni',
'members.removed': 'Tag eltávolítva',
'members.removeError': 'Nem sikerült eltávolítani',
'members.makeOwner': 'Tulajdonossá tétel',
'members.confirmTransfer': 'Átruházza a tulajdonjogot {name} részére? Ön normál taggá válik.',
'members.transferError': 'A tulajdonjog átruházása sikertelen',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Gagal menambahkan',
'members.removed': 'Anggota dihapus',
'members.removeError': 'Gagal menghapus',
'members.makeOwner': 'Jadikan pemilik',
'members.confirmTransfer': 'Alihkan kepemilikan ke {name}? Anda akan menjadi anggota biasa.',
'members.transferError': 'Gagal mengalihkan kepemilikan',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Impossibile aggiungere',
'members.removed': 'Membro rimosso',
'members.removeError': 'Impossibile rimuovere',
'members.makeOwner': 'Rendi proprietario',
'members.confirmTransfer': 'Trasferire la proprietà a {name}? Diventerai un membro normale.',
'members.transferError': 'Trasferimento della proprietà non riuscito',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': '追加に失敗しました',
'members.removed': 'メンバーを削除しました',
'members.removeError': '削除に失敗しました',
'members.makeOwner': 'オーナーにする',
'members.confirmTransfer': '所有権を {name} に移譲しますか?あなたは通常のメンバーになります。',
'members.transferError': '所有権の移譲に失敗しました',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': '추가 실패',
'members.removed': '멤버가 제거되었습니다',
'members.removeError': '제거 실패',
'members.makeOwner': '소유자로 지정',
'members.confirmTransfer': '{name}님에게 소유권을 이전하시겠습니까? 일반 멤버가 됩니다.',
'members.transferError': '소유권 이전 실패',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Toevoegen mislukt',
'members.removed': 'Lid verwijderd',
'members.removeError': 'Verwijderen mislukt',
'members.makeOwner': 'Eigenaar maken',
'members.confirmTransfer': 'Eigenaarschap overdragen aan {name}? Je wordt een gewoon lid.',
'members.transferError': 'Overdracht van eigenaarschap mislukt',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Nie udało się dodać członka',
'members.removed': 'Usunięto członka',
'members.removeError': 'Nie udało się usunąć członka',
'members.makeOwner': 'Ustaw jako właściciela',
'members.confirmTransfer': 'Przekazać własność użytkownikowi {name}? Staniesz się zwykłym członkiem.',
'members.transferError': 'Nie udało się przekazać własności',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Ошибка добавления',
'members.removed': 'Участник удалён',
'members.removeError': 'Ошибка удаления',
'members.makeOwner': 'Назначить владельцем',
'members.confirmTransfer': 'Передать права владельца пользователю {name}? Вы станете обычным участником.',
'members.transferError': 'Не удалось передать права владельца',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Kunde inte lägga till',
'members.removed': 'Medlem borttagen',
'members.removeError': 'Kunde inte ta bort',
'members.makeOwner': 'Gör till ägare',
'members.confirmTransfer': 'Överför ägarskapet till {name}? Du blir en vanlig medlem.',
'members.transferError': 'Det gick inte att överföra ägarskapet',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Eklenemedi',
'members.removed': 'Üye kaldırıldı',
'members.removeError': 'Kaldırılamadı',
'members.makeOwner': 'Sahip yap',
'members.confirmTransfer': 'Sahipliği {name} kullanıcısına devret? Normal bir üye olacaksınız.',
'members.transferError': 'Sahiplik devredilemedi',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Помилка додавання',
'members.removed': 'Учасник видалений',
'members.removeError': 'Помилка видалення',
'members.makeOwner': 'Призначити власником',
'members.confirmTransfer': 'Передати право власності користувачу {name}? Ви станете звичайним учасником.',
'members.transferError': 'Не вдалося передати право власності',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': 'Không thể thêm',
'members.removed': 'Thành viên đã bị xóa',
'members.removeError': 'Không thể xóa',
'members.makeOwner': 'Đặt làm chủ sở hữu',
'members.confirmTransfer': 'Chuyển quyền sở hữu cho {name}? Bạn sẽ trở thành thành viên thường.',
'members.transferError': 'Không thể chuyển quyền sở hữu',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': '新增失敗',
'members.removed': '成員已移除',
'members.removeError': '移除失敗',
'members.makeOwner': '設為擁有者',
'members.confirmTransfer': '將擁有權轉移給 {name}?你將成為一般成員。',
'members.transferError': '轉移擁有權失敗',
};
export default members;
+3
View File
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
'members.addError': '添加失败',
'members.removed': '成员已移除',
'members.removeError': '移除失败',
'members.makeOwner': '设为所有者',
'members.confirmTransfer': '将所有权转移给 {name}?你将成为普通成员。',
'members.transferError': '转移所有权失败',
};
export default members;
+6
View File
@@ -92,3 +92,9 @@ export const tripAddMemberRequestSchema = z.object({
identifier: z.string(),
});
export type TripAddMemberRequest = z.infer<typeof tripAddMemberRequestSchema>;
// Hand the trip over to an existing member (#973).
export const tripTransferOwnershipRequestSchema = z.object({
newOwnerId: z.number().int().positive(),
});
export type TripTransferOwnershipRequest = z.infer<typeof tripTransferOwnershipRequestSchema>;