diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 25c93cbd43e..ad68f8e13cd 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -386,7 +386,9 @@ "couldNotCreateSession": "Sitzung konnte nicht erstellt werden", "noHostFound": "Kein Host gefunden", "userAlreadyVerified": "Benutzer ist bereits verifiziert!", - "couldNotResendInvite": "Einladung konnte nicht erneut gesendet werden" + "couldNotResendInvite": "Einladung konnte nicht erneut gesendet werden", + "inviteSendFailed": "Einladungs-E-Mail konnte nicht gesendet werden", + "emailSendFailed": "Verifizierungs-E-Mail konnte nicht gesendet werden" } }, "authenticator": { diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index dd7a2685d87..0a77a48f1a6 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -386,7 +386,9 @@ "couldNotCreateSession": "Could not create session", "noHostFound": "No host found", "userAlreadyVerified": "User is already verified!", - "couldNotResendInvite": "Could not resend invite" + "couldNotResendInvite": "Could not resend invite", + "inviteSendFailed": "Failed to send invitation email", + "emailSendFailed": "Failed to send verification email" } }, "authenticator": { diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 8929a9d0b99..1a2b9714ccb 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -386,7 +386,9 @@ "couldNotCreateSession": "No se pudo crear la sesión", "noHostFound": "No se encontró el host", "userAlreadyVerified": "¡El usuario ya está verificado!", - "couldNotResendInvite": "No se pudo reenviar la invitación" + "couldNotResendInvite": "No se pudo reenviar la invitación", + "inviteSendFailed": "No se pudo enviar el correo de invitación", + "emailSendFailed": "No se pudo enviar el correo de verificación" } }, "authenticator": { diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 51ab723faa2..44e2fdbf660 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -386,7 +386,9 @@ "couldNotCreateSession": "Impossibile creare la sessione", "noHostFound": "Nessun host trovato", "userAlreadyVerified": "L'utente è già verificato!", - "couldNotResendInvite": "Impossibile reinviare l'invito" + "couldNotResendInvite": "Impossibile reinviare l'invito", + "inviteSendFailed": "Impossibile inviare l'email di invito", + "emailSendFailed": "Impossibile inviare l'email di verifica" } }, "authenticator": { diff --git a/apps/login/locales/ja.json b/apps/login/locales/ja.json index 5e5abeb215b..94b2fba722b 100644 --- a/apps/login/locales/ja.json +++ b/apps/login/locales/ja.json @@ -287,6 +287,21 @@ "required": { "code": "必須項目です" } + }, + "errors": { + "couldNotResendEmail": "メールを再送信できませんでした", + "couldNotVerifyUser": "ユーザーを確認できませんでした", + "couldNotVerifyInvite": "招待を確認できませんでした", + "couldNotVerifyEmail": "メールアドレスを確認できませんでした", + "couldNotVerify": "確認できませんでした", + "couldNotLoadUser": "ユーザーを読み込めませんでした", + "couldNotLoadAuthenticators": "認証方法を読み込めませんでした", + "couldNotCreateSession": "セッションを作成できませんでした", + "noHostFound": "ホストが見つかりません", + "userAlreadyVerified": "ユーザーは既に確認済みです!", + "couldNotResendInvite": "招待を再送信できませんでした", + "inviteSendFailed": "招待メールの送信に失敗しました", + "emailSendFailed": "確認メールの送信に失敗しました" } }, "authenticator": { diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 1f62bca4d2a..dee81bc0902 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -386,7 +386,9 @@ "couldNotCreateSession": "Nie udało się utworzyć sesji", "noHostFound": "Nie znaleziono hosta", "userAlreadyVerified": "Użytkownik jest już zweryfikowany!", - "couldNotResendInvite": "Nie udało się ponownie wysłać zaproszenia" + "couldNotResendInvite": "Nie udało się ponownie wysłać zaproszenia", + "inviteSendFailed": "Nie udało się wysłać wiadomości e-mail z zaproszeniem", + "emailSendFailed": "Nie udało się wysłać wiadomości e-mail weryfikacyjnej" } }, "authenticator": { diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 5eb2eb17c93..0b4b8855d82 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -386,7 +386,9 @@ "couldNotCreateSession": "Не удалось создать сеанс", "noHostFound": "Хост не найден", "userAlreadyVerified": "Пользователь уже подтверждён!", - "couldNotResendInvite": "Не удалось повторно отправить приглашение" + "couldNotResendInvite": "Не удалось повторно отправить приглашение", + "inviteSendFailed": "Не удалось отправить письмо с приглашением", + "emailSendFailed": "Не удалось отправить письмо с подтверждением" } }, "authenticator": { diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 429da9bfb5d..c642f870b46 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -386,7 +386,9 @@ "couldNotCreateSession": "无法创建会话", "noHostFound": "未找到主机", "userAlreadyVerified": "用户已验证!", - "couldNotResendInvite": "无法重新发送邀请" + "couldNotResendInvite": "无法重新发送邀请", + "inviteSendFailed": "发送邀请邮件失败", + "emailSendFailed": "发送验证邮件失败" } }, "authenticator": { diff --git a/apps/login/src/app/(login)/authenticator/set/page.tsx b/apps/login/src/app/(login)/authenticator/set/page.tsx index c52d408eb5c..a78239bffc4 100644 --- a/apps/login/src/app/(login)/authenticator/set/page.tsx +++ b/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -193,6 +193,7 @@ export default async function Page(props: { searchParams: Promise }) { let human: HumanUser | undefined; let id: string | undefined; + let error: string | undefined; + const doSend = send === "true"; const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; @@ -49,9 +51,9 @@ export default async function Page(props: { searchParams: Promise }) { urlTemplate: `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + (requestId ? `&requestId=${requestId}` : ""), - }).catch((error) => { - console.error("Could not send invitation email", error); - throw Error("Failed to send invitation email"); + }).catch((apiError) => { + console.error("Could not send invitation email", apiError); + error = "inviteSendFailed"; }); } else { await sendEmailCode({ @@ -59,9 +61,9 @@ export default async function Page(props: { searchParams: Promise }) { urlTemplate: `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + (requestId ? `&requestId=${requestId}` : ""), - }).catch((error) => { - console.error("Could not send verification email", error); - throw Error("Failed to send verification email"); + }).catch((apiError) => { + console.error("Could not send verification email", apiError); + error = "emailSendFailed"; }); } } @@ -143,6 +145,14 @@ export default async function Page(props: { searchParams: Promise }) {
+ {error && ( +
+ + + +
+ )} + {!id && (
diff --git a/apps/login/src/components/choose-authenticator-to-setup.tsx b/apps/login/src/components/choose-authenticator-to-setup.tsx index 9eb744f3a5c..780fb3ab748 100644 --- a/apps/login/src/components/choose-authenticator-to-setup.tsx +++ b/apps/login/src/components/choose-authenticator-to-setup.tsx @@ -1,7 +1,4 @@ -import { - LoginSettings, - PasskeysType, -} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { LoginSettings, PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { Alert, AlertType } from "./alert"; import { PASSKEYS, PASSWORD } from "./auth-methods"; @@ -13,11 +10,7 @@ type Props = { loginSettings: LoginSettings; }; -export function ChooseAuthenticatorToSetup({ - authMethods, - params, - loginSettings, -}: Props) { +export function ChooseAuthenticatorToSetup({ authMethods, params, loginSettings }: Props) { if (authMethods.length !== 0) { return ( @@ -26,26 +19,14 @@ export function ChooseAuthenticatorToSetup({ ); } else { return ( - <> - {loginSettings.passkeysType == PasskeysType.NOT_ALLOWED && - !loginSettings.allowUsernamePassword && ( - - - - )} - -
- {!authMethods.includes(AuthenticationMethodType.PASSWORD) && - loginSettings.allowUsernamePassword && - PASSWORD(false, "/password/set?" + params)} - {!authMethods.includes(AuthenticationMethodType.PASSKEY) && - loginSettings.passkeysType == PasskeysType.ALLOWED && - PASSKEYS(false, "/passkey/set?" + params)} -
- +
+ {!authMethods.includes(AuthenticationMethodType.PASSWORD) && + loginSettings.allowUsernamePassword && + PASSWORD(false, "/password/set?" + params)} + {!authMethods.includes(AuthenticationMethodType.PASSKEY) && + loginSettings.passkeysType == PasskeysType.ALLOWED && + PASSKEYS(false, "/passkey/set?" + params)} +
); } } diff --git a/apps/login/src/lib/server/loginname.test.ts b/apps/login/src/lib/server/loginname.test.ts index b483aa0d707..bd3e81441f2 100644 --- a/apps/login/src/lib/server/loginname.test.ts +++ b/apps/login/src/lib/server/loginname.test.ts @@ -249,6 +249,7 @@ describe("sendLoginname", () => { authMethodTypes: [AuthenticationMethodType.PASSWORD], }); mockListIDPLinks.mockResolvedValue({ result: [] }); + mockGetActiveIdentityProviders.mockResolvedValue({ identityProviders: [] }); const result = await sendLoginname({ loginName: "user@example.com", @@ -259,6 +260,29 @@ describe("sendLoginname", () => { }); }); + test("should redirect to organization IDP when password not allowed, no user IDP links, but organization has active IDP", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: false }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + mockListIDPLinks.mockResolvedValue({ result: [] }); + mockGetActiveIdentityProviders.mockResolvedValue({ + identityProviders: [{ id: "org-idp-123", type: 0 }], + }); + mockIdpTypeToSlug.mockReturnValue("google"); + mockStartIdentityProviderFlow.mockResolvedValue("https://org-idp.example.com/auth"); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ redirect: "https://org-idp.example.com/auth" }); + expect(mockGetActiveIdentityProviders).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + orgId: "org123", // User's organization from resourceOwner + }); + }); + test("should redirect to passkey when user has only passkey method and it's allowed", async () => { mockGetLoginSettings.mockResolvedValue({ passkeysType: PasskeysType.ALLOWED }); mockListAuthenticationMethodTypes.mockResolvedValue({ @@ -373,6 +397,7 @@ describe("sendLoginname", () => { authMethodTypes: [AuthenticationMethodType.PASSWORD], }); mockListIDPLinks.mockResolvedValue({ result: [] }); + mockGetActiveIdentityProviders.mockResolvedValue({ identityProviders: [] }); const result = await sendLoginname({ loginName: "user@example.com", diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index b56bedc777a..4bd0ef6df0b 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -23,6 +23,7 @@ import { } from "../zitadel"; import { createSessionAndUpdateCookie } from "./cookie"; import { getOriginalHost } from "./host"; +import { IDPLink } from "@zitadel/proto/zitadel/user/v2/idp_pb"; export type SendLoginnameCommand = { loginName: string; @@ -68,63 +69,72 @@ export async function sendLoginname(command: SendLoginnameCommand) { const { result: potentialUsers } = searchResult; - const redirectUserToSingleIDPIfAvailable = async () => { - const identityProviders = await getActiveIdentityProviders({ - serviceUrl, - orgId: command.organization, - }).then((resp) => { - return resp.identityProviders; - }); - - if (identityProviders.length === 1) { - const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = await getOriginalHost(); - - const identityProviderType = identityProviders[0].type; - - const provider = idpTypeToSlug(identityProviderType); - - const params = new URLSearchParams(); - - if (command.requestId) { - params.set("requestId", command.requestId); - } - - if (command.organization) { - params.set("organization", command.organization); - } - - const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; - - const url = await startIdentityProviderFlow({ + const redirectUserToIDP = async (userId?: string, organization?: string) => { + // If userId is provided, check for user-specific IDP links first + let identityProviders: IDPLink[] = []; + if (userId) { + identityProviders = await listIDPLinks({ serviceUrl, - idpId: identityProviders[0].id, - urls: { - successUrl: - `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` + - new URLSearchParams(params), - failureUrl: - `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` + - new URLSearchParams(params), - }, + userId, + }).then((resp) => { + return resp.result; + }); + } + + // If no IDP links exist for the user (or no userId provided), try to get active IDPs from the organization + if (identityProviders.length === 0) { + const activeIdps = await getActiveIdentityProviders({ + serviceUrl, + orgId: organization, + }).then((resp) => { + return resp.identityProviders; }); - if (!url) { - return { error: t("errors.couldNotStartIDPFlow") }; + // If exactly one active IDP exists in the organization, redirect to it + if (activeIdps.length === 1) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = await getOriginalHost(); + + const identityProviderType = activeIdps[0].type; + const provider = idpTypeToSlug(identityProviderType); + + const params = new URLSearchParams(); + + if (userId) { + params.set("userId", userId); + } + + if (command.requestId) { + params.set("requestId", command.requestId); + } + + if (organization) { + params.set("organization", organization); + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + const url = await startIdentityProviderFlow({ + serviceUrl, + idpId: activeIdps[0].id, + urls: { + successUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` + + new URLSearchParams(params), + failureUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` + + new URLSearchParams(params), + }, + }); + + if (!url) { + return { error: t("errors.couldNotStartIDPFlow") }; + } + + return { redirect: url }; } - - return { redirect: url }; } - }; - - const redirectUserToIDP = async (userId: string) => { - const identityProviders = await listIDPLinks({ - serviceUrl, - userId, - }).then((resp) => { - return resp.result; - }); if (identityProviders.length === 1) { const _headers = await headers(); @@ -147,14 +157,18 @@ export async function sendLoginname(command: SendLoginnameCommand) { const identityProviderType = idpTypeToIdentityProviderType(idpType); const provider = idpTypeToSlug(identityProviderType); - const params = new URLSearchParams({ userId }); + const params = new URLSearchParams(); + + if (userId) { + params.set("userId", userId); + } if (command.requestId) { params.set("requestId", command.requestId); } - if (command.organization) { - params.set("organization", command.organization); + if (organization) { + params.set("organization", organization); } const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; @@ -240,6 +254,9 @@ export async function sendLoginname(command: SendLoginnameCommand) { return { error: t("errors.initialUserNotSupported") }; } + // Resolve organization from command or session + const organization = command.organization ?? session.factors?.user?.organizationId; + const methods = await listAuthenticationMethodTypes({ serviceUrl, userId: session.factors?.user?.id, @@ -250,15 +267,15 @@ export async function sendLoginname(command: SendLoginnameCommand) { const params = new URLSearchParams({ loginName: session.factors?.user?.loginName as string, send: "true", // set this to true to request a new code immediately - invite: "true", + invite: "true", // humanUser?.email?.isVerified ? "false" : "true", // sendInviteEmailCode results in an error if user is already initialized }); if (command.requestId) { params.append("requestId", command.requestId); } - if (command.organization || session.factors?.user?.organizationId) { - params.append("organization", command.organization ?? (session.factors?.user?.organizationId as string)); + if (organization) { + params.append("organization", organization); } return { redirect: `/verify?` + params }; @@ -270,7 +287,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { case AuthenticationMethodType.PASSWORD: // user has only password as auth method if (!userLoginSettings?.allowUsernamePassword) { // Check if user has IDPs available as alternative, that could eventually be used to register/link. - const idpResp = await redirectUserToIDP(userId); + const idpResp = await redirectUserToIDP(userId, organization); if (idpResp?.redirect) { return idpResp; } @@ -286,8 +303,8 @@ export async function sendLoginname(command: SendLoginnameCommand) { // TODO: does this have to be checked in loginSettings.allowDomainDiscovery - if (command.organization || session.factors?.user?.organizationId) { - paramsPassword.append("organization", command.organization ?? session.factors?.user?.organizationId); + if (organization) { + paramsPassword.append("organization", organization); } if (command.requestId) { @@ -312,14 +329,14 @@ export async function sendLoginname(command: SendLoginnameCommand) { paramsPasskey.append("requestId", command.requestId); } - if (command.organization || session.factors?.user?.organizationId) { - paramsPasskey.append("organization", command.organization ?? session.factors?.user?.organizationId); + if (organization) { + paramsPasskey.append("organization", organization); } return { redirect: "/passkey?" + paramsPasskey }; case AuthenticationMethodType.IDP: - const resp = await redirectUserToIDP(userId); + const resp = await redirectUserToIDP(userId, organization); if (resp?.error) { return { error: resp.error }; @@ -339,13 +356,13 @@ export async function sendLoginname(command: SendLoginnameCommand) { passkeyParams.append("requestId", command.requestId); } - if (command.organization || session.factors?.user?.organizationId) { - passkeyParams.append("organization", command.organization ?? session.factors?.user?.organizationId); + if (organization) { + passkeyParams.append("organization", organization); } return { redirect: "/passkey?" + passkeyParams }; } else if (methods.authMethodTypes.includes(AuthenticationMethodType.IDP)) { - return redirectUserToIDP(userId); + return redirectUserToIDP(userId, organization); } else if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)) { // Check if password authentication is allowed if (!userLoginSettings?.allowUsernamePassword) { @@ -363,8 +380,8 @@ export async function sendLoginname(command: SendLoginnameCommand) { paramsPasswordDefault.append("requestId", command.requestId); } - if (command.organization || session.factors?.user?.organizationId) { - paramsPasswordDefault.append("organization", command.organization ?? session.factors?.user?.organizationId); + if (organization) { + paramsPasswordDefault.append("organization", organization); } return { @@ -376,7 +393,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { // user not found, check if register is enabled on instance / organization context if (loginSettingsByContext?.allowRegister && !loginSettingsByContext?.allowUsernamePassword) { - const resp = await redirectUserToSingleIDPIfAvailable(); + const resp = await redirectUserToIDP(undefined, command.organization); if (resp) { return resp; }