Merged in feat/SW-1710-access-checks-my-stay (pull request #1486)
feat(SW-1710): add access checks to my stay page for viewing booking Approved-by: Michael Zetterberg Approved-by: Chuma Mcphoy (We Ahead) Approved-by: Pontus Dreij
This commit is contained in:
@@ -10,7 +10,7 @@ export default async function MyStayPage({
|
||||
}: PageArgs<LangParams & { refId: string }>) {
|
||||
return (
|
||||
<Suspense fallback={<MyStaySkeleton />}>
|
||||
<MyStay reservationId={params.refId} />
|
||||
<MyStay refId={decodeURIComponent(params.refId)} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Input from "@/components/TempDesignSystem/Form/Input"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Title from "@/components/TempDesignSystem/Text/Title"
|
||||
|
||||
import {
|
||||
type AdditionalInfoFormSchema,
|
||||
additionalInfoFormSchema,
|
||||
} from "./schema"
|
||||
|
||||
import styles from "./findMyBooking.module.css"
|
||||
|
||||
export default function AdditionalInfoForm({
|
||||
confirmationNumber,
|
||||
lastName,
|
||||
}: {
|
||||
confirmationNumber: string
|
||||
lastName: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const intl = useIntl()
|
||||
const form = useForm<AdditionalInfoFormSchema>({
|
||||
resolver: zodResolver(additionalInfoFormSchema),
|
||||
mode: "all",
|
||||
criteriaMode: "all",
|
||||
reValidateMode: "onChange",
|
||||
})
|
||||
|
||||
function onSubmit() {
|
||||
const values = form.getValues()
|
||||
const value = new URLSearchParams({
|
||||
...values,
|
||||
confirmationNumber,
|
||||
lastName,
|
||||
}).toString()
|
||||
document.cookie = `bv=${value}; Path=/; Max-Age=30; Secure; SameSite=Strict`
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className={styles.form}>
|
||||
<div>
|
||||
<Title level="h2" as="h3">
|
||||
{intl.formatMessage({
|
||||
id: "One last step",
|
||||
})}
|
||||
</Title>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "We need some more details to confirm your identity.",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.inputs}>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "First name" })}
|
||||
name="firstName"
|
||||
placeholder="Anna"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
label={intl.formatMessage({ id: "Email" })}
|
||||
name="email"
|
||||
placeholder="anna@scandichotels.com"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button
|
||||
type="submit"
|
||||
intent="primary"
|
||||
theme="base"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{intl.formatMessage({ id: "Confirm" })}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
.form {
|
||||
box-shadow: var(--popup-box-shadow);
|
||||
padding: var(--Spacing-x3) 0;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.form > div {
|
||||
padding: var(--Spacing-x3);
|
||||
.form > div:not(.buttons) {
|
||||
padding: 0 var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.inputs {
|
||||
@@ -12,23 +15,23 @@
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.inputs {
|
||||
.grid {
|
||||
grid-template-areas:
|
||||
"a a"
|
||||
"b c"
|
||||
"d d";
|
||||
}
|
||||
|
||||
.inputs > div:nth-child(1) {
|
||||
.grid > div:nth-child(1) {
|
||||
grid-area: a;
|
||||
}
|
||||
.inputs > div:nth-child(2) {
|
||||
.grid > div:nth-child(2) {
|
||||
grid-area: b;
|
||||
}
|
||||
.inputs > div:nth-child(3) {
|
||||
.grid > div:nth-child(3) {
|
||||
grid-area: c;
|
||||
}
|
||||
.inputs > div:nth-child(4) {
|
||||
.grid > div:nth-child(4) {
|
||||
grid-area: d;
|
||||
}
|
||||
}
|
||||
@@ -38,9 +41,14 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
padding: var(--Spacing-x3) var(--Spacing-x3) 0;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.buttons > button:only-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.buttons > button {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { type FindMyBookingFormSchema, findMyBookingFormSchema } from "./schema"
|
||||
|
||||
import styles from "./findMyBooking.module.css"
|
||||
|
||||
export default function Form() {
|
||||
export default function FindMyBooking() {
|
||||
const router = useRouter()
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
@@ -53,7 +53,7 @@ export default function Form() {
|
||||
|
||||
async function onSubmit(data: FindMyBookingFormSchema) {
|
||||
update.mutate({
|
||||
bookingNumber: data.bookingNumber,
|
||||
confirmationNumber: data.confirmationNumber,
|
||||
lastName: data.lastName,
|
||||
})
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export default function Form() {
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className={styles.form}>
|
||||
<div>
|
||||
<Title level="h2">
|
||||
<Title level="h2" as="h3">
|
||||
{intl.formatMessage({ id: "Find your stay" })}
|
||||
</Title>
|
||||
<Body>
|
||||
@@ -71,27 +71,27 @@ export default function Form() {
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.inputs}>
|
||||
<div className={[styles.inputs, styles.grid].join(" ")}>
|
||||
<Input
|
||||
label="Booking number"
|
||||
name="bookingNumber"
|
||||
label={intl.formatMessage({ id: "Booking number" })}
|
||||
name="confirmationNumber"
|
||||
placeholder="XXXXXX"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
label="First name"
|
||||
label={intl.formatMessage({ id: "First name" })}
|
||||
name="firstName"
|
||||
placeholder="Anna"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
label="Last name"
|
||||
label={intl.formatMessage({ id: "Last name" })}
|
||||
name="lastName"
|
||||
placeholder="Andersson"
|
||||
registerOptions={{ required: true }}
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
label={intl.formatMessage({ id: "Email" })}
|
||||
name="email"
|
||||
placeholder="anna@scandichotels.com"
|
||||
registerOptions={{ required: true }}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { defineMessage } from "react-intl"
|
||||
import { z } from "zod"
|
||||
|
||||
export {
|
||||
type AdditionalInfoFormSchema,
|
||||
additionalInfoFormSchema,
|
||||
type FindMyBookingFormSchema,
|
||||
findMyBookingFormSchema,
|
||||
}
|
||||
|
||||
defineMessage({
|
||||
id: "Invalid booking number",
|
||||
})
|
||||
@@ -17,8 +24,15 @@ defineMessage({
|
||||
id: "Email address is required",
|
||||
})
|
||||
|
||||
export const findMyBookingFormSchema = z.object({
|
||||
bookingNumber: z
|
||||
const additionalInfoFormSchema = z.object({
|
||||
firstName: z.string().trim().max(250).min(1, {
|
||||
message: "First name is required",
|
||||
}),
|
||||
email: z.string().max(250).email({ message: "Email address is required" }),
|
||||
})
|
||||
|
||||
const findMyBookingFormSchema = additionalInfoFormSchema.extend({
|
||||
confirmationNumber: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[0-9]+(-[0-9])?$/, {
|
||||
@@ -27,14 +41,11 @@ export const findMyBookingFormSchema = z.object({
|
||||
.min(1, {
|
||||
message: "Booking number is required",
|
||||
}),
|
||||
firstName: z.string().trim().max(250).min(1, {
|
||||
message: "First name is required",
|
||||
}),
|
||||
lastName: z.string().trim().max(250).min(1, {
|
||||
message: "Last name is required",
|
||||
}),
|
||||
email: z.string().max(250).email({ message: "Email address is required" }),
|
||||
})
|
||||
|
||||
export interface FindMyBookingFormSchema
|
||||
extends z.output<typeof findMyBookingFormSchema> {}
|
||||
type AdditionalInfoFormSchema = z.output<typeof additionalInfoFormSchema>
|
||||
|
||||
type FindMyBookingFormSchema = z.output<typeof findMyBookingFormSchema>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "@jest/globals"
|
||||
|
||||
import accessBooking, {
|
||||
ACCESS_GRANTED,
|
||||
ERROR_BAD_REQUEST,
|
||||
ERROR_FORBIDDEN,
|
||||
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(
|
||||
ERROR_FORBIDDEN
|
||||
)
|
||||
})
|
||||
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",
|
||||
countryCode: "SE",
|
||||
}
|
||||
|
||||
const loggedIn: Guest = {
|
||||
email: "logged-in@scandichotels.com",
|
||||
firstName: "Authenticated",
|
||||
lastName: "Booking",
|
||||
membershipNumber: "01234567890123",
|
||||
phoneNumber: "+46701234567",
|
||||
countryCode: "SE",
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { SafeUser } from "@/types/user"
|
||||
import type { Guest } from "@/server/routers/booking/output"
|
||||
|
||||
export {
|
||||
ACCESS_GRANTED,
|
||||
accessBooking as default,
|
||||
ERROR_BAD_REQUEST,
|
||||
ERROR_FORBIDDEN,
|
||||
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) {
|
||||
if (user) {
|
||||
return ERROR_FORBIDDEN
|
||||
} else {
|
||||
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_FORBIDDEN = {
|
||||
code: "FORBIDDEN",
|
||||
status: 403,
|
||||
} 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,119 @@ 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 bv = cookies().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 === ACCESS_GRANTED) {
|
||||
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 === ERROR_BAD_REQUEST) {
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.form}>
|
||||
<AdditionalInfoForm
|
||||
confirmationNumber={confirmationNumber}
|
||||
lastName={lastName}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (access === ERROR_UNAUTHORIZED) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
"In crib": "i tremmeseng",
|
||||
"In extra bed": "i ekstra seng",
|
||||
"In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
|
||||
"In order to view your booking, please log in.": "Log ind for at se din reservation.",
|
||||
"Included": "Inkluderet",
|
||||
"Indoor pool": "Indendørs pool",
|
||||
"Indoor windows and excellent lighting": "Indoor windows and excellent lighting",
|
||||
@@ -463,6 +464,7 @@
|
||||
"OK": "OK",
|
||||
"OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER",
|
||||
"On your journey": "På din rejse",
|
||||
"One last step": "Et sidste skridt",
|
||||
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Ups! Noget gik galt under visningen af din overraskelse. Opdater siden, eller prøv igen senere. Hvis problemet fortsætter, skal du <link>kontakte supporten.</link>",
|
||||
"Open": "Åben",
|
||||
"Open for application": "Åben for ansøgning",
|
||||
@@ -710,6 +712,7 @@
|
||||
"We had an issue processing your booking. Please try again. No charges have been made.": "Vi havde et problem med at behandle din booking. Prøv venligst igen. Ingen gebyrer er blevet opkrævet.",
|
||||
"We have a special gift waiting for you!": "Vi har en speciel gave, der venter på dig!",
|
||||
"We look forward to your visit!": "Vi ser frem til dit besøg!",
|
||||
"We need some more details to confirm your identity.": "Vi har brug for nogle flere detaljer for at bekræfte din identitet.",
|
||||
"We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.": "We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.",
|
||||
"We successfully connected your accounts!": "We successfully connected your accounts!",
|
||||
"We're sorry": "Vi beklager",
|
||||
|
||||
@@ -326,6 +326,7 @@
|
||||
"In crib": "im Kinderbett",
|
||||
"In extra bed": "im zusätzlichen Bett",
|
||||
"In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
|
||||
"In order to view your booking, please log in.": "Um Ihre Buchung einzusehen, loggen Sie sich bitte ein.",
|
||||
"Included": "Iinklusive",
|
||||
"Indoor pool": "Innenpool",
|
||||
"Indoor windows and excellent lighting": "Indoor windows and excellent lighting",
|
||||
@@ -464,6 +465,7 @@
|
||||
"OK": "OK",
|
||||
"OTHER PAYMENT METHODS": "ANDERE BEZAHLMETHODE",
|
||||
"On your journey": "Auf deiner Reise",
|
||||
"One last step": "Ein letzter Schritt",
|
||||
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Ups! Beim Anzeigen Ihrer Überraschung ist ein Fehler aufgetreten. Bitte aktualisieren Sie die Seite oder versuchen Sie es später erneut. Wenn das Problem weiterhin besteht, <link>kontaktieren Sie den Support.</link>",
|
||||
"Open": "Offen",
|
||||
"Open for application": "Offen für Bewerbungen",
|
||||
@@ -708,6 +710,7 @@
|
||||
"We had an issue processing your booking. Please try again. No charges have been made.": "Wir hatten ein Problem beim Verarbeiten Ihrer Buchung. Bitte versuchen Sie es erneut. Es wurden keine Gebühren erhoben.",
|
||||
"We have a special gift waiting for you!": "Wir haben ein besonderes Geschenk für Sie!",
|
||||
"We look forward to your visit!": "Wir freuen uns auf Ihren Besuch!",
|
||||
"We need some more details to confirm your identity.": "Zur Bestätigung Ihrer Identität benötigen wir noch einige weitere Angaben.",
|
||||
"We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.": "We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.",
|
||||
"We successfully connected your accounts!": "We successfully connected your accounts!",
|
||||
"We're sorry": "Es tut uns leid",
|
||||
|
||||
@@ -331,6 +331,7 @@
|
||||
"In crib": "In crib",
|
||||
"In extra bed": "In extra bed",
|
||||
"In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
|
||||
"In order to view your booking, please log in.": "In order to view your booking, please log in.",
|
||||
"Included": "Included",
|
||||
"Indoor pool": "Indoor pool",
|
||||
"Indoor windows and excellent lighting": "Indoor windows and excellent lighting",
|
||||
@@ -470,6 +471,7 @@
|
||||
"OK": "OK",
|
||||
"OTHER PAYMENT METHODS": "OTHER PAYMENT METHODS",
|
||||
"On your journey": "On your journey",
|
||||
"One last step": "One last step",
|
||||
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>",
|
||||
"Open": "Open",
|
||||
"Open for application": "Open for application",
|
||||
@@ -716,6 +718,7 @@
|
||||
"We had an issue processing your booking. Please try again. No charges have been made.": "We had an issue processing your booking. Please try again. No charges have been made.",
|
||||
"We have a special gift waiting for you!": "We have a special gift waiting for you!",
|
||||
"We look forward to your visit!": "We look forward to your visit!",
|
||||
"We need some more details to confirm your identity.": "We need some more details to confirm your identity.",
|
||||
"We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.": "We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.",
|
||||
"We successfully connected your accounts!": "We successfully connected your accounts!",
|
||||
"We're sorry": "We're sorry",
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
"In crib": "Pinnasängyssä",
|
||||
"In extra bed": "Oma vuodepaikka",
|
||||
"In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
|
||||
"In order to view your booking, please log in.": "Nähdäksesi varauksesi, ole hyvä ja kirjaudu sisään.",
|
||||
"Included": "Sisälly hintaan",
|
||||
"Indoor pool": "Sisäuima-allas",
|
||||
"Indoor windows and excellent lighting": "Indoor windows and excellent lighting",
|
||||
@@ -463,6 +464,7 @@
|
||||
"OK": "OK",
|
||||
"OTHER PAYMENT METHODS": "MUISE KORT",
|
||||
"On your journey": "Matkallasi",
|
||||
"One last step": "Viimeinen askel",
|
||||
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Hups! Jotain meni pieleen yllätyksesi näyttämisessä. Päivitä sivu tai yritä myöhemmin uudelleen. Jos ongelma jatkuu, <link>ota yhteyttä tukeen.</link>",
|
||||
"Open": "Avata",
|
||||
"Open for application": "Avoinna hakemuksille",
|
||||
@@ -708,6 +710,7 @@
|
||||
"We had an issue processing your booking. Please try again. No charges have been made.": "Meillä oli ongelma varauksen käsittelyssä. Yritä uudelleen. Ei maksuja on tehty.",
|
||||
"We have a special gift waiting for you!": "Meillä on erityinen lahja odottamassa sinua!",
|
||||
"We look forward to your visit!": "Odotamme innolla vierailuasi!",
|
||||
"We need some more details to confirm your identity.": "Tarvitsemme lisätietoja henkilöllisyytesi vahvistamiseksi.",
|
||||
"We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.": "We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.",
|
||||
"We successfully connected your accounts!": "We successfully connected your accounts!",
|
||||
"We're sorry": "Olemme pahoillamme",
|
||||
|
||||
@@ -324,6 +324,7 @@
|
||||
"In crib": "i sprinkelseng",
|
||||
"In extra bed": "i ekstraseng",
|
||||
"In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
|
||||
"In order to view your booking, please log in.": "For å se bestillingen din, vennligst logg inn.",
|
||||
"Included": "Inkludert",
|
||||
"Indoor pool": "Innendørs basseng",
|
||||
"Indoor windows and excellent lighting": "Indoor windows and excellent lighting",
|
||||
@@ -462,6 +463,7 @@
|
||||
"OK": "OK",
|
||||
"OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER",
|
||||
"On your journey": "På reisen din",
|
||||
"One last step": "Et siste skritt",
|
||||
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Beklager! Noe gikk galt under visningen av overraskelsen din. Oppdater siden eller prøv igjen senere. Hvis problemet vedvarer, <link>kontakt brukerstøtten.</link>",
|
||||
"Open": "Åpen",
|
||||
"Open for application": "Åpen for søknad",
|
||||
@@ -706,6 +708,7 @@
|
||||
"We had an issue processing your booking. Please try again. No charges have been made.": "Vi hadde et problem med å behandle din bestilling. Vær så snill å prøv igjen. Ingen gebyrer er blevet belastet.",
|
||||
"We have a special gift waiting for you!": "Vi har en spesiell gave som venter på deg!",
|
||||
"We look forward to your visit!": "Vi ser frem til ditt besøk!",
|
||||
"We need some more details to confirm your identity.": "Vi trenger noen flere detaljer for å bekrefte identiteten din.",
|
||||
"We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.": "We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.",
|
||||
"We successfully connected your accounts!": "We successfully connected your accounts!",
|
||||
"We're sorry": "Vi beklager",
|
||||
|
||||
@@ -324,6 +324,7 @@
|
||||
"In crib": "I spjälsäng",
|
||||
"In extra bed": "Egen sängplats",
|
||||
"In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.": "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
|
||||
"In order to view your booking, please log in.": "För att se din bokning, vänligen logga in.",
|
||||
"Included": "Inkluderad",
|
||||
"Indoor pool": "Inomhuspool",
|
||||
"Indoor windows and excellent lighting": "Fönster inomhus och utmärkt belysning",
|
||||
@@ -462,6 +463,7 @@
|
||||
"OK": "OK",
|
||||
"OTHER PAYMENT METHODS": "ANDRE BETALINGSMETODER",
|
||||
"On your journey": "På din resa",
|
||||
"One last step": "Ett sista steg",
|
||||
"Oops! Something went wrong while showing your surprise. Please refresh the page or try again later. If the issue persists, <link>contact the support.</link>": "Hoppsan! Något gick fel när din överraskning visades. Uppdatera sidan eller försök igen senare. Om problemet kvarstår, <link>kontakta supporten.</link>",
|
||||
"Open": "Öppna",
|
||||
"Open for application": "Öppen för ansökan",
|
||||
@@ -706,6 +708,7 @@
|
||||
"We had an issue processing your booking. Please try again. No charges have been made.": "Vi hade ett problem med att bearbeta din bokning. Vänligen försök igen. Inga avgifter har debiterats.",
|
||||
"We have a special gift waiting for you!": "Vi har en speciell present som väntar på dig!",
|
||||
"We look forward to your visit!": "Vi ser fram emot ditt besök!",
|
||||
"We need some more details to confirm your identity.": "Vi behöver lite mer information för att bekräfta din identitet.",
|
||||
"We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.": "We require your birth date in order to link your Scandic account with your SAS EuroBonus account. Please check that it is correct.",
|
||||
"We successfully connected your accounts!": "We successfully connected your accounts!",
|
||||
"We're sorry": "Vi beklagar",
|
||||
|
||||
@@ -112,7 +112,7 @@ export const cancelBookingInput = z.object({
|
||||
})
|
||||
|
||||
export const createRefIdInput = z.object({
|
||||
bookingNumber: z
|
||||
confirmationNumber: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^\s*[0-9]+(-[0-9])?\s*$/)
|
||||
|
||||
@@ -80,6 +80,8 @@ const guestSchema = z.object({
|
||||
countryCode: z.string().nullable().default(""),
|
||||
})
|
||||
|
||||
export type Guest = z.output<typeof guestSchema>
|
||||
|
||||
export const packageSchema = z
|
||||
.object({
|
||||
type: z.string().nullable(),
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/server/trpc"
|
||||
|
||||
import { getHotel } from "../hotels/query"
|
||||
import encryptValue from "../utils/encryptValue"
|
||||
import { encrypt } from "../utils/encryption"
|
||||
import {
|
||||
bookingConfirmationInput,
|
||||
createRefIdInput,
|
||||
@@ -241,8 +241,8 @@ export const bookingQueryRouter = router({
|
||||
createRefId: serviceProcedure
|
||||
.input(createRefIdInput)
|
||||
.mutation(async function ({ input }) {
|
||||
const { bookingNumber, lastName } = input
|
||||
const encryptedRefId = encryptValue(`${bookingNumber},${lastName}`)
|
||||
const { confirmationNumber, lastName } = input
|
||||
const encryptedRefId = encrypt(`${confirmationNumber},${lastName}`)
|
||||
|
||||
if (!encryptedRefId) {
|
||||
throw serverErrorByStatus(422, "Was not able to encrypt ref id")
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Lang } from "@/constants/languages"
|
||||
import { env } from "@/env/server"
|
||||
import * as api from "@/lib/api"
|
||||
|
||||
import encryptValue from "../utils/encryptValue"
|
||||
import { encrypt } from "../utils/encryption"
|
||||
|
||||
import type { FriendTransaction, Stay } from "./output"
|
||||
|
||||
@@ -93,7 +93,7 @@ async function updateStaysBookingUrl(
|
||||
d.attributes.confirmationNumber.toString() +
|
||||
"," +
|
||||
apiJson.data.attributes.lastName
|
||||
const encryptedBookingValue = encryptValue(originalString)
|
||||
const encryptedBookingValue = encrypt(originalString)
|
||||
if (!!encryptedBookingValue) {
|
||||
bookingUrl.searchParams.set("RefId", encryptedBookingValue)
|
||||
} else {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
export default function encryptValue(originalString: string) {
|
||||
try {
|
||||
const encryptionKey = env.BOOKING_ENCRYPTION_KEY
|
||||
const bufferKey = Buffer.from(encryptionKey, "utf8")
|
||||
const cipher = crypto.createCipheriv("DES-ECB", bufferKey, null)
|
||||
cipher.setAutoPadding(false)
|
||||
const bufferString = Buffer.from(originalString, "utf8")
|
||||
const paddingSize =
|
||||
bufferKey.length - (bufferString.length % bufferKey.length)
|
||||
const paddedStr = Buffer.concat([
|
||||
bufferString,
|
||||
Buffer.alloc(paddingSize, 0),
|
||||
])
|
||||
const buffers: Buffer[] = []
|
||||
buffers.push(cipher.update(paddedStr))
|
||||
buffers.push(cipher.final())
|
||||
const result = Buffer.concat(buffers).toString("base64").replace(/\+/g, "-")
|
||||
return result
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
54
apps/scandic-web/server/routers/utils/encryption.ts
Normal file
54
apps/scandic-web/server/routers/utils/encryption.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import "server-only"
|
||||
|
||||
import crypto from "crypto"
|
||||
|
||||
import { env } from "@/env/server"
|
||||
|
||||
export { decrypt, encrypt }
|
||||
|
||||
const algorithm = "DES-ECB"
|
||||
const encryptionKey = env.BOOKING_ENCRYPTION_KEY
|
||||
const bufferKey = Buffer.from(encryptionKey, "utf8")
|
||||
|
||||
function encrypt(originalString: string) {
|
||||
try {
|
||||
const cipher = crypto.createCipheriv(algorithm, bufferKey, null)
|
||||
cipher.setAutoPadding(false)
|
||||
const bufferString = Buffer.from(originalString, "utf8")
|
||||
const paddingSize =
|
||||
bufferKey.length - (bufferString.length % bufferKey.length)
|
||||
const paddedStr = Buffer.concat([
|
||||
bufferString,
|
||||
Buffer.alloc(paddingSize, 0),
|
||||
])
|
||||
const buffers: Buffer[] = []
|
||||
buffers.push(cipher.update(paddedStr))
|
||||
buffers.push(cipher.final())
|
||||
const result = Buffer.concat(buffers).toString("base64").replace(/\+/g, "-")
|
||||
return result
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(encryptedString: string) {
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(algorithm, bufferKey, null)
|
||||
decipher.setAutoPadding(false)
|
||||
const buffers: Buffer[] = []
|
||||
buffers.push(decipher.update(encryptedString, "base64"))
|
||||
buffers.push(decipher.final())
|
||||
const result = Buffer.concat(buffers)
|
||||
.toString("utf8")
|
||||
/*
|
||||
* Hexadecimal byte (null byte) replace. These occur when decrypting because
|
||||
* we're disabling the auto padding for historical/compatibility reasons.
|
||||
*/
|
||||
.replace(/(\x00)*/g, "")
|
||||
return result
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user