Merged in feature/sas-login (pull request #1256)

First steps towards the SAS partnership

* otp flow now pretends to do the linking

* Update LinkAccountForm header

* Update redirect times

* Clean up comments

* Set maxAge on sas cookies

* make all SAS routes protected

* Merge remote-tracking branch 'refs/remotes/origin/feature/sas-login' into feature/sas-login

* Require auth for sas link flow

* Fix resend otp

* Add error support to OneTimePasswordForm

* Add Sentry to SAS error boundary

* Move SAS_REQUEST_OTP_STATE_STORAGE_COOKIE_NAME

* Add missing translations

* Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/sas-login

* Merge branch 'feature/sas-login' of bitbucket.org:scandic-swap/web into feature/sas-login

* Add TooManyCodesError component

* Refactor GenericError to support new errors

* Add FailedAttemptsError

* remove removed component <VWOScript/>

* Merge branch 'feature/sas-login' of bitbucket.org:scandic-swap/web into feature/sas-login

* remove local cookie-bot reference

* Fix sas campaign logo scaling

* feature toggle the SAS stuff

* Merge branch 'feature/sas-login' of bitbucket.org:scandic-swap/web into feature/sas-login

* fix: use env vars for SAS endpoints


Approved-by: Linus Flood
This commit is contained in:
Joakim Jäderberg
2025-02-05 14:43:14 +00:00
parent e3b1bfc414
commit 46ebbbba8f
62 changed files with 2606 additions and 89 deletions

View File

@@ -1,71 +1,3 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { ProtectedLayout } from "@/components/ProtectedLayout"
import { overview } from "@/constants/routes/myPages"
import { getProfile } from "@/lib/trpc/memoizedRequests"
import { auth } from "@/auth"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
export default async function ProtectedLayout({
children,
}: React.PropsWithChildren) {
const intl = await getIntl()
const session = await auth()
/**
* Fallback to make sure every route nested in the
* protected route group is actually protected.
*/
const h = headers()
const redirectTo = encodeURIComponent(
h.get("x-url") ?? h.get("x-pathname") ?? overview[getLang()]
)
const redirectURL = `/${getLang()}/login?redirectTo=${redirectTo}`
if (!session) {
console.log(`[layout:protected] no session, redirecting to: ${redirectURL}`)
redirect(redirectURL)
}
const user = await getProfile()
if (user && "error" in user) {
// redirect(redirectURL)
console.error("[layout:protected] error in user", user)
console.error(
"[layout:protected] full user: ",
JSON.stringify(user, null, 4)
)
switch (user.cause) {
case "unauthorized": // fall through
case "forbidden": // fall through
case "token_expired":
console.error(
`[layout:protected] user error, redirecting to: ${redirectURL}`
)
redirect(redirectURL)
case "notfound":
console.error(`[layout:protected] notfound user loading error`)
break
case "unknown":
console.error(`[layout:protected] unknown user loading error`)
break
default:
console.error(`[layout:protected] unhandled user loading error`)
break
}
return <p>{intl.formatMessage({ id: "Something went wrong!" })}</p>
}
if (!user) {
console.error(
"[layout:protected] no user found, redirecting to: ",
redirectURL
)
redirect(redirectURL)
}
return children
}
export default ProtectedLayout

View File

@@ -0,0 +1,3 @@
import { ProtectedLayout } from "@/components/ProtectedLayout"
export default ProtectedLayout

View File

@@ -0,0 +1,105 @@
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { z } from "zod"
import { env } from "@/env/server"
import { serverClient } from "@/lib/trpc/server"
import { safeTry } from "@/utils/safeTry"
import { SAS_TOKEN_STORAGE_KEY, stateSchema } from "../sasUtils"
import type { NextRequest } from "next/server"
const searchParamsSchema = z.object({
code: z.string(),
state: z.string(),
})
const tokenResponseSchema = z.object({
access_token: z.string(),
expires_in: z.number(),
token_type: z.literal("Bearer"),
})
export async function GET(
request: NextRequest,
{ params }: { params: { lang: string } }
) {
const { lang } = params
const result = searchParamsSchema.safeParse({
code: request.nextUrl.searchParams.get("code"),
state: request.nextUrl.searchParams.get("state"),
})
if (!result.success) {
console.error("[SAS] Invalid search params", result.error)
redirect(`/${lang}/sas-x-scandic/error?errorCode=invalid_query`)
}
const { code, state } = result.data
const tokenResponse = await fetch(
new URL("oauth/token", env.SAS_AUTH_ENDPOINT),
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: new URL(
`/${lang}/sas-x-scandic/callback`,
new URL(env.PUBLIC_URL)
).toString(),
client_id: env.SAS_AUTH_CLIENTID,
}),
}
)
if (!tokenResponse.ok) {
const error = await tokenResponse.text()
console.error("[SAS] Failed to get token", error)
redirect(`/${lang}/sas-x-scandic/error?errorCode=token_error`)
}
const tokenData = tokenResponseSchema.parse(await tokenResponse.json())
const stateResult = stateSchema.safeParse(
JSON.parse(decodeURIComponent(state))
)
if (!stateResult.success) {
redirect(`/${lang}/sas-x-scandic/error?errorCode=invalid_state`)
}
const cookieStore = cookies()
cookieStore.set(SAS_TOKEN_STORAGE_KEY, tokenData.access_token, {
maxAge: 3600,
httpOnly: true,
})
if (stateResult.data.intent === "link") {
const [data, error] = await safeTry(
serverClient().partner.sas.requestOtp({})
)
// status: 'SENT' => OK
if (!data || error) {
//TODO: Check what error we get
console.error("[SAS] Failed to request OTP", error)
redirect(`/${lang}/sas-x-scandic/error`)
}
console.log("[SAS] Request OTP response", data)
const otpUrl = new URL(
`/${lang}/sas-x-scandic/otp`,
new URL(env.PUBLIC_URL)
)
otpUrl.searchParams.set("intent", stateResult.data.intent)
otpUrl.searchParams.set("to", data.otpReceiver)
redirect(otpUrl.toString())
}
redirect(`/${lang}/sas-x-scandic/error?errorCode=unknown_intent`)
}

View File

@@ -0,0 +1,35 @@
"use client"
import Link from "next/link"
import { useIntl } from "react-intl"
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { SASModal, SASModalContactBlock, SASModalDivider } from "./SASModal"
export function AlreadyLinkedError() {
const intl = useIntl()
return (
<SASModal>
<ErrorCircleFilledIcon height={64} width={64} color="red" />
<Title as="h2" level="h1" textAlign="center" textTransform="regular">
{intl.formatMessage({ id: "Accounts are already linked" })}
</Title>
<Body textAlign="center">
{/* TODO copy */}
{intl.formatMessage({
id: "We could not connect your accounts to give you access. Please contact us and well help you resolve this issue.",
})}
</Body>
{/* TODO link to SASxScandic page on My Pages */}
<Button theme="base" asChild>
<Link href="#">{intl.formatMessage({ id: "View your account" })}</Link>
</Button>
<SASModalDivider />
<SASModalContactBlock />
</SASModal>
)
}

View File

@@ -0,0 +1,34 @@
"use client"
import { useIntl } from "react-intl"
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { SASModal, SASModalContactBlock, SASModalDivider } from "./SASModal"
export function DateOfBirthError() {
const intl = useIntl()
return (
<SASModal>
<ErrorCircleFilledIcon height={64} width={64} color="red" />
<Title as="h2" level="h1" textAlign="center" textTransform="regular">
{intl.formatMessage({ id: "Date of birth not matching" })}
</Title>
<Body textAlign="center">
{/* TODO copy */}
{intl.formatMessage({
id: "We could not connect your accounts to give you access. Please contact us and well help you resolve this issue.",
})}
</Body>
{/* TODO link to where? */}
<Button theme="base">
{intl.formatMessage({ id: "View your account" })}
</Button>
<SASModalDivider />
<SASModalContactBlock />
</SASModal>
)
}

View File

@@ -0,0 +1,28 @@
"use client"
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import { GenericError } from "./GenericError"
export function FailedAttemptsError() {
const intl = useIntl()
return (
<GenericError
title={intl.formatMessage({ id: "Too many failed attempts" })}
variant="info"
>
<Body textAlign="center">
{intl.formatMessage({
id: "Please wait 1 hour before trying again.",
})}
</Body>
<Button theme="base" disabled>
{intl.formatMessage({ id: "Send new code" })}
</Button>
</GenericError>
)
}

View File

@@ -0,0 +1,39 @@
"use client"
import Image from "next/image"
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
import Title from "@/components/TempDesignSystem/Text/Title"
import { SASModal } from "./SASModal"
import type { ReactNode } from "react"
export function GenericError({
title,
variant = "error",
children,
}: {
title: ReactNode
variant?: "error" | "info"
children: ReactNode
}) {
return (
<SASModal>
{variant === "error" ? (
<ErrorCircleFilledIcon height={64} width={64} color="red" />
) : (
<Image
src="/_static/img/scandic-loyalty-time.svg"
alt=""
width={140}
height={110}
style={{ marginTop: 16 }}
/>
)}
<Title as="h3" level="h1" textAlign="center" textTransform="regular">
{title}
</Title>
{children}
</SASModal>
)
}

View File

@@ -0,0 +1,45 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x3);
background-color: white;
width: 100%;
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x4);
text-align: center;
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
margin-top: auto;
@media screen and (min-width: 768px) {
& {
border-radius: var(--Corner-radius-Medium);
margin-top: initial;
width: 560px;
}
}
}
.divider {
width: 100%;
position: relative;
& > span {
position: relative;
padding: 0 var(--Spacing-x2);
background-color: white;
}
&::before {
position: absolute;
bottom: calc(50% - 1px);
content: "";
display: block;
height: 1px;
width: 100%;
background-color: var(--Base-Border-Subtle);
}
}
.contactBlockTitle {
margin-bottom: var(--Spacing-x1);
}

View File

@@ -0,0 +1,53 @@
"use client"
import { useIntl } from "react-intl"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./SASModal.module.css"
export function SASModal({ children }: { children: React.ReactNode }) {
return <section className={styles.container}>{children}</section>
}
export function SASModalDivider() {
const intl = useIntl()
return (
<div className={styles.divider}>
<Body asChild color="uiTextPlaceholder">
<span>{intl.formatMessage({ id: "or" })}</span>
</Body>
</div>
)
}
export function SASModalContactBlock() {
const intl = useIntl()
const phone = intl.formatMessage({ id: "+46 8 517 517 00" })
return (
<div style={{ display: "flex", flexDirection: "column" }}>
<Title
level="h4"
as="h3"
textTransform="regular"
className={styles.contactBlockTitle}
>
{intl.formatMessage({ id: "Contact our memberservice" })}
</Title>
<Link
href={`tel:${phone.replaceAll(" ", "")}`}
textDecoration="underline"
>
{phone}
</Link>
<Link href="mailto:member@scandichotels.com" textDecoration="underline">
member@scandichotels.com
</Link>
</div>
)
}

View File

@@ -0,0 +1,28 @@
"use client"
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import Body from "@/components/TempDesignSystem/Text/Body"
import { GenericError } from "./GenericError"
export function TooManyCodesError() {
const intl = useIntl()
return (
<GenericError
title={intl.formatMessage({ id: "Youve requested too many codes" })}
variant="info"
>
<Body textAlign="center">
{intl.formatMessage({
id: "Please wait 1 hour before trying again.",
})}
</Body>
<Button theme="base" disabled>
{intl.formatMessage({ id: "Send new code" })}
</Button>
</GenericError>
)
}

View File

@@ -0,0 +1,40 @@
"use client"
import * as Sentry from "@sentry/nextjs"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import { GenericError } from "./components/GenericError"
import { SASModalContactBlock } from "./components/SASModal"
export default function Error({
error,
}: {
error: Error & { digest?: string }
}) {
const intl = useIntl()
useEffect(() => {
if (!error) return
console.error(error)
Sentry.captureException(error)
}, [error])
return (
<GenericError
title={intl.formatMessage({
id: "We could not connect your accounts",
})}
>
<Body textAlign="center">
{intl.formatMessage({
id: "We could not connect your accounts to give you access. Please contact us and well help you resolve this issue.",
})}
</Body>
<SASModalContactBlock />
</GenericError>
)
}

View File

@@ -0,0 +1,34 @@
import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n"
import { DateOfBirthError } from "../components/DateOfBirthError"
import { GenericError } from "../components/GenericError"
import { SASModalContactBlock } from "../components/SASModal"
import type { LangParams, PageArgs, SearchParams } from "@/types/params"
export default async function Page({
searchParams,
params,
}: PageArgs<LangParams> & SearchParams<{ errorCode?: "dateOfBirthMismatch" }>) {
const intl = await getIntl()
if (searchParams.errorCode === "dateOfBirthMismatch") {
return <DateOfBirthError />
}
return (
<GenericError
title={intl.formatMessage({
id: "We could not connect your accounts",
})}
>
<Body textAlign="center">
{intl.formatMessage({
id: "We could not connect your accounts to give you access. Please contact us and well help you resolve this issue.",
})}
</Body>
<SASModalContactBlock />
</GenericError>
)
}

View File

@@ -0,0 +1,53 @@
.layout {
background-size: cover;
background-position: center;
height: 100vh;
display: grid;
grid-template-rows: 80px 1fr;
}
.header {
background: white;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
justify-content: center;
align-items: center;
padding: 0 var(--Spacing-x2);
}
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
margin-left: auto;
margin-right: auto;
}
.backLink {
align-items: center;
color: var(--Scandic-Brand-Burgundy);
display: flex;
font-size: var(--Spacing-x2);
gap: var(--Spacing-x1);
.long {
display: none;
}
}
@media screen and (min-width: 768px) {
.backLink {
.short {
display: none;
}
.long {
display: block;
}
}
}

View File

@@ -0,0 +1,63 @@
import { ArrowLeft } from "react-feather"
import { overview as profileOverview } from "@/constants/routes/myPages"
import Image from "@/components/Image"
import { ProtectedLayout } from "@/components/ProtectedLayout"
import Link from "@/components/TempDesignSystem/Link"
import { getIntl } from "@/i18n"
import background from "@/public/_static/img/partner/sas/sas_x_scandic_airplane_window_background.jpg"
import styles from "./layout.module.css"
import type { PropsWithChildren } from "react"
import type { LangParams, LayoutArgs } from "@/types/params"
export default async function SasXScandicLayout({
children,
params,
}: PropsWithChildren<LayoutArgs<LangParams>>) {
const intl = await getIntl()
return (
<div
className={styles.layout}
style={{ backgroundImage: `url(${background.src})` }}
>
<header className={styles.header}>
{/* TODO should this link to my-pages sas page? */}
<Link className={styles.backLink} href={profileOverview[params.lang]}>
<ArrowLeft height={20} width={20} />
<span className={styles.long}>
{intl.formatMessage({ id: "Back to Scandichotels.com" })}
</span>
<span className={styles.short}>
{intl.formatMessage({ id: "Back" })}
</span>
</Link>
<MainMenuLogo />
</header>
<section className={styles.content}>{children}</section>
</div>
)
}
async function MainMenuLogo() {
const intl = await getIntl()
return <Logo alt={intl.formatMessage({ id: "Back to scandichotels.com" })} />
}
function Logo({ alt }: { alt: string }) {
return (
<Image
alt={alt}
className={styles.logo}
height={22}
src="/_static/img/scandic-logotype.svg"
priority
width={103}
/>
)
}

View File

@@ -0,0 +1,136 @@
"use client"
import Image from "next/image"
import { type ReactNode, useTransition } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import DateSelect from "@/components/TempDesignSystem/Form/Date"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Title from "@/components/TempDesignSystem/Text/Title"
import styles from "./link-sas.module.css"
type LinkAccountForm = {
dateOfBirth: string | null
termsAndConditions: boolean
}
export function LinkAccountForm({
initialDateOfBirth,
onSubmit,
}: {
initialDateOfBirth: string | null
onSubmit: (dateOfBirth: string) => Promise<void>
}) {
let [isPending, startTransition] = useTransition()
const intl = useIntl()
const form = useForm<LinkAccountForm>({
defaultValues: {
dateOfBirth: initialDateOfBirth,
termsAndConditions: false,
},
})
const handleSubmit = form.handleSubmit((data) => {
startTransition(async () => {
if (!data.dateOfBirth || !data.termsAndConditions) return
await onSubmit(data.dateOfBirth)
})
})
const dateOfBirth = form.watch("dateOfBirth")
const termsAndConditions = form.watch("termsAndConditions")
const disableSubmit = !dateOfBirth || !termsAndConditions
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.titles}>
<Image
alt={"Scandic ❤️ SAS"}
height={25}
width={182}
src="/_static/img/partner/sas/sas-campaign-logo.png"
/>
<Title level="h3" textTransform="regular">
{intl.formatMessage({ id: "Link your accounts" })}
</Title>
</div>
<div className={styles.dateSelect}>
<Body>
{intl.formatMessage({
id: "Birth date",
})}
</Body>
<DateSelect
name="dateOfBirth"
registerOptions={{
required: {
value: true,
message: intl.formatMessage({ id: "Birth date is required" }),
},
}}
/>
</div>
<Caption textAlign="left">
{intl.formatMessage({
id: "We require this additional information in order to match your Scandic account with your EuroBonus account.",
})}
</Caption>
<div className={styles.termsAndConditions}>
<Checkbox
name="termsAndConditions"
registerOptions={{
required: {
value: true,
message: intl.formatMessage({
id: "You must accept the terms and conditions",
}),
},
}}
>
<Body>
{intl.formatMessage({ id: "I accept the terms and conditions" })}
</Body>
</Checkbox>
<Body className={styles.termsDescription}>
{intl.formatMessage<ReactNode>(
{
id: "By linking your accounts you accept the <sasScandicTermsAndConditionsLink>Scandic Friends & SAS Terms and Conditions</sasScandicTermsAndConditionsLink>. You will be connected throughout the duration of your employment or until further notice, and you can opt out at any time.",
},
{
sasScandicTermsAndConditionsLink: (str) => (
<Link
// TODO correct link
href={"#"}
weight="bold"
variant="default"
textDecoration="underline"
>
{str}
</Link>
),
}
)}
</Body>
</div>
<div className={styles.ctaContainer}>
<Button
theme="base"
fullWidth
className={styles.ctaButton}
type="submit"
disabled={isPending || disableSubmit}
>
{intl.formatMessage({ id: "Link my accounts" })}
</Button>
</div>
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,43 @@
.titles {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x2);
margin-top: var(--Spacing-x3);
}
.dateSelect {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
width: 100%;
}
.termsAndConditions {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.termsDescription {
margin-left: calc(var(--Spacing-x4) + var(--Spacing-x-half));
}
.ctaContainer {
display: flex;
justify-content: center;
padding: var(--Spacing-x3) var(--Spacing-x3) 0;
width: calc(100% + var(--Spacing-x3) + var(--Spacing-x3));
border-top: 1px solid var(--Base-Border-Subtle);
}
.ctaButton {
max-width: 350px;
}
.form {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x3);
}

View File

@@ -0,0 +1,41 @@
import { redirect } from "next/navigation"
import React from "react"
import { getProfileSafely } from "@/lib/trpc/memoizedRequests"
import { AlreadyLinkedError } from "../components/AlreadyLinkedError"
import { SASModal } from "../components/SASModal"
import { LinkAccountForm } from "./LinkAccountForm"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SASxScandicLinkPage({
params,
}: PageArgs<LangParams>) {
const profile = await getProfileSafely()
// TODO actually check if profile is already linked
const alreadyLinked = false
async function handleLinkAccount(dateOfBirth: string) {
"use server"
if (dateOfBirth !== profile?.dateOfBirth) {
// TODO update users date of birth here
console.log("updating date of birth")
}
redirect(`/${params.lang}/sas-x-scandic/login?intent=link`)
}
return alreadyLinked ? (
<AlreadyLinkedError />
) : (
<SASModal>
<LinkAccountForm
initialDateOfBirth={profile?.dateOfBirth ?? null}
onSubmit={handleLinkAccount}
/>
</SASModal>
)
}

View File

@@ -0,0 +1,41 @@
import React from "react"
import CheckCircle from "@/components/Icons/CheckCircle"
import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { SASModal } from "../../components/SASModal"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SASxScandicLinkPage({
params,
}: PageArgs<LangParams>) {
const intl = await getIntl()
return (
<SASModal>
{/* <Redirect
url={`INSERT CORRECT URL HERE`}
timeout={3000}
/> */}
<CheckCircle height={64} width={64} color="uiSemanticSuccess" />
<Title as="h2" level="h1" textAlign="center">
{intl.formatMessage({ id: "Your accounts are connected" })}
</Title>
<div>
<Body textAlign="center">
{intl.formatMessage({
id: "We successfully connected your accounts!",
})}
</Body>
<Body textAlign="center">
{intl.formatMessage({
id: "Redirecting you to my pages.",
})}
</Body>
</div>
</SASModal>
)
}

View File

@@ -0,0 +1,90 @@
import { redirect } from "next/navigation"
import React from "react"
import { z } from "zod"
import { env } from "@/env/server"
import Image from "@/components/Image"
import { Redirect } from "@/components/Redirect"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import { SASModal } from "../components/SASModal"
import type { LangParams, PageArgs, SearchParams } from "@/types/params"
import type { State } from "../sasUtils"
const searchParamsSchema = z.object({
intent: z.enum(["link"]),
})
export default async function SASxScandicLoginPage({
searchParams,
params,
}: PageArgs<LangParams> & SearchParams) {
const result = searchParamsSchema.safeParse(searchParams)
if (!result.success) {
// TOOD where to redirect?
redirect(`/${params.lang}/sas-x-scandic/link`)
}
const parsedParams = result.data
const intl = await getIntl()
const redirectUri = new URL(
"/en/sas-x-scandic/callback",
env.PUBLIC_URL
).toString()
const state: State = { intent: parsedParams.intent }
const urlState = encodeURIComponent(JSON.stringify(state))
const clientId = env.SAS_AUTH_CLIENTID
const sasLoginHostname = env.SAS_AUTH_ENDPOINT
const audience = "eb-partner-api"
// TODO check if this is correct scopes
const scope = encodeURIComponent("openid profile email")
const loginLink = `${sasLoginHostname}/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${urlState}&audience=${audience}`
return (
<SASModal>
<Redirect url={loginLink} timeout={3000} />
<Image
src="/_static/img/scandic-loyalty-time.svg"
alt=""
width="140"
height="110"
style={{ marginTop: 16 }}
/>
<Title as="h2" level="h1" textTransform="regular">
{intl.formatMessage({ id: "Redirecting you to SAS" })}
</Title>
<Body textAlign="center">
{intl.formatMessage({
id: "In order to verify your account linking we will ask you to sign in to your SAS EuroBonus account.",
})}
</Body>
<Footnote textAlign="center">
{intl.formatMessage<React.ReactNode>(
{
id: "If you are not redirected automatically, please <loginLink>click here</loginLink>.",
},
{
loginLink: (str) => (
<Link
href={loginLink}
color="red"
variant="default"
size="tiny"
textDecoration="underline"
>
{str}
</Link>
),
}
)}
</Footnote>
</SASModal>
)
}

View File

@@ -0,0 +1,92 @@
.container-modal {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x3);
background-color: white;
width: 100%;
padding: var(--Spacing-x3);
text-align: center;
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
margin-top: auto;
@media screen and (min-width: 768px) {
& {
border-radius: var(--Corner-radius-Medium);
margin-top: initial;
width: 512px;
}
}
}
.otp-container {
display: flex;
gap: var(--Spacing-x1);
@media screen and (min-width: 768px) {
& {
gap: var(--Spacing-x2);
}
}
&.error .slot {
border: 1px solid var(--UI-Text-Error);
}
}
.slot {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: content-box;
width: 34px;
height: 0px;
padding: var(--Spacing-x3) 0;
font-family: var(--typography-Body-Regular-fontFamily);
border: 1px solid var(--Base-Border-Normal);
border-radius: var(--Corner-radius-Medium);
text-align: center;
&.active {
border: 1px solid var(--UI-Text-Active);
outline: 1px solid var(--UI-Text-Active);
}
}
.caret {
position: absolute;
pointer-events: none;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
animation: blink 1s infinite;
& .child {
width: 1px;
height: 16px;
background-color: var(--UI-Text-Active);
}
}
.disabled-link {
cursor: default;
color: var(--UI-Text-Disabled);
opacity: 0.4;
}
.error-message {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}

View File

@@ -0,0 +1,198 @@
"use client"
import { cx } from "class-variance-authority"
import { OTPInput, type SlotProps } from "input-otp"
import { type ReactNode, useState, useTransition } from "react"
import { useIntl } from "react-intl"
import { trpc } from "@/lib/trpc/client"
import ErrorCircleFilledIcon from "@/components/Icons/ErrorCircleFilled"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { GenericError } from "../components/GenericError"
import { SASModal, SASModalContactBlock } from "../components/SASModal"
import Loading from "./loading"
import styles from "./OneTimePasswordForm.module.css"
import type { RequestOtpError } from "@/server/routers/partners/sas/otp/request/requestOtpError"
import type { VerifyOtpError } from "@/server/routers/partners/sas/otp/verify/verifyOtpError"
export default function OneTimePasswordForm({
heading,
ingress,
footnote,
otpLength,
onSubmit,
error,
}: {
heading: string
ingress: string | ReactNode
footnote?: string | ReactNode
otpLength: number
onSubmit: (args: { otp: string }) => Promise<void>
error?: ReactNode
}) {
const [isPending, startTransition] = useTransition()
const [disableResend, setDisableResend] = useState(false)
const intl = useIntl()
const [otp, setOtp] = useState("")
const requestOtp = trpc.partner.sas.requestOtp.useMutation({})
if (requestOtp.isPending || isPending) {
return <Loading />
}
if (requestOtp.isError) {
const cause = requestOtp.error?.data?.cause as RequestOtpError
const title = intl.formatMessage({ id: "Error requesting OTP" })
const body = getRequestErrorBody(intl, cause?.errorCode)
return (
<GenericError title={title}>
<Body textAlign="center">{body}</Body>
<SASModalContactBlock />
</GenericError>
)
}
function handleRequestNewOtp(event: React.MouseEvent) {
event.preventDefault()
if (disableResend) return
setOtp("")
requestOtp.reset()
requestOtp.mutate({})
setDisableResend(true)
setTimeout(() => {
setDisableResend(false)
}, 15_000)
}
function handleOTPEntered(otp: string) {
startTransition(async () => {
await onSubmit({ otp })
})
}
return (
<SASModal>
<Subtitle textAlign={"center"}>{heading}</Subtitle>
<div>
<Body textAlign={"center"}>{ingress}</Body>
</div>
<OTPInput
value={otp}
onChange={setOtp}
maxLength={otpLength}
inputMode="numeric"
onComplete={(otp) => {
handleOTPEntered(otp)
}}
containerClassName={cx(styles["otp-container"], {
[styles.error]: Boolean(error),
})}
render={({ slots }) => (
<>
{slots.map((slot, idx) => (
<Slot key={idx} {...slot} />
))}
</>
)}
/>
{error && (
<div className={styles["error-message"]}>
<ErrorCircleFilledIcon height={20} width={20} color="red" />
<Caption color="red">{error}</Caption>
</div>
)}
<div>
<Footnote>{footnote}</Footnote>
<Footnote>
{intl.formatMessage<React.ReactNode>(
{
id: "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>",
},
{
resendOtpLink: (str) => (
<Link
href="#"
onClick={handleRequestNewOtp}
color="red"
variant="default"
size="tiny"
className={disableResend ? styles["disabled-link"] : ""}
>
{str}
</Link>
),
}
)}
</Footnote>
</div>
</SASModal>
)
}
function Slot(props: SlotProps) {
return (
<div className={`${styles.slot} ${props.isActive ? styles.active : ""}`}>
{props.char !== null && <div>{props.char}</div>}
{props.hasFakeCaret && <FakeCaret />}
</div>
)
}
function FakeCaret() {
return (
<div className={styles.caret}>
<div className={styles.child} />
</div>
)
}
const getRequestErrorBody = (
intl: ReturnType<typeof useIntl>,
errorCode: RequestOtpError["errorCode"]
) => {
switch (errorCode) {
case "TOO_MANY_REQUESTS":
return intl.formatMessage({
id: "Too many requests. Please try again later.",
})
default:
return intl.formatMessage({
id: "An error occurred while requesting a new OTP",
})
}
}
const getVerifyErrorBody = (
intl: ReturnType<typeof useIntl>,
errorCode: VerifyOtpError["errorCode"]
) => {
switch (errorCode) {
case "WRONG_OTP":
return intl.formatMessage({
id: "The code you entered is incorrect. Please try again.",
})
case "OTP_EXPIRED":
return intl.formatMessage({
id: "OTP has expired. Please try again.",
})
default:
return intl.formatMessage({
id: "An error occurred while requesting a new OTP",
})
}
}

View File

@@ -0,0 +1,11 @@
import LoadingSpinner from "@/components/LoadingSpinner"
import { SASModal } from "../components/SASModal"
export default function Loading() {
return (
<SASModal>
<LoadingSpinner />
</SASModal>
)
}

View File

@@ -0,0 +1,128 @@
import { cookies } from "next/headers"
import { redirect, RedirectType } from "next/navigation"
import { z } from "zod"
import { serverClient } from "@/lib/trpc/server"
import { getIntl } from "@/i18n"
import { safeTry } from "@/utils/safeTry"
import { SAS_TOKEN_STORAGE_KEY } from "../sasUtils"
import OneTimePasswordForm from "./OneTimePasswordForm"
import type { LangParams, PageArgs, SearchParams } from "@/types/params"
import type { Lang } from "@/constants/languages"
const searchParamsSchema = z.object({
intent: z.enum(["link"]),
to: z.string(),
error: z.enum(["invalidCode"]).optional(),
})
export default async function SASxScandicOneTimePasswordPage({
searchParams,
params,
}: PageArgs<LangParams> & SearchParams) {
const intl = await getIntl()
const cookieStore = cookies()
const tokenCookie = cookieStore.get(SAS_TOKEN_STORAGE_KEY)
const result = searchParamsSchema.safeParse(searchParams)
if (!result.success) {
throw new Error("Invalid search params")
}
const { intent, to, error } = result.data
if (!verifyTokenValidity(tokenCookie?.value)) {
redirect(`/${params.lang}/sas-x-scandic/login?intent=${intent}`)
}
const errors = {
invalidCode: intl.formatMessage({
id: "The code youve entered is incorrect.",
}),
}
async function handleOtpVerified({ otp }: { otp: string }) {
"use server"
const [data, error] = await safeTry(
serverClient().partner.sas.verifyOtp({ otp })
)
// TODO correct status?
// TODO handle all errors
// STATUS === VERIFIED => ok
// STATUS === ABUSED => otpRetryCount > otpMaxRetryCount
if (error || data?.status !== "VERIFIED") {
const search = new URLSearchParams({
...searchParams,
error: "invalidCode",
}).toString()
redirect(`/${params.lang}/sas-x-scandic/otp?${search}`)
}
switch (intent) {
case "link":
return handleLinkAccount({ lang: params.lang })
default:
throw new Error("")
}
}
return (
<OneTimePasswordForm
heading={intl.formatMessage({ id: "Verification code" })}
ingress={intl.formatMessage<React.ReactNode>(
{
id: "Please enter the code sent to <maskedContactInfo></maskedContactInfo> in order to confirm your account linking.",
},
{
maskedContactInfo: () => (
<>
<br />
<strong>{to}</strong>
<br />
</>
),
}
)}
footnote={intl.formatMessage({
id: "This verifcation is needed for additional security.",
})}
otpLength={6}
onSubmit={handleOtpVerified}
error={error ? errors[error] : undefined}
/>
)
}
function verifyTokenValidity(token: string | undefined) {
if (!token) {
return false
}
try {
const decoded = JSON.parse(atob(token.split(".")[1]))
const expiry = decoded.exp * 1000
return Date.now() < expiry
} catch (error) {
return false
}
}
async function handleLinkAccount({ lang }: { lang: Lang }) {
const [res, error] = await safeTry(serverClient().partner.sas.linkAccount())
if (!res || error) {
console.error("[SAS] link account error", error)
redirect(`/${lang}/sas-x-scandic/error?errorCode=link_error`)
}
console.log("[SAS] link account response", res)
switch (res.linkingState) {
case "linked":
redirect(`/${lang}/sas-x-scandic/link/success`, RedirectType.replace)
break
}
}

View File

@@ -0,0 +1,9 @@
import { z } from "zod"
export const SAS_TOKEN_STORAGE_KEY = "sas-x-scandic-token"
// TODO nonce??
export const stateSchema = z.object({
intent: z.literal("link"),
})
export type State = z.infer<typeof stateSchema>

View File

@@ -0,0 +1,68 @@
import "@/app/globals.css"
import "@scandic-hotels/design-system/style.css"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import Script from "next/script"
import { env } from "@/env/server"
import TrpcProvider from "@/lib/trpc/Provider"
import TokenRefresher from "@/components/Auth/TokenRefresher"
import CookieBotConsent from "@/components/CookieBot"
import StorageCleaner from "@/components/HotelReservation/EnterDetails/StorageCleaner"
import { ToastHandler } from "@/components/TempDesignSystem/Toasts"
import { preloadUserTracking } from "@/components/TrackingSDK"
import AdobeSDKScript from "@/components/TrackingSDK/AdobeSDKScript"
import GTMScript from "@/components/TrackingSDK/GTMScript"
import RouterTracking from "@/components/TrackingSDK/RouterTracking"
import { getIntl } from "@/i18n"
import ServerIntlProvider from "@/i18n/Provider"
import { setLang } from "@/i18n/serverContext"
import type { LangParams, LayoutArgs } from "@/types/params"
export default async function RootLayout({
children,
params,
}: React.PropsWithChildren<LayoutArgs<LangParams>>) {
if (!env.SAS_ENABLED) {
return null
}
setLang(params.lang)
preloadUserTracking()
const { defaultLocale, locale, messages } = await getIntl()
return (
<html lang={params.lang}>
<head>
<AdobeSDKScript />
<GTMScript />
<Script
strategy="beforeInteractive"
data-blockingmode="auto"
data-cbid="6d539de8-3e67-4f0f-a0df-8cef9070f712"
data-culture="@cultureCode"
id="Cookiebot"
src="https://consent.cookiebot.com/uc.js"
/>
<Script id="ensure-adobeDataLayer">{`
window.adobeDataLayer = window.adobeDataLayer || []
`}</Script>
</head>
<body>
<ServerIntlProvider intl={{ defaultLocale, locale, messages }}>
<TrpcProvider>
<RouterTracking />
{children}
<ToastHandler />
<TokenRefresher />
<StorageCleaner />
<CookieBotConsent />
<ReactQueryDevtools initialIsOpen={false} />
</TrpcProvider>
</ServerIntlProvider>
</body>
</html>
)
}