Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
66 changes: 66 additions & 0 deletions ui/actions/auth/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
2 changes: 1 addition & 1 deletion ui/actions/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 8 additions & 36 deletions ui/app/(auth)/invitation/accept/accept-invitation-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,48 +8,17 @@ import { useEffect, useRef, useState } from "react";

import { acceptInvitation } from "@/actions/invitations";
import { Button } from "@/components/shadcn";
import {
getInvitationErrorDisplay,
INVITATION_ERROR_FLOW,
} from "@/lib/invitation-errors";

type AcceptState =
| { kind: "no-token" }
| { kind: "accepting" }
| { kind: "error"; message: string; canRetry: boolean; needsSignOut: 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,
Expand All @@ -72,7 +41,10 @@ export function AcceptInvitationClient({
const result = await acceptInvitation(token);

if (result?.error) {
const { message, canRetry, needsSignOut } = mapApiError(result.status);
const { message, canRetry, needsSignOut } = getInvitationErrorDisplay(
result,
INVITATION_ERROR_FLOW.ACCEPT,
);
setState({ kind: "error", message, canRetry, needsSignOut });
} else {
router.push("/");
Expand Down
41 changes: 36 additions & 5 deletions ui/components/auth/oss/sign-up-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import {
FormField,
FormMessage,
} from "@/components/ui/form";
import {
getInvitationErrorDisplay,
INVITATION_ERROR_FLOW,
isInvitationTokenError,
} from "@/lib/invitation-errors";
import { ApiError, SignUpFormData, signUpSchema } from "@/types";

const AUTH_ERROR_PATHS = {
Expand All @@ -31,6 +36,10 @@ const AUTH_ERROR_PATHS = {
INVITATION_TOKEN: "/data",
} as const;

const FORM_ERROR_TYPE = {
SERVER: "server",
} as const;

export const SignUpForm = ({
invitationToken,
googleAuthUrl,
Expand Down Expand Up @@ -86,31 +95,53 @@ 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",
type: FORM_ERROR_TYPE.SERVER,
message: errorMessage,
});
break;
case AUTH_ERROR_PATHS.INVITATION_TOKEN:
form.setError("invitationToken", {
type: "server",
type: FORM_ERROR_TYPE.SERVER,
message: errorMessage,
});
break;
Expand Down
Loading
Loading