diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 834325f07..25c93cbd4 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -182,6 +182,18 @@ "completeRegister": { "title": "Registrierung abschließen", "description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen." + }, + "accountNotFound": { + "title": "Konto nicht gefunden", + "description": "Wir konnten kein Konto finden, das mit Ihren Identitätsanbieter-Anmeldedaten verknüpft ist.", + "info": "Es wurde kein bestehendes Konto gefunden. Bitte melden Sie sich mit einem bestehenden Konto an oder wenden Sie sich an Ihren Administrator.", + "backToLogin": "Zurück zur Anmeldung" + }, + "registrationFailed": { + "title": "Registrierung nicht verfügbar", + "description": "Wir konnten den Registrierungsprozess nicht abschließen.", + "info": "Die Organisation für die Registrierung konnte nicht ermittelt werden. Bitte wenden Sie sich an Ihren Administrator.", + "backToLogin": "Zurück zur Anmeldung" } }, "ldap": { diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 3a2ebcbee..dd7a2685d 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -182,6 +182,18 @@ "completeRegister": { "title": "Complete your data", "description": "You need to complete your registration by providing your email address and name." + }, + "accountNotFound": { + "title": "Account Not Found", + "description": "We couldn't find an account associated with your identity provider credentials.", + "info": "No existing account was found. Please sign in with an existing account or contact your administrator for assistance.", + "backToLogin": "Back to Login" + }, + "registrationFailed": { + "title": "Registration Not Available", + "description": "We couldn't complete the registration process.", + "info": "Unable to determine the organization for registration. Please contact your administrator for assistance.", + "backToLogin": "Back to Login" } }, "ldap": { diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index e38923f98..8929a9d0b 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -182,6 +182,18 @@ "completeRegister": { "title": "Completar registro", "description": "Para completar el registro, debes establecer una contraseña." + }, + "accountNotFound": { + "title": "Cuenta no encontrada", + "description": "No pudimos encontrar una cuenta asociada con tus credenciales del proveedor de identidad.", + "info": "No se encontró ninguna cuenta existente. Por favor, inicia sesión con una cuenta existente o contacta a tu administrador para obtener asistencia.", + "backToLogin": "Volver al inicio de sesión" + }, + "registrationFailed": { + "title": "Registro no disponible", + "description": "No pudimos completar el proceso de registro.", + "info": "No se pudo determinar la organización para el registro. Por favor, contacta a tu administrador para obtener asistencia.", + "backToLogin": "Volver al inicio de sesión" } }, "ldap": { diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 8d29c5170..51ab723fa 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -182,6 +182,18 @@ "completeRegister": { "title": "Completa la registrazione", "description": "Completa la registrazione del tuo account." + }, + "accountNotFound": { + "title": "Account non trovato", + "description": "Non siamo riusciti a trovare un account associato alle tue credenziali del provider di identità.", + "info": "Nessun account esistente è stato trovato. Accedi con un account esistente o contatta il tuo amministratore per assistenza.", + "backToLogin": "Torna al login" + }, + "registrationFailed": { + "title": "Registrazione non disponibile", + "description": "Non siamo riusciti a completare il processo di registrazione.", + "info": "Impossibile determinare l'organizzazione per la registrazione. Contatta il tuo amministratore per assistenza.", + "backToLogin": "Torna al login" } }, "ldap": { diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 3113f8805..1f62bca4d 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -182,6 +182,18 @@ "completeRegister": { "title": "Ukończ rejestrację", "description": "Ukończ rejestrację swojego konta." + }, + "accountNotFound": { + "title": "Nie znaleziono konta", + "description": "Nie mogliśmy znaleźć konta powiązanego z danymi logowania Twojego dostawcy tożsamości.", + "info": "Nie znaleziono istniejącego konta. Zaloguj się przy użyciu istniejącego konta lub skontaktuj się z administratorem w celu uzyskania pomocy.", + "backToLogin": "Powrót do logowania" + }, + "registrationFailed": { + "title": "Rejestracja niedostępna", + "description": "Nie mogliśmy ukończyć procesu rejestracji.", + "info": "Nie można określić organizacji do rejestracji. Skontaktuj się z administratorem w celu uzyskania pomocy.", + "backToLogin": "Powrót do logowania" } }, "ldap": { diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 03ea8a7f5..5eb2eb17c 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -182,6 +182,18 @@ "completeRegister": { "title": "Завершите регистрацию", "description": "Завершите регистрацию вашего аккаунта." + }, + "accountNotFound": { + "title": "Аккаунт не найден", + "description": "Мы не смогли найти аккаунт, связанный с учетными данными вашего провайдера идентификации.", + "info": "Существующий аккаунт не найден. Пожалуйста, войдите с существующим аккаунтом или обратитесь к администратору за помощью.", + "backToLogin": "Вернуться к входу" + }, + "registrationFailed": { + "title": "Регистрация недоступна", + "description": "Мы не смогли завершить процесс регистрации.", + "info": "Не удалось определить организацию для регистрации. Пожалуйста, обратитесь к администратору за помощью.", + "backToLogin": "Вернуться к входу" } }, "ldap": { @@ -319,7 +331,7 @@ "userNotFound": "Пользователь не найден в системе", "couldNotLinkIDP": "Не удалось привязать IDP к пользователю", "couldNotRegisterUser": "Не удалось зарегистрировать пользователя" - } + } }, "invite": { "title": "Пригласить пользователя", diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 4d0d259e3..429da9bfb 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -182,6 +182,18 @@ "completeRegister": { "title": "完成注册", "description": "完成您的账户注册。" + }, + "accountNotFound": { + "title": "未找到账户", + "description": "我们无法找到与您的身份提供商凭据关联的账户。", + "info": "未找到现有账户。请使用现有账户登录或联系您的管理员寻求帮助。", + "backToLogin": "返回登录" + }, + "registrationFailed": { + "title": "注册不可用", + "description": "我们无法完成注册过程。", + "info": "无法确定注册的组织。请联系您的管理员寻求帮助。", + "backToLogin": "返回登录" } }, "ldap": { diff --git a/apps/login/src/app/(login)/idp/[provider]/account-not-found/page.tsx b/apps/login/src/app/(login)/idp/[provider]/account-not-found/page.tsx new file mode 100644 index 000000000..45c2af13f --- /dev/null +++ b/apps/login/src/app/(login)/idp/[provider]/account-not-found/page.tsx @@ -0,0 +1,66 @@ +import { Alert, AlertType } from "@/components/alert"; +import { Button } from "@/components/button"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; +import Link from "next/link"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("idp"); + return { title: t("accountNotFound.title") }; +} + +export default async function Page(props: { searchParams: Promise> }) { + const searchParams = await props.searchParams; + const { organization, postErrorRedirectUrl } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+

+ +

+

+ +

+ +
+ + + +
+ + {postErrorRedirectUrl && ( + + + + )} +
+
+ ); +} diff --git a/apps/login/src/app/(login)/idp/[provider]/registration-failed/page.tsx b/apps/login/src/app/(login)/idp/[provider]/registration-failed/page.tsx new file mode 100644 index 000000000..9a8dcfe3b --- /dev/null +++ b/apps/login/src/app/(login)/idp/[provider]/registration-failed/page.tsx @@ -0,0 +1,66 @@ +import { Alert, AlertType } from "@/components/alert"; +import { Button } from "@/components/button"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; +import Link from "next/link"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("idp"); + return { title: t("registrationFailed.title") }; +} + +export default async function Page(props: { searchParams: Promise> }) { + const searchParams = await props.searchParams; + const { organization, postErrorRedirectUrl } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+

+ +

+

+ +

+ +
+ + + +
+ + {postErrorRedirectUrl && ( + + + + )} +
+
+ ); +} diff --git a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx index a19a7f634..7723c21f6 100644 --- a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx @@ -20,6 +20,7 @@ import { updateHuman, } from "@/lib/zitadel"; import { ConnectError, create } from "@zitadel/client"; +import { redirect } from "next/navigation"; import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; import { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; @@ -72,7 +73,7 @@ export default async function Page(props: { }) { const params = await props.params; const searchParams = await props.searchParams; - let { id, token, requestId, organization, link } = searchParams; + let { id, token, requestId, organization, link, postErrorRedirectUrl } = searchParams; const { provider } = params; const _headers = await headers(); @@ -275,7 +276,12 @@ export default async function Page(props: { } if (!orgToRegisterOn) { - return loginFailed(branding, "No organization found for registration"); + // Redirect to registration-failed page - couldn't determine organization for registration + const queryParams = new URLSearchParams(); + if (requestId) queryParams.set("requestId", requestId); + if (organization) queryParams.set("organization", organization); + if (postErrorRedirectUrl) queryParams.set("postErrorRedirectUrl", postErrorRedirectUrl); + redirect(`/idp/${provider}/registration-failed?${queryParams.toString()}`); } return completeIDP({ @@ -309,6 +315,11 @@ export default async function Page(props: { ); } - // return login failed if no linking or creation is allowed and no user was found - return loginFailed(branding, "No user found"); + // Redirect to account-not-found page with postErrorRedirectUrl + // This provides a graceful fallback when no user was found and creation/linking is not allowed + const queryParams = new URLSearchParams(); + if (requestId) queryParams.set("requestId", requestId); + if (organization) queryParams.set("organization", organization); + if (postErrorRedirectUrl) queryParams.set("postErrorRedirectUrl", postErrorRedirectUrl); + redirect(`/idp/${provider}/account-not-found?${queryParams.toString()}`); } diff --git a/apps/login/src/app/(login)/idp/page.tsx b/apps/login/src/app/(login)/idp/page.tsx index 99b1ca81c..09616a055 100644 --- a/apps/login/src/app/(login)/idp/page.tsx +++ b/apps/login/src/app/(login)/idp/page.tsx @@ -50,6 +50,7 @@ export default async function Page(props: { searchParams: Promise )} diff --git a/apps/login/src/app/(login)/loginname/page.tsx b/apps/login/src/app/(login)/loginname/page.tsx index 0264edb2f..7c98f9d7e 100644 --- a/apps/login/src/app/(login)/loginname/page.tsx +++ b/apps/login/src/app/(login)/loginname/page.tsx @@ -86,6 +86,7 @@ export default async function Page(props: { searchParams: Promise )} diff --git a/apps/login/src/components/sign-in-with-idp.tsx b/apps/login/src/components/sign-in-with-idp.tsx index cdf9e024d..32119219c 100644 --- a/apps/login/src/components/sign-in-with-idp.tsx +++ b/apps/login/src/components/sign-in-with-idp.tsx @@ -20,6 +20,7 @@ export interface SignInWithIDPProps { requestId?: string; organization?: string; linkOnly?: boolean; + postErrorRedirectUrl?: string; showLabel?: boolean; } @@ -28,6 +29,7 @@ export function SignInWithIdp({ requestId, organization, linkOnly, + postErrorRedirectUrl, showLabel = true, }: Readonly) { const [state, action, _isPending] = useActionState(redirectToIdp, {}); @@ -58,6 +60,7 @@ export function SignInWithIdp({ + {postErrorRedirectUrl && } ) : null; diff --git a/apps/login/src/lib/server/idp.test.ts b/apps/login/src/lib/server/idp.test.ts new file mode 100644 index 000000000..68025988e --- /dev/null +++ b/apps/login/src/lib/server/idp.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; +import { redirectToIdp } from "./idp"; + +// Mock all the dependencies +vi.mock("next/headers", () => ({ + headers: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn((url: string) => { + throw new Error(`REDIRECT: ${url}`); + }), +})); + +vi.mock("../service-url", () => ({ + getServiceUrlFromHeaders: vi.fn(), +})); + +vi.mock("./host", () => ({ + getOriginalHost: vi.fn(), +})); + +vi.mock("../zitadel", () => ({ + startIdentityProviderFlow: vi.fn(), +})); + +describe("redirectToIdp", () => { + let mockHeaders: any; + let mockGetServiceUrlFromHeaders: any; + let mockGetOriginalHost: any; + let mockStartIdentityProviderFlow: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Import mocked modules + const { headers } = await import("next/headers"); + const { getServiceUrlFromHeaders } = await import("../service-url"); + const { getOriginalHost } = await import("./host"); + const { startIdentityProviderFlow } = await import("../zitadel"); + + // Setup mocks + mockHeaders = vi.mocked(headers); + mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders); + mockGetOriginalHost = vi.mocked(getOriginalHost); + mockStartIdentityProviderFlow = vi.mocked(startIdentityProviderFlow); + + // Default mock implementations + mockHeaders.mockResolvedValue({} as any); + mockGetServiceUrlFromHeaders.mockReturnValue({ + serviceUrl: "https://api.example.com", + }); + mockGetOriginalHost.mockResolvedValue("example.com"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("postErrorRedirectUrl parameter handling", () => { + test("should include postErrorRedirectUrl in success and failure URLs when provided", async () => { + const formData = new FormData(); + formData.append("id", "idp123"); + formData.append("provider", "google"); + formData.append("requestId", "req123"); + formData.append("organization", "org123"); + formData.append("postErrorRedirectUrl", "https://app.example.com/error"); + + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + try { + await redirectToIdp(undefined, formData); + } catch (error: any) { + // Redirect throws in tests + expect(error.message).toContain("REDIRECT:"); + } + + expect(mockStartIdentityProviderFlow).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + idpId: "idp123", + urls: { + successUrl: expect.stringContaining("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror"), + failureUrl: expect.stringContaining("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror"), + }, + }); + + // Verify both URLs contain all expected parameters + const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0]; + const successUrl = callArgs.urls.successUrl; + const failureUrl = callArgs.urls.failureUrl; + + expect(successUrl).toContain("requestId=req123"); + expect(successUrl).toContain("organization=org123"); + expect(successUrl).toContain("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror"); + + expect(failureUrl).toContain("requestId=req123"); + expect(failureUrl).toContain("organization=org123"); + expect(failureUrl).toContain("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror"); + }); + + test("should not include postErrorRedirectUrl in URLs when not provided", async () => { + const formData = new FormData(); + formData.append("id", "idp123"); + formData.append("provider", "google"); + formData.append("requestId", "req123"); + formData.append("organization", "org123"); + + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + try { + await redirectToIdp(undefined, formData); + } catch (error: any) { + // Redirect throws in tests + expect(error.message).toContain("REDIRECT:"); + } + + expect(mockStartIdentityProviderFlow).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + idpId: "idp123", + urls: { + successUrl: expect.not.stringContaining("postErrorRedirectUrl"), + failureUrl: expect.not.stringContaining("postErrorRedirectUrl"), + }, + }); + + const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0]; + expect(callArgs.urls.successUrl).not.toContain("postErrorRedirectUrl"); + expect(callArgs.urls.failureUrl).not.toContain("postErrorRedirectUrl"); + }); + + test("should not include postErrorRedirectUrl when it is an empty string", async () => { + const formData = new FormData(); + formData.append("id", "idp123"); + formData.append("provider", "google"); + formData.append("postErrorRedirectUrl", ""); + + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + try { + await redirectToIdp(undefined, formData); + } catch (error: any) { + // Redirect throws in tests + expect(error.message).toContain("REDIRECT:"); + } + + const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0]; + expect(callArgs.urls.successUrl).not.toContain("postErrorRedirectUrl"); + expect(callArgs.urls.failureUrl).not.toContain("postErrorRedirectUrl"); + }); + + test("should include postErrorRedirectUrl in LDAP redirect URL", async () => { + const formData = new FormData(); + formData.append("id", "ldap123"); + formData.append("provider", "ldap"); + formData.append("requestId", "req123"); + formData.append("organization", "org123"); + formData.append("postErrorRedirectUrl", "/custom-error"); + + try { + await redirectToIdp(undefined, formData); + } catch (error: any) { + // Redirect throws in tests + expect(error.message).toContain("REDIRECT: /idp/ldap?"); + expect(error.message).toContain("requestId=req123"); + expect(error.message).toContain("organization=org123"); + expect(error.message).toContain("postErrorRedirectUrl=%2Fcustom-error"); + } + }); + + test("should handle postErrorRedirectUrl with special characters", async () => { + const formData = new FormData(); + formData.append("id", "idp123"); + formData.append("provider", "google"); + formData.append("postErrorRedirectUrl", "https://app.example.com/error?code=123&message=test"); + + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + try { + await redirectToIdp(undefined, formData); + } catch (error: any) { + // Redirect throws in tests + expect(error.message).toContain("REDIRECT:"); + } + + const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0]; + const successUrl = new URL(callArgs.urls.successUrl); + const failureUrl = new URL(callArgs.urls.failureUrl); + + // Verify the URL is properly encoded + expect(successUrl.searchParams.get("postErrorRedirectUrl")).toBe( + "https://app.example.com/error?code=123&message=test", + ); + expect(failureUrl.searchParams.get("postErrorRedirectUrl")).toBe( + "https://app.example.com/error?code=123&message=test", + ); + }); + + test("should preserve postErrorRedirectUrl alongside linkOnly parameter", async () => { + const formData = new FormData(); + formData.append("id", "idp123"); + formData.append("provider", "google"); + formData.append("linkOnly", "true"); + formData.append("postErrorRedirectUrl", "/custom-error"); + + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + try { + await redirectToIdp(undefined, formData); + } catch (error: any) { + // Redirect throws in tests + expect(error.message).toContain("REDIRECT:"); + } + + const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0]; + const successUrl = callArgs.urls.successUrl; + const failureUrl = callArgs.urls.failureUrl; + + // Both parameters should be present + expect(successUrl).toContain("link=true"); + expect(successUrl).toContain("postErrorRedirectUrl=%2Fcustom-error"); + expect(failureUrl).toContain("link=true"); + expect(failureUrl).toContain("postErrorRedirectUrl=%2Fcustom-error"); + }); + + test("should handle relative postErrorRedirectUrl paths", async () => { + const formData = new FormData(); + formData.append("id", "idp123"); + formData.append("provider", "github"); + formData.append("postErrorRedirectUrl", "/loginname"); + + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + try { + await redirectToIdp(undefined, formData); + } catch (error: any) { + // Redirect throws in tests + expect(error.message).toContain("REDIRECT:"); + } + + const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0]; + expect(callArgs.urls.successUrl).toContain("postErrorRedirectUrl=%2Floginname"); + expect(callArgs.urls.failureUrl).toContain("postErrorRedirectUrl=%2Floginname"); + }); + }); + + describe("General redirect behavior", () => { + test("should return error when IDP flow returns null", async () => { + const formData = new FormData(); + formData.append("id", "idp123"); + formData.append("provider", "google"); + + mockStartIdentityProviderFlow.mockResolvedValue(null); + + const result = await redirectToIdp(undefined, formData); + + expect(result).toEqual({ error: "Unexpected response from IDP flow" }); + }); + + test("should redirect when IDP flow returns a valid URL", async () => { + const formData = new FormData(); + formData.append("id", "idp123"); + formData.append("provider", "google"); + + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + try { + await redirectToIdp(undefined, formData); + // Should not reach here + expect(true).toBe(false); + } catch (error: any) { + // Redirect throws in tests + expect(error.message).toBe("REDIRECT: https://idp.example.com/auth"); + } + }); + }); +}); diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index 96773e6f6..d9a193576 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -29,10 +29,12 @@ export async function redirectToIdp(prevState: RedirectToIdpState, formData: For const organization = formData.get("organization") as string; const idpId = formData.get("id") as string; const provider = formData.get("provider") as string; + const postErrorRedirectUrl = formData.get("postErrorRedirectUrl") as string; if (linkOnly) params.set("link", "true"); if (requestId) params.set("requestId", requestId); if (organization) params.set("organization", organization); + if (postErrorRedirectUrl) params.set("postErrorRedirectUrl", postErrorRedirectUrl); // redirect to LDAP page where username and password is requested if (provider === "ldap") {