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

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