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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user