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

@@ -2,6 +2,7 @@
import { cx } from "class-variance-authority"
import { OTPInput, type SlotProps } from "input-otp"
import { useParams, useRouter } from "next/navigation"
import { type ReactNode, useState, useTransition } from "react"
import { useIntl } from "react-intl"
@@ -13,7 +14,6 @@ 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"
@@ -22,7 +22,7 @@ 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"
import type { OtpError } from "./page"
export default function OneTimePasswordForm({
heading,
@@ -37,8 +37,10 @@ export default function OneTimePasswordForm({
footnote?: string | ReactNode
otpLength: number
onSubmit: (args: { otp: string }) => Promise<void>
error?: ReactNode
error?: OtpError
}) {
const router = useRouter()
const params = useParams()
const [isPending, startTransition] = useTransition()
const [disableResend, setDisableResend] = useState(false)
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) {
event.preventDefault()
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 (
<SASModal>
<Subtitle textAlign={"center"}>{heading}</Subtitle>
@@ -110,10 +155,10 @@ export default function OneTimePasswordForm({
</>
)}
/>
{error && (
{errorMessage && (
<div className={styles["error-message"]}>
<ErrorCircleFilledIcon height={20} width={20} color="red" />
<Caption color="red">{error}</Caption>
<Caption color="red">{errorMessage}</Caption>
</div>
)}
<div>
@@ -124,18 +169,7 @@ export default function OneTimePasswordForm({
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>
),
resendOtpLink: getResendOtpLink,
}
)}
</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 { Lang } from "@/constants/languages"
const otpError = z.enum(["invalidCode", "expiredCode"])
const searchParamsSchema = z.object({
intent: z.enum(["link"]),
to: z.string(),
error: z.enum(["invalidCode"]).optional(),
error: otpError.optional(),
})
export type OtpError = z.infer<typeof otpError>
export default async function SASxScandicOneTimePasswordPage({
searchParams,
params,
@@ -37,37 +40,48 @@ export default async function SASxScandicOneTimePasswordPage({
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()
if (error || !data) {
throw error || new Error("OTP verification failed")
}
redirect(`/${params.lang}/sas-x-scandic/otp?${search}`)
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({
...searchParams,
error: "invalidCode",
}).toString()
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) {
case "link":
return handleLinkAccount({ lang: params.lang })
default:
throw new Error("")
}
}
@@ -93,7 +107,7 @@ export default async function SASxScandicOneTimePasswordPage({
})}
otpLength={6}
onSubmit={handleOtpVerified}
error={error ? errors[error] : undefined}
error={error}
/>
)
}