From 467510dd5907320900456313b6962c34f88baa23 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 27 May 2026 14:31:08 +0200 Subject: [PATCH 1/4] fix(ui): improve invitation error messages - Map invitation API errors with status, code, and detail - Preserve sign-up response status for invitation handling - Add regression coverage for invitation error mapping --- ui/actions/auth/auth.test.ts | 66 ++++++ ui/actions/auth/auth.ts | 2 +- .../accept/accept-invitation-client.tsx | 41 +--- ui/components/auth/oss/sign-up-form.tsx | 20 ++ ui/lib/invitation-errors.test.ts | 200 ++++++++++++++++++ ui/lib/invitation-errors.ts | 124 +++++++++++ ui/types/components.ts | 12 +- 7 files changed, 423 insertions(+), 42 deletions(-) create mode 100644 ui/actions/auth/auth.test.ts create mode 100644 ui/lib/invitation-errors.test.ts create mode 100644 ui/lib/invitation-errors.ts 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/accept/accept-invitation-client.tsx b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx index f334053b481..4d03526ea26 100644 --- a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx +++ b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx @@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from "react"; import { acceptInvitation } from "@/actions/invitations"; import { Button } from "@/components/shadcn"; +import { getInvitationErrorDisplay } from "@/lib/invitation-errors"; type AcceptState = | { kind: "no-token" } @@ -15,41 +16,6 @@ type AcceptState = | { 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, @@ -72,7 +38,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, + "accept", + ); setState({ kind: "error", message, canRetry, needsSignOut }); } else { router.push("/"); diff --git a/ui/components/auth/oss/sign-up-form.tsx b/ui/components/auth/oss/sign-up-form.tsx index 6d971b5dabb..b1c78788c05 100644 --- a/ui/components/auth/oss/sign-up-form.tsx +++ b/ui/components/auth/oss/sign-up-form.tsx @@ -21,6 +21,10 @@ import { FormField, FormMessage, } from "@/components/ui/form"; +import { + getInvitationErrorDisplay, + isInvitationTokenError, +} from "@/lib/invitation-errors"; import { ApiError, SignUpFormData, signUpSchema } from "@/types"; const AUTH_ERROR_PATHS = { @@ -86,6 +90,22 @@ 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] }, + "signup", + ); + form.setError("invitationToken", { + type: "server", + message, + }); + return; + } + newUser.errors.forEach((error: ApiError) => { const errorMessage = error.detail; const pointer = error.source?.pointer; diff --git a/ui/lib/invitation-errors.test.ts b/ui/lib/invitation-errors.test.ts new file mode 100644 index 00000000000..0341cdc902e --- /dev/null +++ b/ui/lib/invitation-errors.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; + +import { + getInvitationErrorDisplay, + 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, "accept"); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.expired); + expect(result.canRetry).toBe(false); + expect(result.needsSignOut).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, "accept"); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.noLongerValid); + expect(result.canRetry).toBe(false); + expect(result.needsSignOut).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, "accept"); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.notValid); + expect(result.canRetry).toBe(false); + expect(result.needsSignOut).toBe(false); + }); + }); + + describe("when mapping invitation signup errors", () => { + it("should identify invitation token data errors", () => { + // Given + const error = { + status: "400", + code: "invalid", + detail: "Invalid invitation code.", + source: { pointer: "/data" }, + }; + + // When + const result = isInvitationTokenError(error); + + // Then + expect(result).toBe(true); + }); + + 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, "signup"); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.invalidFallback); + 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, "signup"); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.notValid); + expect(result.canRetry).toBe(false); + expect(result.needsSignOut).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, "accept"); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.invalidFallback); + 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, "accept"); + + // Then + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.unexpected); + expect(result.canRetry).toBe(true); + }); + }); +}); diff --git a/ui/lib/invitation-errors.ts b/ui/lib/invitation-errors.ts new file mode 100644 index 00000000000..def8cb29d2c --- /dev/null +++ b/ui/lib/invitation-errors.ts @@ -0,0 +1,124 @@ +import type { ApiError, ApiResponse } from "@/types"; + +const INVITATION_ERROR_DETAIL = { + NO_LONGER_VALID: "This invitation is no longer valid.", +} as const; + +const INVITATION_ERROR_POINTER = { + DATA: "/data", + INVITATION_TOKEN: "/data/attributes/invitation_token", +} as const; + +const INVITATION_ERROR_CODE = { + INVALID: "invalid", + NOT_FOUND: "not_found", + TOKEN_EXPIRED: "token_expired", +} as const; + +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.", + noLongerValid: + "This invitation is no longer valid. Please contact your administrator for a new invitation.", + notValid: "This invitation is not valid. Please check the link you received.", + invalidFallback: + "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; + needsSignOut: 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.DATA || + 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.status === 410 && code === INVITATION_ERROR_CODE.TOKEN_EXPIRED) { + return { + message: INVITATION_ERROR_MESSAGES.expired, + canRetry: false, + needsSignOut: false, + }; + } + + if ( + response.status === 400 && + code === INVITATION_ERROR_CODE.INVALID && + detail === INVITATION_ERROR_DETAIL.NO_LONGER_VALID + ) { + return { + message: INVITATION_ERROR_MESSAGES.noLongerValid, + canRetry: false, + needsSignOut: false, + }; + } + + if (response.status === 404 && code === INVITATION_ERROR_CODE.NOT_FOUND) { + return { + message: INVITATION_ERROR_MESSAGES.notValid, + canRetry: false, + needsSignOut: false, + }; + } + + if ( + flow === INVITATION_ERROR_FLOW.SIGNUP && + response.status === 400 && + code === INVITATION_ERROR_CODE.INVALID && + firstError && + isInvitationTokenError(firstError) + ) { + return { + message: INVITATION_ERROR_MESSAGES.notValid, + canRetry: false, + needsSignOut: false, + }; + } + + if (code === INVITATION_ERROR_CODE.INVALID) { + return { + message: INVITATION_ERROR_MESSAGES.invalidFallback, + canRetry: false, + needsSignOut: false, + }; + } + + return { + message: INVITATION_ERROR_MESSAGES.unexpected, + canRetry: true, + needsSignOut: false, + }; +} 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 = { From c382ec49579fd0316ed3ceb91f921f87d0ee5918 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 27 May 2026 14:47:29 +0200 Subject: [PATCH 2/4] docs(ui): update changelog - Document invitation error message fix --- ui/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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) --- From bcf98c80d9dec3b49fa588595aa31a1ddee5dd34 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 27 May 2026 14:59:00 +0200 Subject: [PATCH 3/4] refactor(ui): reuse invitation constants - Replace invitation flow literals with shared constants - Reuse a form error type constant in signup errors --- .../accept/accept-invitation-client.tsx | 7 ++++-- ui/components/auth/oss/sign-up-form.tsx | 25 +++++++++++++------ ui/lib/invitation-errors.ts | 2 +- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx index 4d03526ea26..fb7e17a03ff 100644 --- a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx +++ b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx @@ -8,7 +8,10 @@ import { useEffect, useRef, useState } from "react"; import { acceptInvitation } from "@/actions/invitations"; import { Button } from "@/components/shadcn"; -import { getInvitationErrorDisplay } from "@/lib/invitation-errors"; +import { + getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, +} from "@/lib/invitation-errors"; type AcceptState = | { kind: "no-token" } @@ -40,7 +43,7 @@ export function AcceptInvitationClient({ if (result?.error) { const { message, canRetry, needsSignOut } = getInvitationErrorDisplay( result, - "accept", + INVITATION_ERROR_FLOW.ACCEPT, ); setState({ kind: "error", message, canRetry, needsSignOut }); } else { diff --git a/ui/components/auth/oss/sign-up-form.tsx b/ui/components/auth/oss/sign-up-form.tsx index b1c78788c05..ee43aaa53db 100644 --- a/ui/components/auth/oss/sign-up-form.tsx +++ b/ui/components/auth/oss/sign-up-form.tsx @@ -23,6 +23,7 @@ import { } from "@/components/ui/form"; import { getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, isInvitationTokenError, } from "@/lib/invitation-errors"; import { ApiError, SignUpFormData, signUpSchema } from "@/types"; @@ -35,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, @@ -97,10 +102,10 @@ export const SignUpForm = ({ if (invitationToken && invitationTokenError) { const { message } = getInvitationErrorDisplay( { status: newUser.status, errors: [invitationTokenError] }, - "signup", + INVITATION_ERROR_FLOW.SIGNUP, ); form.setError("invitationToken", { - type: "server", + type: FORM_ERROR_TYPE.SERVER, message, }); return; @@ -111,26 +116,32 @@ export const SignUpForm = ({ 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; diff --git a/ui/lib/invitation-errors.ts b/ui/lib/invitation-errors.ts index def8cb29d2c..32ca928a9e8 100644 --- a/ui/lib/invitation-errors.ts +++ b/ui/lib/invitation-errors.ts @@ -15,7 +15,7 @@ const INVITATION_ERROR_CODE = { TOKEN_EXPIRED: "token_expired", } as const; -const INVITATION_ERROR_FLOW = { +export const INVITATION_ERROR_FLOW = { ACCEPT: "accept", SIGNUP: "signup", } as const; From d97f698f07fcf74b14a0ddd472d18071ec488cfa Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Wed, 27 May 2026 16:29:21 +0200 Subject: [PATCH 4/4] fix(ui): address invitation review feedback - Move invitation error mapping under the invitation feature - Remove unreachable sign-out handling from accept errors - Tighten retry and invitation-token error detection --- .../_lib}/invitation-errors.test.ts | 77 ++++++++++++++----- .../invitation/_lib}/invitation-errors.ts | 47 +++++------ .../accept/accept-invitation-client.tsx | 30 ++------ ui/components/auth/oss/sign-up-form.tsx | 17 ++-- 4 files changed, 93 insertions(+), 78 deletions(-) rename ui/{lib => app/(auth)/invitation/_lib}/invitation-errors.test.ts (67%) rename ui/{lib => app/(auth)/invitation/_lib}/invitation-errors.ts (73%) diff --git a/ui/lib/invitation-errors.test.ts b/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts similarity index 67% rename from ui/lib/invitation-errors.test.ts rename to ui/app/(auth)/invitation/_lib/invitation-errors.test.ts index 0341cdc902e..f988eff4086 100644 --- a/ui/lib/invitation-errors.test.ts +++ b/ui/app/(auth)/invitation/_lib/invitation-errors.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, INVITATION_ERROR_MESSAGES, isInvitationTokenError, } from "./invitation-errors"; @@ -22,12 +23,14 @@ describe("getInvitationErrorDisplay", () => { }; // When - const result = getInvitationErrorDisplay(response, "accept"); + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); // Then - expect(result.message).toBe(INVITATION_ERROR_MESSAGES.expired); + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.EXPIRED); expect(result.canRetry).toBe(false); - expect(result.needsSignOut).toBe(false); }); it("should show no-longer-valid message for already accepted or revoked invitations", () => { @@ -44,12 +47,14 @@ describe("getInvitationErrorDisplay", () => { }; // When - const result = getInvitationErrorDisplay(response, "accept"); + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); // Then - expect(result.message).toBe(INVITATION_ERROR_MESSAGES.noLongerValid); + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NO_LONGER_VALID); expect(result.canRetry).toBe(false); - expect(result.needsSignOut).toBe(false); }); it("should show not-valid message for missing invitation tokens", () => { @@ -66,22 +71,41 @@ describe("getInvitationErrorDisplay", () => { }; // When - const result = getInvitationErrorDisplay(response, "accept"); + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); // Then - expect(result.message).toBe(INVITATION_ERROR_MESSAGES.notValid); + 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); - expect(result.needsSignOut).toBe(false); }); }); describe("when mapping invitation signup errors", () => { - it("should identify invitation token data errors", () => { + it("should not identify generic data errors as invitation token errors", () => { // Given const error = { status: "400", code: "invalid", - detail: "Invalid invitation code.", + detail: "Invalid request data.", source: { pointer: "/data" }, }; @@ -89,7 +113,7 @@ describe("getInvitationErrorDisplay", () => { const result = isInvitationTokenError(error); // Then - expect(result).toBe(true); + expect(result).toBe(false); }); it("should identify invitation token field errors", () => { @@ -123,10 +147,13 @@ describe("getInvitationErrorDisplay", () => { }; // When - const result = getInvitationErrorDisplay(response, "signup"); + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.SIGNUP, + ); // Then - expect(result.message).toBe(INVITATION_ERROR_MESSAGES.invalidFallback); + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); expect(result.canRetry).toBe(false); }); @@ -145,12 +172,14 @@ describe("getInvitationErrorDisplay", () => { }; // When - const result = getInvitationErrorDisplay(response, "signup"); + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.SIGNUP, + ); // Then - expect(result.message).toBe(INVITATION_ERROR_MESSAGES.notValid); + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.NOT_VALID); expect(result.canRetry).toBe(false); - expect(result.needsSignOut).toBe(false); }); }); @@ -169,10 +198,13 @@ describe("getInvitationErrorDisplay", () => { }; // When - const result = getInvitationErrorDisplay(response, "accept"); + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); // Then - expect(result.message).toBe(INVITATION_ERROR_MESSAGES.invalidFallback); + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.INVALID_FALLBACK); expect(result.canRetry).toBe(false); }); @@ -190,10 +222,13 @@ describe("getInvitationErrorDisplay", () => { }; // When - const result = getInvitationErrorDisplay(response, "accept"); + const result = getInvitationErrorDisplay( + response, + INVITATION_ERROR_FLOW.ACCEPT, + ); // Then - expect(result.message).toBe(INVITATION_ERROR_MESSAGES.unexpected); + expect(result.message).toBe(INVITATION_ERROR_MESSAGES.UNEXPECTED); expect(result.canRetry).toBe(true); }); }); diff --git a/ui/lib/invitation-errors.ts b/ui/app/(auth)/invitation/_lib/invitation-errors.ts similarity index 73% rename from ui/lib/invitation-errors.ts rename to ui/app/(auth)/invitation/_lib/invitation-errors.ts index 32ca928a9e8..295035c4847 100644 --- a/ui/lib/invitation-errors.ts +++ b/ui/app/(auth)/invitation/_lib/invitation-errors.ts @@ -1,11 +1,14 @@ 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 = { - DATA: "/data", INVITATION_TOKEN: "/data/attributes/invitation_token", } as const; @@ -24,20 +27,20 @@ type InvitationErrorFlow = (typeof INVITATION_ERROR_FLOW)[keyof typeof INVITATION_ERROR_FLOW]; export const INVITATION_ERROR_MESSAGES = { - expired: + EXPIRED: "This invitation has expired. Please contact your administrator for a new one.", - noLongerValid: + NO_LONGER_VALID: "This invitation is no longer valid. Please contact your administrator for a new invitation.", - notValid: "This invitation is not valid. Please check the link you received.", - invalidFallback: + 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.", + UNEXPECTED: "Something went wrong while accepting the invitation.", } as const; interface InvitationErrorDisplay { message: string; canRetry: boolean; - needsSignOut: boolean; } interface InvitationErrorResponse @@ -52,10 +55,7 @@ function getFirstError( } export function isInvitationTokenError(error: ApiError): boolean { - return ( - error.source?.pointer === INVITATION_ERROR_POINTER.DATA || - error.source?.pointer === INVITATION_ERROR_POINTER.INVITATION_TOKEN - ); + return error.source?.pointer === INVITATION_ERROR_POINTER.INVITATION_TOKEN; } export function getInvitationErrorDisplay( @@ -66,11 +66,17 @@ export function getInvitationErrorDisplay( 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, + message: INVITATION_ERROR_MESSAGES.EXPIRED, canRetry: false, - needsSignOut: false, }; } @@ -80,17 +86,15 @@ export function getInvitationErrorDisplay( detail === INVITATION_ERROR_DETAIL.NO_LONGER_VALID ) { return { - message: INVITATION_ERROR_MESSAGES.noLongerValid, + message: INVITATION_ERROR_MESSAGES.NO_LONGER_VALID, canRetry: false, - needsSignOut: false, }; } if (response.status === 404 && code === INVITATION_ERROR_CODE.NOT_FOUND) { return { - message: INVITATION_ERROR_MESSAGES.notValid, + message: INVITATION_ERROR_MESSAGES.NOT_VALID, canRetry: false, - needsSignOut: false, }; } @@ -102,23 +106,20 @@ export function getInvitationErrorDisplay( isInvitationTokenError(firstError) ) { return { - message: INVITATION_ERROR_MESSAGES.notValid, + message: INVITATION_ERROR_MESSAGES.NOT_VALID, canRetry: false, - needsSignOut: false, }; } if (code === INVITATION_ERROR_CODE.INVALID) { return { - message: INVITATION_ERROR_MESSAGES.invalidFallback, + message: INVITATION_ERROR_MESSAGES.INVALID_FALLBACK, canRetry: false, - needsSignOut: false, }; } return { - message: INVITATION_ERROR_MESSAGES.unexpected, + message: INVITATION_ERROR_MESSAGES.UNEXPECTED, canRetry: true, - needsSignOut: false, }; } diff --git a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx index fb7e17a03ff..0ed6346de71 100644 --- a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx +++ b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx @@ -3,20 +3,19 @@ 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 { Button } from "@/components/shadcn"; import { getInvitationErrorDisplay, INVITATION_ERROR_FLOW, -} from "@/lib/invitation-errors"; +} 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" }; export function AcceptInvitationClient({ @@ -41,23 +40,16 @@ export function AcceptInvitationClient({ const result = await acceptInvitation(token); if (result?.error) { - const { message, canRetry, needsSignOut } = getInvitationErrorDisplay( + const { message, canRetry } = getInvitationErrorDisplay( result, INVITATION_ERROR_FLOW.ACCEPT, ); - setState({ kind: "error", message, canRetry, needsSignOut }); + 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; @@ -125,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 ee43aaa53db..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"; @@ -21,11 +26,6 @@ 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 = { @@ -33,7 +33,6 @@ 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 = { @@ -139,12 +138,6 @@ export const SignUpForm = ({ message: errorMessage, }); break; - case AUTH_ERROR_PATHS.INVITATION_TOKEN: - form.setError("invitationToken", { - type: FORM_ERROR_TYPE.SERVER, - message: errorMessage, - }); - break; default: toast({ variant: "destructive",