diff --git a/client/src/App.tsx b/client/src/App.tsx index daeb0a6b2..bac8356f7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,7 @@ import JourneyDetailPage from './pages/JourneyDetailPage' import CollectionsPage from './pages/CollectionsPage' import JourneyPublicPage from './pages/JourneyPublicPage' import SharedTripPage from './pages/SharedTripPage' +import JoinTripPage from './pages/JoinTripPage' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import OAuthAuthorizePage from './pages/OAuthAuthorizePage' import { ToastContainer } from './components/shared/Toast' @@ -225,6 +226,16 @@ export default function App() { } /> + {/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous + visitor is redirected to /login (never registration) and returns here. */} + + + + } + /> apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data), deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data), listInvites: () => apiClient.get('/admin/invites').then(r => r.data), - createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), + listInviteTrips: () => apiClient.get('/admin/invites/trips').then(r => r.data), + createInvite: (data: { max_uses: number; expires_in_days?: number; trip_id?: number | null }) => apiClient.post('/admin/invites', data).then(r => r.data), deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data), auditLog: (params?: { limit?: number; offset?: number }) => apiClient.get('/admin/audit-log', { params }).then(r => r.data), @@ -812,6 +813,16 @@ export const shareApi = { getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data), } +// Trip invite links (#1143) — join a trip as an existing, logged-in user. +export const tripInviteApi = { + getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/invite-link`).then(r => r.data), + createLink: (tripId: number | string, expires_in_days?: number | null) => + apiClient.post(`/trips/${tripId}/invite-link`, { expires_in_days: expires_in_days ?? null }).then(r => r.data), + deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/invite-link`).then(r => r.data), + preview: (token: string) => apiClient.get(`/trip-invites/${token}`).then(r => r.data), + accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data), +} + export const notificationsApi = { getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), updatePreferences: (prefs: Record>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), diff --git a/client/src/components/Trips/TripMembersModal.test.tsx b/client/src/components/Trips/TripMembersModal.test.tsx index 17b36fa72..962c9b8e1 100644 --- a/client/src/components/Trips/TripMembersModal.test.tsx +++ b/client/src/components/Trips/TripMembersModal.test.tsx @@ -349,8 +349,9 @@ describe('TripMembersModal', () => { const aliceOption = await screen.findByRole('button', { name: 'alice' }); await user.click(aliceOption); - // Click Invite button - const inviteBtn = screen.getByRole('button', { name: /Invite/i }); + // Click the member "Invite" button (exact — the Share area also has a + // "Create invite link" button that a loose /Invite/i would match too). + const inviteBtn = screen.getByRole('button', { name: 'Invite' }); await user.click(inviteBtn); await waitFor(() => { diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx index ef910c445..ba3245b6f 100644 --- a/client/src/components/Trips/TripMembersModal.tsx +++ b/client/src/components/Trips/TripMembersModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import Modal from '../shared/Modal' -import { tripsApi, authApi, shareApi } from '../../api/client' +import { tripsApi, authApi, shareApi, tripInviteApi } from '../../api/client' import { useToast } from '../shared/Toast' import { useAuthStore } from '../../store/authStore' import { useCanDo } from '../../store/permissionsStore' @@ -163,6 +163,106 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para ) } +/** + * Trip invite link (#1143). One rotating token per trip that an existing, + * logged-in user opens to join the trip as a member. Mirrors ShareLinkSection + * but the link points at /join/:token (login-required, no registration). + */ +function TripInviteLinkSection({ tripId, t }: { tripId: number; t: (key: string, params?: Record) => string }) { + const [token, setToken] = useState(null) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [copied, setCopied] = useState(false) + const toast = useToast() + const copyTimerRef = useRef | null>(null) + + useEffect(() => () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }, []) + + useEffect(() => { + tripInviteApi.getLink(tripId) + .then((d: { token: string | null }) => setToken(d.token)) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [tripId]) + + const inviteUrl = token ? `${window.location.origin}/join/${token}` : null + + const create = async () => { + setBusy(true) + try { const d = await tripInviteApi.createLink(tripId); setToken(d.token) } + catch { toast.error(t('share.createError')) } + finally { setBusy(false) } + } + + const remove = async () => { + setBusy(true) + try { await tripInviteApi.deleteLink(tripId); setToken(null) } + catch { toast.error(t('common.error')) } + finally { setBusy(false) } + } + + const copy = () => { + if (!inviteUrl) return + navigator.clipboard.writeText(inviteUrl) + setCopied(true) + if (copyTimerRef.current) clearTimeout(copyTimerRef.current) + copyTimerRef.current = setTimeout(() => setCopied(false), 2000) + } + + if (loading) return null + + return ( +
+
+ + {t('trip.invite.linkTitle')} +
+

{t('trip.invite.linkHint')}

+ + {inviteUrl ? ( +
+
+ + +
+
+ + +
+
+ ) : ( + + )} +
+ ) +} + interface TripMembersModalProps { isOpen: boolean onClose: () => void @@ -538,6 +638,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: {/* Right column: Share Link */} {canManageShare &&
+
} diff --git a/client/src/pages/JoinTripPage.tsx b/client/src/pages/JoinTripPage.tsx new file mode 100644 index 000000000..60b59ca22 --- /dev/null +++ b/client/src/pages/JoinTripPage.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Users, Check, X } from 'lucide-react' +import { tripInviteApi } from '../api/client' +import { useTranslation } from '../i18n' + +/** + * /join/:token — accept a trip invite as an existing, logged-in user (#1143). + * + * The route is behind ProtectedRoute, so an unauthenticated visitor is bounced + * to /login?redirect=/join/:token and lands back here after signing in — there + * is no registration path from an invite link. Preview resolves the trip name; + * Accept adds the current user as a member and opens the trip. An already-member + * (or the owner) is simply taken straight to the trip. + */ +export default function JoinTripPage() { + const { token } = useParams<{ token: string }>() + const navigate = useNavigate() + const { t } = useTranslation() + + const [state, setState] = useState<'loading' | 'ready' | 'joining' | 'invalid'>('loading') + const [title, setTitle] = useState('') + + useEffect(() => { + let cancelled = false + if (!token) { setState('invalid'); return } + tripInviteApi.preview(token) + .then((data: { title: string }) => { if (!cancelled) { setTitle(data.title); setState('ready') } }) + .catch(() => { if (!cancelled) setState('invalid') }) + return () => { cancelled = true } + }, [token]) + + const accept = () => { + if (!token) return + setState('joining') + tripInviteApi.accept(token) + .then((data: { trip_id: number }) => navigate(`/trips/${data.trip_id}`, { replace: true })) + .catch(() => setState('invalid')) + } + + return ( +
+
+
+ {state === 'invalid' + ? + : } +
+ + {state === 'invalid' ? ( + <> +

{t('trip.invite.invalidTitle')}

+

{t('trip.invite.invalid')}

+ + + ) : ( + <> +

{t('trip.invite.joinHeading')}

+

+ {state === 'loading' ? t('common.loading') : t('trip.invite.joinPrompt', { title })} +

+
+ + +
+ + )} +
+
+ ) +} diff --git a/client/src/pages/admin/AdminUsersTab.tsx b/client/src/pages/admin/AdminUsersTab.tsx index d5f8f23e2..354b726e0 100644 --- a/client/src/pages/admin/AdminUsersTab.tsx +++ b/client/src/pages/admin/AdminUsersTab.tsx @@ -18,7 +18,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): hour12, currentUser, users, isLoading, setShowCreateUser, - invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, + invites, inviteTrips, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, copyInviteLink, handleCreateInvite, handleDeleteInvite, handleEditUser, handleDeleteUser, } = admin @@ -163,6 +163,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')} {inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`} + {inv.trip_title && ` · ${t('admin.invite.boundTo', { trip: inv.trip_title })}`} {` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
@@ -220,6 +221,22 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): ))} + {inviteTrips.length > 0 && ( +
+ + +

{t('admin.invite.tripHint')}

+
+ )}
diff --git a/client/src/pages/admin/useAdmin.ts b/client/src/pages/admin/useAdmin.ts index a5ac3ebe9..19b02c5bb 100644 --- a/client/src/pages/admin/useAdmin.ts +++ b/client/src/pages/admin/useAdmin.ts @@ -74,8 +74,9 @@ export function useAdmin() { // Invite links const [invites, setInvites] = useState([]) + const [inviteTrips, setInviteTrips] = useState<{ id: number; title: string }[]>([]) const [showCreateInvite, setShowCreateInvite] = useState(false) - const [inviteForm, setInviteForm] = useState<{ max_uses: number; expires_in_days: number | '' }>({ max_uses: 1, expires_in_days: 7 }) + const [inviteForm, setInviteForm] = useState<{ max_uses: number; expires_in_days: number | ''; trip_id: number | '' }>({ max_uses: 1, expires_in_days: 7, trip_id: '' }) // File types const [allowedFileTypes, setAllowedFileTypes] = useState('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv') @@ -125,14 +126,16 @@ export function useAdmin() { const loadData = async () => { setIsLoading(true) try { - const [usersData, statsData, invitesData] = await Promise.all([ + const [usersData, statsData, invitesData, inviteTripsData] = await Promise.all([ adminApi.users(), adminApi.stats(), adminApi.listInvites().catch(() => ({ invites: [] })), + adminApi.listInviteTrips().catch(() => ({ trips: [] })), ]) setUsers(usersData.users) setStats(statsData) setInvites(invitesData.invites || []) + setInviteTrips(inviteTripsData.trips || []) } catch (err: unknown) { toast.error(t('admin.toast.loadError')) } finally { @@ -279,10 +282,11 @@ export function useAdmin() { const data = await adminApi.createInvite({ max_uses: inviteForm.max_uses, expires_in_days: inviteForm.expires_in_days || undefined, + trip_id: inviteForm.trip_id === '' ? null : inviteForm.trip_id, }) setInvites(prev => [data.invite, ...prev]) setShowCreateInvite(false) - setInviteForm({ max_uses: 1, expires_in_days: 7 }) + setInviteForm({ max_uses: 1, expires_in_days: 7, trip_id: '' }) // Copy link to clipboard const link = `${window.location.origin}/register?invite=${data.invite.token}` navigator.clipboard.writeText(link).then(() => toast.success(t('admin.invite.copied'))) @@ -371,7 +375,7 @@ export function useAdmin() { requireMfa, setRequireMfa, passkeyLogin, setPasskeyLogin, passkeyConfigured, webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn, - invites, setInvites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, + invites, setInvites, inviteTrips, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, smtpValues, setSmtpValues, smtpLoaded, mapsKey, setMapsKey, weatherKey, setWeatherKey, diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index ce8b39ed9..2d6e7b30a 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3266,6 +3266,29 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec("ALTER TABLE collection_members ADD COLUMN role TEXT NOT NULL DEFAULT 'editor'"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } }, + // Migration 153: per-trip invite links (#1143). One rotating token per trip; + // a logged-in existing user who opens the link joins the trip as a member. + // Deleting the trip drops the token (CASCADE); the creator is nulled if their + // account is removed so the link keeps working. + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS trip_invite_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + expires_at TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_trip_invite_tokens_token ON trip_invite_tokens(token); + `); + }, + // Migration 154: optional trip binding on an admin invite link (#1402). A user + // who REGISTERS via the link is auto-added to the trip. Nullable for backward + // compatibility; ON DELETE SET NULL so removing the trip just unbinds the invite. + () => { + try { db.exec('ALTER TABLE invite_tokens ADD COLUMN trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 340e165d0..3abeb6fdf 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -135,11 +135,15 @@ export class AdminController { @Get('invites') listInvites() { return { invites: this.admin.listInvites() }; } + // Trips an admin can optionally bind a registration invite to (#1402). + @Get('invites/trips') + listInviteTrips() { return { trips: this.admin.listTripsForInvite() }; } + @Post('invites') @HttpCode(201) createInvite(@CurrentUser() user: User, @Body() body: unknown, @Req() req: Request) { const result = this.admin.createInvite(user.id, body); - writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays } }); + writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } }); return { invite: result.invite }; } diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 27340ccbf..715c28b46 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -34,6 +34,7 @@ export class AdminService { // Invites listInvites() { return svc.listInvites(); } + listTripsForInvite() { return svc.listTripsForInvite(); } createInvite(userId: number, body: unknown) { return svc.createInvite(userId, body as Parameters[1]); } deleteInvite(id: string) { return svc.deleteInvite(id); } diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 9cc12b609..eb92c1c18 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -30,6 +30,7 @@ import { AirtrailModule } from './integrations/airtrail.module'; import { JourneyModule } from './journey/journey.module'; import { CollectionsModule } from './collections/collections.module'; import { ShareModule } from './share/share.module'; +import { TripInviteModule } from './trip-invite/trip-invite.module'; import { FeedsModule } from './feeds/feeds.module'; import { SettingsModule } from './settings/settings.module'; import { BackupModule } from './backup/backup.module'; @@ -49,7 +50,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule], + imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule], controllers: [HealthController], providers: [ HealthService, diff --git a/server/src/nest/trip-invite/trip-invite.controller.ts b/server/src/nest/trip-invite/trip-invite.controller.ts new file mode 100644 index 000000000..6426d46a6 --- /dev/null +++ b/server/src/nest/trip-invite/trip-invite.controller.ts @@ -0,0 +1,100 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import type { User } from '../../types'; +import { TripInviteService } from './trip-invite.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { RateLimitService } from '../auth/rate-limit.service'; +import { writeAudit, getClientIp } from '../../services/auditLog'; + +const RL_WINDOW = 15 * 60 * 1000; + +/** + * /api/trips/:tripId/invite-link — manage a trip's invite link (#1143). + * + * Mirrors the public share link: trip access (404) + the 'share_manage' + * permission (403). Unlike the share link this one is not public — see + * TripInviteController for the login-required join. + */ +@Controller('api/trips/:tripId/invite-link') +@UseGuards(JwtAuthGuard) +export class TripInviteLinkController { + constructor(private readonly invites: TripInviteService) {} + + private requireManage(tripId: string, user: User) { + const trip = this.invites.verifyTripAccess(tripId, user.id); + if (!trip) throw new HttpException({ error: 'Trip not found' }, 404); + if (!this.invites.canManage(trip, user)) throw new HttpException({ error: 'No permission' }, 403); + } + + @Get() + get(@CurrentUser() user: User, @Param('tripId') tripId: string) { + // The token grants trip membership, so reading it needs the same + // share_manage permission as creating/rotating it — not just trip access. + this.requireManage(tripId, user); + const info = this.invites.get(tripId); + return info ? info : { token: null }; + } + + @Post() + create( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Body() body: { expires_in_days?: number | string | null }, + @Req() req: Request, + ) { + this.requireManage(tripId, user); + const days = body?.expires_in_days != null && String(body.expires_in_days).trim() !== '' + ? parseInt(String(body.expires_in_days)) + : null; + const info = this.invites.createOrRotate(tripId, user.id, Number.isFinite(days as number) ? days : null); + writeAudit({ userId: user.id, action: 'trip.invite_link_create', resource: tripId, ip: getClientIp(req), details: { expires_in_days: days } }); + return info; + } + + @Delete() + remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) { + this.requireManage(tripId, user); + this.invites.remove(tripId); + writeAudit({ userId: user.id, action: 'trip.invite_link_delete', resource: tripId, ip: getClientIp(req) }); + return { success: true }; + } +} + +/** + * /api/trip-invites/:token — resolve + accept a trip invite as an existing, + * logged-in user. JWT-guarded: an anonymous visitor is redirected to /login by + * the client (never registration). Rate-limited to blunt any token probing even + * though the tokens are 192-bit and unguessable. The join is idempotent and + * owner-safe (see joinTripAsMember). + */ +@Controller('api/trip-invites') +@UseGuards(JwtAuthGuard) +export class TripInviteController { + constructor(private readonly invites: TripInviteService, private readonly rl: RateLimitService) {} + + private limit(req: Request, max: number): void { + if (!this.rl.check('trip_invite', req.ip || 'unknown', max, RL_WINDOW, Date.now())) { + throw new HttpException({ error: 'Too many attempts. Please try again later.' }, 429); + } + } + + @Get(':token') + preview(@Param('token') token: string, @Req() req: Request) { + this.limit(req, 30); + const resolved = this.invites.resolve(token); + if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404); + return { trip_id: resolved.trip_id, title: resolved.title }; + } + + @Post(':token/accept') + @HttpCode(200) + accept(@CurrentUser() user: User, @Param('token') token: string, @Req() req: Request) { + this.limit(req, 20); + const resolved = this.invites.resolve(token); + if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404); + const result = this.invites.join(resolved.trip_id, user.id); + writeAudit({ userId: user.id, action: 'trip.invite_link_join', resource: String(resolved.trip_id), ip: getClientIp(req), details: { joined: result.joined } }); + return { trip_id: resolved.trip_id, joined: result.joined }; + } +} diff --git a/server/src/nest/trip-invite/trip-invite.module.ts b/server/src/nest/trip-invite/trip-invite.module.ts new file mode 100644 index 000000000..285e1bacf --- /dev/null +++ b/server/src/nest/trip-invite/trip-invite.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TripInviteLinkController, TripInviteController } from './trip-invite.controller'; +import { TripInviteService } from './trip-invite.service'; +import { RateLimitService } from '../auth/rate-limit.service'; + +@Module({ + controllers: [TripInviteLinkController, TripInviteController], + providers: [TripInviteService, RateLimitService], +}) +export class TripInviteModule {} diff --git a/server/src/nest/trip-invite/trip-invite.service.ts b/server/src/nest/trip-invite/trip-invite.service.ts new file mode 100644 index 000000000..3ad9cbc7c --- /dev/null +++ b/server/src/nest/trip-invite/trip-invite.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { canAccessTrip } from '../../db/database'; +import { checkPermission } from '../../services/permissions'; +import { joinTripAsMember } from '../../services/tripMembership'; +import type { User } from '../../types'; +import * as svc from '../../services/tripInviteService'; + +type Trip = NonNullable>; + +/** + * Thin Nest wrapper around the trip invite-link service. Trip access and the + * 'share_manage' permission mirror the public share link exactly (the invite + * link lives next to it in the Share area). + */ +@Injectable() +export class TripInviteService { + verifyTripAccess(tripId: string, userId: number) { + return canAccessTrip(tripId, userId); + } + + canManage(trip: Trip, user: User): boolean { + return checkPermission('share_manage', user.role, trip.user_id, user.id, trip.user_id !== user.id); + } + + get(tripId: string) { return svc.getTripInviteLink(tripId); } + createOrRotate(tripId: string, userId: number, expiresInDays?: number | null) { + return svc.createOrRotateTripInviteLink(tripId, userId, expiresInDays ?? null); + } + remove(tripId: string) { return svc.deleteTripInviteLink(tripId); } + + resolve(token: string) { return svc.resolveTripInvite(token); } + /** Join the resolved trip as the current (authenticated, non-guest) user. + * invited_by is null — they joined via a link, not a personal invite. */ + join(tripId: number, userId: number) { return joinTripAsMember(tripId, userId, null); } +} diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index ff3a4e611..9cd55c0bb 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -432,14 +432,20 @@ export async function checkAndNotifyVersion(): Promise { export function listInvites() { return db.prepare(` - SELECT i.*, u.username as created_by_name + SELECT i.*, u.username as created_by_name, t.title as trip_title FROM invite_tokens i JOIN users u ON i.created_by = u.id + LEFT JOIN trips t ON i.trip_id = t.id ORDER BY i.created_at DESC `).all(); } -export function createInvite(createdBy: number, data: { max_uses?: string | number; expires_in_days?: string | number }) { +/** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */ +export function listTripsForInvite() { + return db.prepare('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC').all(); +} + +export function createInvite(createdBy: number, data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null }) { const rawUses = parseInt(String(data.max_uses)); const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5); const token = crypto.randomBytes(16).toString('hex'); @@ -447,19 +453,30 @@ export function createInvite(createdBy: number, data: { max_uses?: string | numb ? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString() : null; + // Optional trip binding: only persist a trip that actually exists, so a stale + // or forged id can never bind (and never auto-adds anyone on registration). + let tripId: number | null = null; + if (data.trip_id != null && String(data.trip_id).trim() !== '') { + const parsed = parseInt(String(data.trip_id)); + if (Number.isInteger(parsed) && db.prepare('SELECT id FROM trips WHERE id = ?').get(parsed)) { + tripId = parsed; + } + } + const ins = db.prepare( - 'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)' - ).run(token, uses, expiresAt, createdBy); + 'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by, trip_id) VALUES (?, ?, ?, ?, ?)' + ).run(token, uses, expiresAt, createdBy, tripId); const inviteId = Number(ins.lastInsertRowid); const invite = db.prepare(` - SELECT i.*, u.username as created_by_name + SELECT i.*, u.username as created_by_name, t.title as trip_title FROM invite_tokens i JOIN users u ON i.created_by = u.id + LEFT JOIN trips t ON i.trip_id = t.id WHERE i.id = ? `).get(inviteId); - return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null }; + return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId }; } export function deleteInvite(id: string) { diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index ec212e731..c726d1819 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -22,6 +22,7 @@ import { verifyJwtAndLoadUser } from '../middleware/auth'; import { User } from '../types'; import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo'; import { avatarUrl } from './avatarUrl'; +import { joinTripAsMember } from './tripMembership'; import { isPasskeyConfigured } from './webauthnConfig'; export { avatarUrl }; @@ -432,6 +433,11 @@ export function registerUser(body: { if (!updated) { console.warn(`[Auth] Invite token ${validInvite.token.slice(0, 8)}... exceeded max_uses due to race condition`); } + // Trip-bound invite (#1402): auto-add the freshly registered user to the + // trip. Idempotent + owner-safe; no-ops if the bound trip was since deleted. + if (validInvite.trip_id) { + joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null); + } } return { diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index aa273dd1b..6eb6a9be7 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -5,6 +5,7 @@ import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config'; import { User } from '../types'; import { decrypt_api_key } from './apiKeyCrypto'; import { resolveAuthToggles } from './authService'; +import { joinTripAsMember } from './tripMembership'; // --------------------------------------------------------------------------- // Types @@ -473,9 +474,15 @@ export function findOrCreateUser( ).run(validInvite.id); if (updated.changes === 0) throw inviteRaceError; } - return db.prepare( + const ins = db.prepare( 'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, avatar, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)', ).run(username, email, hash, role, sub, config.issuer, picture, process.env.APP_VERSION || '0.0.0'); + // Trip-bound invite (#1402): auto-add the new SSO user to the trip inside the + // same atomic step as the invite consume. Idempotent + owner-safe. + if (validInvite?.trip_id) { + joinTripAsMember(Number(validInvite.trip_id), Number(ins.lastInsertRowid), validInvite.created_by ?? null); + } + return ins; }); const result = createUser() as { lastInsertRowid: number | bigint }; user = { id: Number(result.lastInsertRowid), username, email, role, avatar: picture } as User; diff --git a/server/src/services/tripInviteService.ts b/server/src/services/tripInviteService.ts new file mode 100644 index 000000000..369f95dea --- /dev/null +++ b/server/src/services/tripInviteService.ts @@ -0,0 +1,83 @@ +import { db } from '../db/database'; +import crypto from 'crypto'; + +/** + * Per-trip invite links (#1143). + * + * A trip has at most ONE invite token (mirrors the public share link: one + * rotating token per trip). Unlike the share link, this one is NOT public — + * opening it only does something for a logged-in user with an existing account, + * who is then added to the trip as a member. Rotating or disabling the link + * immediately invalidates the old URL. + */ + +export interface TripInviteInfo { + token: string; + expires_at: string | null; + created_at: string; +} + +/** The current invite link for a trip, or null if none exists. */ +export function getTripInviteLink(tripId: string | number): TripInviteInfo | null { + const row = db + .prepare('SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?') + .get(tripId) as { token: string; expires_at: string | null; created_at: string } | undefined; + return row ? { token: row.token, expires_at: row.expires_at, created_at: row.created_at } : null; +} + +/** + * Create the trip's invite link, or rotate it to a fresh token (there is only + * ever one row per trip). An optional expiry (days) can bound the link's life. + */ +export function createOrRotateTripInviteLink( + tripId: string | number, + createdBy: number, + expiresInDays?: number | null, +): TripInviteInfo { + const token = crypto.randomBytes(24).toString('base64url'); + const expiresAt = + expiresInDays && expiresInDays > 0 + ? new Date(Date.now() + expiresInDays * 86400000).toISOString() + : null; + + const existing = db.prepare('SELECT id FROM trip_invite_tokens WHERE trip_id = ?').get(tripId); + if (existing) { + db.prepare( + 'UPDATE trip_invite_tokens SET token = ?, expires_at = ?, created_by = ?, created_at = CURRENT_TIMESTAMP WHERE trip_id = ?', + ).run(token, expiresAt, createdBy, tripId); + } else { + db.prepare( + 'INSERT INTO trip_invite_tokens (trip_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)', + ).run(tripId, token, createdBy, expiresAt); + } + return getTripInviteLink(tripId)!; +} + +/** Remove the trip's invite link entirely (disable). */ +export function deleteTripInviteLink(tripId: string | number): void { + db.prepare('DELETE FROM trip_invite_tokens WHERE trip_id = ?').run(tripId); +} + +/** + * Resolve an invite token to its (still-existing, unexpired) trip. Returns null + * for unknown/expired tokens. Only ever called for an authenticated user, so the + * trip title is safe to return for the join confirmation screen; an anonymous + * caller never reaches this (the endpoint is JWT-guarded). + */ +export function resolveTripInvite(token: string): { trip_id: number; title: string } | null { + const row = db + .prepare( + `SELECT t.id AS trip_id, t.title AS title, ti.expires_at AS expires_at + FROM trip_invite_tokens ti + JOIN trips t ON ti.trip_id = t.id + WHERE ti.token = ?`, + ) + .get(token) as { trip_id: number; title: string; expires_at: string | null } | undefined; + if (!row) return null; + // Check expiry in JS, not SQL: expires_at is stored as an ISO-8601 string + // (…T…Z) which does not compare lexicographically against SQLite's + // space-separated datetime('now'), so a SQL `>` would keep an expired link + // alive until the end of the day. Mirrors the admin-invite validation. + if (row.expires_at && new Date(row.expires_at) < new Date()) return null; + return { trip_id: row.trip_id, title: row.title }; +} diff --git a/server/src/services/tripMembership.ts b/server/src/services/tripMembership.ts new file mode 100644 index 000000000..2a13d2459 --- /dev/null +++ b/server/src/services/tripMembership.ts @@ -0,0 +1,29 @@ +import { db } from '../db/database'; + +/** + * Add an existing user to a trip as a member, by user id. + * + * Idempotent and safe: it skips the trip owner and anyone who is already a + * member, and no-ops if the trip no longer exists. Shared by trip invite-link + * joins (#1143) and the trip-bound admin invite auto-join (#1402). The caller is + * responsible for authenticating/creating the user first, so the id always + * belongs to a real (non-guest) account. + * + * Returns whether a new membership row was actually created. + */ +export function joinTripAsMember( + tripId: number, + userId: number, + invitedBy: number | null, +): { joined: boolean; tripId: number } { + const trip = db.prepare('SELECT id, user_id FROM trips WHERE id = ?').get(tripId) as + | { id: number; user_id: number } + | undefined; + if (!trip) return { joined: false, tripId }; + // The owner already has full access; never add them as a member. + if (trip.user_id === userId) return { joined: false, tripId }; + const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, userId); + if (existing) return { joined: false, tripId }; + db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, userId, invitedBy); + return { joined: true, tripId }; +} diff --git a/server/tests/e2e/trip-invite.e2e.test.ts b/server/tests/e2e/trip-invite.e2e.test.ts new file mode 100644 index 000000000..e01f8f9f0 --- /dev/null +++ b/server/tests/e2e/trip-invite.e2e.test.ts @@ -0,0 +1,160 @@ +/** + * Trip invite-link e2e — exercises /api/trips/:tripId/invite-link (manage) and + * /api/trip-invites/:token (preview + accept) through the real JwtAuthGuard + * against a temp SQLite db. The invite service, permission check and membership + * join are mocked; this focuses on auth (401), trip-access 404, the share_manage + * 403, the login-required join, and invalid-token 404s (#1143). + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { seedUser, sessionCookie } from './harness'; + +const { db, canAccessTrip } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); + return { db: tmp, canAccessTrip: vi.fn() }; +}); + +vi.mock('../../src/db/database', () => ({ db, canAccessTrip, closeDb: () => {}, reinitialize: () => {} })); + +const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); +vi.mock('../../src/services/permissions', () => ({ checkPermission })); + +const { inviteSvc } = vi.hoisted(() => ({ + inviteSvc: { + getTripInviteLink: vi.fn(), + createOrRotateTripInviteLink: vi.fn(), + deleteTripInviteLink: vi.fn(), + resolveTripInvite: vi.fn(), + }, +})); +vi.mock('../../src/services/tripInviteService', () => inviteSvc); + +const { joinTripAsMember } = vi.hoisted(() => ({ joinTripAsMember: vi.fn() })); +vi.mock('../../src/services/tripMembership', () => ({ joinTripAsMember })); + +vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '127.0.0.1' })); + +import { TripInviteModule } from '../../src/nest/trip-invite/trip-invite.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; + +describe('Trip invite-link e2e (real auth guard + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [TripInviteModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + seedUser(db as never, { id: 1 }); + seedUser(db as never, { id: 2, username: 'e2e-user-2', email: 'e2e-2@example.test' }); + app = await build(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + canAccessTrip.mockReturnValue({ user_id: 1 }); + checkPermission.mockReturnValue(true); + inviteSvc.getTripInviteLink.mockReset(); + inviteSvc.createOrRotateTripInviteLink.mockReset(); + inviteSvc.resolveTripInvite.mockReset(); + joinTripAsMember.mockReset(); + }); + + afterAll(async () => { await app.close(); }); + + // ── manage ── + it('401 without a session cookie', async () => { + expect((await request(server).get('/api/trips/5/invite-link')).status).toBe(401); + }); + + it('GET returns the current link for a trip member', async () => { + inviteSvc.getTripInviteLink.mockReturnValueOnce({ token: 'abc', expires_at: null, created_at: 'now' }); + const res = await request(server).get('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body.token).toBe('abc'); + }); + + it('GET returns { token: null } when no link exists', async () => { + inviteSvc.getTripInviteLink.mockReturnValueOnce(null); + const res = await request(server).get('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ token: null }); + }); + + it('POST creates/rotates the link', async () => { + inviteSvc.createOrRotateTripInviteLink.mockReturnValueOnce({ token: 'new', expires_at: null, created_at: 'now' }); + const res = await request(server).post('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)).send({}); + expect([200, 201]).toContain(res.status); + expect(res.body.token).toBe('new'); + }); + + it('403 to create without share_manage', async () => { + checkPermission.mockReturnValue(false); + const res = await request(server).post('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)).send({}); + expect(res.status).toBe(403); + }); + + it('403 to READ the link without share_manage (token grants membership)', async () => { + checkPermission.mockReturnValue(false); + const res = await request(server).get('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(403); + }); + + it('404 when the trip is not accessible', async () => { + canAccessTrip.mockReturnValue(undefined); + const res = await request(server).get('/api/trips/5/invite-link').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(404); + }); + + // ── preview + accept (login required) ── + it('401 to preview an invite without a session (login required, never registration)', async () => { + expect((await request(server).get('/api/trip-invites/tok')).status).toBe(401); + }); + + it('preview resolves the trip title for an authed user', async () => { + inviteSvc.resolveTripInvite.mockReturnValueOnce({ trip_id: 9, title: 'Rome 2026' }); + const res = await request(server).get('/api/trip-invites/tok').set('Cookie', sessionCookie(2)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ trip_id: 9, title: 'Rome 2026' }); + }); + + it('preview 404 for an invalid/expired token', async () => { + inviteSvc.resolveTripInvite.mockReturnValueOnce(null); + const res = await request(server).get('/api/trip-invites/bad').set('Cookie', sessionCookie(2)); + expect(res.status).toBe(404); + }); + + it('accept joins the current user and returns the trip id', async () => { + inviteSvc.resolveTripInvite.mockReturnValueOnce({ trip_id: 9, title: 'Rome 2026' }); + joinTripAsMember.mockReturnValueOnce({ joined: true, tripId: 9 }); + const res = await request(server).post('/api/trip-invites/tok/accept').set('Cookie', sessionCookie(2)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ trip_id: 9, joined: true }); + expect(joinTripAsMember).toHaveBeenCalledWith(9, 2, null); + }); + + it('accept 404 for an invalid/expired token (no join attempted)', async () => { + inviteSvc.resolveTripInvite.mockReturnValueOnce(null); + const res = await request(server).post('/api/trip-invites/bad/accept').set('Cookie', sessionCookie(2)); + expect(res.status).toBe(404); + expect(joinTripAsMember).not.toHaveBeenCalled(); + }); + + it('401 to accept without a session', async () => { + expect((await request(server).post('/api/trip-invites/tok/accept')).status).toBe(401); + }); +}); diff --git a/server/tests/unit/services/oidcService.test.ts b/server/tests/unit/services/oidcService.test.ts index 0b22c5158..45885d396 100644 --- a/server/tests/unit/services/oidcService.test.ts +++ b/server/tests/unit/services/oidcService.test.ts @@ -42,7 +42,7 @@ vi.mock('../../../src/config', () => ({ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; -import { createUser } from '../../helpers/factories'; +import { createUser, createTrip } from '../../helpers/factories'; import { createState, consumeState, @@ -521,6 +521,24 @@ describe('findOrCreateUser', () => { const row = testDb.prepare('SELECT avatar FROM users WHERE id = ?').get(user.id) as any; expect(row.avatar).toBe('https://idp.example.com/u/new.png'); }); + + it('OIDC-SVC-045: a trip-bound invite auto-adds the new SSO user as a trip member (#1402)', () => { + const { user: admin } = createUser(testDb, { role: 'admin' }); + const trip = createTrip(testDb, admin.id); + testDb.prepare( + 'INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by, trip_id) VALUES (?, 5, 0, NULL, ?, ?)' + ).run('inv-trip-join', admin.id, trip.id); + + const result = findOrCreateUser( + { sub: 'sub-trip-join', email: 'joiner@example.com', name: 'Joiner' }, + MOCK_CONFIG, + 'inv-trip-join' + ); + expect('user' in result).toBe(true); + const uid = (result as { user: any }).user.id; + const member = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, uid); + expect(member).toBeTruthy(); + }); }); // ── exchangeCodeForToken ────────────────────────────────────────────────────── diff --git a/server/tests/unit/services/tripInviteService.test.ts b/server/tests/unit/services/tripInviteService.test.ts new file mode 100644 index 000000000..f11f134b9 --- /dev/null +++ b/server/tests/unit/services/tripInviteService.test.ts @@ -0,0 +1,92 @@ +/** + * Unit tests for tripInviteService — TRIP-INVITE-001..006. + * Per-trip invite links (#1143): one rotating token, optional expiry, resolve. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + return { testDb: db, dbMock: { db } }; +}); +vi.mock('../../../src/db/database', () => dbMock); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip } from '../../helpers/factories'; +import { + getTripInviteLink, + createOrRotateTripInviteLink, + deleteTripInviteLink, + resolveTripInvite, +} from '../../../src/services/tripInviteService'; + +beforeAll(() => { createTables(testDb); runMigrations(testDb); }); +beforeEach(() => resetTestDb(testDb)); +afterAll(() => testDb.close()); + +function setup() { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + return { owner, trip }; +} + +describe('tripInviteService', () => { + it('TRIP-INVITE-001: no link exists initially', () => { + const { trip } = setup(); + expect(getTripInviteLink(trip.id)).toBeNull(); + }); + + it('TRIP-INVITE-002: create returns a token and get reads it back', () => { + const { owner, trip } = setup(); + const info = createOrRotateTripInviteLink(trip.id, owner.id); + expect(info.token).toMatch(/^[A-Za-z0-9_-]{20,}$/); + expect(getTripInviteLink(trip.id)?.token).toBe(info.token); + }); + + it('TRIP-INVITE-003: rotating replaces the token and keeps a single row', () => { + const { owner, trip } = setup(); + const first = createOrRotateTripInviteLink(trip.id, owner.id); + const second = createOrRotateTripInviteLink(trip.id, owner.id); + expect(second.token).not.toBe(first.token); + const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { n: number }; + expect(count.n).toBe(1); + // The old token no longer resolves. + expect(resolveTripInvite(first.token)).toBeNull(); + }); + + it('TRIP-INVITE-004: resolve returns the trip for a valid token', () => { + const { owner, trip } = setup(); + const info = createOrRotateTripInviteLink(trip.id, owner.id); + expect(resolveTripInvite(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); + }); + + it('TRIP-INVITE-005: an expired token does not resolve (ISO expiry, incl. same-day)', () => { + const { owner, trip } = setup(); + const info = createOrRotateTripInviteLink(trip.id, owner.id); + // Use the exact ISO-8601 format the service writes, one hour in the past — + // this catches the lexicographic-SQL-comparison bug where a same-UTC-day + // expiry would otherwise still resolve. + testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?') + .run(new Date(Date.now() - 3600_000).toISOString(), trip.id); + expect(resolveTripInvite(info.token)).toBeNull(); + }); + + it('TRIP-INVITE-005b: a not-yet-expired token still resolves', () => { + const { owner, trip } = setup(); + const info = createOrRotateTripInviteLink(trip.id, owner.id); + testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?') + .run(new Date(Date.now() + 3600_000).toISOString(), trip.id); + expect(resolveTripInvite(info.token)).toEqual({ trip_id: trip.id, title: trip.title }); + }); + + it('TRIP-INVITE-006: delete removes the link', () => { + const { owner, trip } = setup(); + const info = createOrRotateTripInviteLink(trip.id, owner.id); + deleteTripInviteLink(trip.id); + expect(getTripInviteLink(trip.id)).toBeNull(); + expect(resolveTripInvite(info.token)).toBeNull(); + }); +}); diff --git a/server/tests/unit/services/tripMembership.test.ts b/server/tests/unit/services/tripMembership.test.ts new file mode 100644 index 000000000..c5f254bfe --- /dev/null +++ b/server/tests/unit/services/tripMembership.test.ts @@ -0,0 +1,66 @@ +/** + * Unit tests for joinTripAsMember — TRIP-JOIN-001..004. + * The shared add-by-id helper behind trip invite links (#1143) and trip-bound + * admin invites (#1402): idempotent, owner-safe, missing-trip-safe. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + return { testDb: db, dbMock: { db } }; +}); +vi.mock('../../../src/db/database', () => dbMock); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip } from '../../helpers/factories'; +import { joinTripAsMember } from '../../../src/services/tripMembership'; + +beforeAll(() => { createTables(testDb); runMigrations(testDb); }); +beforeEach(() => resetTestDb(testDb)); +afterAll(() => testDb.close()); + +function memberRow(tripId: number, userId: number) { + return testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, userId); +} + +describe('joinTripAsMember', () => { + it('TRIP-JOIN-001: adds a non-member and reports joined', () => { + const { user: owner } = createUser(testDb); + const { user: joiner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const r = joinTripAsMember(trip.id, joiner.id, null); + expect(r).toEqual({ joined: true, tripId: trip.id }); + expect(memberRow(trip.id, joiner.id)).toBeTruthy(); + }); + + it('TRIP-JOIN-002: never adds the trip owner as a member', () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + const r = joinTripAsMember(trip.id, owner.id, null); + expect(r.joined).toBe(false); + expect(memberRow(trip.id, owner.id)).toBeUndefined(); + }); + + it('TRIP-JOIN-003: is idempotent for an existing member (no duplicate row)', () => { + const { user: owner } = createUser(testDb); + const { user: joiner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + + expect(joinTripAsMember(trip.id, joiner.id, owner.id).joined).toBe(true); + expect(joinTripAsMember(trip.id, joiner.id, owner.id).joined).toBe(false); + const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, joiner.id) as { n: number }; + expect(count.n).toBe(1); + }); + + it('TRIP-JOIN-004: no-ops for a missing trip', () => { + const { user: joiner } = createUser(testDb); + const r = joinTripAsMember(999999, joiner.id, null); + expect(r.joined).toBe(false); + }); +}); diff --git a/shared/src/admin/admin.schema.ts b/shared/src/admin/admin.schema.ts index e5d5efe2b..08c54b315 100644 --- a/shared/src/admin/admin.schema.ts +++ b/shared/src/admin/admin.schema.ts @@ -25,6 +25,9 @@ export const adminInviteCreateRequestSchema = z.object({ max_uses: z.number().optional(), expires_in_days: z.number().optional(), role: z.enum(['user', 'admin']).optional(), + // Optional trip binding (#1402): a user who registers via the link is + // auto-added to this trip. Nullable/absent = a plain registration invite. + trip_id: z.number().int().positive().nullable().optional(), }); export type AdminInviteCreateRequest = z.infer; diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 09744f2f8..b3522ddad 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -350,5 +350,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'إضافة إلى رحلة (اختياري)', + 'admin.invite.tripNone': 'بدون رحلة', + 'admin.invite.tripHint': 'تتم إضافة المستخدم الجديد تلقائيًا إلى هذه الرحلة عند تسجيله عبر الرابط.', + 'admin.invite.boundTo': 'يُضاف إلى {trip}', }; export default admin; diff --git a/shared/src/i18n/ar/trip.ts b/shared/src/i18n/ar/trip.ts index 362fe33b8..62cfd7b6f 100644 --- a/shared/src/i18n/ar/trip.ts +++ b/shared/src/i18n/ar/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'هل تريد حذف هذا المكان؟', 'trip.confirm.deletePlaces': 'حذف {count} أماكن؟', 'trip.toast.placesDeleted': 'تم حذف {count} أماكن', + 'trip.invite.linkTitle': 'رابط دعوة الرحلة', + 'trip.invite.linkHint': 'أي شخص لديه حساب TREK ويفتح هذا الرابط ينضم إلى الرحلة كعضو. أعد الإنشاء لإبطال الرابط القديم.', + 'trip.invite.create': 'إنشاء رابط دعوة', + 'trip.invite.regenerate': 'إعادة الإنشاء', + 'trip.invite.disable': 'تعطيل', + 'trip.invite.joinHeading': 'انضم إلى هذه الرحلة', + 'trip.invite.joinPrompt': 'لقد تمت دعوتك للانضمام إلى "{title}".', + 'trip.invite.joinCta': 'الانضمام إلى الرحلة', + 'trip.invite.joining': 'جارٍ الانضمام…', + 'trip.invite.invalidTitle': 'الدعوة غير متاحة', + 'trip.invite.invalid': 'رابط الدعوة هذا غير صالح أو انتهت صلاحيته.', + 'trip.invite.backToDashboard': 'العودة إلى لوحة التحكم', }; export default trip; diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 88fc3f083..162bceef6 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -357,5 +357,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Adicionar à viagem (opcional)', + 'admin.invite.tripNone': 'Nenhuma viagem', + 'admin.invite.tripHint': 'O novo usuário é adicionado automaticamente a esta viagem ao se registrar pelo link.', + 'admin.invite.boundTo': 'adiciona a {trip}', }; export default admin; diff --git a/shared/src/i18n/br/trip.ts b/shared/src/i18n/br/trip.ts index 974c67734..9c60e0f08 100644 --- a/shared/src/i18n/br/trip.ts +++ b/shared/src/i18n/br/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlaces': 'Excluir {count} lugares?', 'trip.toast.placesDeleted': '{count} lugares excluídos', 'trip.loadingPhotos': 'Carregando fotos dos lugares...', + 'trip.invite.linkTitle': 'Link de convite da viagem', + 'trip.invite.linkHint': 'Qualquer pessoa com uma conta TREK que abrir este link entra na viagem como membro. Gere novamente para invalidar o link antigo.', + 'trip.invite.create': 'Criar link de convite', + 'trip.invite.regenerate': 'Gerar novamente', + 'trip.invite.disable': 'Desativar', + 'trip.invite.joinHeading': 'Entrar nesta viagem', + 'trip.invite.joinPrompt': 'Você foi convidado para participar de "{title}".', + 'trip.invite.joinCta': 'Entrar na viagem', + 'trip.invite.joining': 'Entrando…', + 'trip.invite.invalidTitle': 'Convite indisponível', + 'trip.invite.invalid': 'Este link de convite é inválido ou expirou.', + 'trip.invite.backToDashboard': 'Voltar ao painel', }; export default trip; diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 2da2751e5..c1b8edb95 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -355,5 +355,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Přidat k cestě (volitelné)', + 'admin.invite.tripNone': 'Žádná cesta', + 'admin.invite.tripHint': 'Nový uživatel bude po registraci přes odkaz automaticky přidán k této cestě.', + 'admin.invite.boundTo': 'přidá k {trip}', }; export default admin; diff --git a/shared/src/i18n/cs/trip.ts b/shared/src/i18n/cs/trip.ts index 41f482c2a..9daa3d5b0 100644 --- a/shared/src/i18n/cs/trip.ts +++ b/shared/src/i18n/cs/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Opravdu chcete toto místo smazat?', 'trip.confirm.deletePlaces': 'Smazat {count} míst?', 'trip.toast.placesDeleted': '{count} míst smazáno', + 'trip.invite.linkTitle': 'Odkaz pro pozvání na cestu', + 'trip.invite.linkHint': 'Kdokoli s účtem TREK, kdo otevře tento odkaz, se připojí k cestě jako člen. Vygenerováním nového odkazu zneplatníte ten starý.', + 'trip.invite.create': 'Vytvořit odkaz pro pozvání', + 'trip.invite.regenerate': 'Vygenerovat znovu', + 'trip.invite.disable': 'Deaktivovat', + 'trip.invite.joinHeading': 'Připojit se k této cestě', + 'trip.invite.joinPrompt': 'Byli jste pozváni k připojení k cestě „{title}".', + 'trip.invite.joinCta': 'Připojit se k cestě', + 'trip.invite.joining': 'Připojování…', + 'trip.invite.invalidTitle': 'Pozvánka není dostupná', + 'trip.invite.invalid': 'Tento odkaz pro pozvání je neplatný nebo jeho platnost vypršela.', + 'trip.invite.backToDashboard': 'Zpět na přehled', }; export default trip; diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index deaa167c5..a94a5d84b 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -359,5 +359,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Konfiguration', 'admin.group.integration': 'Integrationen', 'admin.group.maintenance': 'Wartung', + 'admin.invite.tripLabel': 'Zu Trip hinzufügen (optional)', + 'admin.invite.tripNone': 'Kein Trip', + 'admin.invite.tripHint': 'Der neue Nutzer wird automatisch zu diesem Trip hinzugefügt, wenn er sich über den Link registriert.', + 'admin.invite.boundTo': 'fügt zu {trip} hinzu', }; export default admin; diff --git a/shared/src/i18n/de/trip.ts b/shared/src/i18n/de/trip.ts index 40347e8cd..43f6cd139 100644 --- a/shared/src/i18n/de/trip.ts +++ b/shared/src/i18n/de/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?', 'trip.confirm.deletePlaces': '{count} Orte löschen?', 'trip.toast.placesDeleted': '{count} Orte gelöscht', + 'trip.invite.linkTitle': 'Trip-Einladungslink', + 'trip.invite.linkHint': 'Wer einen TREK-Account hat und diesen Link öffnet, tritt dem Trip als Mitglied bei. Neu generieren macht den alten Link ungültig.', + 'trip.invite.create': 'Einladungslink erstellen', + 'trip.invite.regenerate': 'Neu generieren', + 'trip.invite.disable': 'Deaktivieren', + 'trip.invite.joinHeading': 'Diesem Trip beitreten', + 'trip.invite.joinPrompt': 'Du wurdest eingeladen, „{title}" beizutreten.', + 'trip.invite.joinCta': 'Trip beitreten', + 'trip.invite.joining': 'Trete bei …', + 'trip.invite.invalidTitle': 'Einladung nicht verfügbar', + 'trip.invite.invalid': 'Dieser Einladungslink ist ungültig oder abgelaufen.', + 'trip.invite.backToDashboard': 'Zurück zum Dashboard', }; export default trip; diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index d95f8ca3b..52354aad5 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -355,5 +355,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Add to trip (optional)', + 'admin.invite.tripNone': 'No trip', + 'admin.invite.tripHint': 'The new user is automatically added to this trip when they register via the link.', + 'admin.invite.boundTo': 'adds to {trip}', }; export default admin; diff --git a/shared/src/i18n/en/trip.ts b/shared/src/i18n/en/trip.ts index e25b6c961..9cde19496 100644 --- a/shared/src/i18n/en/trip.ts +++ b/shared/src/i18n/en/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Are you sure you want to delete this place?', 'trip.confirm.deletePlaces': 'Delete {count} places?', 'trip.toast.placesDeleted': '{count} places deleted', + 'trip.invite.linkTitle': 'Trip invite link', + 'trip.invite.linkHint': 'Anyone with a TREK account who opens this link joins the trip as a member. Regenerate to invalidate the old link.', + 'trip.invite.create': 'Create invite link', + 'trip.invite.regenerate': 'Regenerate', + 'trip.invite.disable': 'Disable', + 'trip.invite.joinHeading': 'Join this trip', + 'trip.invite.joinPrompt': 'You\'ve been invited to join "{title}".', + 'trip.invite.joinCta': 'Join trip', + 'trip.invite.joining': 'Joining…', + 'trip.invite.invalidTitle': 'Invite unavailable', + 'trip.invite.invalid': 'This invite link is invalid or has expired.', + 'trip.invite.backToDashboard': 'Back to dashboard', }; export default trip; diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 630535ecf..d79a1dbd0 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -366,5 +366,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Añadir a un viaje (opcional)', + 'admin.invite.tripNone': 'Sin viaje', + 'admin.invite.tripHint': 'El nuevo usuario se añade automáticamente a este viaje cuando se registra mediante el enlace.', + 'admin.invite.boundTo': 'se añade a {trip}', }; export default admin; diff --git a/shared/src/i18n/es/trip.ts b/shared/src/i18n/es/trip.ts index 754d3d96a..3004f6ac1 100644 --- a/shared/src/i18n/es/trip.ts +++ b/shared/src/i18n/es/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?', 'trip.confirm.deletePlaces': '¿Eliminar {count} lugares?', 'trip.toast.placesDeleted': '{count} lugares eliminados', + 'trip.invite.linkTitle': 'Enlace de invitación al viaje', + 'trip.invite.linkHint': 'Cualquier persona con una cuenta de TREK que abra este enlace se unirá al viaje como miembro. Regenéralo para invalidar el enlace anterior.', + 'trip.invite.create': 'Crear enlace de invitación', + 'trip.invite.regenerate': 'Regenerar', + 'trip.invite.disable': 'Desactivar', + 'trip.invite.joinHeading': 'Únete a este viaje', + 'trip.invite.joinPrompt': 'Te han invitado a unirte a «{title}».', + 'trip.invite.joinCta': 'Unirse al viaje', + 'trip.invite.joining': 'Uniéndose…', + 'trip.invite.invalidTitle': 'Invitación no disponible', + 'trip.invite.invalid': 'Este enlace de invitación no es válido o ha caducado.', + 'trip.invite.backToDashboard': 'Volver al panel', }; export default trip; diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 7bc7a25fa..d21862bae 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -363,5 +363,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Ajouter à un voyage (facultatif)', + 'admin.invite.tripNone': 'Aucun voyage', + 'admin.invite.tripHint': 'Le nouvel utilisateur est automatiquement ajouté à ce voyage lorsqu\'il s\'inscrit via le lien.', + 'admin.invite.boundTo': 'ajoute à {trip}', }; export default admin; diff --git a/shared/src/i18n/fr/trip.ts b/shared/src/i18n/fr/trip.ts index 2c2c66d30..9972d35a0 100644 --- a/shared/src/i18n/fr/trip.ts +++ b/shared/src/i18n/fr/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Voulez-vous vraiment supprimer ce lieu ?', 'trip.confirm.deletePlaces': 'Supprimer {count} lieux?', 'trip.toast.placesDeleted': '{count} lieux supprimés', + 'trip.invite.linkTitle': 'Lien d\'invitation au voyage', + 'trip.invite.linkHint': 'Toute personne disposant d\'un compte TREK qui ouvre ce lien rejoint le voyage en tant que membre. Régénérez le lien pour invalider l\'ancien.', + 'trip.invite.create': 'Créer un lien d\'invitation', + 'trip.invite.regenerate': 'Régénérer', + 'trip.invite.disable': 'Désactiver', + 'trip.invite.joinHeading': 'Rejoindre ce voyage', + 'trip.invite.joinPrompt': 'Vous avez été invité à rejoindre « {title} ».', + 'trip.invite.joinCta': 'Rejoindre le voyage', + 'trip.invite.joining': 'Ajout en cours…', + 'trip.invite.invalidTitle': 'Invitation indisponible', + 'trip.invite.invalid': 'Ce lien d\'invitation est invalide ou a expiré.', + 'trip.invite.backToDashboard': 'Retour au tableau de bord', }; export default trip; diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 6cd8ba583..0039872db 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -370,5 +370,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Προσθήκη σε ταξίδι (προαιρετικό)', + 'admin.invite.tripNone': 'Χωρίς ταξίδι', + 'admin.invite.tripHint': 'Ο νέος χρήστης προστίθεται αυτόματα σε αυτό το ταξίδι όταν εγγραφεί μέσω του συνδέσμου.', + 'admin.invite.boundTo': 'προσθήκη στο {trip}', }; export default admin; diff --git a/shared/src/i18n/gr/trip.ts b/shared/src/i18n/gr/trip.ts index e85031903..6bc828c61 100644 --- a/shared/src/i18n/gr/trip.ts +++ b/shared/src/i18n/gr/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Είστε σίγουροι ότι θέλετε να διαγράψετε αυτό το μέρος;', 'trip.confirm.deletePlaces': 'Διαγραφή {count} μερών;', 'trip.toast.placesDeleted': '{count} μέρη διαγράφηκαν', + 'trip.invite.linkTitle': 'Σύνδεσμος πρόσκλησης ταξιδιού', + 'trip.invite.linkHint': 'Όποιος έχει λογαριασμό TREK και ανοίξει αυτόν τον σύνδεσμο, εντάσσεται στο ταξίδι ως μέλος. Δημιουργήστε ξανά τον σύνδεσμο για να ακυρώσετε τον παλιό.', + 'trip.invite.create': 'Δημιουργία συνδέσμου πρόσκλησης', + 'trip.invite.regenerate': 'Δημιουργία ξανά', + 'trip.invite.disable': 'Απενεργοποίηση', + 'trip.invite.joinHeading': 'Συμμετοχή σε αυτό το ταξίδι', + 'trip.invite.joinPrompt': 'Λάβατε πρόσκληση για συμμετοχή στο «{title}».', + 'trip.invite.joinCta': 'Συμμετοχή στο ταξίδι', + 'trip.invite.joining': 'Γίνεται συμμετοχή…', + 'trip.invite.invalidTitle': 'Η πρόσκληση δεν είναι διαθέσιμη', + 'trip.invite.invalid': 'Αυτός ο σύνδεσμος πρόσκλησης δεν είναι έγκυρος ή έχει λήξει.', + 'trip.invite.backToDashboard': 'Επιστροφή στα ταξίδια μου', }; export default trip; diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 0bdf64317..38879b312 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -362,5 +362,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Hozzáadás utazáshoz (opcionális)', + 'admin.invite.tripNone': 'Nincs utazás', + 'admin.invite.tripHint': 'Az új felhasználó automatikusan hozzáadódik ehhez az utazáshoz, amikor a linken keresztül regisztrál.', + 'admin.invite.boundTo': 'hozzáadja a következőhöz: {trip}', }; export default admin; diff --git a/shared/src/i18n/hu/trip.ts b/shared/src/i18n/hu/trip.ts index 60a59544d..32127f4de 100644 --- a/shared/src/i18n/hu/trip.ts +++ b/shared/src/i18n/hu/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlaces': '{count} helyet töröl?', 'trip.toast.placesDeleted': '{count} hely törölve', 'trip.loadingPhotos': 'Helyek fotóinak betöltése...', + 'trip.invite.linkTitle': 'Meghívó link az utazáshoz', + 'trip.invite.linkHint': 'Bárki, akinek van TREK-fiókja és megnyitja ezt a linket, tagként csatlakozik az utazáshoz. A régi link érvénytelenítéséhez hozz létre újat.', + 'trip.invite.create': 'Meghívó link létrehozása', + 'trip.invite.regenerate': 'Újragenerálás', + 'trip.invite.disable': 'Letiltás', + 'trip.invite.joinHeading': 'Csatlakozás az utazáshoz', + 'trip.invite.joinPrompt': 'Meghívtak a(z) „{title}” utazáshoz.', + 'trip.invite.joinCta': 'Csatlakozás', + 'trip.invite.joining': 'Csatlakozás…', + 'trip.invite.invalidTitle': 'A meghívó nem érhető el', + 'trip.invite.invalid': 'Ez a meghívó link érvénytelen vagy lejárt.', + 'trip.invite.backToDashboard': 'Vissza az irányítópultra', }; export default trip; diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index cc1ef715d..06890b6c2 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -358,5 +358,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Tambahkan ke perjalanan (opsional)', + 'admin.invite.tripNone': 'Tanpa perjalanan', + 'admin.invite.tripHint': 'Pengguna baru otomatis ditambahkan ke perjalanan ini saat mereka mendaftar melalui tautan.', + 'admin.invite.boundTo': 'menambahkan ke {trip}', }; export default admin; diff --git a/shared/src/i18n/id/trip.ts b/shared/src/i18n/id/trip.ts index 952341f1d..90bc33e60 100644 --- a/shared/src/i18n/id/trip.ts +++ b/shared/src/i18n/id/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Apakah kamu yakin ingin menghapus tempat ini?', 'trip.confirm.deletePlaces': 'Hapus {count} tempat?', 'trip.toast.placesDeleted': '{count} tempat dihapus', + 'trip.invite.linkTitle': 'Tautan undangan perjalanan', + 'trip.invite.linkHint': 'Siapa pun yang punya akun TREK dan membuka tautan ini akan bergabung ke perjalanan sebagai anggota. Buat ulang untuk membatalkan tautan lama.', + 'trip.invite.create': 'Buat tautan undangan', + 'trip.invite.regenerate': 'Buat ulang', + 'trip.invite.disable': 'Nonaktifkan', + 'trip.invite.joinHeading': 'Gabung ke perjalanan ini', + 'trip.invite.joinPrompt': 'Kamu diundang untuk bergabung ke "{title}".', + 'trip.invite.joinCta': 'Gabung perjalanan', + 'trip.invite.joining': 'Bergabung…', + 'trip.invite.invalidTitle': 'Undangan tidak tersedia', + 'trip.invite.invalid': 'Tautan undangan ini tidak valid atau sudah kedaluwarsa.', + 'trip.invite.backToDashboard': 'Kembali ke dasbor', }; export default trip; diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index e4849115f..9df405b47 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -361,5 +361,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Aggiungi a un viaggio (opzionale)', + 'admin.invite.tripNone': 'Nessun viaggio', + 'admin.invite.tripHint': 'Il nuovo utente viene aggiunto automaticamente a questo viaggio quando si registra tramite il link.', + 'admin.invite.boundTo': 'aggiunge a {trip}', }; export default admin; diff --git a/shared/src/i18n/it/trip.ts b/shared/src/i18n/it/trip.ts index baf8f27dd..12c81e4d2 100644 --- a/shared/src/i18n/it/trip.ts +++ b/shared/src/i18n/it/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlaces': 'Eliminare {count} luoghi?', 'trip.toast.placesDeleted': '{count} luoghi eliminati', 'trip.loadingPhotos': 'Caricamento foto dei luoghi...', + 'trip.invite.linkTitle': 'Link di invito al viaggio', + 'trip.invite.linkHint': 'Chiunque abbia un account TREK e apra questo link entra nel viaggio come membro. Rigenera per invalidare il vecchio link.', + 'trip.invite.create': 'Crea link di invito', + 'trip.invite.regenerate': 'Rigenera', + 'trip.invite.disable': 'Disattiva', + 'trip.invite.joinHeading': 'Unisciti a questo viaggio', + 'trip.invite.joinPrompt': 'Sei stato invitato a unirti a "{title}".', + 'trip.invite.joinCta': 'Unisciti al viaggio', + 'trip.invite.joining': 'Accesso in corso…', + 'trip.invite.invalidTitle': 'Invito non disponibile', + 'trip.invite.invalid': 'Questo link di invito non è valido o è scaduto.', + 'trip.invite.backToDashboard': 'Torna alla dashboard', }; export default trip; diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index becc8e791..4d740c7cb 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -346,5 +346,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': '旅行に追加(任意)', + 'admin.invite.tripNone': '旅行なし', + 'admin.invite.tripHint': '新しいユーザーがリンク経由で登録すると、自動的にこの旅行に追加されます。', + 'admin.invite.boundTo': '{trip}に追加', }; export default admin; diff --git a/shared/src/i18n/ja/trip.ts b/shared/src/i18n/ja/trip.ts index c340a3c9d..862cdfcbc 100644 --- a/shared/src/i18n/ja/trip.ts +++ b/shared/src/i18n/ja/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'この場所を削除してもよろしいですか?', 'trip.confirm.deletePlaces': '{count}件の場所を削除してもよろしいですか?', 'trip.toast.placesDeleted': '{count}件の場所を削除しました', + 'trip.invite.linkTitle': '旅行の招待リンク', + 'trip.invite.linkHint': 'TREKアカウントを持っている人がこのリンクを開くと、メンバーとして旅行に参加します。古いリンクを無効にするには再生成してください。', + 'trip.invite.create': '招待リンクを作成', + 'trip.invite.regenerate': '再生成', + 'trip.invite.disable': '無効にする', + 'trip.invite.joinHeading': 'この旅行に参加', + 'trip.invite.joinPrompt': '「{title}」への参加に招待されました。', + 'trip.invite.joinCta': '旅行に参加', + 'trip.invite.joining': '参加中…', + 'trip.invite.invalidTitle': '招待は利用できません', + 'trip.invite.invalid': 'この招待リンクは無効か、有効期限が切れています。', + 'trip.invite.backToDashboard': 'ダッシュボードに戻る', }; export default trip; diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index b7a52041f..f26e9616a 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -349,5 +349,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': '여행에 추가 (선택 사항)', + 'admin.invite.tripNone': '여행 없음', + 'admin.invite.tripHint': '새 사용자가 이 링크를 통해 가입하면 해당 여행에 자동으로 추가됩니다.', + 'admin.invite.boundTo': '{trip}에 추가', }; export default admin; diff --git a/shared/src/i18n/ko/trip.ts b/shared/src/i18n/ko/trip.ts index 69434fd80..9d3a807f1 100644 --- a/shared/src/i18n/ko/trip.ts +++ b/shared/src/i18n/ko/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': '이 장소를 삭제할까요?', 'trip.confirm.deletePlaces': '{count}개 장소를 삭제할까요?', 'trip.toast.placesDeleted': '{count}개 장소가 삭제되었습니다', + 'trip.invite.linkTitle': '여행 초대 링크', + 'trip.invite.linkHint': 'TREK 계정이 있는 사람이 이 링크를 열면 여행에 멤버로 참여합니다. 기존 링크를 무효화하려면 다시 생성하세요.', + 'trip.invite.create': '초대 링크 만들기', + 'trip.invite.regenerate': '다시 생성', + 'trip.invite.disable': '비활성화', + 'trip.invite.joinHeading': '이 여행 참여하기', + 'trip.invite.joinPrompt': '"{title}" 여행에 초대되었습니다.', + 'trip.invite.joinCta': '여행 참여', + 'trip.invite.joining': '참여 중…', + 'trip.invite.invalidTitle': '초대를 사용할 수 없음', + 'trip.invite.invalid': '이 초대 링크는 유효하지 않거나 만료되었습니다.', + 'trip.invite.backToDashboard': '대시보드로 돌아가기', }; export default trip; diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 167928510..609cd30ce 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -361,5 +361,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Toevoegen aan reis (optioneel)', + 'admin.invite.tripNone': 'Geen reis', + 'admin.invite.tripHint': 'De nieuwe gebruiker wordt automatisch aan deze reis toegevoegd wanneer hij zich via de link registreert.', + 'admin.invite.boundTo': 'voegt toe aan {trip}', }; export default admin; diff --git a/shared/src/i18n/nl/trip.ts b/shared/src/i18n/nl/trip.ts index 9e987428a..41a6d94bd 100644 --- a/shared/src/i18n/nl/trip.ts +++ b/shared/src/i18n/nl/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Weet je zeker dat je deze plaats wilt verwijderen?', 'trip.confirm.deletePlaces': '{count} plaatsen verwijderen?', 'trip.toast.placesDeleted': '{count} plaatsen verwijderd', + 'trip.invite.linkTitle': 'Uitnodigingslink voor reis', + 'trip.invite.linkHint': 'Iedereen met een TREK-account die deze link opent, neemt als lid deel aan de reis. Genereer opnieuw om de oude link ongeldig te maken.', + 'trip.invite.create': 'Uitnodigingslink maken', + 'trip.invite.regenerate': 'Opnieuw genereren', + 'trip.invite.disable': 'Uitschakelen', + 'trip.invite.joinHeading': 'Deelnemen aan deze reis', + 'trip.invite.joinPrompt': 'Je bent uitgenodigd om deel te nemen aan "{title}".', + 'trip.invite.joinCta': 'Deelnemen aan reis', + 'trip.invite.joining': 'Bezig met deelnemen…', + 'trip.invite.invalidTitle': 'Uitnodiging niet beschikbaar', + 'trip.invite.invalid': 'Deze uitnodigingslink is ongeldig of verlopen.', + 'trip.invite.backToDashboard': 'Terug naar dashboard', }; export default trip; diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 87fed4277..762d824ed 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -365,5 +365,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Dodaj do podróży (opcjonalnie)', + 'admin.invite.tripNone': 'Brak podróży', + 'admin.invite.tripHint': 'Nowy użytkownik zostanie automatycznie dodany do tej podróży po rejestracji przez link.', + 'admin.invite.boundTo': 'dodaje do {trip}', }; export default admin; diff --git a/shared/src/i18n/pl/trip.ts b/shared/src/i18n/pl/trip.ts index 29fe17fc5..8882702a3 100644 --- a/shared/src/i18n/pl/trip.ts +++ b/shared/src/i18n/pl/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlaces': 'Usunąć {count} miejsc?', 'trip.toast.placesDeleted': '{count} miejsc usunięto', 'trip.loadingPhotos': 'Ładowanie zdjęć...', + 'trip.invite.linkTitle': 'Link zaproszenia do podróży', + 'trip.invite.linkHint': 'Każda osoba z kontem TREK, która otworzy ten link, dołączy do podróży jako członek. Wygeneruj ponownie, aby unieważnić stary link.', + 'trip.invite.create': 'Utwórz link zaproszenia', + 'trip.invite.regenerate': 'Wygeneruj ponownie', + 'trip.invite.disable': 'Wyłącz', + 'trip.invite.joinHeading': 'Dołącz do tej podróży', + 'trip.invite.joinPrompt': 'Zaproszono Cię do dołączenia do „{title}”.', + 'trip.invite.joinCta': 'Dołącz do podróży', + 'trip.invite.joining': 'Dołączanie…', + 'trip.invite.invalidTitle': 'Zaproszenie niedostępne', + 'trip.invite.invalid': 'Ten link zaproszenia jest nieprawidłowy lub wygasł.', + 'trip.invite.backToDashboard': 'Powrót do panelu', }; export default trip; diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 6015ffd1c..4c9413dda 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -360,5 +360,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Добавить в поездку (необязательно)', + 'admin.invite.tripNone': 'Без поездки', + 'admin.invite.tripHint': 'Новый пользователь автоматически добавляется в эту поездку при регистрации по ссылке.', + 'admin.invite.boundTo': 'добавляет в {trip}', }; export default admin; diff --git a/shared/src/i18n/ru/trip.ts b/shared/src/i18n/ru/trip.ts index af0b460ce..c5911a6f9 100644 --- a/shared/src/i18n/ru/trip.ts +++ b/shared/src/i18n/ru/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Вы уверены, что хотите удалить это место?', 'trip.confirm.deletePlaces': 'Удалить {count} мест?', 'trip.toast.placesDeleted': '{count} мест удалено', + 'trip.invite.linkTitle': 'Ссылка-приглашение в поездку', + 'trip.invite.linkHint': 'Любой пользователь с аккаунтом TREK, открыв эту ссылку, присоединится к поездке как участник. Создайте ссылку заново, чтобы старая перестала работать.', + 'trip.invite.create': 'Создать ссылку-приглашение', + 'trip.invite.regenerate': 'Создать заново', + 'trip.invite.disable': 'Отключить', + 'trip.invite.joinHeading': 'Присоединиться к поездке', + 'trip.invite.joinPrompt': 'Вас пригласили присоединиться к поездке «{title}».', + 'trip.invite.joinCta': 'Присоединиться', + 'trip.invite.joining': 'Присоединение…', + 'trip.invite.invalidTitle': 'Приглашение недоступно', + 'trip.invite.invalid': 'Эта ссылка-приглашение недействительна или срок её действия истёк.', + 'trip.invite.backToDashboard': 'Вернуться на панель управления', }; export default trip; diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index cc6aeab71..9ed39ec06 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -355,5 +355,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Lägg till i resa (valfritt)', + 'admin.invite.tripNone': 'Ingen resa', + 'admin.invite.tripHint': 'Den nya användaren läggs automatiskt till i den här resan när de registrerar sig via länken.', + 'admin.invite.boundTo': 'läggs till i {trip}', }; export default admin; diff --git a/shared/src/i18n/sv/trip.ts b/shared/src/i18n/sv/trip.ts index fcd40dce1..55c142eeb 100644 --- a/shared/src/i18n/sv/trip.ts +++ b/shared/src/i18n/sv/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Är du säker på att du vill radera denna plats?', 'trip.confirm.deletePlaces': 'Radera {count} platser?', 'trip.toast.placesDeleted': '{count} platser raderade', + 'trip.invite.linkTitle': 'Inbjudningslänk till resa', + 'trip.invite.linkHint': 'Alla med ett TREK-konto som öppnar den här länken går med i resan som medlem. Skapa en ny länk för att ogiltigförklara den gamla.', + 'trip.invite.create': 'Skapa inbjudningslänk', + 'trip.invite.regenerate': 'Skapa ny', + 'trip.invite.disable': 'Inaktivera', + 'trip.invite.joinHeading': 'Gå med i resan', + 'trip.invite.joinPrompt': 'Du har bjudits in att gå med i "{title}".', + 'trip.invite.joinCta': 'Gå med i resan', + 'trip.invite.joining': 'Går med…', + 'trip.invite.invalidTitle': 'Inbjudan inte tillgänglig', + 'trip.invite.invalid': 'Den här inbjudningslänken är ogiltig eller har gått ut.', + 'trip.invite.backToDashboard': 'Tillbaka till översikten', }; export default trip; diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 02bcd1dc6..bcd44f6e6 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -364,5 +364,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Seyahate ekle (isteğe bağlı)', + 'admin.invite.tripNone': 'Seyahat yok', + 'admin.invite.tripHint': 'Yeni kullanıcı bağlantı üzerinden kaydolduğunda otomatik olarak bu seyahate eklenir.', + 'admin.invite.boundTo': '{trip} seyahatine ekler', }; export default admin; diff --git a/shared/src/i18n/tr/trip.ts b/shared/src/i18n/tr/trip.ts index c7eff8e9c..736de165f 100644 --- a/shared/src/i18n/tr/trip.ts +++ b/shared/src/i18n/tr/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Bu yeri silmek istediğinizden emin misiniz?', 'trip.confirm.deletePlaces': '{count} Yer silinsin mi?', 'trip.toast.placesDeleted': '{count} yer silindi', + 'trip.invite.linkTitle': 'Seyahat davet bağlantısı', + 'trip.invite.linkHint': 'TREK hesabı olan ve bu bağlantıyı açan herkes seyahate üye olarak katılır. Eski bağlantıyı geçersiz kılmak için yeniden oluşturun.', + 'trip.invite.create': 'Davet bağlantısı oluştur', + 'trip.invite.regenerate': 'Yeniden oluştur', + 'trip.invite.disable': 'Devre dışı bırak', + 'trip.invite.joinHeading': 'Bu seyahate katıl', + 'trip.invite.joinPrompt': '"{title}" seyahatine katılmaya davet edildiniz.', + 'trip.invite.joinCta': 'Seyahate katıl', + 'trip.invite.joining': 'Katılınıyor…', + 'trip.invite.invalidTitle': 'Davet kullanılamıyor', + 'trip.invite.invalid': 'Bu davet bağlantısı geçersiz veya süresi dolmuş.', + 'trip.invite.backToDashboard': 'Panoya dön', }; export default trip; diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index f8197ce79..73deaca20 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -360,5 +360,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': 'Додати до подорожі (необов’язково)', + 'admin.invite.tripNone': 'Без подорожі', + 'admin.invite.tripHint': 'Новий користувач автоматично додається до цієї подорожі, коли реєструється за посиланням.', + 'admin.invite.boundTo': 'додає до {trip}', }; export default admin; diff --git a/shared/src/i18n/uk/trip.ts b/shared/src/i18n/uk/trip.ts index 9f42fcece..ffa9ebafb 100644 --- a/shared/src/i18n/uk/trip.ts +++ b/shared/src/i18n/uk/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Ви впевнені, що хочете видалити це місце?', 'trip.confirm.deletePlaces': 'Видалити {count} місць?', 'trip.toast.placesDeleted': '{count} місць видалено', + 'trip.invite.linkTitle': 'Посилання-запрошення до подорожі', + 'trip.invite.linkHint': 'Будь-хто з обліковим записом TREK, хто відкриє це посилання, приєднається до подорожі як учасник. Згенеруйте заново, щоб анулювати старе посилання.', + 'trip.invite.create': 'Створити посилання-запрошення', + 'trip.invite.regenerate': 'Згенерувати заново', + 'trip.invite.disable': 'Вимкнути', + 'trip.invite.joinHeading': 'Приєднатися до подорожі', + 'trip.invite.joinPrompt': 'Вас запросили приєднатися до «{title}».', + 'trip.invite.joinCta': 'Приєднатися', + 'trip.invite.joining': 'Приєднання…', + 'trip.invite.invalidTitle': 'Запрошення недоступне', + 'trip.invite.invalid': 'Це посилання-запрошення недійсне або термін його дії минув.', + 'trip.invite.backToDashboard': 'Назад до панелі', }; export default trip; diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 9292acd31..868cf9600 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -355,5 +355,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Cấu hình', 'admin.group.integration': 'Tích hợp', 'admin.group.maintenance': 'Bảo trì', + 'admin.invite.tripLabel': 'Thêm vào chuyến đi (tùy chọn)', + 'admin.invite.tripNone': 'Không có chuyến đi', + 'admin.invite.tripHint': 'Người dùng mới sẽ tự động được thêm vào chuyến đi này khi họ đăng ký qua liên kết.', + 'admin.invite.boundTo': 'thêm vào {trip}', }; export default admin; diff --git a/shared/src/i18n/vi/trip.ts b/shared/src/i18n/vi/trip.ts index ee6972313..f9c589d33 100644 --- a/shared/src/i18n/vi/trip.ts +++ b/shared/src/i18n/vi/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': 'Bạn có chắc chắn muốn xóa địa điểm này không?', 'trip.confirm.deletePlaces': 'Xóa {count} địa điểm?', 'trip.toast.placesDeleted': '{count} địa điểm đã bị xóa', + 'trip.invite.linkTitle': 'Liên kết mời tham gia chuyến đi', + 'trip.invite.linkHint': 'Bất kỳ ai có tài khoản TREK mở liên kết này đều sẽ tham gia chuyến đi với tư cách thành viên. Tạo lại để vô hiệu hóa liên kết cũ.', + 'trip.invite.create': 'Tạo liên kết mời', + 'trip.invite.regenerate': 'Tạo lại', + 'trip.invite.disable': 'Vô hiệu hóa', + 'trip.invite.joinHeading': 'Tham gia chuyến đi này', + 'trip.invite.joinPrompt': 'Bạn được mời tham gia "{title}".', + 'trip.invite.joinCta': 'Tham gia chuyến đi', + 'trip.invite.joining': 'Đang tham gia…', + 'trip.invite.invalidTitle': 'Lời mời không khả dụng', + 'trip.invite.invalid': 'Liên kết mời này không hợp lệ hoặc đã hết hạn.', + 'trip.invite.backToDashboard': 'Quay lại bảng điều khiển', }; export default trip; diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 8498dd5a5..5f237df89 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -342,5 +342,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': '加入行程(選填)', + 'admin.invite.tripNone': '不指定行程', + 'admin.invite.tripHint': '新使用者透過連結註冊時,會自動加入此行程。', + 'admin.invite.boundTo': '加入 {trip}', }; export default admin; diff --git a/shared/src/i18n/zh-TW/trip.ts b/shared/src/i18n/zh-TW/trip.ts index 83e86c9ae..ff5794734 100644 --- a/shared/src/i18n/zh-TW/trip.ts +++ b/shared/src/i18n/zh-TW/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': '確定要刪除這個地點嗎?', 'trip.confirm.deletePlaces': '刪除 {count} 個地點?', 'trip.toast.placesDeleted': '已刪除 {count} 個地點', + 'trip.invite.linkTitle': '行程邀請連結', + 'trip.invite.linkHint': '任何擁有 TREK 帳號的人開啟此連結,都會以成員身分加入行程。重新產生可使舊連結失效。', + 'trip.invite.create': '建立邀請連結', + 'trip.invite.regenerate': '重新產生', + 'trip.invite.disable': '停用', + 'trip.invite.joinHeading': '加入這趟行程', + 'trip.invite.joinPrompt': '您受邀加入「{title}」。', + 'trip.invite.joinCta': '加入行程', + 'trip.invite.joining': '加入中…', + 'trip.invite.invalidTitle': '邀請無法使用', + 'trip.invite.invalid': '此邀請連結無效或已過期。', + 'trip.invite.backToDashboard': '返回儀表板', }; export default trip; diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index be5b7df08..b6cd6d2c6 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -340,5 +340,9 @@ const admin: TranslationStrings = { 'admin.group.config': 'Configuration', 'admin.group.integration': 'Integrations', 'admin.group.maintenance': 'Maintenance', + 'admin.invite.tripLabel': '添加到行程(可选)', + 'admin.invite.tripNone': '不选择行程', + 'admin.invite.tripHint': '新用户通过链接注册时会自动加入此行程。', + 'admin.invite.boundTo': '加入 {trip}', }; export default admin; diff --git a/shared/src/i18n/zh/trip.ts b/shared/src/i18n/zh/trip.ts index d30327c62..ee0c46b9e 100644 --- a/shared/src/i18n/zh/trip.ts +++ b/shared/src/i18n/zh/trip.ts @@ -27,5 +27,17 @@ const trip: TranslationStrings = { 'trip.confirm.deletePlace': '确定要删除这个地点吗?', 'trip.confirm.deletePlaces': '删除 {count} 个地点?', 'trip.toast.placesDeleted': '已删除 {count} 个地点', + 'trip.invite.linkTitle': '行程邀请链接', + 'trip.invite.linkHint': '任何拥有 TREK 账户的人打开此链接都会以成员身份加入行程。重新生成可使旧链接失效。', + 'trip.invite.create': '创建邀请链接', + 'trip.invite.regenerate': '重新生成', + 'trip.invite.disable': '停用', + 'trip.invite.joinHeading': '加入此行程', + 'trip.invite.joinPrompt': '您已被邀请加入"{title}"。', + 'trip.invite.joinCta': '加入行程', + 'trip.invite.joining': '加入中…', + 'trip.invite.invalidTitle': '邀请不可用', + 'trip.invite.invalid': '此邀请链接无效或已过期。', + 'trip.invite.backToDashboard': '返回仪表盘', }; export default trip; diff --git a/wiki/Admin-Users-and-Invites.md b/wiki/Admin-Users-and-Invites.md index b44be98f1..6e81478ba 100644 --- a/wiki/Admin-Users-and-Invites.md +++ b/wiki/Admin-Users-and-Invites.md @@ -56,6 +56,7 @@ Click **Create Invite** (invite links section, below the user table). Configure: - **Max uses** — how many times the link can be used before it expires: `1×`, `2×`, `3×`, `4×`, `5×`, or `∞` (unlimited). Defaults to `1×`. - **Expiry** — how long the link remains valid: `1d`, `3d`, `7d`, `14d`, or `∞` (no expiry). Defaults to `7d`. +- **Add to trip** (optional) — bind the invite to a trip. Anyone who registers through the link is automatically added to that trip as a member. Defaults to **No trip** (a plain registration invite). The selector only appears when at least one trip exists. After creation the link is copied to your clipboard automatically. Share it with the intended recipient. The URL format is: @@ -71,6 +72,7 @@ Existing invites are listed below the creation button. Each row shows: - A status badge — `active`, `used up`, or `expired` - **Usage** — `used / max` (or `used / ∞` for unlimited) - **Expiry** date, if set +- **Adds to** — the bound trip, if the invite is trip-bound - **Created by** — the admin who generated the link - A **copy link** button (only shown for active invites) - A **delete** (revoke) button diff --git a/wiki/Invite-Links.md b/wiki/Invite-Links.md index 52d6224f5..2558171d4 100644 --- a/wiki/Invite-Links.md +++ b/wiki/Invite-Links.md @@ -10,6 +10,8 @@ Invite new users to register on your TREK instance, even when open registration An invite link lets a person register a new TREK account without requiring the site to have open registration enabled. Visiting `/register?invite=` pre-validates the token and switches the page to the Register form. The token's use count is incremented on successful registration, and the link stops working once the use limit is reached. +> Invite links here are for **registering new accounts**. To let people who *already* have an account join a specific trip, use the trip invite link in the trip's Share panel — see [Trip-Members-and-Sharing](Trip-Members-and-Sharing). + ## Creating invite links > **Admin:** invite link management is available in [Admin-Users-and-Invites](Admin-Users-and-Invites). Only admins can create invite links. @@ -20,6 +22,8 @@ When creating an invite link you set two parameters: **Expiry** — how long until the link stops working. Choose from preset buttons: **1d, 3d, 7d, 14d**, or **∞** (no expiry). +**Add to trip (optional)** — bind the invite to one of your trips. When someone registers through the link, they are automatically added to that trip as a member. Leave it on **No trip** for a plain registration invite. The selector only appears when at least one trip exists. + Once created, a 32-character hexadecimal token is generated and the URL is automatically copied to your clipboard. ## Sharing the link diff --git a/wiki/Trip-Members-and-Sharing.md b/wiki/Trip-Members-and-Sharing.md index b15cc2337..5fa8d744f 100644 --- a/wiki/Trip-Members-and-Sharing.md +++ b/wiki/Trip-Members-and-Sharing.md @@ -65,6 +65,26 @@ Changes to toggles take effect immediately for an existing link. Click **Delete link** (red button below the URL) to revoke access. The token is invalidated and existing viewers are redirected. +## Trip Invite Link + +Below the public share link, users with the `share_manage` permission can also create a **trip invite link** — a single rotating link that lets people **join the trip as members**: + +``` +/join/ +``` + +Unlike the read-only share link, this one is **not anonymous**: + +- Whoever opens it must have a TREK account and be **signed in**. If they are not, they are sent to the login page and returned to the invite afterwards — there is **no registration** from this link. +- Anyone who opens it while signed in is added to the trip as a **member** (the same access a manually-added member gets). The owner and existing members are simply taken straight to the trip. +- To invite people who do **not** yet have an account, use an admin invite link with an optional trip binding instead — see [Invite-Links](Invite-Links). + +### Creating and revoking + +- Click **Create invite link** to generate the link, then copy it with the copy button. +- Click **Regenerate** to rotate the token — the previous link stops working immediately. +- Click **Disable** to remove the link entirely. + > **Admin:** To invite users who do not yet have an account, create invite links in the admin panel. See [Invite-Links](Invite-Links). ## Related Pages