mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
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:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
is_guest?: boolean
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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<number, number> {
|
||||
const n = members.length
|
||||
@@ -1238,6 +1239,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
{p.is_guest && <GuestBadge size="xs" />}
|
||||
</button>
|
||||
{splitMode === 'equally' ? (
|
||||
on ? (
|
||||
|
||||
@@ -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]}
|
||||
</div>
|
||||
<span style={{ flex: 1 }}>{m.username}</span>
|
||||
<span style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.username}</span>
|
||||
{m.is_guest && <GuestBadge size="xs" />}
|
||||
</span>
|
||||
{isAssigned && <Check size={12} className="text-content-muted" />}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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) ? <img src={member.avatar_url || `/uploads/avatars/${member.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
{member.username}
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>{member.username}</span>
|
||||
{member.is_guest && <GuestBadge size="xs" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -479,7 +479,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
{ value: '', label: t('todo.unassigned'), icon: <User size={14} className="text-content-faint" /> },
|
||||
...members.map(m => ({
|
||||
value: String(m.id),
|
||||
label: m.username,
|
||||
label: m.is_guest ? `${m.username} · ${t('members.guest')}` : m.username,
|
||||
icon: m.avatar ? (
|
||||
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
|
||||
) : (
|
||||
|
||||
@@ -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()}
|
||||
</span>
|
||||
)}
|
||||
{assignedUser.is_guest && <UserRound size={11} style={{ opacity: 0.7 }} />}
|
||||
{assignedUser.username}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -423,4 +423,42 @@ describe('TripMembersModal', () => {
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
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(<TripMembersModal {...defaultProps} />);
|
||||
// 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(<TripMembersModal {...defaultProps} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||
@@ -331,7 +379,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||
<Users size={13} className="text-content-faint" />
|
||||
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
|
||||
{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')})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -343,7 +391,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }:
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{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 }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Guests (#1362) — accountless participants, managed by the owner */}
|
||||
{(isCurrentOwner || guests.length > 0) && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<UserRound size={13} className="text-content-faint" />
|
||||
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
|
||||
{t('members.guests')}{guests.length > 0 ? ` (${guests.length})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', margin: '0 0 10px', lineHeight: 1.5 }}>{t('members.guestsHint')}</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{guests.map(g => (
|
||||
<div key={g.id} className="bg-surface-secondary border border-edge-secondary" style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px', borderRadius: 10,
|
||||
}}>
|
||||
<Avatar username={g.username} avatarUrl={null} />
|
||||
{renamingGuestId === g.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={e => 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' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{g.username}</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99 }}>
|
||||
<UserRound size={9} /> {t('members.guest')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isCurrentOwner && renamingGuestId !== g.id && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setRenamingGuestId(g.id); setRenameValue(g.username) }}
|
||||
title={t('common.rename')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteGuest(g.id)}
|
||||
disabled={removingId === g.id}
|
||||
title={t('members.removeAccess')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: removingId === g.id ? 0.4 : 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isCurrentOwner && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: guests.length > 0 ? 8 : 0 }}>
|
||||
<input
|
||||
value={newGuestName}
|
||||
onChange={e => 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' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddGuest}
|
||||
disabled={addingGuest || !newGuestName.trim()}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
|
||||
background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: addingGuest || !newGuestName.trim() ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: addingGuest || !newGuestName.trim() ? 0.4 : 1, flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Plus size={13} /> {addingGuest ? '…' : t('members.addGuest')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right column: Share Link */}
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
title={t('members.guestsHint')}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0,
|
||||
fontSize: `calc(${fs}px * var(--fs-scale-caption, 1))`, fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-tertiary)',
|
||||
padding: '1px 6px', borderRadius: 99,
|
||||
}}
|
||||
>
|
||||
<UserRound size={fs - 1} /> {t('members.guest')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & { avatar?: string | null })[];
|
||||
let onlineUserIds = new Set<number>();
|
||||
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;
|
||||
|
||||
@@ -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<User, 'id' | 'username' | 'avatar'>[];
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<number>();
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<User, 'id' | 'username' | 'email' | 'avatar'>;
|
||||
|
||||
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<User, 'id' | 'username' | 'email' | 'avatar'> | 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TripsService>);
|
||||
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<TripsService>);
|
||||
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<TripsService>);
|
||||
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<TripsService>);
|
||||
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<TripsService>);
|
||||
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<TripsService>);
|
||||
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<TripsService>)).bundle(user, '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
const bundle = vi.fn().mockReturnValue({ trip: { id: 9 }, days: [] });
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof tripMemberSchema>;
|
||||
|
||||
// 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<typeof tripCreateGuestRequestSchema>;
|
||||
|
||||
export const tripRenameGuestRequestSchema = z.object({
|
||||
name: z.string().min(1).max(50),
|
||||
});
|
||||
export type TripRenameGuestRequest = z.infer<typeof tripRenameGuestRequestSchema>;
|
||||
|
||||
export const tripCreateRequestSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().nullable().optional(),
|
||||
|
||||
Reference in New Issue
Block a user