mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
|
||||
'members.addError': 'فشلت الإضافة',
|
||||
'members.removed': 'تمت إزالة العضو',
|
||||
'members.removeError': 'فشلت الإزالة',
|
||||
'members.makeOwner': 'تعيين كمالك',
|
||||
'members.confirmTransfer': 'نقل الملكية إلى {name}؟ ستصبح عضوًا عاديًا.',
|
||||
'members.transferError': 'فشل نقل الملكية',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
|
||||
'members.addError': 'Αποτυχία προσθήκης',
|
||||
'members.removed': 'Το μέλος αφαιρέθηκε',
|
||||
'members.removeError': 'Αποτυχία αφαίρεσης',
|
||||
'members.makeOwner': 'Ορισμός ως κάτοχο',
|
||||
'members.confirmTransfer': 'Μεταβίβαση ιδιοκτησίας στον/στην {name}; Θα γίνετε απλό μέλος.',
|
||||
'members.transferError': 'Αποτυχία μεταβίβασης ιδιοκτησίας',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
|
||||
'members.addError': '追加に失敗しました',
|
||||
'members.removed': 'メンバーを削除しました',
|
||||
'members.removeError': '削除に失敗しました',
|
||||
'members.makeOwner': 'オーナーにする',
|
||||
'members.confirmTransfer': '所有権を {name} に移譲しますか?あなたは通常のメンバーになります。',
|
||||
'members.transferError': '所有権の移譲に失敗しました',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
|
||||
'members.addError': '추가 실패',
|
||||
'members.removed': '멤버가 제거되었습니다',
|
||||
'members.removeError': '제거 실패',
|
||||
'members.makeOwner': '소유자로 지정',
|
||||
'members.confirmTransfer': '{name}님에게 소유권을 이전하시겠습니까? 일반 멤버가 됩니다.',
|
||||
'members.transferError': '소유권 이전 실패',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
|
||||
'members.addError': 'Ошибка добавления',
|
||||
'members.removed': 'Участник удалён',
|
||||
'members.removeError': 'Ошибка удаления',
|
||||
'members.makeOwner': 'Назначить владельцем',
|
||||
'members.confirmTransfer': 'Передать права владельца пользователю {name}? Вы станете обычным участником.',
|
||||
'members.transferError': 'Не удалось передать права владельца',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
|
||||
'members.addError': 'Помилка додавання',
|
||||
'members.removed': 'Учасник видалений',
|
||||
'members.removeError': 'Помилка видалення',
|
||||
'members.makeOwner': 'Призначити власником',
|
||||
'members.confirmTransfer': 'Передати право власності користувачу {name}? Ви станете звичайним учасником.',
|
||||
'members.transferError': 'Не вдалося передати право власності',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
|
||||
'members.addError': '新增失敗',
|
||||
'members.removed': '成員已移除',
|
||||
'members.removeError': '移除失敗',
|
||||
'members.makeOwner': '設為擁有者',
|
||||
'members.confirmTransfer': '將擁有權轉移給 {name}?你將成為一般成員。',
|
||||
'members.transferError': '轉移擁有權失敗',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -20,5 +20,8 @@ const members: TranslationStrings = {
|
||||
'members.addError': '添加失败',
|
||||
'members.removed': '成员已移除',
|
||||
'members.removeError': '移除失败',
|
||||
'members.makeOwner': '设为所有者',
|
||||
'members.confirmTransfer': '将所有权转移给 {name}?你将成为普通成员。',
|
||||
'members.transferError': '转移所有权失败',
|
||||
};
|
||||
export default members;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user