Skip to content

Trip Access Authentication

Passwordless authentication for passengers to access their trip details via personalized magic links. Allows travel agents to share trip information directly with passengers, independent of the client authentication system.

+-------------------+ +-------------------+ +-------------------+
| Filament Admin | | Laravel Backend | | Astro Frontend |
| (Backoffice) | | | | (Trip Page) |
+-------------------+ +-------------------+ +-------------------+
| | |
| "Share Trip with Pax" | |
|----------------------------->| |
| | |
| | Magic Link Email |
| |----------------------------->|
| | |
| | Verify Token |
| |<-----------------------------|
| | |
| | Session + Redirect |
| |----------------------------->|
| | |
AspectClient AuthTrip Access
Target userClients (booking owners)Passengers (travelers)
ScopeAll client bookingsSingle booking only
Token storageclients + SanctumSanctum personal_access_tokens (polymorphic)
Token ownerClient modelPassenger model
Duration7 days90 days (default)
Use caseClient portalTrip itinerary page
1. Agent clicks "Share Trip with Pax" in Filament booking view
|
v
2. Agent selects passengers (checkbox list)
- Only passengers with email addresses shown
- All selected by default
- Lead passenger marked with badge
|
v
3. For each selected passenger:
- TripAccessTokenService creates Sanctum token
- Previous tokens for same booking revoked
- TripAccessNotification queued
- Email sent with HMAC-signed magic link
|
v
4. Passenger clicks email link -> /auth/trip?token={encoded}
|
v
5. POST /api/trip/verify
- Decodes base64 payload (token, booking_reference, passenger_id, signature)
- Validates HMAC signature (prevents tampering)
- Finds valid Sanctum token via TripAccessTokenService
- Verifies booking_reference and passenger_id match token abilities
- Marks token as used
- Returns booking details + access_token
|
v
6. Astro stores in server session:
- bookingReference: string
- accessToken: string (Sanctum token for subsequent calls)
- expiresAt: timestamp
- passengerName: string (for personalized greeting)
|
v
7. Redirect to /{market}/trip/{reference}
- Page shows personalized greeting
- Trip details rendered from session

Trip access emails are sent automatically the first time a booking transitions to a paid state, so admins no longer need to click “Share Trip with Pax” after every deposit.

Trigger point: PaymentService::updateBookingPaymentStatus() dispatches ShareTripWithPassengersJob after the initial-deposit transition (the branch that routes to PendingFlightBooking or PendingLandConfirmation). The balance-payment branch (AwaitingBalance -> FullyPaid) does not dispatch — the customer already received the email at deposit time.

Idempotency: bookings.trip_shared_at is the single source of truth. TripShareService stamps it on first successful send; the job and the dispatch site both short-circuit when it is already set, so the manual Filament action and the automatic job can coexist without duplicating emails.

Failure isolation: Per-passenger errors inside TripShareService are caught, reported via report() (Sentry) and Log::error(), and counted in the returned TripShareResult — one broken mailbox never blocks the rest, and the payment flow itself is never blocked because sending runs in the queued job.

Queue: ShareTripWithPassengersJob runs on the default queue (tries=3, timeout=120s, backoff=[30, 60, 180]). It is already covered by the existing compose queue worker — no compose changes were needed.

Source: backend/app/Jobs/ShareTripWithPassengersJob.php, backend/app/Services/TripShareService.php, backend/app/Services/Payment/PaymentService.php

The magic link payload includes an HMAC-SHA256 signature to prevent tampering:

$payload = [
'token' => $plainTextToken,
'booking_reference' => $booking->booking_reference,
'passenger_id' => $passenger->id,
];
$signature = hash_hmac('sha256', json_encode($payload), config('app.key'));

The signature is verified before any database lookup, preventing timing attacks.

PropertyValuePurpose
Storagepersonal_access_tokens tableStandard Sanctum polymorphic storage
TokenablePassenger modelToken owner is the passenger
Nametrip-access:{booking_reference}Identifies token purpose and booking
Abilitiestrip-access, booking:{id}, passenger:{id}Scoped permissions
Expiry90 days defaultConfigurable via DEFAULT_VALIDITY_DAYS
Single-useNoToken can be reused until expiry

Each token is created with abilities that encode the access context:

$abilities = [
'trip-access', // Identifies as trip access token
'booking:' . $bookingId, // Links to specific booking
'passenger:' . $passengerId, // Links to specific passenger
];

Trip access tokens use Laravel Sanctum’s personal_access_tokens table (polymorphic):

-- Sanctum's personal_access_tokens table
CREATE TABLE personal_access_tokens (
id BIGINT PRIMARY KEY,
tokenable_type VARCHAR(255), -- 'App\Models\Passenger' for trip access
tokenable_id BIGINT, -- Passenger ID
name VARCHAR(255), -- 'trip-access:{booking_reference}'
token VARCHAR(64) UNIQUE, -- Hashed token
abilities JSON, -- ['trip-access', 'booking:123', 'passenger:456']
last_used_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX(tokenable_type, tokenable_id)
);

The service wraps Sanctum token operations with booking context:

use App\Services\TripAccessTokenService;
$tokenService = app(TripAccessTokenService::class);
// Create token for passenger
$tokenData = $tokenService->createForPassenger($booking, $passenger);
// Returns: ['token' => NewAccessToken, 'plain_text' => 'id|token']
// Find valid token
$token = $tokenService->findValidToken($plainTextToken);
// Returns: PersonalAccessToken or null
// Get booking ID from token abilities
$bookingId = $tokenService->getBookingIdFromToken($token);
// Get passenger from token
$passenger = $tokenService->getPassengerFromToken($token);
// Revoke tokens for specific booking
$tokenService->revokeTokensForBooking($passenger, $booking);
// Revoke all trip tokens for passenger
$tokenService->revokeAllTokens($passenger);
POST /api/trip/verify

Rate Limit: 10 requests/minute

Request:

{
"token": "base64_encoded_payload"
}

Response (success):

{
"success": true,
"data": {
"booking": { /* BookingDetailsResource */ },
"passenger": {
"id": 4,
"name": "John Doe"
},
"access_token": "1|abc123def456...",
"expires_at": "2026-03-29T15:41:27.000000Z"
}
}

Error codes: invalid_token, expired_token

Source: backend/app/Http/Controllers/Api/Trip/AccessController.php:26

GET /api/trip/show?token={access_token}

Rate Limit: 60 requests/minute

Request: Sanctum token as query parameter.

Response: Fresh booking details for the authenticated passenger.

Source: backend/app/Http/Controllers/Api/Trip/AccessController.php:110

Located in ViewBooking.php, the action:

  1. Shows checkbox list of passengers with email addresses
  2. Displays passenger name, lead badge, and email
  3. Pre-selects all passengers
  4. Allows bulk select/deselect
  5. Sends emails to selected passengers only
Action::make('share_trip_with_pax')
->label('Share Trip with Pax')
->icon(Heroicon::OutlinedEnvelope)
->color('gray')
->form(fn (): array => $this->getShareTripForm())
->action(fn (array $data) => $this->shareWithSelectedPassengers($data['passengers'] ?? []))

Source: backend/app/Filament/Resources/Bookings/Pages/ViewBooking.php:28

The trip page supports two access methods:

MethodURL PatternUse Case
Admin Preview/{market}/trip/{ref}?preview={signed_token}Short-lived (60 min) signed URL for backoffice preview
Passenger Session/{market}/trip/{ref}Long-lived session from magic link verification
// Trip page access check
if (previewToken) {
// Admin preview via signed URL
booking = await getBookingByPreviewToken(previewToken, clientIp);
} else if (tripAccess && tripAccess.bookingReference === reference) {
// Passenger session access
const result = await getTripDetails(tripAccess.accessToken);
booking = result.data?.booking;
}

Source: frontend/src/pages/[market]/trip/[reference].astro:46

TripAccessNotification renders the designed Blade template resources/views/emails/post-booking.blade.php via MailMessage::view(...) (ported from the original HTML design at backend/emails/post-booking.html, delivered by PR #1982 and refreshed in PR #2021 / issue #2027). It replaces the previous bare-bones MailMessage with greeting + button.

Subject and visible copy are Spanish-only at this stage:

  • With a tour title: "Tu reserva está confirmada — {tour title}"
  • Without a tour title: "Tu reserva está confirmada"
View varSource
firstNamePassenger::$first_name
localizadorBooking::$booking_reference
datesOffer::getTravelDates() → outbound departure day → return arrival-home day of the bound international flight, formatted d/m/Y; falls back to Offer::$departure_date / duration-based Offer::getReturnDate() when no international flight is bound (land-only / pre-flight), and to departure only when there is no return. Flight-aware so long-haul return legs spanning an extra travel day show the day the traveler actually lands (ref #2081)
destinationProductByMarket::getPrimaryCountryName() (falls back to tour title, then )
passengersBooking::$passengers — looped in the “Resumen de la reserva” card
tripUrlPer-passenger HMAC-signed magic link generated by generateTripAccessUrl()
heroUrlPer-tour hero asset URL — see below
assetsStatic asset URL prefix built from config('app.url') by resolveAssetsBaseUrl()
contactPhoneVolareEntity::current()->phone (defaults to +34 919 49 45 52 when unset)
contactEmailHardcoded to reservas@byvolare.com — a reservations-specific address, intentionally distinct from VolareEntity->email (the entity’s general contact address)
whatsappUrlReal WhatsApp link https://wa.me/34655547584

The email hero comes from a new nullable product_templates.mail_hero_image column (migration 2026_05_28_151730_add_mail_hero_image_to_product_templates_table.php). Admins upload a GIF/PNG/JPG in two places:

  • The Filament EditSupplierTourMedia page
  • The “Visual & Media” step of the supplier-tour wizard (SupplierTourForm)

Uploads land in the product-templates/mail-hero/ directory. TripAccessNotification::resolveHeroUrl() returns the stored value as-is when it’s already an absolute URL, otherwise resolves it via Storage::disk(config('filesystems.default'))->url($path). When the column is null it returns null and the hero block is omitted from the email — there is no generic fallback image (a tour-specific default would be misleading).

Logos, icons, social, titulos, and sello live under backend/public/emails/transaccional/ and are served at {APP_URL}/emails/transaccional/.... The template references them via the $assets prefix.

The refreshed design (PR #2021 / issue #2027) restructured the template:

  • The top “Si no ves bien el correo” display bar and the footer “See in browser” / “Unsuscribe” legal links were removed entirely; the notification no longer passes webVersionUrl or unsubscribeUrl.
  • The Contacto section is now Email full-width on top, then Teléfono + WhatsApp two-up (the previous “Área Privada” card was removed).
  • The “Próximos pasos” timeline went from 4 steps to 3 (the “60 días antes / pago restante” step was removed).
  • Page background is now white; body copy was updated; social handles point at the production volare_es / volāre accounts.
  • The non-functional “DESCARGAR DOCUMENTO” CTA (and its “También puedes descargar…” copy) was removed (issue #2065) — there is no PDF voucher download flow, so the button is gone until one is built.

None. The previously placeholder “DESCARGAR DOCUMENTO” CTA (documentUrl => '#') was removed in issue #2065; every link in the template now points at a real destination.

Source: backend/app/Notifications/TripAccessNotification.php, backend/resources/views/emails/post-booking.blade.php

BookingPaymentConfirmedNotification is a separate customer-facing receipt sent once per booking the first time a successful payment lands. It is distinct from the trip-access magic-link email and goes to a single payer address — not multiplied per passenger. The notification renders the designed Blade template resources/views/emails/payment-confirmed.blade.php (ported from the original HTML design at backend/emails/confirmacion-pago.html, delivered by PR #1982 and refreshed in PR #2021 / issue #2027).

Subject and visible copy are Spanish-only at this stage:

  • With a tour title: "Confirmación de pago — {tour title}"
  • Without a tour title: "Confirmación de pago"

The email is sent to a single recipient. The notification is dispatched via Notification::route('mail', $recipient) so it never targets a Notifiable model directly:

  1. booking.contact_email (captured at checkout) — primary.
  2. booking.client?->email — fallback when contact_email is blank.
  3. Neither set — the dispatch is skipped, a Log::warning('Payment confirmation email skipped: no recipient') is logged, and the surrounding payment flow is never blocked.

Fires from PaymentService::updateBookingPaymentStatus() immediately after the existing ShareTripWithPassengersJob::dispatch() block, on the same initial-deposit transition (the branch that routes to PendingFlightBooking or PendingLandConfirmation). The balance-payment branch (AwaitingBalanceFullyPaid) does not dispatch — one receipt per booking, not per installment.

bookings.payment_confirmation_sent_at is the idempotency gate (migration 2026_05_28_165400_add_payment_confirmation_sent_at_to_bookings_table.php). It is stamped with now() after a successful send, and the dispatch site short-circuits when it is already set. It is intentionally independent of trip_shared_at so the two emails can be resent without coupling.

View varSource
firstNameFirst word of Client::$name; falls back to lead passenger’s first_name; final fallback "viajero"
localizadorBooking::$booking_reference
importeLatest successful Payment::$amount + currency, formatted via PHP intl NumberFormatter('es_ES', CURRENCY) (e.g. "300,00 €"); falls back to "300,00 EUR" when intl is unavailable
datesOffer::getTravelDates() → outbound departure day → return arrival-home day of the bound international flight, formatted d/m/Y; falls back to Offer::$departure_date / duration-based Offer::getReturnDate() when no international flight is bound (land-only / pre-flight), and to departure only when there is no return. Flight-aware so long-haul return legs spanning an extra travel day show the day the traveler actually lands (ref #2081)
destinationProductByMarket::getPrimaryCountryName() (falls back to tour title, then )
passengersBooking::$passengers — looped under the “Pasajeros” label in the “Resumen de la compra” card
bannerUrlPer-tour banner URL — see below
assetsStatic asset URL prefix built from config('app.url') (/emails/transaccional)

The banner reuses the product_templates pattern as the trip-access hero, but lives in its own column: mail_payment_image (migration 2026_05_28_165359_add_mail_payment_image_to_product_templates_table.php). Unlike mail_hero_image, this slot is static — PNG/JPG only, no GIFs. Admins upload it in two places, sitting right next to the mail_hero_image field:

  • The Filament EditSupplierTourMedia page (Visual & Media section)
  • The “Visual & Media” step of the supplier-tour wizard (SupplierTourForm)

Uploads land in product-templates/mail-payment/. BookingPaymentConfirmedNotification::resolveBannerUrl() returns the stored value as-is when it’s already an absolute URL, otherwise resolves it via Storage::disk(config('filesystems.default'))->url($path). When the column is null it returns null and the banner is omitted from the email — there is no generic fallback image.

The refreshed design (PR #2021 / issue #2027) also reworked the receipt:

  • The top “Si no ves bien el correo” display bar and the footer “See in browser” / “Unsuscribe” legal links were removed entirely; the notification no longer passes webVersionUrl or unsubscribeUrl.
  • The passenger list label was renamed “Nombre de los pasajeros” → “Pasajeros”.
  • The payment-status copy is split into two paragraphs: “Tu primer pago ha sido procesado correctamente.” plus a reminder that the second-payment instructions arrive 60 days before departure.
  • The invoice request address changed facturacion@byvolare.comfacturas@byvolare.com; social handles point at the production accounts.

Source: backend/app/Notifications/BookingPaymentConfirmedNotification.php, backend/resources/views/emails/payment-confirmed.blade.php, backend/app/Services/Payment/PaymentService.php

The Filament ViewBooking page shows a Trip Share Status section that surfaces, per booking, whether passengers have actually opened their trip-access link.

The section is composed by BookingTripShareSection (under app/Filament/Resources/Bookings/Schemas/) and reads its data from TripShareStatusService::forBooking(Booking $booking): array.

What it shows:

  • Section header — derived from bookings.trip_shared_at:
    • Not shared yet. when the column is null.
    • Shared on {date · time} · {X} of {N} opened the link. once at least one mail has been dispatched successfully.
  • One row per booking passenger:
    • Passenger name
    • Email (or )
    • Access badge — the only reliable per-passenger signal we track today:
      • Opened on {date · time} (green) — the passenger’s Sanctum token has a non-null last_used_at.
      • Not opened yet (gray) — a token exists for this passenger but it has never been used.
      • (gray) — no token row exists at all.

There is intentionally no per-passenger “Sent” badge. The Sanctum token is created before the mail dispatch inside TripShareService::shareWithPassengers, so the existence of a token is not a guarantee that the mail actually went out. A proper “delivered” signal would require integrating mail-provider webhooks (Resend events). The booking-level trip_shared_at is reliable (TripShareService only stamps it when sent > 0), which is what powers the section header.

The header counts opened rows by filtering the same forBooking() result it already holds — no extra openedCount() call. Two queries still fire per render (one for the section header, one for the row table), which is acceptable for an admin page.

Source: backend/app/Services/TripShareStatusService.php, backend/app/Filament/Resources/Bookings/Schemas/BookingTripShareSection.php

EndpointLimitPurpose
POST /api/trip/verify10/minPrevent brute force
GET /api/trip/show60/minNormal usage
FilePurpose
backend/app/Services/TripAccessTokenService.phpToken management service
backend/app/Services/TripShareStatusService.phpPer-passenger access state for the admin panel
backend/app/Models/Passenger.phpUses HasApiTokens trait for Sanctum
backend/app/Http/Controllers/Api/Trip/AccessController.phpAPI endpoints
backend/app/Notifications/TripAccessNotification.phpTrip-access magic-link email notification
backend/resources/views/emails/post-booking.blade.phpDesigned Blade template rendered by the trip-access notification
backend/emails/post-booking.htmlOriginal trip-access design source (PR #1982)
backend/app/Notifications/BookingPaymentConfirmedNotification.phpPayment receipt email notification
backend/resources/views/emails/payment-confirmed.blade.phpDesigned Blade template rendered by the payment-confirmation notification
backend/emails/confirmacion-pago.htmlOriginal payment-confirmation design source (PR #1982)
backend/public/emails/transaccional/Runtime-served static assets (logos, icons, social, titulos, sello)
bookings.payment_confirmation_sent_at columnIdempotency gate for the payment-confirmation email
product_templates.mail_payment_image columnPer-tour static banner used by the payment-confirmation email
backend/app/Filament/Resources/Bookings/Pages/ViewBooking.phpAdmin action
backend/app/Filament/Resources/Bookings/Schemas/BookingTripShareSection.phpAdmin “Trip Share Status” panel
frontend/src/pages/auth/trip.astroMagic link verification page
frontend/src/pages/[market]/trip/[reference].astroTrip itinerary page
frontend/src/features/trip-access/services/tripAccessApi.tsFrontend API client
backend/routes/api.phpRoute definitions (trip prefix)
  • HMAC verification: Payload signed with app.key, prevents tampering
  • Sanctum validation: Token must exist and not be expired
  • Abilities verification: Token abilities must contain correct booking/passenger IDs
  • Activity logging: Token usage tracked via last_used_at
  • Token rotation: Re-sharing revokes previous tokens for same booking/passenger
  • Single booking: Each token grants access to one booking only
  • No modification: Read-only access to trip details
  • No cascading: Compromised token doesn’t expose other bookings
  • Isolated tokens: Tokens for different bookings are independent
  • Trip page at /{market}/trip/{reference} is not publicly accessible
  • Requires either valid preview token OR active session from magic link
  • Session tied to specific booking reference