diff --git a/client/src/api/client.ts b/client/src/api/client.ts index f72fad94..4c29d27d 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -15,7 +15,8 @@ import { type RegisterRequest, type LoginRequest, type ForgotPasswordRequest, type ResetPasswordRequest, type ChangePasswordRequest, type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest, - type TripAddMemberRequest, type TripTransferOwnershipRequest, type AssignmentReorderRequest, + type TripAddMemberRequest, type TripTransferOwnershipRequest, + type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest, @@ -341,6 +342,9 @@ export const tripsApi = { 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), + createGuest: (id: number | string, name: string) => apiClient.post(`/trips/${id}/guests`, { name } satisfies TripCreateGuestRequest).then(r => r.data), + renameGuest: (id: number | string, userId: number, name: string) => apiClient.put(`/trips/${id}/guests/${userId}`, { name } satisfies TripRenameGuestRequest).then(r => r.data), + deleteGuest: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/guests/${userId}`).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/Budget/BudgetPanelMemberChips.tsx b/client/src/components/Budget/BudgetPanelMemberChips.tsx index 8a141858..c042638c 100644 --- a/client/src/components/Budget/BudgetPanelMemberChips.tsx +++ b/client/src/components/Budget/BudgetPanelMemberChips.tsx @@ -7,6 +7,7 @@ export interface TripMember { id: number username: string avatar_url?: string | null + is_guest?: boolean } // ── Chip with custom tooltip ───────────────────────────────────────────────── diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 952d3cd4..d51a4f0b 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -18,6 +18,7 @@ import { SYMBOLS, CURRENCIES, SPLIT_COLORS } from './BudgetPanel.constants' import { COST_CATEGORY_LIST, catMeta } from './costsCategories' import type { BudgetItem } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' +import GuestBadge from '../shared/GuestBadge' export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record { const n = members.length @@ -1238,6 +1239,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo ? : {(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}} {p.id === me ? t('costs.you') : p.username} + {p.is_guest && } {splitMode === 'equally' ? ( on ? ( diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx index f5fa55fe..19be2f25 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.tsx @@ -10,6 +10,7 @@ import type { PackingItem, PackingBag } from '../../types' import { katColor } from './packingListPanel.helpers' import type { TripMember, CategoryAssignee } from './usePackingListPanel' import { ArtikelZeile } from './PackingListPanelItemRow' +import GuestBadge from '../shared/GuestBadge' interface KategorieGruppeProps { kategorie: string @@ -206,7 +207,10 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen }}> {m.username[0]} - {m.username} + + {m.username} + {m.is_guest && } + {isAssigned && } ) diff --git a/client/src/components/Packing/usePackingListPanel.ts b/client/src/components/Packing/usePackingListPanel.ts index 6e1008b0..db2890ac 100644 --- a/client/src/components/Packing/usePackingListPanel.ts +++ b/client/src/components/Packing/usePackingListPanel.ts @@ -16,12 +16,14 @@ export interface TripMember { username: string avatar?: string | null avatar_url?: string | null + is_guest?: boolean } export interface CategoryAssignee { user_id: number username: string avatar?: string | null + is_guest?: boolean } export interface PackingListPanelProps { @@ -59,8 +61,8 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck useEffect(() => { tripsApi.getMembers(tripId).then(data => { const all: TripMember[] = [] - if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url }) - if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url }))) + if (data.owner) all.push({ id: data.owner.id, username: data.owner.username, avatar: data.owner.avatar_url, is_guest: false }) + if (data.members) all.push(...data.members.map((m: any) => ({ id: m.id, username: m.username, avatar: m.avatar_url, is_guest: !!m.is_guest }))) setTripMembers(all) }).catch(() => {}) packingApi.getCategoryAssignees(tripId).then(data => { diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index 0e2c2b6c..5c315531 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' +import GuestBadge from '../shared/GuestBadge' import { mapsApi } from '../../api/client' import { useSettingsStore } from '../../store/settingsStore' import { getCategoryIcon } from '../shared/categoryIcons' @@ -91,6 +92,7 @@ interface TripMember { username: string avatar?: string | null avatar_url?: string | null + is_guest?: boolean } interface PlaceInspectorProps { @@ -486,7 +488,8 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip }}> {(member.avatar_url || member.avatar) ? : member.username?.[0]?.toUpperCase()} - {member.username} + {member.username} + {member.is_guest && } ))} diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx index 502f07e6..bf194d9c 100644 --- a/client/src/components/Todo/TodoListPanel.tsx +++ b/client/src/components/Todo/TodoListPanel.tsx @@ -479,7 +479,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: { { value: '', label: t('todo.unassigned'), icon: }, ...members.map(m => ({ value: String(m.id), - label: m.username, + label: m.is_guest ? `${m.username} · ${t('members.guest')}` : m.username, icon: m.avatar ? ( ) : ( diff --git a/client/src/components/Todo/TodoRow.tsx b/client/src/components/Todo/TodoRow.tsx index 4198f402..fde1a112 100644 --- a/client/src/components/Todo/TodoRow.tsx +++ b/client/src/components/Todo/TodoRow.tsx @@ -1,4 +1,4 @@ -import { CheckSquare, Square, ChevronRight, Flag, Calendar, GripVertical } from 'lucide-react' +import { CheckSquare, Square, ChevronRight, Flag, Calendar, GripVertical, UserRound } from 'lucide-react' import type { TodoItem } from '../../types' import { katColor, PRIO_CONFIG, type Member } from './todoListModel' @@ -131,6 +131,7 @@ export default function TodoRow({ item, members, categories, today, isSelected, {assignedUser.username.charAt(0).toUpperCase()} )} + {assignedUser.is_guest && } {assignedUser.username} )} diff --git a/client/src/components/Todo/todoListModel.ts b/client/src/components/Todo/todoListModel.ts index e390fb2d..3961fa18 100644 --- a/client/src/components/Todo/todoListModel.ts +++ b/client/src/components/Todo/todoListModel.ts @@ -21,4 +21,4 @@ export function katColor(kat: string, allCategories: string[]) { export type FilterType = 'all' | 'my' | 'overdue' | 'done' | string -export interface Member { id: number; username: string; avatar: string | null } +export interface Member { id: number; username: string; avatar: string | null; is_guest?: boolean } diff --git a/client/src/components/Trips/TripMembersModal.test.tsx b/client/src/components/Trips/TripMembersModal.test.tsx index d41d62c4..17b36fa7 100644 --- a/client/src/components/Trips/TripMembersModal.test.tsx +++ b/client/src/components/Trips/TripMembersModal.test.tsx @@ -423,4 +423,42 @@ describe('TripMembersModal', () => { render(); await screen.findByText('All users already have access.'); }); + + it('FE-COMP-MEMBERS-026: owner sees the guests section and can add a guest (#1362)', async () => { + let createdName: string | null = null; + server.use( + http.post('/api/trips/1/guests', async ({ request }) => { + createdName = ((await request.json()) as { name: string }).name; + return HttpResponse.json({ member: { id: 99, username: createdName, is_guest: true } }); + }), + ); + render(); + // The guests section + add affordance is shown to the owner. + await screen.findByText('Guests'); + const input = screen.getByPlaceholderText('Guest name'); + await userEvent.type(input, 'Grandpa'); + await userEvent.click(screen.getByRole('button', { name: /Add guest/i })); + await waitFor(() => expect(createdName).toBe('Grandpa')); + }); + + it('FE-COMP-MEMBERS-027: a guest member is shown in the guests section with a Guest badge, not the members list (#1362)', async () => { + server.use( + http.get('/api/trips/1/members', () => + HttpResponse.json({ + owner: { id: ownerUser.id, username: ownerUser.username, avatar_url: null, is_guest: false }, + members: [ + { id: 2, username: 'alice', avatar_url: null, is_guest: false }, + { id: 3, username: 'Grandma', avatar_url: null, is_guest: true }, + ], + current_user_id: ownerUser.id, + }) + ), + ); + render(); + await screen.findByText('Grandma'); + // The guest carries a "Guest" badge. + expect(screen.getAllByText('Guest').length).toBeGreaterThan(0); + // Access count covers owner + the real member only (2), not the guest. + expect(screen.getByText(/Access \(2/)).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx index a167baa9..ef910c44 100644 --- a/client/src/components/Trips/TripMembersModal.tsx +++ b/client/src/components/Trips/TripMembersModal.tsx @@ -5,7 +5,7 @@ import { useToast } from '../shared/Toast' import { useAuthStore } from '../../store/authStore' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' -import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react' +import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react' import { useTranslation } from '../../i18n' import { getApiErrorMessage } from '../../types' import CustomSelect from '../shared/CustomSelect' @@ -178,6 +178,10 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: const [adding, setAdding] = useState(false) const [removingId, setRemovingId] = useState(null) const [transferringId, setTransferringId] = useState(null) + const [newGuestName, setNewGuestName] = useState('') + const [addingGuest, setAddingGuest] = useState(false) + const [renamingGuestId, setRenamingGuestId] = useState(null) + const [renameValue, setRenameValue] = useState('') const toast = useToast() const { user } = useAuthStore() const { t } = useTranslation() @@ -243,6 +247,48 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: } } + const handleAddGuest = async () => { + const name = newGuestName.trim() + if (!name) return + setAddingGuest(true) + try { + await tripsApi.createGuest(tripId, name) + setNewGuestName('') + await loadMembers() + toast.success(t('members.guestAdded')) + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('members.guestAddError'))) + } finally { + setAddingGuest(false) + } + } + + const handleRenameGuest = async (userId) => { + const name = renameValue.trim() + if (!name) { setRenamingGuestId(null); return } + try { + await tripsApi.renameGuest(tripId, userId, name) + setRenamingGuestId(null) + await loadMembers() + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('members.guestRenameError'))) + } + } + + const handleDeleteGuest = async (userId) => { + if (!confirm(t('members.confirmRemoveGuest'))) return + setRemovingId(userId) + try { + await tripsApi.deleteGuest(tripId, userId) + await loadMembers() + toast.success(t('members.guestRemoved')) + } catch { + toast.error(t('members.removeError')) + } finally { + setRemovingId(null) + } + } + const handleRemove = async (userId, isSelf) => { const msg = isSelf ? t('members.confirmLeave') @@ -260,18 +306,20 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: } } - // Users not yet in the trip + // Users not yet in the trip (guests are accountless and never live in the directory) const existingIds = new Set([ data?.owner?.id, ...(data?.members?.map(m => m.id) || []), ]) - const availableUsers = allUsers.filter(u => !existingIds.has(u.id)) + const availableUsers = allUsers.filter(u => !existingIds.has(u.id) && !u.is_guest) const isCurrentOwner = data?.owner?.id === user?.id - const allMembers = data ? [ + // Real members (owner + accounts) and guests (#1362) are listed separately. + const realMembers = data ? [ { ...data.owner, role: 'owner' }, - ...data.members, + ...data.members.filter(m => !m.is_guest), ] : [] + const guests = data ? data.members.filter(m => m.is_guest) : [] return ( @@ -331,7 +379,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
- {t('members.access')} ({allMembers.length} {allMembers.length === 1 ? t('members.person') : t('members.persons')}) + {t('members.access')} ({realMembers.length} {realMembers.length === 1 ? t('members.person') : t('members.persons')})
@@ -343,7 +391,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: ) : (
- {allMembers.map(member => { + {realMembers.map(member => { const isSelf = member.id === user?.id const canRemove = isSelf || (canManageMembers && member.role !== 'owner') return ( @@ -394,6 +442,97 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: )}
+ {/* Guests (#1362) — accountless participants, managed by the owner */} + {(isCurrentOwner || guests.length > 0) && ( +
+
+ + + {t('members.guests')}{guests.length > 0 ? ` (${guests.length})` : ''} + +
+

{t('members.guestsHint')}

+ +
+ {guests.map(g => ( +
+ + {renamingGuestId === g.id ? ( + setRenameValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleRenameGuest(g.id); if (e.key === 'Escape') setRenamingGuestId(null) }} + onBlur={() => handleRenameGuest(g.id)} + maxLength={50} + className="bg-surface border border-edge text-content" + style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '4px 8px', borderRadius: 8, outline: 'none', fontFamily: 'inherit' }} + /> + ) : ( +
+ {g.username} + + {t('members.guest')} + +
+ )} + {isCurrentOwner && renamingGuestId !== g.id && ( + <> + + + + )} +
+ ))} +
+ + {isCurrentOwner && ( +
0 ? 8 : 0 }}> + setNewGuestName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAddGuest() }} + placeholder={t('members.guestNamePlaceholder')} + maxLength={50} + className="bg-surface border border-edge text-content" + style={{ flex: 1, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))', padding: '8px 10px', borderRadius: 10, outline: 'none', fontFamily: 'inherit' }} + /> + +
+ )} +
+ )} + {/* Right column: Share Link */} diff --git a/client/src/components/shared/GuestBadge.tsx b/client/src/components/shared/GuestBadge.tsx new file mode 100644 index 00000000..d5f71acb --- /dev/null +++ b/client/src/components/shared/GuestBadge.tsx @@ -0,0 +1,24 @@ +import { UserRound } from 'lucide-react' +import { useTranslation } from '../../i18n' + +/** + * Small "Guest" pill (#1362) shown next to a member's name in assignment pickers + * so it's clear the person is an accountless guest. Purely presentational. + */ +export default function GuestBadge({ size = 'sm' }: { size?: 'sm' | 'xs' }) { + const { t } = useTranslation() + const fs = size === 'xs' ? 9 : 10 + return ( + + {t('members.guest')} + + ) +} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 9a9f62c4..78a92a62 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3147,6 +3147,17 @@ function runMigrations(db: Database.Database): void { } } }, + // Guest members (#1362): people added to a trip without an account. A guest is a + // users row flagged is_guest=1 (no usable credentials) joined into trip_members, + // so it's assignable everywhere a member is — but must never authenticate or show + // up in the global user directory. The flag is the discriminator for those guards. + () => { + try { + db.exec('ALTER TABLE users ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0'); + } catch (err: any) { + if (!err.message?.includes('duplicate column name')) throw err; + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index d6a71b50..bd6967ef 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -27,6 +27,7 @@ function createTables(db: Database.Database): void { must_change_password INTEGER DEFAULT 0, password_version INTEGER NOT NULL DEFAULT 0, feed_token TEXT, + is_guest INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/server/src/nest/trips/trips.controller.ts b/server/src/nest/trips/trips.controller.ts index bc64e5b6..cbe9844c 100644 --- a/server/src/nest/trips/trips.controller.ts +++ b/server/src/nest/trips/trips.controller.ts @@ -314,6 +314,60 @@ export class TripsController { } } + /** Loads the trip or throws 404, then asserts the caller is its owner (guest CRUD, #1362). */ + private requireOwner(id: string, user: User): void { + const access = this.trips.canAccessTrip(id, user.id); + if (!access) { + throw new HttpException({ error: 'Trip not found' }, 404); + } + if (access.user_id !== user.id) { + throw new HttpException({ error: 'Only the owner can manage guests' }, 403); + } + } + + @Post(':id/guests') + @HttpCode(201) + createGuest(@CurrentUser() user: User, @Param('id') id: string, @Body('name') name: unknown) { + this.requireOwner(id, user); + if (typeof name !== 'string' || !name.trim()) { + throw new HttpException({ error: 'Guest name is required' }, 400); + } + try { + // No notifyInvite: a guest has no inbox. + return this.trips.createGuest(id, name, user.id); + } catch (e: unknown) { + if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400); + throw e; + } + } + + @Put(':id/guests/:userId') + renameGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string, @Body('name') name: unknown) { + this.requireOwner(id, user); + if (typeof name !== 'string' || !name.trim()) { + throw new HttpException({ error: 'Guest name is required' }, 400); + } + try { + if (!this.trips.renameGuest(id, parseInt(userId), name)) { + throw new HttpException({ error: 'Guest not found' }, 404); + } + return { success: true }; + } catch (e: unknown) { + if (e instanceof HttpException) throw e; + if (e instanceof ValidationError) throw new HttpException({ error: e.message }, 400); + throw e; + } + } + + @Delete(':id/guests/:userId') + deleteGuest(@CurrentUser() user: User, @Param('id') id: string, @Param('userId') userId: string) { + this.requireOwner(id, user); + if (!this.trips.deleteGuest(id, parseInt(userId))) { + throw new HttpException({ error: 'Guest not found' }, 404); + } + return { success: true }; + } + @Get(':id/bundle') bundle(@CurrentUser() user: User, @Param('id') id: string) { const trip = this.trips.get(id, user.id) as { user_id: number } | undefined; diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 2a04b048..d2be26ca 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -99,6 +99,18 @@ export class TripsService { return tripSvc.transferOwnership(tripId, newOwnerId, currentOwnerId); } + createGuest(tripId: string, name: string, invitedBy: number) { + return tripSvc.createGuest(tripId, name, invitedBy); + } + + renameGuest(tripId: string, guestUserId: number, name: string): boolean { + return tripSvc.renameGuest(tripId, guestUserId, name); + } + + deleteGuest(tripId: string, guestUserId: number): boolean { + return tripSvc.deleteGuest(tripId, guestUserId); + } + exportICS(tripId: string) { return tripSvc.exportICS(tripId); } diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index fc958543..c6ef3a6a 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -59,8 +59,10 @@ export const isDocker = (() => { // ── User CRUD ────────────────────────────────────────────────────────────── export function listUsers() { + // Guests (#1362) are accountless trip participants, not real users — keep them out + // of admin user management entirely. const users = db.prepare( - 'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users ORDER BY created_at DESC' + 'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC' ).all() as (Pick & { avatar?: string | null })[]; let onlineUserIds = new Set(); try { @@ -93,10 +95,11 @@ export function createUser(data: { username: string; email: string; password: st return { error: 'Invalid role', status: 400 }; } - const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username); + // Guests (#1362) live in a reserved synthetic namespace; never let one block a real account. + const existingUsername = db.prepare('SELECT id FROM users WHERE username = ? AND COALESCE(is_guest, 0) = 0').get(username); if (existingUsername) return { error: 'Username already taken', status: 409 }; - const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email); + const existingEmail = db.prepare('SELECT id FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0').get(email); if (existingEmail) return { error: 'Email already taken', status: 409 }; const passwordHash = bcrypt.hashSync(password, BCRYPT_COST); @@ -129,11 +132,11 @@ export function updateUser(id: string, data: { username?: string; email?: string } if (username && username !== user.username) { - const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, id); + const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ? AND COALESCE(is_guest, 0) = 0').get(username, id); if (conflict) return { error: 'Username already taken', status: 409 }; } if (email && email !== user.email) { - const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id); + const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0').get(email, id); if (conflict) return { error: 'Email already taken', status: 409 }; } @@ -195,7 +198,7 @@ export function deleteUser(id: string, currentUserId: number) { // ── Stats ────────────────────────────────────────────────────────────────── export function getStats() { - const totalUsers = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; + const totalUsers = (db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }).count; const totalTrips = (db.prepare('SELECT COUNT(*) as count FROM trips').get() as { count: number }).count; const totalPlaces = (db.prepare('SELECT COUNT(*) as count FROM places').get() as { count: number }).count; const totalFiles = (db.prepare('SELECT COUNT(*) as count FROM trip_files').get() as { count: number }).count; diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 96ded9db..ba99c434 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -277,7 +277,7 @@ export function getPendingMfaSecret(userId: number): string | null { // --------------------------------------------------------------------------- export function getAppConfig(authenticatedUser: { id: number } | null) { - const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; + const userCount = (db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }).count; const isDemo = process.env.DEMO_MODE?.toLowerCase() === 'true'; const toggles = resolveAuthToggles(); const version: string = process.env.APP_VERSION ?? require('../../package.json').version; @@ -378,7 +378,7 @@ export function registerUser(body: { const email = typeof body.email === 'string' ? body.email.trim() : ''; const { password, invite_token } = body; - const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; + const userCount = (db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }).count; let validInvite: any = null; if (invite_token) { @@ -406,7 +406,8 @@ export function registerUser(body: { return { error: 'Invalid email format', status: 400 }; } - const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, username); + // Ignore guests (#1362): their synthetic username/email must never block a real signup. + const existingUser = db.prepare('SELECT id FROM users WHERE (LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)) AND COALESCE(is_guest, 0) = 0').get(email, username); if (existingUser) { return { error: 'Registration failed. Please try different credentials.', status: 409 }; } @@ -469,7 +470,9 @@ export function loginUser(body: { return { error: 'Email and password are required', status: 400 }; } - const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined; + // Guests (#1362) carry a synthetic email but must never authenticate — treat a + // matched guest row exactly like an unknown email (dummy-hash timing preserved). + const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0').get(email) as User | undefined; // Always run bcrypt — even for unknown/OIDC-only users — so response time // does not reveal whether the email exists in the database (CWE-203/208). @@ -649,7 +652,7 @@ export function updateSettings( if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) { return { error: 'Username can only contain letters, numbers, underscores, dots and hyphens', status: 400 }; } - const conflict = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?').get(trimmed, userId); + const conflict = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0').get(trimmed, userId); if (conflict) return { error: 'Username already taken', status: 409 }; } @@ -658,7 +661,7 @@ export function updateSettings( if (!trimmed || !EMAIL_REGEX.test(trimmed)) { return { error: 'Invalid email format', status: 400 }; } - const conflict = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(trimmed, userId); + const conflict = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0').get(trimmed, userId); if (conflict) return { error: 'Email already taken', status: 409 }; } @@ -735,8 +738,10 @@ export async function deleteAvatar(userId: number) { // --------------------------------------------------------------------------- export function listUsers(excludeUserId: number) { + // The global user directory feeds the trip member-add / contributor pickers — + // guests (#1362) are trip-scoped and must never be selectable here. const users = db.prepare( - 'SELECT id, username, avatar FROM users WHERE id != ? ORDER BY username ASC' + 'SELECT id, username, avatar FROM users WHERE id != ? AND COALESCE(is_guest, 0) = 0 ORDER BY username ASC' ).all(excludeUserId) as Pick[]; return users.map(u => ({ ...u, avatar_url: avatarUrl(u) })); } @@ -1201,7 +1206,8 @@ export function requestPasswordReset(rawEmail: string, createdIp: string | null) return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' }; } - const user = db.prepare('SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ?').get(email) as + // A guest (#1362) must never receive a reset link — treat its synthetic email as unknown. + const user = db.prepare('SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0').get(email) as | { id: number; email: string; password_hash: string | null; oidc_sub: string | null } | undefined; diff --git a/server/src/services/inAppNotifications.ts b/server/src/services/inAppNotifications.ts index 4dca62b9..ea972b4d 100644 --- a/server/src/services/inAppNotifications.ts +++ b/server/src/services/inAppNotifications.ts @@ -73,17 +73,22 @@ interface NotificationRow { export function resolveRecipients(scope: NotificationScope, target: number, excludeUserId?: number | null): number[] { let userIds: number[] = []; + // Guests (#1362) are trip members for assignment purposes but have no inbox/email, + // so they must never be resolved as notification recipients on any scope. This is the + // single chokepoint for in-app/email/webhook/ntfy, so filtering here covers all channels. if (scope === 'trip') { const owner = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(target) as { user_id: number } | undefined; - const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(target) as { user_id: number }[]; + const members = db.prepare('SELECT m.user_id FROM trip_members m JOIN users u ON u.id = m.user_id WHERE m.trip_id = ? AND COALESCE(u.is_guest, 0) = 0').all(target) as { user_id: number }[]; const ids = new Set(); if (owner) ids.add(owner.user_id); for (const m of members) ids.add(m.user_id); userIds = Array.from(ids); } else if (scope === 'user') { - userIds = [target]; + // A guest can be a todo assignee (scope='user'); never notify them. + const u = db.prepare('SELECT is_guest FROM users WHERE id = ?').get(target) as { is_guest?: number } | undefined; + userIds = u && u.is_guest ? [] : [target]; } else if (scope === 'admin') { - const admins = db.prepare('SELECT id FROM users WHERE role = ?').all('admin') as { id: number }[]; + const admins = db.prepare("SELECT id FROM users WHERE role = ? AND COALESCE(is_guest, 0) = 0").all('admin') as { id: number }[]; userIds = admins.map(a => a.id); } diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts index c13573dc..42c7a0fe 100644 --- a/server/src/services/notifications.ts +++ b/server/src/services/notifications.ts @@ -93,7 +93,8 @@ export function getMcpSafeUrl(): string { } export function getUserEmail(userId: number): string | null { - return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null; + // Defense-in-depth (#1362): a guest's synthetic email must never be emailed. + return (db.prepare('SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0').get(userId) as { email: string } | undefined)?.email || null; } export function getUserLanguage(userId: number): string { diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index 558b3a12..546ce91c 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -367,7 +367,8 @@ export function findOrCreateUser( // Try to find existing user by sub, then by email let user = db.prepare('SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?').get(sub, config.issuer) as User | undefined; if (!user) { - user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email) as User | undefined; + // Never link/log-in to a guest (#1362) via its synthetic email. + user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ? AND COALESCE(is_guest, 0) = 0').get(email) as User | undefined; } if (user) { @@ -405,7 +406,7 @@ export function findOrCreateUser( } // --- New user registration --- - const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; + const userCount = (db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }).count; const isFirstUser = userCount === 0; let validInvite: any = null; diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index 058cdf26..1b0b0c3b 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -1,5 +1,6 @@ import path from 'path'; import fs from 'fs'; +import { randomUUID } from 'crypto'; import { db, isOwner } from '../db/database'; import { Trip, User } from '../types'; import { listDays, listAccommodations } from './dayService'; @@ -347,8 +348,10 @@ export function getTripOwner(tripId: string | number): { user_id: number } | und // ── Members ─────────────────────────────────────────────────────────────── export function listMembers(tripId: string | number, tripOwnerId: number) { + // u.is_guest rides along (#1362) so guests stay assignable everywhere a member is, + // while the UI can badge them and suppress owner-only actions. The owner is never a guest. const members = db.prepare(` - SELECT u.id, u.username, u.email, u.avatar, + SELECT u.id, u.username, u.email, u.avatar, u.is_guest, CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role, m.added_at, ib.username as invited_by_username @@ -357,13 +360,13 @@ export function listMembers(tripId: string | number, tripOwnerId: number) { LEFT JOIN users ib ON ib.id = m.invited_by WHERE m.trip_id = ? ORDER BY m.added_at ASC - `).all(tripOwnerId, tripId) as { id: number; username: string; email: string; avatar: string | null; role: string; added_at: string; invited_by_username: string | null }[]; + `).all(tripOwnerId, tripId) as { id: number; username: string; email: string; avatar: string | null; is_guest: number; role: string; added_at: string; invited_by_username: string | null }[]; const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(tripOwnerId) as Pick; return { - owner: { ...owner, role: 'owner', avatar_url: owner.avatar ? `/uploads/avatars/${owner.avatar}` : null }, - members: members.map(m => ({ ...m, avatar_url: m.avatar ? `/uploads/avatars/${m.avatar}` : null })), + owner: { ...owner, role: 'owner', is_guest: false, avatar_url: owner.avatar ? `/uploads/avatars/${owner.avatar}` : null }, + members: members.map(m => ({ ...m, is_guest: !!m.is_guest, avatar_url: m.avatar ? `/uploads/avatars/${m.avatar}` : null })), }; } @@ -376,8 +379,10 @@ export interface AddMemberResult { export function addMember(tripId: string | number, identifier: string, tripOwnerId: number, invitedByUserId: number): AddMemberResult { if (!identifier) throw new ValidationError('Email or username required'); + // Guests (#1362) are not invitable accounts — exclude them so a trip-scoped guest + // can never be resolved (and re-attached to another trip) through the invite box. const target = db.prepare( - 'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?' + 'SELECT id, username, email, avatar FROM users WHERE (email = ? OR username = ?) AND COALESCE(is_guest, 0) = 0' ).get(identifier.trim(), identifier.trim()) as Pick | undefined; if (!target) throw new NotFoundError('User not found'); @@ -425,8 +430,10 @@ export function transferOwnership( 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; + const newOwner = db.prepare('SELECT id, email, is_guest FROM users WHERE id = ?').get(newOwnerId) as { id: number; email: string; is_guest?: number } | undefined; if (!newOwner) throw new NotFoundError('User not found'); + // A guest (#1362) can never log in, so it must never become the owner of a trip. + if (newOwner.is_guest) throw new ValidationError('Cannot transfer ownership to a guest'); 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'); @@ -445,6 +452,85 @@ export function transferOwnership( return { tripTitle: trip.title, fromEmail, toEmail: newOwner.email }; } +// ── Guest members (#1362) ─────────────────────────────────────────────────── +// +// A guest is a credential-less users row (is_guest=1) joined into trip_members, so +// it is assignable everywhere a real member is (budget splits, packing, to-dos, day +// participants) yet can never authenticate (the auth/global-list guards exclude +// is_guest=1). The display name lives in users.username so every existing JOIN that +// renders a member name shows the guest correctly; a synthetic, non-deliverable +// email keeps the UNIQUE/NOT NULL constraints satisfied. + +export interface GuestMember { + id: number; + username: string; + email: string; + role: 'member'; + is_guest: true; + avatar_url: null; +} + +/** username is UNIQUE across all users — keep the typed name but disambiguate guests + * that happen to share it (e.g. two "Anna"s) with a numeric suffix. */ +function uniqueGuestUsername(name: string, excludeId?: number): string { + let candidate = name; + let n = 2; + const probe = excludeId != null + ? db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?') + : db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)'); + while (excludeId != null ? probe.get(candidate, excludeId) : probe.get(candidate)) { + candidate = `${name} ${n++}`; + } + return candidate; +} + +export function createGuest(tripId: string | number, name: string, invitedByUserId: number): { member: GuestMember } { + const display = (name || '').trim(); + if (!display) throw new ValidationError('Guest name is required'); + if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer'); + + const email = `guest-${randomUUID()}@guests.invalid`; + const username = uniqueGuestUsername(display); + + const create = db.transaction(() => { + const res = db.prepare( + "INSERT INTO users (username, email, password_hash, role, is_guest) VALUES (?, ?, '', 'user', 1)" + ).run(username, email); + const guestId = Number(res.lastInsertRowid); + db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, guestId, invitedByUserId); + return guestId; + }); + const guestId = create(); + + return { member: { id: guestId, username, email, role: 'member', is_guest: true, avatar_url: null } }; +} + +/** Confirms a user id is a guest of THIS trip, so guest mutations stay trip-scoped. */ +function guestOfTrip(tripId: string | number, guestUserId: number): boolean { + return !!db.prepare( + 'SELECT u.id FROM users u JOIN trip_members m ON m.user_id = u.id WHERE u.id = ? AND m.trip_id = ? AND u.is_guest = 1' + ).get(guestUserId, tripId); +} + +export function renameGuest(tripId: string | number, guestUserId: number, name: string): boolean { + const display = (name || '').trim(); + if (!display) throw new ValidationError('Guest name is required'); + if (display.length > 50) throw new ValidationError('Guest name must be 50 characters or fewer'); + if (!guestOfTrip(tripId, guestUserId)) return false; + + const username = uniqueGuestUsername(display, guestUserId); + db.prepare('UPDATE users SET username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_guest = 1').run(username, guestUserId); + return true; +} + +export function deleteGuest(tripId: string | number, guestUserId: number): boolean { + if (!guestOfTrip(tripId, guestUserId)) return false; + // Deleting the guest's users row cascades its membership and every assignment join + // (trip_members, budget/packing/assignment links) via the ON DELETE foreign keys. + db.prepare('DELETE FROM users WHERE id = ? AND is_guest = 1').run(guestUserId); + return true; +} + // ── ICS export ──────────────────────────────────────────────────────────── // RFC 5545 §3.1: content lines longer than 75 octets must be folded with a CRLF diff --git a/server/src/types.ts b/server/src/types.ts index 9b750e31..9bf0801d 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -19,6 +19,9 @@ export interface User { must_change_password?: number | boolean; first_seen_version?: string; login_count?: number; + // Guest members (#1362): accountless trip participants. Flagged guests must never + // authenticate or appear in the global user directory. + is_guest?: number | boolean; created_at?: string; updated_at?: string; } diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index 1646cd0a..f7c325db 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -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); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 7a44c2b2..27e115e8 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -291,6 +291,40 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { }); }); + describe('guests (#1362)', () => { + it('404 without access, 403 for a non-owner, 400 without a name; else creates', () => { + expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue(undefined) })).createGuest(user, '9', 'Anna'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); + // access.user_id (5) ≠ requester (1) → not the owner + expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).createGuest(user, '9', 'Anna'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); + expect(thrown(() => new TripsController(svc()).createGuest(user, '9', ' '))).toEqual({ status: 400, body: { error: 'Guest name is required' } }); + const createGuest = vi.fn().mockReturnValue({ member: { id: 7, username: 'Anna', is_guest: true } }); + const s = svc({ createGuest } as Partial); + expect(new TripsController(s).createGuest(user, '9', 'Anna')).toEqual({ member: { id: 7, username: 'Anna', is_guest: true } }); + expect(createGuest).toHaveBeenCalledWith('9', 'Anna', user.id); + }); + + it('rename: 403 non-owner, 404 when the guest is missing, else success', () => { + expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).renameGuest(user, '9', '7', 'Bob'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); + const miss = svc({ renameGuest: vi.fn().mockReturnValue(false) } as Partial); + expect(thrown(() => new TripsController(miss).renameGuest(user, '9', '7', 'Bob'))).toEqual({ status: 404, body: { error: 'Guest not found' } }); + const ok = svc({ renameGuest: vi.fn().mockReturnValue(true) } as Partial); + expect(new TripsController(ok).renameGuest(user, '9', '7', 'Bob')).toEqual({ success: true }); + }); + + it('delete: 403 non-owner, 404 when the guest is missing, else success', () => { + expect(thrown(() => new TripsController(svc({ canAccessTrip: vi.fn().mockReturnValue({ user_id: 5 }) })).deleteGuest(user, '9', '7'))).toEqual({ status: 403, body: { error: 'Only the owner can manage guests' } }); + const miss = svc({ deleteGuest: vi.fn().mockReturnValue(false) } as Partial); + expect(thrown(() => new TripsController(miss).deleteGuest(user, '9', '7'))).toEqual({ status: 404, body: { error: 'Guest not found' } }); + const ok = svc({ deleteGuest: vi.fn().mockReturnValue(true) } as Partial); + expect(new TripsController(ok).deleteGuest(user, '9', '7')).toEqual({ success: true }); + }); + + it('maps a ValidationError from createGuest to 400', () => { + const ve = svc({ createGuest: vi.fn().mockImplementation(() => { throw new ValidationError('Guest name must be 50 characters or fewer'); }) } as Partial); + expect(thrown(() => new TripsController(ve).createGuest(user, '9', 'x'.repeat(60)))).toEqual({ status: 400, body: { error: 'Guest name must be 50 characters or fewer' } }); + }); + }); + it('GET /:id/bundle 404 then aggregates', () => { expect(thrown(() => new TripsController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial)).bundle(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } }); const bundle = vi.fn().mockReturnValue({ trip: { id: 9 }, days: [] }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index 296a124a..c7b4f95d 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -18,6 +18,7 @@ const { tripSvc } = vi.hoisted(() => ({ 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(), + createGuest: vi.fn(), renameGuest: vi.fn(), deleteGuest: vi.fn(), exportICS: vi.fn(), copyTripById: vi.fn(), TRIP_SELECT: 'SELECT * FROM trips t', }, })); @@ -52,6 +53,9 @@ describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => 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.createGuest('9', 'Anna', 1); expect(tripSvc.createGuest).toHaveBeenCalledWith('9', 'Anna', 1); + s.renameGuest('9', 7, 'Bob'); expect(tripSvc.renameGuest).toHaveBeenCalledWith('9', 7, 'Bob'); + s.deleteGuest('9', 7); expect(tripSvc.deleteGuest).toHaveBeenCalledWith('9', 7); s.exportICS('9'); expect(tripSvc.exportICS).toHaveBeenCalledWith('9'); }); diff --git a/server/tests/unit/services/notificationService.test.ts b/server/tests/unit/services/notificationService.test.ts index bbef6ef6..de2e4e89 100644 --- a/server/tests/unit/services/notificationService.test.ts +++ b/server/tests/unit/services/notificationService.test.ts @@ -244,6 +244,28 @@ describe('send() — recipient resolution', () => { expect(recipients).not.toContain(actor.id); }); + it('NSVC-007b — guests are never notified, on trip or user scope (#1362)', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + + const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number; + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member.id); + // A guest joined into the trip — assignable, but has no inbox. + const guestId = (testDb.prepare("INSERT INTO users (username, email, password_hash, role, is_guest) VALUES ('Guest', 'guest-x@guests.invalid', '', 'user', 1)").run()).lastInsertRowid as number; + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, guestId); + + await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Owner', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } }); + let recipients = (testDb.prepare('SELECT recipient_id FROM notifications').all() as { recipient_id: number }[]).map(r => r.recipient_id); + expect(recipients).toContain(member.id); + expect(recipients).not.toContain(guestId); + + // Even a direct user-scope notification (e.g. a todo assigned to the guest) is dropped. + await send({ event: 'vacay_invite', actorId: owner.id, scope: 'user', targetId: guestId, params: { actor: 'owner@test.com', planId: '1' } }); + recipients = (testDb.prepare('SELECT recipient_id FROM notifications').all() as { recipient_id: number }[]).map(r => r.recipient_id); + expect(recipients).not.toContain(guestId); + }); + it('NSVC-008 — user scope sends to exactly one user', async () => { const { user: target } = createUser(testDb); const { user: other } = createUser(testDb); diff --git a/server/tests/unit/services/tripService.test.ts b/server/tests/unit/services/tripService.test.ts index 4425aca9..3cab3456 100644 --- a/server/tests/unit/services/tripService.test.ts +++ b/server/tests/unit/services/tripService.test.ts @@ -34,7 +34,7 @@ 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, addTripMember } from '../../helpers/factories'; -import { exportICS, generateDays, deleteOldCover, updateTrip, transferOwnership } from '../../../src/services/tripService'; +import { exportICS, generateDays, deleteOldCover, updateTrip, transferOwnership, createGuest, renameGuest, deleteGuest, listMembers, addMember } from '../../../src/services/tripService'; import fs from 'fs'; beforeAll(() => { @@ -549,3 +549,79 @@ describe('transferOwnership (#973)', () => { expect(() => transferOwnership(trip.id, owner.id, owner.id)).toThrow('You already own this trip'); }); }); + +describe('guest members (#1362)', () => { + it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const { member } = createGuest(trip.id, ' Anna ', owner.id); + expect(member.username).toBe('Anna'); + expect(member.is_guest).toBe(true); + + const row = testDb.prepare('SELECT username, email, password_hash, is_guest, role FROM users WHERE id = ?').get(member.id) as any; + expect(row.is_guest).toBe(1); + expect(row.password_hash).toBe(''); + expect(row.email).toMatch(/@guests\.invalid$/); + expect(row.role).toBe('user'); + + // Joined as a trip member. + const m = testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id); + expect(m).toBeTruthy(); + + // Surfaces in listMembers with is_guest=true and the typed display name. + const { members } = listMembers(trip.id, owner.id) as any; + const guest = members.find((x: any) => x.id === member.id); + expect(guest.username).toBe('Anna'); + expect(guest.is_guest).toBe(true); + }); + + it('TRIP-SVC-031: a duplicate guest name is disambiguated with a numeric suffix', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const a = createGuest(trip.id, 'Sam', owner.id); + const b = createGuest(trip.id, 'Sam', owner.id); + expect(a.member.username).toBe('Sam'); + expect(b.member.username).toBe('Sam 2'); + }); + + it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', () => { + const { user: owner } = createUser(testDb); + const { user: other } = createUser(testDb); + const otherTrip = createTrip(testDb, other.id); + const trip = createTrip(testDb, owner.id); + const { member } = createGuest(trip.id, 'Bob', owner.id); + + expect(renameGuest(trip.id, member.id, 'Robert')).toBe(true); + expect((testDb.prepare('SELECT username FROM users WHERE id = ?').get(member.id) as any).username).toBe('Robert'); + + // A real user cannot be renamed through the guest path… + expect(renameGuest(trip.id, owner.id, 'Hacked')).toBe(false); + // …and a guest cannot be renamed from a different trip. + expect(renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false); + }); + + it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const { member } = createGuest(trip.id, 'Carol', owner.id); + + // Real members are not deletable via the guest path. + expect(deleteGuest(trip.id, owner.id)).toBe(false); + + expect(deleteGuest(trip.id, member.id)).toBe(true); + expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(member.id)).toBeUndefined(); + expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined(); + }); + + it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + const { member } = createGuest(trip.id, 'Dora', owner.id); + + // The synthetic username/email must not resolve through the invite box. + expect(() => addMember(trip.id, 'Dora', owner.id, owner.id)).toThrow('User not found'); + // Ownership can never be handed to a guest. + expect(() => transferOwnership(trip.id, member.id, owner.id)).toThrow('Cannot transfer ownership to a guest'); + }); +}); diff --git a/shared/src/i18n/ar/members.ts b/shared/src/i18n/ar/members.ts index 95e92fa9..d5eaeb7a 100644 --- a/shared/src/i18n/ar/members.ts +++ b/shared/src/i18n/ar/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'تعيين كمالك', 'members.confirmTransfer': 'نقل الملكية إلى {name}؟ ستصبح عضوًا عاديًا.', 'members.transferError': 'فشل نقل الملكية', + 'members.guests': 'الضيوف', + 'members.guest': 'ضيف', + 'members.guestsHint': 'أشخاص بدون حساب. يمكن إسنادهم إلى التكاليف والأمتعة والمهام، لكن لا يمكنهم تسجيل الدخول.', + 'members.addGuest': 'إضافة ضيف', + 'members.guestNamePlaceholder': 'اسم الضيف', + 'members.guestAdded': 'تمت إضافة الضيف', + 'members.guestAddError': 'فشلت إضافة الضيف', + 'members.guestRenameError': 'فشلت إعادة تسمية الضيف', + 'members.guestRemoved': 'تمت إزالة الضيف', + 'members.confirmRemoveGuest': 'إزالة هذا الضيف؟ سيتم أيضًا إزالة تعييناته وحصص التكاليف الخاصة به.', }; export default members; diff --git a/shared/src/i18n/br/members.ts b/shared/src/i18n/br/members.ts index 521c5aea..e4a1f2a4 100644 --- a/shared/src/i18n/br/members.ts +++ b/shared/src/i18n/br/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { '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', + 'members.guests': 'Convidados', + 'members.guest': 'Convidado', + 'members.guestsHint': 'Pessoas sem conta. Podem ser atribuídas a custos, bagagem e tarefas, mas não podem entrar.', + 'members.addGuest': 'Adicionar convidado', + 'members.guestNamePlaceholder': 'Nome do convidado', + 'members.guestAdded': 'Convidado adicionado', + 'members.guestAddError': 'Falha ao adicionar convidado', + 'members.guestRenameError': 'Falha ao renomear convidado', + 'members.guestRemoved': 'Convidado removido', + 'members.confirmRemoveGuest': 'Remover este convidado? As atribuições e os custos dele também serão removidos.', }; export default members; diff --git a/shared/src/i18n/cs/members.ts b/shared/src/i18n/cs/members.ts index bbbe3f5f..795e3fad 100644 --- a/shared/src/i18n/cs/members.ts +++ b/shared/src/i18n/cs/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { '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', + 'members.guests': 'Hosté', + 'members.guest': 'Host', + 'members.guestsHint': 'Lidé bez účtu. Lze jim přiřadit náklady, balení a úkoly, ale nemohou se přihlásit.', + 'members.addGuest': 'Přidat hosta', + 'members.guestNamePlaceholder': 'Jméno hosta', + 'members.guestAdded': 'Host přidán', + 'members.guestAddError': 'Nepodařilo se přidat hosta', + 'members.guestRenameError': 'Nepodařilo se přejmenovat hosta', + 'members.guestRemoved': 'Host odebrán', + 'members.confirmRemoveGuest': 'Odebrat tohoto hosta? Jeho přiřazení a podíly na nákladech budou také odebrány.', }; export default members; diff --git a/shared/src/i18n/de/members.ts b/shared/src/i18n/de/members.ts index 83dc9a70..ea47e217 100644 --- a/shared/src/i18n/de/members.ts +++ b/shared/src/i18n/de/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'Zum Eigentümer machen', 'members.confirmTransfer': 'Eigentümerschaft an {name} übertragen? Du wirst zu einem normalen Mitglied.', 'members.transferError': 'Übertragung fehlgeschlagen', + 'members.guests': 'Gäste', + 'members.guest': 'Gast', + 'members.guestsHint': 'Personen ohne Account. Sie können Kosten, Gepäck und Aufgaben zugewiesen bekommen, sich aber nicht anmelden.', + 'members.addGuest': 'Gast hinzufügen', + 'members.guestNamePlaceholder': 'Name des Gasts', + 'members.guestAdded': 'Gast hinzugefügt', + 'members.guestAddError': 'Gast konnte nicht hinzugefügt werden', + 'members.guestRenameError': 'Gast konnte nicht umbenannt werden', + 'members.guestRemoved': 'Gast entfernt', + 'members.confirmRemoveGuest': 'Diesen Gast entfernen? Seine Zuweisungen und Kostenanteile werden ebenfalls entfernt.', }; export default members; diff --git a/shared/src/i18n/en/members.ts b/shared/src/i18n/en/members.ts index 4433ea14..5d4aa898 100644 --- a/shared/src/i18n/en/members.ts +++ b/shared/src/i18n/en/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'Make owner', 'members.confirmTransfer': 'Transfer ownership to {name}? You will become a regular member.', 'members.transferError': 'Failed to transfer ownership', + 'members.guests': 'Guests', + 'members.guest': 'Guest', + 'members.guestsHint': 'People without an account. They can be assigned to costs, packing and tasks, but cannot sign in.', + 'members.addGuest': 'Add guest', + 'members.guestNamePlaceholder': 'Guest name', + 'members.guestAdded': 'Guest added', + 'members.guestAddError': 'Failed to add guest', + 'members.guestRenameError': 'Failed to rename guest', + 'members.guestRemoved': 'Guest removed', + 'members.confirmRemoveGuest': 'Remove this guest? Their assignments and cost shares will be removed too.', }; export default members; diff --git a/shared/src/i18n/es/members.ts b/shared/src/i18n/es/members.ts index f78926d7..470cf902 100644 --- a/shared/src/i18n/es/members.ts +++ b/shared/src/i18n/es/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { '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', + 'members.guests': 'Invitados', + 'members.guest': 'Invitado', + 'members.guestsHint': 'Personas sin cuenta. Se les pueden asignar gastos, equipaje y tareas, pero no pueden iniciar sesión.', + 'members.addGuest': 'Añadir invitado', + 'members.guestNamePlaceholder': 'Nombre del invitado', + 'members.guestAdded': 'Invitado añadido', + 'members.guestAddError': 'Error al añadir el invitado', + 'members.guestRenameError': 'Error al renombrar el invitado', + 'members.guestRemoved': 'Invitado eliminado', + 'members.confirmRemoveGuest': '¿Eliminar este invitado? También se eliminarán sus asignaciones y partes de gastos.', }; export default members; diff --git a/shared/src/i18n/fr/members.ts b/shared/src/i18n/fr/members.ts index 03cab09a..7999fd9a 100644 --- a/shared/src/i18n/fr/members.ts +++ b/shared/src/i18n/fr/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { '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é', + 'members.guests': 'Invités', + 'members.guest': 'Invité', + 'members.guestsHint': 'Personnes sans compte. On peut leur attribuer des dépenses, des bagages et des tâches, mais elles ne peuvent pas se connecter.', + 'members.addGuest': 'Ajouter un invité', + 'members.guestNamePlaceholder': 'Nom de l\'invité', + 'members.guestAdded': 'Invité ajouté', + 'members.guestAddError': 'Échec de l\'ajout de l\'invité', + 'members.guestRenameError': 'Échec du renommage de l\'invité', + 'members.guestRemoved': 'Invité supprimé', + 'members.confirmRemoveGuest': 'Supprimer cet invité ? Ses affectations et parts de dépenses seront aussi supprimées.', }; export default members; diff --git a/shared/src/i18n/gr/members.ts b/shared/src/i18n/gr/members.ts index 523aea9f..f32456f0 100644 --- a/shared/src/i18n/gr/members.ts +++ b/shared/src/i18n/gr/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'Ορισμός ως κάτοχο', 'members.confirmTransfer': 'Μεταβίβαση ιδιοκτησίας στον/στην {name}; Θα γίνετε απλό μέλος.', 'members.transferError': 'Αποτυχία μεταβίβασης ιδιοκτησίας', + 'members.guests': 'Επισκέπτες', + 'members.guest': 'Επισκέπτης', + 'members.guestsHint': 'Άτομα χωρίς λογαριασμό. Μπορούν να ανατεθούν σε έξοδα, αποσκευές και εργασίες, αλλά δεν μπορούν να συνδεθούν.', + 'members.addGuest': 'Προσθήκη επισκέπτη', + 'members.guestNamePlaceholder': 'Όνομα επισκέπτη', + 'members.guestAdded': 'Ο επισκέπτης προστέθηκε', + 'members.guestAddError': 'Αποτυχία προσθήκης επισκέπτη', + 'members.guestRenameError': 'Αποτυχία μετονομασίας επισκέπτη', + 'members.guestRemoved': 'Ο επισκέπτης αφαιρέθηκε', + 'members.confirmRemoveGuest': 'Αφαίρεση αυτού του επισκέπτη; Θα αφαιρεθούν επίσης οι αναθέσεις και τα μερίδια εξόδων του.', }; export default members; diff --git a/shared/src/i18n/hu/members.ts b/shared/src/i18n/hu/members.ts index cba64609..6c392685 100644 --- a/shared/src/i18n/hu/members.ts +++ b/shared/src/i18n/hu/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { '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', + 'members.guests': 'Vendégek', + 'members.guest': 'Vendég', + 'members.guestsHint': 'Fiók nélküli személyek. Hozzárendelhetők költségekhez, csomagoláshoz és feladatokhoz, de nem tudnak bejelentkezni.', + 'members.addGuest': 'Vendég hozzáadása', + 'members.guestNamePlaceholder': 'Vendég neve', + 'members.guestAdded': 'Vendég hozzáadva', + 'members.guestAddError': 'Nem sikerült hozzáadni a vendéget', + 'members.guestRenameError': 'Nem sikerült átnevezni a vendéget', + 'members.guestRemoved': 'Vendég eltávolítva', + 'members.confirmRemoveGuest': 'Eltávolítja ezt a vendéget? A hozzárendelései és költségrészei is törlődnek.', }; export default members; diff --git a/shared/src/i18n/id/members.ts b/shared/src/i18n/id/members.ts index b9c41442..8b8ad747 100644 --- a/shared/src/i18n/id/members.ts +++ b/shared/src/i18n/id/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'Jadikan pemilik', 'members.confirmTransfer': 'Alihkan kepemilikan ke {name}? Anda akan menjadi anggota biasa.', 'members.transferError': 'Gagal mengalihkan kepemilikan', + 'members.guests': 'Tamu', + 'members.guest': 'Tamu', + 'members.guestsHint': 'Orang tanpa akun. Mereka dapat ditugaskan ke biaya, kemasan, dan tugas, tetapi tidak dapat masuk.', + 'members.addGuest': 'Tambah tamu', + 'members.guestNamePlaceholder': 'Nama tamu', + 'members.guestAdded': 'Tamu ditambahkan', + 'members.guestAddError': 'Gagal menambahkan tamu', + 'members.guestRenameError': 'Gagal mengganti nama tamu', + 'members.guestRemoved': 'Tamu dihapus', + 'members.confirmRemoveGuest': 'Hapus tamu ini? Penugasan dan bagian biayanya juga akan dihapus.', }; export default members; diff --git a/shared/src/i18n/it/members.ts b/shared/src/i18n/it/members.ts index 08c0a69c..f90746ba 100644 --- a/shared/src/i18n/it/members.ts +++ b/shared/src/i18n/it/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'Rendi proprietario', 'members.confirmTransfer': 'Trasferire la proprietà a {name}? Diventerai un membro normale.', 'members.transferError': 'Trasferimento della proprietà non riuscito', + 'members.guests': 'Ospiti', + 'members.guest': 'Ospite', + 'members.guestsHint': 'Persone senza account. Possono essere assegnate a spese, bagagli e attività, ma non possono accedere.', + 'members.addGuest': 'Aggiungi ospite', + 'members.guestNamePlaceholder': 'Nome ospite', + 'members.guestAdded': 'Ospite aggiunto', + 'members.guestAddError': 'Impossibile aggiungere l\'ospite', + 'members.guestRenameError': 'Impossibile rinominare l\'ospite', + 'members.guestRemoved': 'Ospite rimosso', + 'members.confirmRemoveGuest': 'Rimuovere questo ospite? Verranno rimosse anche le sue assegnazioni e quote di spesa.', }; export default members; diff --git a/shared/src/i18n/ja/members.ts b/shared/src/i18n/ja/members.ts index 9d5691d2..503f84f6 100644 --- a/shared/src/i18n/ja/members.ts +++ b/shared/src/i18n/ja/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'オーナーにする', 'members.confirmTransfer': '所有権を {name} に移譲しますか?あなたは通常のメンバーになります。', 'members.transferError': '所有権の移譲に失敗しました', + 'members.guests': 'ゲスト', + 'members.guest': 'ゲスト', + 'members.guestsHint': 'アカウントを持たない人。費用・持ち物・タスクに割り当てできますが、ログインはできません。', + 'members.addGuest': 'ゲストを追加', + 'members.guestNamePlaceholder': 'ゲスト名', + 'members.guestAdded': 'ゲストを追加しました', + 'members.guestAddError': 'ゲストの追加に失敗しました', + 'members.guestRenameError': 'ゲストの名前変更に失敗しました', + 'members.guestRemoved': 'ゲストを削除しました', + 'members.confirmRemoveGuest': 'このゲストを削除しますか?割り当てと費用の負担分も削除されます。', }; export default members; diff --git a/shared/src/i18n/ko/members.ts b/shared/src/i18n/ko/members.ts index ab3afacd..c7534838 100644 --- a/shared/src/i18n/ko/members.ts +++ b/shared/src/i18n/ko/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': '소유자로 지정', 'members.confirmTransfer': '{name}님에게 소유권을 이전하시겠습니까? 일반 멤버가 됩니다.', 'members.transferError': '소유권 이전 실패', + 'members.guests': '게스트', + 'members.guest': '게스트', + 'members.guestsHint': '계정이 없는 사람입니다. 비용, 짐, 할 일에 배정할 수 있지만 로그인할 수 없습니다.', + 'members.addGuest': '게스트 추가', + 'members.guestNamePlaceholder': '게스트 이름', + 'members.guestAdded': '게스트가 추가되었습니다', + 'members.guestAddError': '게스트 추가 실패', + 'members.guestRenameError': '게스트 이름 변경 실패', + 'members.guestRemoved': '게스트가 제거되었습니다', + 'members.confirmRemoveGuest': '이 게스트를 제거할까요? 배정 및 비용 분담도 함께 제거됩니다.', }; export default members; diff --git a/shared/src/i18n/nl/members.ts b/shared/src/i18n/nl/members.ts index 92c6333e..eff7301e 100644 --- a/shared/src/i18n/nl/members.ts +++ b/shared/src/i18n/nl/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'Eigenaar maken', 'members.confirmTransfer': 'Eigenaarschap overdragen aan {name}? Je wordt een gewoon lid.', 'members.transferError': 'Overdracht van eigenaarschap mislukt', + 'members.guests': 'Gasten', + 'members.guest': 'Gast', + 'members.guestsHint': 'Mensen zonder account. Ze kunnen worden toegewezen aan kosten, bagage en taken, maar kunnen niet inloggen.', + 'members.addGuest': 'Gast toevoegen', + 'members.guestNamePlaceholder': 'Naam van gast', + 'members.guestAdded': 'Gast toegevoegd', + 'members.guestAddError': 'Kan gast niet toevoegen', + 'members.guestRenameError': 'Kan gast niet hernoemen', + 'members.guestRemoved': 'Gast verwijderd', + 'members.confirmRemoveGuest': 'Deze gast verwijderen? Hun toewijzingen en kostenaandelen worden ook verwijderd.', }; export default members; diff --git a/shared/src/i18n/pl/members.ts b/shared/src/i18n/pl/members.ts index b5c04bf8..e54e4081 100644 --- a/shared/src/i18n/pl/members.ts +++ b/shared/src/i18n/pl/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { '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', + 'members.guests': 'Goście', + 'members.guest': 'Gość', + 'members.guestsHint': 'Osoby bez konta. Można im przypisać koszty, pakowanie i zadania, ale nie mogą się zalogować.', + 'members.addGuest': 'Dodaj gościa', + 'members.guestNamePlaceholder': 'Imię gościa', + 'members.guestAdded': 'Dodano gościa', + 'members.guestAddError': 'Nie udało się dodać gościa', + 'members.guestRenameError': 'Nie udało się zmienić nazwy gościa', + 'members.guestRemoved': 'Usunięto gościa', + 'members.confirmRemoveGuest': 'Usunąć tego gościa? Jego przypisania i udziały w kosztach również zostaną usunięte.', }; export default members; diff --git a/shared/src/i18n/ru/members.ts b/shared/src/i18n/ru/members.ts index a0c4affb..fdcc8de8 100644 --- a/shared/src/i18n/ru/members.ts +++ b/shared/src/i18n/ru/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'Назначить владельцем', 'members.confirmTransfer': 'Передать права владельца пользователю {name}? Вы станете обычным участником.', 'members.transferError': 'Не удалось передать права владельца', + 'members.guests': 'Гости', + 'members.guest': 'Гость', + 'members.guestsHint': 'Люди без учётной записи. Им можно назначать расходы, вещи и задачи, но они не могут войти.', + 'members.addGuest': 'Добавить гостя', + 'members.guestNamePlaceholder': 'Имя гостя', + 'members.guestAdded': 'Гость добавлен', + 'members.guestAddError': 'Не удалось добавить гостя', + 'members.guestRenameError': 'Не удалось переименовать гостя', + 'members.guestRemoved': 'Гость удалён', + 'members.confirmRemoveGuest': 'Удалить этого гостя? Его назначения и доли расходов также будут удалены.', }; export default members; diff --git a/shared/src/i18n/sv/members.ts b/shared/src/i18n/sv/members.ts index 0189e894..846c5bfa 100644 --- a/shared/src/i18n/sv/members.ts +++ b/shared/src/i18n/sv/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { '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', + 'members.guests': 'Gäster', + 'members.guest': 'Gäst', + 'members.guestsHint': 'Personer utan konto. De kan tilldelas kostnader, packning och uppgifter, men kan inte logga in.', + 'members.addGuest': 'Lägg till gäst', + 'members.guestNamePlaceholder': 'Gästens namn', + 'members.guestAdded': 'Gäst tillagd', + 'members.guestAddError': 'Det gick inte att lägga till gästen', + 'members.guestRenameError': 'Det gick inte att byta namn på gästen', + 'members.guestRemoved': 'Gäst borttagen', + 'members.confirmRemoveGuest': 'Ta bort den här gästen? Deras tilldelningar och kostnadsandelar tas också bort.', }; export default members; diff --git a/shared/src/i18n/tr/members.ts b/shared/src/i18n/tr/members.ts index bb39de84..ca0df8d0 100644 --- a/shared/src/i18n/tr/members.ts +++ b/shared/src/i18n/tr/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'Sahip yap', 'members.confirmTransfer': 'Sahipliği {name} kullanıcısına devret? Normal bir üye olacaksınız.', 'members.transferError': 'Sahiplik devredilemedi', + 'members.guests': 'Misafirler', + 'members.guest': 'Misafir', + 'members.guestsHint': 'Hesabı olmayan kişiler. Masraflara, valize ve görevlere atanabilirler, ancak giriş yapamazlar.', + 'members.addGuest': 'Misafir ekle', + 'members.guestNamePlaceholder': 'Misafir adı', + 'members.guestAdded': 'Misafir eklendi', + 'members.guestAddError': 'Misafir eklenemedi', + 'members.guestRenameError': 'Misafir yeniden adlandırılamadı', + 'members.guestRemoved': 'Misafir kaldırıldı', + 'members.confirmRemoveGuest': 'Bu misafir kaldırılsın mı? Atamaları ve masraf payları da kaldırılacak.', }; export default members; diff --git a/shared/src/i18n/uk/members.ts b/shared/src/i18n/uk/members.ts index f529f08e..7e398f4b 100644 --- a/shared/src/i18n/uk/members.ts +++ b/shared/src/i18n/uk/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': 'Призначити власником', 'members.confirmTransfer': 'Передати право власності користувачу {name}? Ви станете звичайним учасником.', 'members.transferError': 'Не вдалося передати право власності', + 'members.guests': 'Гості', + 'members.guest': 'Гість', + 'members.guestsHint': 'Люди без облікового запису. Їм можна призначати витрати, речі та завдання, але вони не можуть увійти.', + 'members.addGuest': 'Додати гостя', + 'members.guestNamePlaceholder': 'Ім\'я гостя', + 'members.guestAdded': 'Гостя додано', + 'members.guestAddError': 'Не вдалося додати гостя', + 'members.guestRenameError': 'Не вдалося перейменувати гостя', + 'members.guestRemoved': 'Гостя видалено', + 'members.confirmRemoveGuest': 'Видалити цього гостя? Його призначення та частки витрат також буде видалено.', }; export default members; diff --git a/shared/src/i18n/vi/members.ts b/shared/src/i18n/vi/members.ts index bbfd775f..b15d6b07 100644 --- a/shared/src/i18n/vi/members.ts +++ b/shared/src/i18n/vi/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { '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', + 'members.guests': 'Khách', + 'members.guest': 'Khách', + 'members.guestsHint': 'Người không có tài khoản. Có thể giao chi phí, hành lý và công việc cho họ, nhưng họ không thể đăng nhập.', + 'members.addGuest': 'Thêm khách', + 'members.guestNamePlaceholder': 'Tên khách', + 'members.guestAdded': 'Đã thêm khách', + 'members.guestAddError': 'Không thể thêm khách', + 'members.guestRenameError': 'Không thể đổi tên khách', + 'members.guestRemoved': 'Đã xóa khách', + 'members.confirmRemoveGuest': 'Xóa khách này? Các phân công và phần chi phí của họ cũng sẽ bị xóa.', }; export default members; diff --git a/shared/src/i18n/zh-TW/members.ts b/shared/src/i18n/zh-TW/members.ts index 4f57aad1..b7033a86 100644 --- a/shared/src/i18n/zh-TW/members.ts +++ b/shared/src/i18n/zh-TW/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': '設為擁有者', 'members.confirmTransfer': '將擁有權轉移給 {name}?你將成為一般成員。', 'members.transferError': '轉移擁有權失敗', + 'members.guests': '訪客', + 'members.guest': '訪客', + 'members.guestsHint': '沒有帳號的人。可以為其指派費用、行李和任務,但他們無法登入。', + 'members.addGuest': '新增訪客', + 'members.guestNamePlaceholder': '訪客姓名', + 'members.guestAdded': '已新增訪客', + 'members.guestAddError': '新增訪客失敗', + 'members.guestRenameError': '重新命名訪客失敗', + 'members.guestRemoved': '已移除訪客', + 'members.confirmRemoveGuest': '移除此訪客?其指派和費用分攤也將被移除。', }; export default members; diff --git a/shared/src/i18n/zh/members.ts b/shared/src/i18n/zh/members.ts index 9c45e735..62f08acf 100644 --- a/shared/src/i18n/zh/members.ts +++ b/shared/src/i18n/zh/members.ts @@ -23,5 +23,15 @@ const members: TranslationStrings = { 'members.makeOwner': '设为所有者', 'members.confirmTransfer': '将所有权转移给 {name}?你将成为普通成员。', 'members.transferError': '转移所有权失败', + 'members.guests': '访客', + 'members.guest': '访客', + 'members.guestsHint': '没有账户的人。可以为其分配费用、行李和任务,但他们无法登录。', + 'members.addGuest': '添加访客', + 'members.guestNamePlaceholder': '访客姓名', + 'members.guestAdded': '已添加访客', + 'members.guestAddError': '添加访客失败', + 'members.guestRenameError': '重命名访客失败', + 'members.guestRemoved': '已移除访客', + 'members.confirmRemoveGuest': '移除此访客?其分配和费用分摊也将被移除。', }; export default members; diff --git a/shared/src/trip/trip.schema.ts b/shared/src/trip/trip.schema.ts index c4bd36d6..8abd0a57 100644 --- a/shared/src/trip/trip.schema.ts +++ b/shared/src/trip/trip.schema.ts @@ -55,9 +55,22 @@ export const tripMemberSchema = z.object({ role: z.string().optional(), added_at: z.string().nullable().optional(), invited_by_username: z.string().nullable().optional(), + // Guest members (#1362): accountless participant, assignable but never able to log in. + is_guest: z.boolean().optional(), }); export type TripMember = z.infer; +// Guest CRUD (#1362) — owner-only management of accountless participants. +export const tripCreateGuestRequestSchema = z.object({ + name: z.string().min(1).max(50), +}); +export type TripCreateGuestRequest = z.infer; + +export const tripRenameGuestRequestSchema = z.object({ + name: z.string().min(1).max(50), +}); +export type TripRenameGuestRequest = z.infer; + export const tripCreateRequestSchema = z.object({ title: z.string().min(1), description: z.string().nullable().optional(),