fix(ui): improve invitation error messages (#11376)

This commit is contained in:
Alan Buscaglia
2026-05-28 09:37:28 +02:00
committed by GitHub
parent 03dfa3816d
commit d98933c2e7
8 changed files with 483 additions and 72 deletions
+1
View File
@@ -11,6 +11,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)
---
+66
View File
@@ -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 });
});
});
+1 -1
View File
@@ -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;
@@ -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);
});
});
});
@@ -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<ApiResponse, "error" | "status"> {
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,
};
}
@@ -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({
<p className="text-default-500">{state.message}</p>
<div className="flex gap-3">
{state.canRetry && <Button onClick={doAccept}>Retry</Button>}
{state.needsSignOut ? (
<Button variant="outline" onClick={handleSignOutAndRedirect}>
Sign in with a different account
</Button>
) : (
<Button asChild variant="outline">
<Link href="/sign-in">Go to Sign In</Link>
</Button>
)}
<Button asChild variant="outline">
<Link href="/sign-in">Go to Sign In</Link>
</Button>
</div>
</div>
)}
+35 -11
View File
@@ -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;
+7 -5
View File
@@ -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 = {