Merged in feat/sas-otp-error-handling (pull request #1272)

Feat/sas otp error handling

* Improve error handling for SAS OTP
* Remove failing and deprecated test

Approved-by: Joakim Jäderberg
This commit is contained in:
Anton Gunnarsson
2025-02-07 14:18:00 +00:00
parent fbe05eb456
commit 18288cb849
11 changed files with 187 additions and 94 deletions

View File

@@ -82,13 +82,23 @@ export async function GET(
const [data, error] = await safeTry( const [data, error] = await safeTry(
serverClient().partner.sas.requestOtp({}) serverClient().partner.sas.requestOtp({})
) )
// status: 'SENT' => OK
if (!data || error) { if (!data || error) {
//TODO: Check what error we get
console.error("[SAS] Failed to request OTP", error) console.error("[SAS] Failed to request OTP", error)
redirect(`/${lang}/sas-x-scandic/error`) redirect(`/${lang}/sas-x-scandic/error`)
} }
switch (data.status) {
case "ABUSED":
redirect(`/${params.lang}/sas-x-scandic/error?errorCode=tooManyCodes`)
case "NOTSENT":
redirect(`/${params.lang}/sas-x-scandic/error`)
case "NULL":
case "RETRY":
case "EXPIRED":
// These errors should never happen for request, but according to the API spec they can
throw new Error(`Unhandled request OTP status ${data.status}`)
}
console.log("[SAS] Request OTP response", data) console.log("[SAS] Request OTP response", data)
const otpUrl = new URL( const otpUrl = new URL(

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 TooManyFailedAttemptsError() {
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

@@ -1,9 +1,12 @@
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import { getIntl } from "@/i18n" import { getIntl } from "@/i18n"
import { AlreadyLinkedError } from "../components/AlreadyLinkedError"
import { DateOfBirthError } from "../components/DateOfBirthError" import { DateOfBirthError } from "../components/DateOfBirthError"
import { GenericError } from "../components/GenericError" import { GenericError } from "../components/GenericError"
import { SASModalContactBlock } from "../components/SASModal" import { SASModalContactBlock } from "../components/SASModal"
import { TooManyCodesError } from "../components/TooManyCodesError"
import { TooManyFailedAttemptsError } from "../components/TooManyFailedAttemptsError"
import type { LangParams, PageArgs, SearchParams } from "@/types/params" import type { LangParams, PageArgs, SearchParams } from "@/types/params"
@@ -13,10 +16,24 @@ export default async function Page({
}: PageArgs<LangParams> & SearchParams<{ errorCode?: "dateOfBirthMismatch" }>) { }: PageArgs<LangParams> & SearchParams<{ errorCode?: "dateOfBirthMismatch" }>) {
const intl = await getIntl() const intl = await getIntl()
if (searchParams.errorCode === "dateOfBirthMismatch") { const { errorCode } = searchParams
if (errorCode === "dateOfBirthMismatch") {
return <DateOfBirthError /> return <DateOfBirthError />
} }
if (errorCode === "tooManyFailedAttempts") {
return <TooManyFailedAttemptsError />
}
if (errorCode === "tooManyCodes") {
return <TooManyCodesError />
}
if (errorCode === "alreadyLinked") {
return <AlreadyLinkedError />
}
return ( return (
<GenericError <GenericError
title={intl.formatMessage({ title={intl.formatMessage({

View File

@@ -31,9 +31,11 @@ export default async function SASxScandicLinkPage({
} }
} }
return alreadyLinked ? ( if (alreadyLinked) {
<AlreadyLinkedError /> redirect(`/${params.lang}/sas-x-scandic/error?errorCode=alreadyLinked`)
) : ( }
return (
<SASModal> <SASModal>
<LinkAccountForm <LinkAccountForm
initialDateOfBirth={profile?.dateOfBirth ?? null} initialDateOfBirth={profile?.dateOfBirth ?? null}

View File

@@ -2,6 +2,7 @@
import { cx } from "class-variance-authority" import { cx } from "class-variance-authority"
import { OTPInput, type SlotProps } from "input-otp" import { OTPInput, type SlotProps } from "input-otp"
import { useParams, useRouter } from "next/navigation"
import { type ReactNode, useState, useTransition } from "react" import { type ReactNode, useState, useTransition } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
@@ -13,7 +14,6 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote" import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { toast } from "@/components/TempDesignSystem/Toasts"
import { GenericError } from "../components/GenericError" import { GenericError } from "../components/GenericError"
import { SASModal, SASModalContactBlock } from "../components/SASModal" import { SASModal, SASModalContactBlock } from "../components/SASModal"
@@ -22,7 +22,7 @@ import Loading from "./loading"
import styles from "./OneTimePasswordForm.module.css" import styles from "./OneTimePasswordForm.module.css"
import type { RequestOtpError } from "@/server/routers/partners/sas/otp/request/requestOtpError" import type { RequestOtpError } from "@/server/routers/partners/sas/otp/request/requestOtpError"
import type { VerifyOtpError } from "@/server/routers/partners/sas/otp/verify/verifyOtpError" import type { OtpError } from "./page"
export default function OneTimePasswordForm({ export default function OneTimePasswordForm({
heading, heading,
@@ -37,8 +37,10 @@ export default function OneTimePasswordForm({
footnote?: string | ReactNode footnote?: string | ReactNode
otpLength: number otpLength: number
onSubmit: (args: { otp: string }) => Promise<void> onSubmit: (args: { otp: string }) => Promise<void>
error?: ReactNode error?: OtpError
}) { }) {
const router = useRouter()
const params = useParams()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [disableResend, setDisableResend] = useState(false) const [disableResend, setDisableResend] = useState(false)
const intl = useIntl() const intl = useIntl()
@@ -64,6 +66,20 @@ export default function OneTimePasswordForm({
) )
} }
switch (requestOtp.data?.status) {
case "ABUSED":
router.push(`/${params.lang}/sas-x-scandic/error?errorCode=tooManyCodes`)
return <Loading />
case "NOTSENT":
router.push(`/${params.lang}/sas-x-scandic/error`)
return <Loading />
case "NULL":
case "RETRY":
case "EXPIRED":
// These errors should never happen for request, but according to the API spec they can
throw new Error(`Unhandled request OTP status ${requestOtp.data?.status}`)
}
function handleRequestNewOtp(event: React.MouseEvent) { function handleRequestNewOtp(event: React.MouseEvent) {
event.preventDefault() event.preventDefault()
if (disableResend) return if (disableResend) return
@@ -84,6 +100,35 @@ export default function OneTimePasswordForm({
}) })
} }
const getResendOtpLink = (str: ReactNode) => (
<Link
href="#"
onClick={handleRequestNewOtp}
color="red"
variant="default"
size="tiny"
className={disableResend ? styles["disabled-link"] : ""}
>
{str}
</Link>
)
const errorMessages: Record<OtpError, ReactNode> = {
invalidCode: intl.formatMessage({
id: "The code youve entered is incorrect.",
}),
expiredCode: intl.formatMessage<ReactNode>(
{
id: "The code youve entered have expired. <resendOtpLink>Resend code.</resendOtpLink>",
},
{
resendOtpLink: getResendOtpLink,
}
),
}
const errorMessage = error ? errorMessages[error] : undefined
return ( return (
<SASModal> <SASModal>
<Subtitle textAlign={"center"}>{heading}</Subtitle> <Subtitle textAlign={"center"}>{heading}</Subtitle>
@@ -110,10 +155,10 @@ export default function OneTimePasswordForm({
</> </>
)} )}
/> />
{error && ( {errorMessage && (
<div className={styles["error-message"]}> <div className={styles["error-message"]}>
<ErrorCircleFilledIcon height={20} width={20} color="red" /> <ErrorCircleFilledIcon height={20} width={20} color="red" />
<Caption color="red">{error}</Caption> <Caption color="red">{errorMessage}</Caption>
</div> </div>
)} )}
<div> <div>
@@ -124,18 +169,7 @@ export default function OneTimePasswordForm({
id: "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>", id: "Didn't receive a code? <resendOtpLink>Resend code</resendOtpLink>",
}, },
{ {
resendOtpLink: (str) => ( resendOtpLink: getResendOtpLink,
<Link
href="#"
onClick={handleRequestNewOtp}
color="red"
variant="default"
size="tiny"
className={disableResend ? styles["disabled-link"] : ""}
>
{str}
</Link>
),
} }
)} )}
</Footnote> </Footnote>
@@ -176,23 +210,3 @@ const getRequestErrorBody = (
}) })
} }
} }
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

@@ -13,12 +13,15 @@ import OneTimePasswordForm from "./OneTimePasswordForm"
import type { LangParams, PageArgs, SearchParams } from "@/types/params" import type { LangParams, PageArgs, SearchParams } from "@/types/params"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
const otpError = z.enum(["invalidCode", "expiredCode"])
const searchParamsSchema = z.object({ const searchParamsSchema = z.object({
intent: z.enum(["link"]), intent: z.enum(["link"]),
to: z.string(), to: z.string(),
error: z.enum(["invalidCode"]).optional(), error: otpError.optional(),
}) })
export type OtpError = z.infer<typeof otpError>
export default async function SASxScandicOneTimePasswordPage({ export default async function SASxScandicOneTimePasswordPage({
searchParams, searchParams,
params, params,
@@ -37,24 +40,30 @@ export default async function SASxScandicOneTimePasswordPage({
redirect(`/${params.lang}/sas-x-scandic/login?intent=${intent}`) 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 }) { async function handleOtpVerified({ otp }: { otp: string }) {
"use server" "use server"
const [data, error] = await safeTry( const [data, error] = await safeTry(
serverClient().partner.sas.verifyOtp({ otp }) serverClient().partner.sas.verifyOtp({ otp })
) )
// TODO correct status? if (error || !data) {
// TODO handle all errors throw error || new Error("OTP verification failed")
// STATUS === VERIFIED => ok }
// STATUS === ABUSED => otpRetryCount > otpMaxRetryCount
if (error || data?.status !== "VERIFIED") { switch (data.status) {
case "ABUSED":
redirect(
`/${params.lang}/sas-x-scandic/error?errorCode=tooManyFailedAttempts`
)
case "EXPIRED": {
const search = new URLSearchParams({
...searchParams,
error: "expiredCode",
}).toString()
redirect(`/${params.lang}/sas-x-scandic/otp?${search}`)
}
case "RETRY": {
const search = new URLSearchParams({ const search = new URLSearchParams({
...searchParams, ...searchParams,
error: "invalidCode", error: "invalidCode",
@@ -62,12 +71,17 @@ export default async function SASxScandicOneTimePasswordPage({
redirect(`/${params.lang}/sas-x-scandic/otp?${search}`) redirect(`/${params.lang}/sas-x-scandic/otp?${search}`)
} }
case "NOTSENT":
case "NULL":
case "SENT":
case "PENDING":
// These errors should never happen for verify, but according to the API spec they can
throw new Error("Unhandled OTP status")
}
switch (intent) { switch (intent) {
case "link": case "link":
return handleLinkAccount({ lang: params.lang }) return handleLinkAccount({ lang: params.lang })
default:
throw new Error("")
} }
} }
@@ -93,7 +107,7 @@ export default async function SASxScandicOneTimePasswordPage({
})} })}
otpLength={6} otpLength={6}
onSubmit={handleOtpVerified} onSubmit={handleOtpVerified}
error={error ? errors[error] : undefined} error={error}
/> />
) )
} }

View File

@@ -523,6 +523,7 @@
"Terms and conditions": "Terms and conditions", "Terms and conditions": "Terms and conditions",
"Thank you": "Thank you", "Thank you": "Thank you",
"Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>contact us.</emailLink>": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>contact us.</emailLink>", "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>contact us.</emailLink>": "Thank you for booking with us! We look forward to welcoming you and hope you have a pleasant stay. If you have any questions or need to make changes to your reservation, please <emailLink>contact us.</emailLink>",
"The code youve entered have expired. <resendOtpLink>Resend code.</resendOtpLink>": "The code youve entered have expired. <resendOtpLink>Resend code.</resendOtpLink>",
"The code youve entered is incorrect.": "The code youve entered is incorrect.", "The code youve entered is incorrect.": "The code youve entered is incorrect.",
"The new price is": "The new price is", "The new price is": "The new price is",
"The price has increased": "The price has increased", "The price has increased": "The price has increased",

View File

@@ -18,7 +18,16 @@ import type { OtpState } from "../getOTPState"
const inputSchema = z.object({}) const inputSchema = z.object({})
const outputSchema = z.object({ const outputSchema = z.object({
status: z.string(), status: z.enum([
"VERIFIED",
"ABUSED",
"EXPIRED",
"PENDING",
"RETRY",
"SENT",
"NULL",
"NOTSENT",
]),
referenceId: z.string().uuid(), referenceId: z.string().uuid(),
databaseUUID: z.string().uuid(), databaseUUID: z.string().uuid(),
otpExpiration: z.number(), otpExpiration: z.number(),
@@ -42,14 +51,15 @@ export const requestOtp = protectedProcedure
tokenResponse.status, tokenResponse.status,
tokenResponse.statusText tokenResponse.statusText
) )
if (!tokenResponse.ok) {
const errorBody = await tokenResponse.json()
console.error("[SAS] requestOtp error", errorBody)
throw createError(errorBody)
}
const parseResult = outputSchema.safeParse(await tokenResponse.json()) const body = await tokenResponse.json()
const parseResult = outputSchema.safeParse(body)
if (!parseResult.success) { if (!parseResult.success) {
console.error("[SAS] requestOtp error", body)
if (!tokenResponse.ok) {
throw createError(body)
}
throw createError(parseResult.error) throw createError(parseResult.error)
} }

View File

@@ -4,13 +4,6 @@ import { parseSASRequestOtpError } from "./requestOtpError"
describe("requestOtpError", () => { describe("requestOtpError", () => {
it("parses error with invalid error code", () => { it("parses error with invalid error code", () => {
const error = {
status: "status",
error: "error",
errorCode: "a",
databaseUUID: "9ffefefe-df0e-4229-9792-5ed31bef1db4",
}
const actual = parseSASRequestOtpError({ const actual = parseSASRequestOtpError({
status: "status", status: "status",
error: "error", error: "error",
@@ -21,17 +14,4 @@ describe("requestOtpError", () => {
errorCode: "UNKNOWN", errorCode: "UNKNOWN",
}) })
}) })
it("parses error as TOO_MANY_REQUESTS error code", () => {
const actual = parseSASRequestOtpError({
status: "status",
error: "error",
errorCode: 10,
databaseUUID: "9ffefefe-df0e-4229-9792-5ed31bef1db4",
otpExpiration: "2021-09-01T00:00:00Z",
})
expect(actual).toEqual({
errorCode: "TOO_MANY_REQUESTS",
})
})
}) })

View File

@@ -51,7 +51,16 @@ const getErrorCodeByNumber = (number: number): RequestOtpResponseError => {
} }
const sasOtpRequestErrorSchema = z.object({ const sasOtpRequestErrorSchema = z.object({
status: z.string(), status: z.enum([
"VERIFIED",
"ABUSED",
"EXPIRED",
"PENDING",
"RETRY",
"SENT",
"NULL",
"NOTSENT",
]),
otpExpiration: z.string().datetime(), otpExpiration: z.string().datetime(),
error: z.string(), error: z.string(),
errorCode: z.number(), errorCode: z.number(),

View File

@@ -17,7 +17,16 @@ const inputSchema = z.object({
}) })
const outputSchema = z.object({ const outputSchema = z.object({
status: z.string(), // TODO: Change to enum status: z.enum([
"VERIFIED",
"ABUSED",
"EXPIRED",
"PENDING",
"RETRY",
"SENT",
"NULL",
"NOTSENT",
]),
referenceId: z.string().uuid(), referenceId: z.string().uuid(),
databaseUUID: z.string().uuid().optional(), databaseUUID: z.string().uuid().optional(),
}) })
@@ -29,7 +38,6 @@ export const verifyOtp = protectedProcedure
const sasAuthToken = getSasToken() const sasAuthToken = getSasToken()
if (!sasAuthToken) { if (!sasAuthToken) {
// TODO: Should we verify that the SAS token isn't expired?
throw createError("AUTH_TOKEN_NOT_FOUND") throw createError("AUTH_TOKEN_NOT_FOUND")
} }