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:
Christian Andolf
2025-03-10 09:25:18 +00:00
21 changed files with 527 additions and 133 deletions

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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",
}

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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*$/)

View File

@@ -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(),

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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 ""
}
}

View 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 ""
}
}