mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
fix(ui): improve invitation error messages (#11376)
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user