feat(trips): guest members for accountless participants (#1362, #1291)

Add "guest" trip participants — people without a Trek account who can still be
assigned to costs, packing, to-dos and day-plan activities. A guest is a
credential-less users row (is_guest=1) joined into trip_members, so it is
assignable everywhere a real member is, with the cost-splitting, settlement,
packing and assignment paths working unchanged.

Guests are firewalled from everything account-related: they can never sign in
(password, OIDC and reset lookups skip them), never appear in the global user
directory, the member-add picker or admin user management, are never resolved as
notification recipients, can't be invited to another trip, and can't be made
owner. The trip owner manages guests from the share dialog in a dedicated,
clearly-labelled section (add / rename / remove), and guests carry a "Guest"
badge wherever members are picked. All 22 locales stay in parity.
This commit is contained in:
Maurice
2026-06-30 14:56:57 +02:00
parent 3ecf7e5bef
commit e56930ddaf
51 changed files with 892 additions and 42 deletions
+80
View File
@@ -784,6 +784,86 @@ describe('Trip members', () => {
expect(res.status).toBe(400);
});
it('TRIP-GUEST-001 — owner creates a guest; it appears as a member and is shielded from auth (#1362)', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Camping' });
const created = await request(app)
.post(`/api/trips/${trip.id}/guests`)
.set('Cookie', authCookie(owner.id))
.send({ name: 'Grandma' });
expect(created.status).toBe(201);
expect(created.body.member.is_guest).toBe(true);
expect(created.body.member.username).toBe('Grandma');
const guestId = created.body.member.id;
// Surfaces in the members list every assignment picker consumes.
const members = await request(app).get(`/api/trips/${trip.id}/members`).set('Cookie', authCookie(owner.id));
const guest = members.body.members.find((m: any) => m.id === guestId);
expect(guest).toBeTruthy();
expect(guest.is_guest).toBe(true);
// NOT in the global user directory (the member-add picker source).
const dir = await request(app).get('/api/auth/users').set('Cookie', authCookie(owner.id));
expect(dir.body.users.some((u: any) => u.id === guestId)).toBe(false);
// The synthetic email can never authenticate (resolves as an unknown email).
const email = (testDb.prepare('SELECT email FROM users WHERE id = ?').get(guestId) as any).email;
const login = await request(app).post('/api/auth/login').send({ email, password: 'anything' });
expect(login.status).toBe(401);
});
it('TRIP-GUEST-002 — guest CRUD is owner-only', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Camping' });
addTripMember(testDb, trip.id, member.id);
// A non-owner member cannot create a guest.
const denied = await request(app)
.post(`/api/trips/${trip.id}/guests`)
.set('Cookie', authCookie(member.id))
.send({ name: 'Nope' });
expect(denied.status).toBe(403);
const created = await request(app)
.post(`/api/trips/${trip.id}/guests`)
.set('Cookie', authCookie(owner.id))
.send({ name: 'Kid' });
const guestId = created.body.member.id;
// Rename + delete by the owner.
const renamed = await request(app)
.put(`/api/trips/${trip.id}/guests/${guestId}`)
.set('Cookie', authCookie(owner.id))
.send({ name: 'Junior' });
expect(renamed.status).toBe(200);
expect((testDb.prepare('SELECT username FROM users WHERE id = ?').get(guestId) as any).username).toBe('Junior');
const removed = await request(app)
.delete(`/api/trips/${trip.id}/guests/${guestId}`)
.set('Cookie', authCookie(owner.id));
expect(removed.status).toBe(200);
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guestId)).toBeUndefined();
});
it('TRIP-GUEST-003 — a guest cannot be invited as a member to any trip (#1362)', async () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Camping' });
const otherTrip = createTrip(testDb, owner.id, { title: 'Other' });
const created = await request(app)
.post(`/api/trips/${trip.id}/guests`)
.set('Cookie', authCookie(owner.id))
.send({ name: 'Eve' });
const email = (testDb.prepare('SELECT email FROM users WHERE id = ?').get(created.body.member.id) as any).email;
const invite = await request(app)
.post(`/api/trips/${otherTrip.id}/members`)
.set('Cookie', authCookie(owner.id))
.send({ identifier: email });
expect(invite.status).toBe(404);
});
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);