diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 5821b395bb9..dda54f1ac08 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed - Compliance page now loads the most recent scan when opened from the sidebar instead of showing the "no compliance data available" alert [(#11374)](https://github.com/prowler-cloud/prowler/pull/11374) +- Invitation links now show specific expired, no-longer-valid, and invalid-token messages based on API error responses [(#11376)](https://github.com/prowler-cloud/prowler/pull/11376) --- diff --git a/ui/actions/auth/auth.test.ts b/ui/actions/auth/auth.test.ts new file mode 100644 index 00000000000..80409dd7e37 --- /dev/null +++ b/ui/actions/auth/auth.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn(), +})); + +vi.mock("next-auth", () => ({ + AuthError: class AuthError extends Error {}, +})); + +vi.mock("@/auth.config", () => ({ + signIn: vi.fn(), + signOut: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", +})); + +vi.mock("@/lib/sentry-breadcrumbs", () => ({ + addAuthEvent: vi.fn(), +})); + +import { createNewUser } from "./auth"; + +describe("auth actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + }); + + it("should preserve HTTP status when user creation fails", async () => { + // Given + const apiResponse = { + errors: [ + { + status: "400", + code: "invalid", + detail: "Invalid invitation code.", + source: { pointer: "/data/attributes/invitation_token" }, + }, + ], + }; + fetchMock.mockResolvedValue( + new Response(JSON.stringify(apiResponse), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + ); + + // When + const result = await createNewUser({ + name: "Jane Doe", + email: "jane@example.com", + password: "TestPassword123!", + confirmPassword: "TestPassword123!", + company: "Prowler", + invitationToken: "invitation-token", + termsAndConditions: undefined, + isSamlMode: false, + }); + + // Then + expect(result).toEqual({ ...apiResponse, status: 400 }); + }); +}); diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index ca6fd2d099f..f59d0319185 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -78,7 +78,7 @@ export const createNewUser = async (formData: SignUpFormData) => { const parsedResponse = await response.json(); if (!response.ok) { - return parsedResponse; + return { ...parsedResponse, status: response.status }; } return parsedResponse; diff --git a/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts b/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts new file mode 100644 index 00000000000..f988eff4086 --- /dev/null +++ b/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest"; + +import { + getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, + INVITATION_ERROR_MESSAGES, + isInvitationTokenError, +} from "./invitation-errors"; + +describe("getInvitationErrorDisplay", () => { + describe("when mapping invitation accept errors", () => { + it("should show expired message for token_expired responses", () => { + // Given + const response = { + status: 410, + errors: [ + { + status: "410", + code: "token_expired", + detail: "The invitation token has expired and is no longer valid.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.EXPIRED); + expect(result.canRetry).toBe(false); + }); + + it("should show no-longer-valid message for already accepted or revoked invitations", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "This invitation is no longer valid.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NO_LONGER_VALID); + expect(result.canRetry).toBe(false); + }); + + it("should show not-valid message for missing invitation tokens", () => { + // Given + const response = { + status: 404, + errors: [ + { + status: "404", + code: "not_found", + detail: "Invitation is not valid.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NOT_VALID); + expect(result.canRetry).toBe(false); + }); + + it("should not allow retry for client-side malformed tokens", () => { + // Given + const response = { + error: "Invalid invitation token", + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); + expect(result.canRetry).toBe(false); + }); + }); + + describe("when mapping invitation signup errors", () => { + it("should not identify generic data errors as invitation token errors", () => { + // Given + const error = { + status: "400", + code: "invalid", + detail: "Invalid request data.", + source: { pointer: "/data" }, + }; + + // When + const result = isInvitationTokenError(error); + + // Then + expect(result).toBe(false); + }); + + it("should identify invitation token field errors", () => { + // Given + const error = { + status: "400", + code: "invalid", + detail: "Invalid invitation code.", + source: { pointer: "/data/attributes/invitation_token" }, + }; + + // When + const result = isInvitationTokenError(error); + + // Then + expect(result).toBe(true); + }); + + it("should use generic invalid fallback for non-invitation signup errors", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "Invalid email address.", + source: { pointer: "/data/attributes/email" }, + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.SIGNUP, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); + expect(result.canRetry).toBe(false); + }); + + it("should show not-valid message for signup invalid invitation tokens", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "Invalid invitation code.", + source: { pointer: "/data/attributes/invitation_token" }, + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.SIGNUP, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NOT_VALID); + expect(result.canRetry).toBe(false); + }); + }); + + describe("when the response is unexpected", () => { + it("should use generic invalid fallback for unmapped invalid responses", () => { + // Given + const response = { + status: 400, + errors: [ + { + status: "400", + code: "invalid", + detail: "Unexpected invalid invitation response.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); + expect(result.canRetry).toBe(false); + }); + + it("should allow retry for unknown responses", () => { + // Given + const response = { + status: 500, + errors: [ + { + status: "500", + code: "server_error", + detail: "Something exploded.", + }, + ], + }; + + // When + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.UNEXPECTED); + expect(result.canRetry).toBe(true); + }); + }); +}); diff --git a/ui/app/(auth)/invitation/_lib/invitation-errors.ts b/ui/app/(auth)/invitation/_lib/invitation-errors.ts new file mode 100644 index 00000000000..295035c4847 --- /dev/null +++ b/ui/app/(auth)/invitation/_lib/invitation-errors.ts @@ -0,0 +1,125 @@ +import type { ApiError, ApiResponse } from "@/types"; + +const CLIENT_INVITATION_ERROR = { + INVALID_TOKEN: "Invalid invitation token", +} as const; + +const INVITATION_ERROR_DETAIL = { + NO_LONGER_VALID: "This invitation is no longer valid.", +} as const; + +const INVITATION_ERROR_POINTER = { + INVITATION_TOKEN: "/data/attributes/invitation_token", +} as const; + +const INVITATION_ERROR_CODE = { + INVALID: "invalid", + NOT_FOUND: "not_found", + TOKEN_EXPIRED: "token_expired", +} as const; + +export const INVITATION_ERROR_FLOW = { + ACCEPT: "accept", + SIGNUP: "signup", +} as const; + +type InvitationErrorFlow = + (typeof INVITATION_ERROR_FLOW)[keyof typeof INVITATION_ERROR_FLOW]; + +export const INVITATION_ERROR_MESSAGES = { + EXPIRED: + "This invitation has expired. Please contact your administrator for a new one.", + NO_LONGER_VALID: + "This invitation is no longer valid. Please contact your administrator for a new invitation.", + NOT_VALID: + "This invitation is not valid. Please check the link you received.", + INVALID_FALLBACK: + "This invitation is invalid. Please check the link or contact your administrator.", + UNEXPECTED: "Something went wrong while accepting the invitation.", +} as const; + +interface InvitationErrorDisplay { + message: string; + canRetry: boolean; +} + +interface InvitationErrorResponse + extends Pick { + errors?: ApiError[]; +} + +function getFirstError( + response: InvitationErrorResponse, +): ApiError | undefined { + return response.errors?.[0]; +} + +export function isInvitationTokenError(error: ApiError): boolean { + return error.source?.pointer === INVITATION_ERROR_POINTER.INVITATION_TOKEN; +} + +export function getInvitationErrorDisplay( + response: InvitationErrorResponse, + flow: InvitationErrorFlow, +): InvitationErrorDisplay { + const firstError = getFirstError(response); + const code = firstError?.code; + const detail = firstError?.detail; + + if (response.error === CLIENT_INVITATION_ERROR.INVALID_TOKEN) { + return { + message: INVITATION_ERROR_MESSAGES.INVALID_FALLBACK, + canRetry: false, + }; + } + + if (response.status === 410 && code === INVITATION_ERROR_CODE.TOKEN_EXPIRED) { + return { + message: INVITATION_ERROR_MESSAGES.EXPIRED, + canRetry: false, + }; + } + + if ( + response.status === 400 && + code === INVITATION_ERROR_CODE.INVALID && + detail === INVITATION_ERROR_DETAIL.NO_LONGER_VALID + ) { + return { + message: INVITATION_ERROR_MESSAGES.NO_LONGER_VALID, + canRetry: false, + }; + } + + if (response.status === 404 && code === INVITATION_ERROR_CODE.NOT_FOUND) { + return { + message: INVITATION_ERROR_MESSAGES.NOT_VALID, + canRetry: false, + }; + } + + if ( + flow === INVITATION_ERROR_FLOW.SIGNUP && + response.status === 400 && + code === INVITATION_ERROR_CODE.INVALID && + firstError && + isInvitationTokenError(firstError) + ) { + return { + message: INVITATION_ERROR_MESSAGES.NOT_VALID, + canRetry: false, + }; + } + + if (code === INVITATION_ERROR_CODE.INVALID) { + return { + message: INVITATION_ERROR_MESSAGES.INVALID_FALLBACK, + canRetry: false, + }; + } + + return { + message: INVITATION_ERROR_MESSAGES.UNEXPECTED, + canRetry: true, + }; +} diff --git a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx index f334053b481..0ed6346de71 100644 --- a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx +++ b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx @@ -3,53 +3,21 @@ import { Icon } from "@iconify/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { signOut } from "next-auth/react"; import { useEffect, useRef, useState } from "react"; import { acceptInvitation } from "@/actions/invitations"; +import { + getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, +} from "@/app/(auth)/invitation/_lib/invitation-errors"; import { Button } from "@/components/shadcn"; type AcceptState = | { kind: "no-token" } | { kind: "accepting" } - | { kind: "error"; message: string; canRetry: boolean; needsSignOut: boolean } + | { kind: "error"; message: string; canRetry: boolean } | { kind: "choose" }; -function mapApiError(status: number | undefined): { - message: string; - canRetry: boolean; - needsSignOut: boolean; -} { - switch (status) { - case 410: - return { - message: - "This invitation has expired. Please contact your administrator for a new one.", - canRetry: false, - needsSignOut: false, - }; - case 400: - return { - message: "This invitation has already been used.", - canRetry: false, - needsSignOut: false, - }; - case 404: - return { - message: - "This invitation was sent to a different email address. Please sign in with the correct account.", - canRetry: false, - needsSignOut: true, - }; - default: - return { - message: "Something went wrong while accepting the invitation.", - canRetry: true, - needsSignOut: false, - }; - } -} - export function AcceptInvitationClient({ isAuthenticated, token, @@ -72,20 +40,16 @@ export function AcceptInvitationClient({ const result = await acceptInvitation(token); if (result?.error) { - const { message, canRetry, needsSignOut } = mapApiError(result.status); - setState({ kind: "error", message, canRetry, needsSignOut }); + const { message, canRetry } = getInvitationErrorDisplay( + result, + INVITATION_ERROR_FLOW.ACCEPT, + ); + setState({ kind: "error", message, canRetry }); } else { router.push("/"); } } - async function handleSignOutAndRedirect() { - if (!token) return; - const callbackPath = `/invitation/accept?invitation_token=${encodeURIComponent(token)}`; - await signOut({ redirect: false }); - router.push(`/sign-in?callbackUrl=${encodeURIComponent(callbackPath)}`); - } - useEffect(() => { if (hasStartedRef.current) return; hasStartedRef.current = true; @@ -153,15 +117,9 @@ export function AcceptInvitationClient({

{state.message}

{state.canRetry && } - {state.needsSignOut ? ( - - ) : ( - - )} +
)} diff --git a/ui/components/auth/oss/sign-up-form.tsx b/ui/components/auth/oss/sign-up-form.tsx index 6d971b5dabb..e18ded58453 100644 --- a/ui/components/auth/oss/sign-up-form.tsx +++ b/ui/components/auth/oss/sign-up-form.tsx @@ -6,6 +6,11 @@ import { useRouter } from "next/navigation"; import { useForm, useWatch } from "react-hook-form"; import { createNewUser } from "@/actions/auth"; +import { + getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, + isInvitationTokenError, +} from "@/app/(auth)/invitation/_lib/invitation-errors"; import { AuthDivider } from "@/components/auth/oss/auth-divider"; import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link"; import { AuthLayout } from "@/components/auth/oss/auth-layout"; @@ -28,7 +33,10 @@ const AUTH_ERROR_PATHS = { EMAIL: "/data/attributes/email", PASSWORD: "/data/attributes/password", COMPANY_NAME: "/data/attributes/company_name", - INVITATION_TOKEN: "/data", +} as const; + +const FORM_ERROR_TYPE = { + SERVER: "server", } as const; export const SignUpForm = ({ @@ -86,31 +94,47 @@ export const SignUpForm = ({ router.push("/sign-in"); } } else { + const invitationTokenError = newUser.errors.find((error: ApiError) => + isInvitationTokenError(error), + ); + + if (invitationToken && invitationTokenError) { + const { message } = getInvitationErrorDisplay( + { status: newUser.status, errors: [invitationTokenError] }, + INVITATION_ERROR_FLOW.SIGNUP, + ); + form.setError("invitationToken", { + type: FORM_ERROR_TYPE.SERVER, + message, + }); + return; + } + newUser.errors.forEach((error: ApiError) => { const errorMessage = error.detail; const pointer = error.source?.pointer; switch (pointer) { case AUTH_ERROR_PATHS.NAME: - form.setError("name", { type: "server", message: errorMessage }); + form.setError("name", { + type: FORM_ERROR_TYPE.SERVER, + message: errorMessage, + }); break; case AUTH_ERROR_PATHS.EMAIL: - form.setError("email", { type: "server", message: errorMessage }); + form.setError("email", { + type: FORM_ERROR_TYPE.SERVER, + message: errorMessage, + }); break; case AUTH_ERROR_PATHS.COMPANY_NAME: form.setError("company", { - type: "server", + type: FORM_ERROR_TYPE.SERVER, message: errorMessage, }); break; case AUTH_ERROR_PATHS.PASSWORD: form.setError("password", { - type: "server", - message: errorMessage, - }); - break; - case AUTH_ERROR_PATHS.INVITATION_TOKEN: - form.setError("invitationToken", { - type: "server", + type: FORM_ERROR_TYPE.SERVER, message: errorMessage, }); break; diff --git a/ui/types/components.ts b/ui/types/components.ts index ba8cb531443..a375d6e15af 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -419,13 +419,15 @@ export interface SearchParamsProps { [key: string]: string | string[] | undefined; } +export interface ApiErrorSource { + pointer?: string; +} + export interface ApiError { detail: string; - status: string; - source: { - pointer: string; - }; - code: string; + status?: string; + source?: ApiErrorSource; + code?: string; } export type ApiResponse = {