From 18288cb8497cc6b350fc58facc61f875b02cfa66 Mon Sep 17 00:00:00 2001 From: Anton Gunnarsson Date: Fri, 7 Feb 2025 14:18:00 +0000 Subject: [PATCH] Merged in feat/sas-otp-error-handling (pull request #1272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feat/sas otp error handling * Improve error handling for SAS OTP * Remove failing and deprecated test Approved-by: Joakim Jäderberg --- .../sas-x-scandic/callback/route.ts | 14 ++- .../components/TooManyFailedAttemptsError.tsx | 28 ++++++ .../(protected)/sas-x-scandic/error/page.tsx | 19 +++- .../(protected)/sas-x-scandic/link/page.tsx | 8 +- .../sas-x-scandic/otp/OneTimePasswordForm.tsx | 88 +++++++++++-------- .../(protected)/sas-x-scandic/otp/page.tsx | 56 +++++++----- i18n/dictionaries/en.json | 1 + .../partners/sas/otp/request/requestOtp.ts | 24 +++-- .../sas/otp/request/requestOtpError.test.ts | 20 ----- .../sas/otp/request/requestOtpError.ts | 11 ++- .../partners/sas/otp/verify/verifyOtp.ts | 12 ++- 11 files changed, 187 insertions(+), 94 deletions(-) create mode 100644 app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/components/TooManyFailedAttemptsError.tsx diff --git a/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/callback/route.ts b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/callback/route.ts index 931b5dbad..c1b2b432d 100644 --- a/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/callback/route.ts +++ b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/callback/route.ts @@ -82,13 +82,23 @@ export async function GET( 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`) } + 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) const otpUrl = new URL( diff --git a/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/components/TooManyFailedAttemptsError.tsx b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/components/TooManyFailedAttemptsError.tsx new file mode 100644 index 000000000..e193e5b69 --- /dev/null +++ b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/components/TooManyFailedAttemptsError.tsx @@ -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 ( + + + {intl.formatMessage({ + id: "Please wait 1 hour before trying again.", + })} + + + + ) +} diff --git a/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/error/page.tsx b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/error/page.tsx index feee2d359..b9d362a77 100644 --- a/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/error/page.tsx +++ b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/error/page.tsx @@ -1,9 +1,12 @@ import Body from "@/components/TempDesignSystem/Text/Body" import { getIntl } from "@/i18n" +import { AlreadyLinkedError } from "../components/AlreadyLinkedError" import { DateOfBirthError } from "../components/DateOfBirthError" import { GenericError } from "../components/GenericError" import { SASModalContactBlock } from "../components/SASModal" +import { TooManyCodesError } from "../components/TooManyCodesError" +import { TooManyFailedAttemptsError } from "../components/TooManyFailedAttemptsError" import type { LangParams, PageArgs, SearchParams } from "@/types/params" @@ -13,10 +16,24 @@ export default async function Page({ }: PageArgs & SearchParams<{ errorCode?: "dateOfBirthMismatch" }>) { const intl = await getIntl() - if (searchParams.errorCode === "dateOfBirthMismatch") { + const { errorCode } = searchParams + + if (errorCode === "dateOfBirthMismatch") { return } + if (errorCode === "tooManyFailedAttempts") { + return + } + + if (errorCode === "tooManyCodes") { + return + } + + if (errorCode === "alreadyLinked") { + return + } + return ( - ) : ( + if (alreadyLinked) { + redirect(`/${params.lang}/sas-x-scandic/error?errorCode=alreadyLinked`) + } + + return ( Promise - 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 + case "NOTSENT": + router.push(`/${params.lang}/sas-x-scandic/error`) + return + 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) => ( + + {str} + + ) + + const errorMessages: Record = { + invalidCode: intl.formatMessage({ + id: "The code you’ve entered is incorrect.", + }), + expiredCode: intl.formatMessage( + { + id: "The code you’ve entered have expired. Resend code.", + }, + { + resendOtpLink: getResendOtpLink, + } + ), + } + + const errorMessage = error ? errorMessages[error] : undefined + return ( {heading} @@ -110,10 +155,10 @@ export default function OneTimePasswordForm({ )} /> - {error && ( + {errorMessage && (
- {error} + {errorMessage}
)}
@@ -124,18 +169,7 @@ export default function OneTimePasswordForm({ id: "Didn't receive a code? Resend code", }, { - resendOtpLink: (str) => ( - - {str} - - ), + resendOtpLink: getResendOtpLink, } )} @@ -176,23 +210,3 @@ const getRequestErrorBody = ( }) } } - -const getVerifyErrorBody = ( - intl: ReturnType, - 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", - }) - } -} diff --git a/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/otp/page.tsx b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/otp/page.tsx index cf7df475e..b03dfd0a1 100644 --- a/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/otp/page.tsx +++ b/app/[lang]/(partner)/(sas)/(protected)/sas-x-scandic/otp/page.tsx @@ -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 + 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 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() + 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} /> ) } diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index cf835975e..b59fca4eb 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -523,6 +523,7 @@ "Terms and conditions": "Terms and conditions", "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 contact us.": "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 contact us.", + "The code you’ve entered have expired. Resend code.": "The code you’ve entered have expired. Resend code.", "The code you’ve entered is incorrect.": "The code you’ve entered is incorrect.", "The new price is": "The new price is", "The price has increased": "The price has increased", diff --git a/server/routers/partners/sas/otp/request/requestOtp.ts b/server/routers/partners/sas/otp/request/requestOtp.ts index be0434001..700989a5f 100644 --- a/server/routers/partners/sas/otp/request/requestOtp.ts +++ b/server/routers/partners/sas/otp/request/requestOtp.ts @@ -18,7 +18,16 @@ import type { OtpState } from "../getOTPState" const inputSchema = z.object({}) const outputSchema = z.object({ - status: z.string(), + status: z.enum([ + "VERIFIED", + "ABUSED", + "EXPIRED", + "PENDING", + "RETRY", + "SENT", + "NULL", + "NOTSENT", + ]), referenceId: z.string().uuid(), databaseUUID: z.string().uuid(), otpExpiration: z.number(), @@ -42,14 +51,15 @@ export const requestOtp = protectedProcedure tokenResponse.status, 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) { + console.error("[SAS] requestOtp error", body) + + if (!tokenResponse.ok) { + throw createError(body) + } throw createError(parseResult.error) } diff --git a/server/routers/partners/sas/otp/request/requestOtpError.test.ts b/server/routers/partners/sas/otp/request/requestOtpError.test.ts index 5bac6bcca..371711c34 100644 --- a/server/routers/partners/sas/otp/request/requestOtpError.test.ts +++ b/server/routers/partners/sas/otp/request/requestOtpError.test.ts @@ -4,13 +4,6 @@ import { parseSASRequestOtpError } from "./requestOtpError" describe("requestOtpError", () => { it("parses error with invalid error code", () => { - const error = { - status: "status", - error: "error", - errorCode: "a", - databaseUUID: "9ffefefe-df0e-4229-9792-5ed31bef1db4", - } - const actual = parseSASRequestOtpError({ status: "status", error: "error", @@ -21,17 +14,4 @@ describe("requestOtpError", () => { 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", - }) - }) }) diff --git a/server/routers/partners/sas/otp/request/requestOtpError.ts b/server/routers/partners/sas/otp/request/requestOtpError.ts index 8f1621c5f..bd2b358eb 100644 --- a/server/routers/partners/sas/otp/request/requestOtpError.ts +++ b/server/routers/partners/sas/otp/request/requestOtpError.ts @@ -51,7 +51,16 @@ const getErrorCodeByNumber = (number: number): RequestOtpResponseError => { } const sasOtpRequestErrorSchema = z.object({ - status: z.string(), + status: z.enum([ + "VERIFIED", + "ABUSED", + "EXPIRED", + "PENDING", + "RETRY", + "SENT", + "NULL", + "NOTSENT", + ]), otpExpiration: z.string().datetime(), error: z.string(), errorCode: z.number(), diff --git a/server/routers/partners/sas/otp/verify/verifyOtp.ts b/server/routers/partners/sas/otp/verify/verifyOtp.ts index 91e7e372e..3c868b658 100644 --- a/server/routers/partners/sas/otp/verify/verifyOtp.ts +++ b/server/routers/partners/sas/otp/verify/verifyOtp.ts @@ -17,7 +17,16 @@ const inputSchema = 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(), databaseUUID: z.string().uuid().optional(), }) @@ -29,7 +38,6 @@ export const verifyOtp = protectedProcedure const sasAuthToken = getSasToken() if (!sasAuthToken) { - // TODO: Should we verify that the SAS token isn't expired? throw createError("AUTH_TOKEN_NOT_FOUND") }