feat(SW-1710): add access checks to my stay page for viewing booking
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "@jest/globals"
|
||||
|
||||
import accessBooking, {
|
||||
ACCESS_GRANTED,
|
||||
ERROR_BAD_REQUEST,
|
||||
ERROR_NOT_FOUND,
|
||||
ERROR_UNAUTHORIZED,
|
||||
} from "./accessBooking"
|
||||
|
||||
import type { SafeUser } from "@/types/user"
|
||||
import type { Guest } from "@/server/routers/booking/output"
|
||||
|
||||
describe("Access booking", () => {
|
||||
describe("for logged in booking", () => {
|
||||
it("should enable access if all is provided", () => {
|
||||
expect(accessBooking(loggedIn, "Booking", user)).toBe(ACCESS_GRANTED)
|
||||
})
|
||||
it("should prompt to login", () => {
|
||||
expect(accessBooking(loggedIn, "Booking", null)).toBe(ERROR_UNAUTHORIZED)
|
||||
})
|
||||
it("should deny access", () => {
|
||||
expect(accessBooking(loggedIn, "NotBooking", user)).toBe(ERROR_NOT_FOUND)
|
||||
})
|
||||
})
|
||||
describe("for anonymous booking", () => {
|
||||
it("should enable access if all is provided", () => {
|
||||
const cookieString = new URLSearchParams({
|
||||
confirmationNumber: "123456789",
|
||||
firstName: "Anonymous",
|
||||
lastName: "Booking",
|
||||
email: "logged-out@scandichotels.com",
|
||||
}).toString()
|
||||
expect(accessBooking(loggedOut, "Booking", null, cookieString)).toBe(
|
||||
ACCESS_GRANTED
|
||||
)
|
||||
})
|
||||
it("should prompt logout if user is logged in", () => {
|
||||
const cookieString = new URLSearchParams({
|
||||
confirmationNumber: "123456789",
|
||||
firstName: "Anonymous",
|
||||
lastName: "Booking",
|
||||
email: "logged-out@scandichotels.com",
|
||||
}).toString()
|
||||
expect(accessBooking(loggedOut, "Booking", user, cookieString)).toBe(
|
||||
ACCESS_GRANTED
|
||||
)
|
||||
})
|
||||
it("should prompt for more if first name is missing", () => {
|
||||
const cookieString = new URLSearchParams({
|
||||
confirmationNumber: "123456789",
|
||||
lastName: "Booking",
|
||||
email: "logged-out@scandichotels.com",
|
||||
}).toString()
|
||||
expect(accessBooking(loggedOut, "Booking", null, cookieString)).toBe(
|
||||
ERROR_BAD_REQUEST
|
||||
)
|
||||
})
|
||||
it("should prompt for more if email is missing", () => {
|
||||
const cookieString = new URLSearchParams({
|
||||
confirmationNumber: "123456789",
|
||||
firstName: "Anonymous",
|
||||
lastName: "Booking",
|
||||
}).toString()
|
||||
expect(accessBooking(loggedOut, "Booking", null, cookieString)).toBe(
|
||||
ERROR_BAD_REQUEST
|
||||
)
|
||||
})
|
||||
it("should prompt for more if cookie is invalid", () => {
|
||||
const cookieString = new URLSearchParams({}).toString()
|
||||
expect(accessBooking(loggedOut, "Booking", null, cookieString)).toBe(
|
||||
ERROR_BAD_REQUEST
|
||||
)
|
||||
})
|
||||
it("should deny access", () => {
|
||||
expect(accessBooking(loggedOut, "NotBooking", null)).toBe(ERROR_NOT_FOUND)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const user: SafeUser = {
|
||||
address: {
|
||||
city: undefined,
|
||||
country: "Sweden",
|
||||
countryCode: "SE",
|
||||
streetAddress: undefined,
|
||||
zipCode: undefined,
|
||||
},
|
||||
dateOfBirth: "",
|
||||
email: "",
|
||||
firstName: "",
|
||||
language: undefined,
|
||||
lastName: "",
|
||||
membership: undefined,
|
||||
memberships: [],
|
||||
name: "",
|
||||
phoneNumber: undefined,
|
||||
profileId: "",
|
||||
}
|
||||
|
||||
const loggedOut: Guest = {
|
||||
email: "logged-out@scandichotels.com",
|
||||
firstName: "Anonymous",
|
||||
lastName: "Booking",
|
||||
membershipNumber: null,
|
||||
phoneNumber: "+46701234567",
|
||||
}
|
||||
|
||||
const loggedIn: Guest = {
|
||||
email: "logged-in@scandichotels.com",
|
||||
firstName: "Authenticated",
|
||||
lastName: "Booking",
|
||||
membershipNumber: "01234567890123",
|
||||
phoneNumber: "+46701234567",
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { SafeUser } from "@/types/user"
|
||||
import type { Guest } from "@/server/routers/booking/output"
|
||||
|
||||
export {
|
||||
ACCESS_GRANTED,
|
||||
accessBooking as default,
|
||||
ERROR_BAD_REQUEST,
|
||||
ERROR_NOT_FOUND,
|
||||
ERROR_UNAUTHORIZED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request can access a confirmed booking or not.
|
||||
*/
|
||||
function accessBooking(
|
||||
guest: Guest,
|
||||
lastName: string,
|
||||
user: SafeUser | null,
|
||||
cookie: string = ""
|
||||
) {
|
||||
if (guest.membershipNumber) {
|
||||
if (user) {
|
||||
if (lastName === guest.lastName) {
|
||||
return ACCESS_GRANTED
|
||||
}
|
||||
} else {
|
||||
return ERROR_UNAUTHORIZED
|
||||
}
|
||||
}
|
||||
|
||||
if (guest.lastName === lastName) {
|
||||
const params = new URLSearchParams(cookie)
|
||||
if (
|
||||
params.get("firstName") === guest.firstName &&
|
||||
params.get("email") === guest.email
|
||||
) {
|
||||
return ACCESS_GRANTED
|
||||
} else {
|
||||
return ERROR_BAD_REQUEST
|
||||
}
|
||||
}
|
||||
|
||||
return ERROR_NOT_FOUND
|
||||
}
|
||||
|
||||
const ERROR_BAD_REQUEST = {
|
||||
code: "BAD_REQUEST",
|
||||
status: 400,
|
||||
} as const
|
||||
|
||||
const ERROR_UNAUTHORIZED = {
|
||||
code: "UNAUTHORIZED",
|
||||
status: 401,
|
||||
} as const
|
||||
|
||||
const ERROR_NOT_FOUND = {
|
||||
code: "NOT_FOUND",
|
||||
status: 404,
|
||||
} as const
|
||||
|
||||
const ACCESS_GRANTED = {
|
||||
code: "ACCESS_GRANTED",
|
||||
status: 200,
|
||||
} as const
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { notFound } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
@@ -9,12 +10,20 @@ import {
|
||||
getBookingConfirmation,
|
||||
getProfileSafely,
|
||||
} from "@/lib/trpc/memoizedRequests"
|
||||
import { decrypt } from "@/server/routers/utils/encryption"
|
||||
|
||||
import Image from "@/components/Image"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import { getIntl } from "@/i18n"
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import AdditionalInfoForm from "../FindMyBooking/AdditionalInfoForm"
|
||||
import LinkedReservationSkeleton from "./LinkedReservation/LinkedReservationSkeleton"
|
||||
import accessBooking, {
|
||||
ACCESS_GRANTED,
|
||||
ERROR_BAD_REQUEST,
|
||||
ERROR_UNAUTHORIZED,
|
||||
} from "./accessBooking"
|
||||
import { Ancillaries } from "./Ancillaries"
|
||||
import BookingSummary from "./BookingSummary"
|
||||
import { Header } from "./Header"
|
||||
@@ -25,83 +34,120 @@ import { Room } from "./Room"
|
||||
|
||||
import styles from "./myStay.module.css"
|
||||
|
||||
export async function MyStay({ reservationId }: { reservationId: string }) {
|
||||
const bookingConfirmation = await getBookingConfirmation(reservationId)
|
||||
export async function MyStay({ refId }: { refId: string }) {
|
||||
const value = decrypt(refId)
|
||||
if (!value) {
|
||||
return notFound()
|
||||
}
|
||||
const [confirmationNumber, lastName] = value.split(",")
|
||||
const bookingConfirmation = await getBookingConfirmation(confirmationNumber)
|
||||
if (!bookingConfirmation) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const { booking, hotel, room } = bookingConfirmation
|
||||
|
||||
const linkedBookingPromises = booking.linkedReservations
|
||||
? booking.linkedReservations.map((linkedBooking) => {
|
||||
return getBookingConfirmation(linkedBooking.confirmationNumber)
|
||||
})
|
||||
: []
|
||||
|
||||
const userResponse = await getProfileSafely()
|
||||
const user = userResponse && !("error" in userResponse) ? userResponse : null
|
||||
const user = await getProfileSafely()
|
||||
const cookie = cookies()
|
||||
const bv = cookie.get("bv")?.value
|
||||
const intl = await getIntl()
|
||||
const lang = getLang()
|
||||
const homeUrl = homeHrefs[env.NODE_ENV][lang]
|
||||
const fromDate = dt(booking.checkInDate).format("YYYY-MM-DD")
|
||||
const toDate = dt(booking.checkOutDate).format("YYYY-MM-DD")
|
||||
const hotelId = hotel.operaId
|
||||
const ancillaryInput = { fromDate, hotelId, toDate }
|
||||
const ancillaryPackages = await getAncillaryPackages(ancillaryInput)
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.blurOverlay} />
|
||||
const access = accessBooking(booking.guest, lastName, user, bv)
|
||||
if (access.status === ACCESS_GRANTED.status) {
|
||||
const linkedBookingPromises = booking.linkedReservations
|
||||
? booking.linkedReservations.map((linkedBooking) => {
|
||||
return getBookingConfirmation(linkedBooking.confirmationNumber)
|
||||
})
|
||||
: []
|
||||
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={
|
||||
hotel.gallery?.heroImages[0]?.imageSizes.large ??
|
||||
hotel.galleryImages[0]?.imageSizes.large ??
|
||||
""
|
||||
}
|
||||
alt={hotel.name}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.headerContainer}>
|
||||
<Header hotel={hotel} />
|
||||
<ReferenceCard booking={booking} hotel={hotel} />
|
||||
</div>
|
||||
{booking.showAncillaries && (
|
||||
<Ancillaries
|
||||
ancillaries={ancillaryPackages}
|
||||
booking={booking}
|
||||
user={user}
|
||||
const lang = getLang()
|
||||
const ancillaryPackages = await getAncillaryPackages({
|
||||
fromDate: dt(booking.checkInDate).format("YYYY-MM-DD"),
|
||||
hotelId: hotel.operaId,
|
||||
toDate: dt(booking.checkOutDate).format("YYYY-MM-DD"),
|
||||
})
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.blurOverlay} />
|
||||
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={
|
||||
hotel.gallery?.heroImages[0]?.imageSizes.large ??
|
||||
hotel.galleryImages[0]?.imageSizes.large ??
|
||||
""
|
||||
}
|
||||
alt={hotel.name}
|
||||
fill
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Room booking={booking} room={room} hotel={hotel} user={user} />
|
||||
{booking.linkedReservations.map((linkedRes, index) => (
|
||||
<Suspense
|
||||
key={linkedRes.confirmationNumber}
|
||||
fallback={<LinkedReservationSkeleton />}
|
||||
>
|
||||
<LinkedReservation
|
||||
bookingPromise={linkedBookingPromises[index]}
|
||||
index={index}
|
||||
/>
|
||||
</Suspense>
|
||||
))}
|
||||
</div>
|
||||
<BookingSummary booking={booking} hotel={hotel} room={room} />
|
||||
<Promo
|
||||
buttonText={intl.formatMessage({ id: "Book another stay" })}
|
||||
href={`${homeUrl}?hotel=${hotel.operaId}`}
|
||||
text={intl.formatMessage({
|
||||
id: "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "Book your next stay" })}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
<div className={styles.content}>
|
||||
<div className={styles.headerContainer}>
|
||||
<Header hotel={hotel} />
|
||||
<ReferenceCard booking={booking} hotel={hotel} />
|
||||
</div>
|
||||
{booking.showAncillaries && (
|
||||
<Ancillaries
|
||||
ancillaries={ancillaryPackages}
|
||||
booking={booking}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Room booking={booking} room={room} hotel={hotel} user={user} />
|
||||
{booking.linkedReservations.map((linkedRes, index) => (
|
||||
<Suspense
|
||||
key={linkedRes.confirmationNumber}
|
||||
fallback={<LinkedReservationSkeleton />}
|
||||
>
|
||||
<LinkedReservation
|
||||
bookingPromise={linkedBookingPromises[index]}
|
||||
index={index}
|
||||
/>
|
||||
</Suspense>
|
||||
))}
|
||||
</div>
|
||||
<BookingSummary booking={booking} hotel={hotel} room={room} />
|
||||
<Promo
|
||||
buttonText={intl.formatMessage({ id: "Book another stay" })}
|
||||
href={`${homeHrefs[env.NODE_ENV][lang]}?hotel=${hotel.operaId}`}
|
||||
text={intl.formatMessage({
|
||||
id: "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
|
||||
})}
|
||||
title={intl.formatMessage({ id: "Book your next stay" })}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (access.status === ERROR_BAD_REQUEST.status) {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.form}>
|
||||
<AdditionalInfoForm
|
||||
confirmationNumber={confirmationNumber}
|
||||
lastName={lastName}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (access.status === ERROR_UNAUTHORIZED.status) {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.logIn}>
|
||||
<Body textAlign="center">
|
||||
{intl.formatMessage({
|
||||
id: "In order to view your booking, please log in.",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return notFound()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.main {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
@@ -52,11 +51,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
width: var(--max-width-content);
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
.form {
|
||||
max-width: 640px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: var(--Spacing-x5) 0;
|
||||
}
|
||||
|
||||
.headerSkeleton {
|
||||
@@ -103,3 +102,7 @@
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.logIn {
|
||||
padding: var(--Spacing-x5) var(--Spacing-x2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user