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:
@@ -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
|
||||
|
||||
3
app/[lang]/(partner)/(sas)/(protected)/layout.tsx
Normal file
3
app/[lang]/(partner)/(sas)/(protected)/layout.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ProtectedLayout } from "@/components/ProtectedLayout"
|
||||
|
||||
export default ProtectedLayout
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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 we’ll 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>
|
||||
)
|
||||
}
|
||||
@@ -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 we’ll help you resolve this issue.",
|
||||
})}
|
||||
</Body>
|
||||
{/* TODO link to where? */}
|
||||
<Button theme="base">
|
||||
{intl.formatMessage({ id: "View your account" })}
|
||||
</Button>
|
||||
<SASModalDivider />
|
||||
<SASModalContactBlock />
|
||||
</SASModal>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: "You’ve 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>
|
||||
)
|
||||
}
|
||||
@@ -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 we’ll help you resolve this issue.",
|
||||
})}
|
||||
</Body>
|
||||
<SASModalContactBlock />
|
||||
</GenericError>
|
||||
)
|
||||
}
|
||||
@@ -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 we’ll help you resolve this issue.",
|
||||
})}
|
||||
</Body>
|
||||
<SASModalContactBlock />
|
||||
</GenericError>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
|
||||
import { SASModal } from "../components/SASModal"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<SASModal>
|
||||
<LoadingSpinner />
|
||||
</SASModal>
|
||||
)
|
||||
}
|
||||
@@ -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 you’ve 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
68
app/[lang]/(partner)/layout.tsx
Normal file
68
app/[lang]/(partner)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user