diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 9da622340b..36dc145120 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -72,6 +72,10 @@ "linkingError": { "title": "Konto-Verknüpfung fehlgeschlagen", "description": "Beim Verknüpfen Ihres Kontos ist ein Fehler aufgetreten." + }, + "completeRegister": { + "title": "Registrierung abschließen", + "description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen." } }, "mfa": { @@ -149,11 +153,13 @@ }, "title": "Registrieren", "description": "Erstellen Sie Ihr ZITADEL-Konto.", + "noMethodAvailableWarning": "Keine Authentifizierungsmethode verfügbar. Bitte wenden Sie sich an den Administrator.", "selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten", "agreeTo": "Um sich zu registrieren, müssen Sie den Nutzungsbedingungen zustimmen", "termsOfService": "Nutzungsbedingungen", "privacyPolicy": "Datenschutzrichtlinie", "submit": "Weiter", + "orUseIDP": "oder verwenden Sie einen Identitätsanbieter", "password": { "title": "Passwort festlegen", "description": "Legen Sie das Passwort für Ihr Konto fest", diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 37a1b62289..0b1cbeb472 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -72,6 +72,10 @@ "linkingError": { "title": "Account linking failed", "description": "An error occurred while trying to link your account." + }, + "completeRegister": { + "title": "Complete your data", + "description": "You need to complete your registration by providing your email address and name." } }, "mfa": { @@ -149,11 +153,13 @@ }, "title": "Register", "description": "Create your ZITADEL account.", + "noMethodAvailableWarning": "No authentication method available. Please contact your administrator.", "selectMethod": "Select the method you would like to authenticate", "agreeTo": "To register you must agree to the terms and conditions", "termsOfService": "Terms of Service", "privacyPolicy": "Privacy Policy", "submit": "Continue", + "orUseIDP": "or use an Identity Provider", "password": { "title": "Set Password", "description": "Set the password for your account", diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 8969618c67..5cd40f764a 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -72,6 +72,10 @@ "linkingError": { "title": "Error al vincular la cuenta", "description": "Ocurrió un error al intentar vincular tu cuenta." + }, + "completeRegister": { + "title": "Completar registro", + "description": "Para completar el registro, debes establecer una contraseña." } }, "mfa": { @@ -149,11 +153,13 @@ }, "title": "Registrarse", "description": "Crea tu cuenta ZITADEL.", + "noMethodAvailableWarning": "No hay métodos de autenticación disponibles. Por favor, contacta a tu administrador.", "selectMethod": "Selecciona el método con el que deseas autenticarte", "agreeTo": "Para registrarte debes aceptar los términos y condiciones", "termsOfService": "Términos de Servicio", "privacyPolicy": "Política de Privacidad", "submit": "Continuar", + "orUseIDP": "o usa un Proveedor de Identidad", "password": { "title": "Establecer Contraseña", "description": "Establece la contraseña para tu cuenta", diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 83fc5f3bfc..a19aa91cfb 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -72,6 +72,10 @@ "linkingError": { "title": "Collegamento account fallito", "description": "Si è verificato un errore durante il tentativo di collegare il tuo account." + }, + "completeRegister": { + "title": "Completa la registrazione", + "description": "Completa la registrazione del tuo account." } }, "mfa": { @@ -149,11 +153,13 @@ }, "title": "Registrati", "description": "Crea il tuo account ZITADEL.", + "noMethodAvailableWarning": "Nessun metodo di autenticazione disponibile. Contatta l'amministratore di sistema per assistenza.", "selectMethod": "Seleziona il metodo con cui desideri autenticarti", "agreeTo": "Per registrarti devi accettare i termini e le condizioni", "termsOfService": "Termini di Servizio", "privacyPolicy": "Informativa sulla Privacy", "submit": "Continua", + "orUseIDP": "o usa un Identity Provider", "password": { "title": "Imposta Password", "description": "Imposta la password per il tuo account", diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index ad9f5d9a65..b97e7e4b47 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -72,6 +72,10 @@ "linkingError": { "title": "Powiązanie konta nie powiodło się", "description": "Wystąpił błąd podczas próby powiązania konta." + }, + "completeRegister": { + "title": "Ukończ rejestrację", + "description": "Ukończ rejestrację swojego konta." } }, "mfa": { @@ -149,11 +153,13 @@ }, "title": "Rejestracja", "description": "Utwórz konto ZITADEL.", + "noMethodAvailableWarning": "Brak dostępnych metod uwierzytelniania. Skontaktuj się z administratorem.", "selectMethod": "Wybierz metodę uwierzytelniania, której chcesz użyć", "agreeTo": "Aby się zarejestrować, musisz zaakceptować warunki korzystania", "termsOfService": "Regulamin", "privacyPolicy": "Polityka prywatności", "submit": "Kontynuuj", + "orUseIDP": "lub użyj dostawcy tożsamości", "password": { "title": "Ustaw hasło", "description": "Ustaw hasło dla swojego konta", diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 73b0810e93..77ea8ba79e 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -72,6 +72,10 @@ "linkingError": { "title": "Ошибка привязки аккаунта", "description": "Произошла ошибка при попытке привязать аккаунт." + }, + "completeRegister": { + "title": "Завершите регистрацию", + "description": "Завершите регистрацию вашего аккаунта." } }, "mfa": { @@ -149,11 +153,13 @@ }, "title": "Регистрация", "description": "Создайте свой аккаунт ZITADEL.", + "noMethodAvailableWarning": "Нет доступных методов аутентификации. Обратитесь к администратору.", "selectMethod": "Выберите метод аутентификации", "agreeTo": "Для регистрации необходимо принять условия:", "termsOfService": "Условия использования", "privacyPolicy": "Политика конфиденциальности", "submit": "Продолжить", + "orUseIDP": "или используйте Identity Provider", "password": { "title": "Установить пароль", "description": "Установите пароль для вашего аккаунта", diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index bba15c62dd..0ad9c7e056 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -72,6 +72,10 @@ "linkingError": { "title": "账户链接失败", "description": "链接账户时发生错误。" + }, + "completeRegister": { + "title": "完成注册", + "description": "完成您的账户注册。" } }, "mfa": { @@ -149,11 +153,13 @@ }, "title": "注册", "description": "创建您的 ZITADEL 账户。", + "noMethodAvailableWarning": "没有可用的认证方法。请联系您的系统管理员。", "selectMethod": "选择您想使用的认证方法", "agreeTo": "注册即表示您同意条款和条件", "termsOfService": "服务条款", "privacyPolicy": "隐私政策", "submit": "继续", + "orUseIDP": "或使用身份提供者", "password": { "title": "设置密码", "description": "为您的账户设置密码", 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 1cee8b587c..bfbde8b252 100644 --- a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx @@ -1,5 +1,6 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { IdpSignin } from "@/components/idp-signin"; +import { completeIDP } from "@/components/idps/pages/complete-idp"; import { linkingFailed } from "@/components/idps/pages/linking-failed"; import { linkingSuccess } from "@/components/idps/pages/linking-success"; import { loginFailed } from "@/components/idps/pages/login-failed"; @@ -9,24 +10,63 @@ import { addHuman, addIDPLink, getBrandingSettings, + getDefaultOrg, getIDPByID, getLoginSettings, getOrgsByDomain, listUsers, retrieveIDPIntent, + updateHuman, } from "@/lib/zitadel"; import { ConnectError, create } from "@zitadel/client"; 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"; import { AddHumanUserRequest, AddHumanUserRequestSchema, + UpdateHumanUserRequestSchema, } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; +async function resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, +}: { + organization?: string; + addHumanUser?: { username?: string }; + serviceUrl: string; +}): Promise { + if (organization) return organization; + + if (addHumanUser?.username && ORG_SUFFIX_REGEX.test(addHumanUser.username)) { + const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username); + const suffix = matched?.[1] ?? ""; + + const orgs = await getOrgsByDomain({ + serviceUrl, + domain: suffix, + }); + const orgToCheckForDiscovery = + orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + + if (orgToCheckForDiscovery) { + const orgLoginSettings = await getLoginSettings({ + serviceUrl, + organization: orgToCheckForDiscovery, + }); + if (orgLoginSettings?.allowDomainDiscovery) { + return orgToCheckForDiscovery; + } + } + } + return undefined; +} + export default async function Page(props: { searchParams: Promise>; params: Promise<{ provider: string }>; @@ -35,17 +75,26 @@ export default async function Page(props: { const searchParams = await props.searchParams; const locale = getLocale(); const t = await getTranslations({ locale, namespace: "idp" }); - const { id, token, requestId, organization, link } = searchParams; + let { id, token, requestId, organization, link } = searchParams; const { provider } = params; const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const branding = await getBrandingSettings({ + let branding = await getBrandingSettings({ serviceUrl, organization, }); + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + organization = org.id; + } + } + if (!provider || !id || !token) { return loginFailed(branding, "IDP context missing"); } @@ -59,18 +108,6 @@ export default async function Page(props: { const { idpInformation, userId } = intent; let { addHumanUser } = intent; - // sign in user. If user should be linked continue - if (userId && !link) { - // TODO: update user if idp.options.isAutoUpdate is true - - return loginSuccess( - userId, - { idpIntentId: id, idpIntentToken: token }, - requestId, - branding, - ); - } - if (!idpInformation) { return loginFailed(branding, "IDP information missing"); } @@ -79,12 +116,41 @@ export default async function Page(props: { serviceUrl, id: idpInformation.idpId, }); + const options = idp?.config?.options; if (!idp) { throw new Error("IDP not found"); } + // sign in user. If user should be linked continue + if (userId && !link) { + // if auto update is enabled, we will update the user with the new information + if (options?.isAutoUpdate && addHumanUser) { + try { + await updateHuman({ + serviceUrl, + request: create(UpdateHumanUserRequestSchema, { + userId: userId, + profile: addHumanUser.profile, + email: addHumanUser.email, + phone: addHumanUser.phone, + }), + }); + } catch (error: unknown) { + // Log the error and continue with the login process + console.warn("An error occurred while updating the user:", error); + } + } + + return loginSuccess( + userId, + { idpIntentId: id, idpIntentToken: token }, + requestId, + branding, + ); + } + if (link) { if (!options?.isLinkingAllowed) { // linking was probably disallowed since the invitation was created @@ -176,88 +242,95 @@ export default async function Page(props: { } } - if (options?.isAutoCreation) { - let orgToRegisterOn: string | undefined = organization; - let newUser; + let newUser; + // automatic creation of a user is allowed and data is complete + if (options?.isAutoCreation && addHumanUser) { + const orgToRegisterOn = await resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, + }); - if ( - !orgToRegisterOn && - addHumanUser?.username && // username or email? - ORG_SUFFIX_REGEX.test(addHumanUser.username) - ) { - const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username); - const suffix = matched?.[1] ?? ""; - - // this just returns orgs where the suffix is set as primary domain - const orgs = await getOrgsByDomain({ - serviceUrl, - domain: suffix, + let addHumanUserWithOrganization: AddHumanUserRequest; + if (orgToRegisterOn) { + const organizationSchema = create(OrganizationSchema, { + org: { case: "orgId", value: orgToRegisterOn }, }); - const orgToCheckForDiscovery = - orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; - const orgLoginSettings = await getLoginSettings({ - serviceUrl, - organization: orgToCheckForDiscovery, + addHumanUserWithOrganization = create(AddHumanUserRequestSchema, { + ...addHumanUser, + organization: organizationSchema, }); - if (orgLoginSettings?.allowDomainDiscovery) { - orgToRegisterOn = orgToCheckForDiscovery; - } - } - - if (addHumanUser) { - let addHumanUserWithOrganization: AddHumanUserRequest; - if (orgToRegisterOn) { - const organizationSchema = create(OrganizationSchema, { - org: { case: "orgId", value: orgToRegisterOn }, - }); - - addHumanUserWithOrganization = create(AddHumanUserRequestSchema, { - ...addHumanUser, - organization: organizationSchema, - }); - } else { - addHumanUserWithOrganization = create( - AddHumanUserRequestSchema, - addHumanUser, - ); - } - - try { - newUser = await addHuman({ - serviceUrl, - request: addHumanUserWithOrganization, - }); - } catch (error: unknown) { - console.error( - "An error occurred while creating the user:", - error, - addHumanUser, - ); - return loginFailed( - branding, - (error as ConnectError).message - ? (error as ConnectError).message - : "Could not create user", - ); - } - } - - if (newUser) { - return ( - -
-

{t("registerSuccess.title")}

-

{t("registerSuccess.description")}

- -
-
+ } else { + addHumanUserWithOrganization = create( + AddHumanUserRequestSchema, + addHumanUser, ); } + + try { + newUser = await addHuman({ + serviceUrl, + request: addHumanUserWithOrganization, + }); + } catch (error: unknown) { + console.error( + "An error occurred while creating the user:", + error, + addHumanUser, + ); + return loginFailed( + branding, + (error as ConnectError).message + ? (error as ConnectError).message + : "Could not create user", + ); + } + } else if (options?.isCreationAllowed) { + // if no user was found, we will create a new user manually / redirect to the registration page + const orgToRegisterOn = await resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, + }); + + if (orgToRegisterOn) { + branding = await getBrandingSettings({ + serviceUrl, + organization: orgToRegisterOn, + }); + } + + if (!orgToRegisterOn) { + return loginFailed(branding, "No organization found for registration"); + } + + return completeIDP({ + branding, + idpIntent: { idpIntentId: id, idpIntentToken: token }, + addHumanUser, + organization: orgToRegisterOn, + requestId, + idpUserId: idpInformation?.userId, + idpId: idpInformation?.idpId, + idpUserName: idpInformation?.userName, + }); + } + + if (newUser) { + return ( + +
+

{t("registerSuccess.title")}

+

{t("registerSuccess.description")}

+ +
+
+ ); } // return login failed if no linking or creation is allowed and no user was found diff --git a/apps/login/src/app/(login)/loginname/page.tsx b/apps/login/src/app/(login)/loginname/page.tsx index 79372729c4..adb6ec0eef 100644 --- a/apps/login/src/app/(login)/loginname/page.tsx +++ b/apps/login/src/app/(login)/loginname/page.tsx @@ -75,7 +75,7 @@ export default async function Page(props: { submit={submit} allowRegister={!!loginSettings?.allowRegister} > - {identityProviders && ( + {identityProviders && loginSettings?.allowExternalIdp && ( { + return resp.identityProviders.filter((idp) => { + return idp.options?.isAutoCreation || idp.options?.isCreationAllowed; // check if IDP allows to create account automatically or manual creation is allowed + }); + }); + if (!loginSettings?.allowRegister) { return ( @@ -69,16 +83,39 @@ export default async function Page(props: {

{t("title")}

{t("description")}

- {legal && passwordComplexitySettings && ( - + {!organization && {tError("unknownContext")}} + + {legal && + passwordComplexitySettings && + organization && + (loginSettings.allowUsernamePassword || + loginSettings.passkeysType == PasskeysType.ALLOWED) && ( + + )} + + {loginSettings?.allowExternalIdp && !!identityProviders.length && ( + <> +
+

{t("orUseIDP")}

+
+ + + )}
diff --git a/apps/login/src/app/(login)/register/password/page.tsx b/apps/login/src/app/(login)/register/password/page.tsx index ee6fa03e59..d6a24fa47f 100644 --- a/apps/login/src/app/(login)/register/password/page.tsx +++ b/apps/login/src/app/(login)/register/password/page.tsx @@ -33,7 +33,7 @@ export default async function Page(props: { } } - const missingData = !firstname || !lastname || !email; + const missingData = !firstname || !lastname || !email || !organization; const legal = await getLegalAndSupportSettings({ serviceUrl, @@ -73,7 +73,7 @@ export default async function Page(props: { email={email} firstname={firstname} lastname={lastname} - organization={organization} + organization={organization as string} // organization is guaranteed to be a string here otherwise we would have returned earlier requestId={requestId} > )} diff --git a/apps/login/src/components/idps/pages/complete-idp.tsx b/apps/login/src/components/idps/pages/complete-idp.tsx new file mode 100644 index 0000000000..e1ed5e7401 --- /dev/null +++ b/apps/login/src/components/idps/pages/complete-idp.tsx @@ -0,0 +1,54 @@ +import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete"; +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { AddHumanUserRequest } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { DynamicTheme } from "../../dynamic-theme"; + +export async function completeIDP({ + idpUserId, + idpId, + idpUserName, + addHumanUser, + requestId, + organization, + branding, + idpIntent, +}: { + idpUserId: string; + idpId: string; + idpUserName: string; + addHumanUser?: AddHumanUserRequest; + requestId?: string; + organization: string; + branding?: BrandingSettings; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; +}) { + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "idp" }); + + return ( + +
+

{t("completeRegister.title")}

+

{t("completeRegister.description")}

+ + +
+
+ ); +} diff --git a/apps/login/src/components/invite-form.tsx b/apps/login/src/components/register-form-idp-incomplete.tsx similarity index 67% rename from apps/login/src/components/invite-form.tsx rename to apps/login/src/components/register-form-idp-incomplete.tsx index 35c0bec028..6194b34052 100644 --- a/apps/login/src/components/invite-form.tsx +++ b/apps/login/src/components/register-form-idp-incomplete.tsx @@ -1,6 +1,6 @@ "use client"; -import { inviteUser } from "@/lib/server/invite"; +import { registerUserAndLinkToIDP } from "@/lib/server/register"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -20,26 +20,39 @@ type Inputs = | FieldValues; type Props = { - firstname?: string; - lastname?: string; - email?: string; - organization?: string; + organization: string; + requestId?: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + defaultValues?: { + firstname?: string; + lastname?: string; + email?: string; + }; + idpUserId: string; + idpId: string; + idpUserName: string; }; -export function InviteForm({ - email, - firstname, - lastname, +export function RegisterFormIDPIncomplete({ organization, + requestId, + idpIntent, + defaultValues, + idpUserId, + idpId, + idpUserName, }: Props) { const t = useTranslations("register"); const { register, handleSubmit, formState } = useForm({ mode: "onBlur", defaultValues: { - email: email ?? "", - firstName: firstname ?? "", - lastname: lastname ?? "", + email: defaultValues?.email ?? "", + firstname: defaultValues?.firstname ?? "", + lastname: defaultValues?.lastname ?? "", }, }); @@ -48,39 +61,37 @@ export function InviteForm({ const router = useRouter(); - async function submitAndContinue(values: Inputs) { + async function submitAndRegister(values: Inputs) { setLoading(true); - const response = await inviteUser({ + const response = await registerUserAndLinkToIDP({ + idpId: idpId, + idpUserName: idpUserName, + idpUserId: idpUserId, email: values.email, firstName: values.firstname, lastName: values.lastname, organization: organization, + requestId: requestId, + idpIntent: idpIntent, }) .catch(() => { - setError("Could not create invitation Code"); + setError("Could not register user"); return; }) .finally(() => { setLoading(false); }); - if (response && typeof response === "object" && "error" in response) { + if (response && "error" in response && response.error) { setError(response.error); return; } - if (!response) { - setError("Could not create invitation Code"); - return; + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); } - const params = new URLSearchParams({}); - - if (response) { - params.append("userId", response); - } - - return router.push(`/invite/success?` + params); + return response; } const { errors } = formState; @@ -88,16 +99,6 @@ export function InviteForm({ return (
-
- -
@@ -116,6 +118,18 @@ export function InviteForm({ {...register("lastname", { required: "This field is required" })} label="Last name" error={errors.lastname?.message as string} + data-testid="lastname-text-input" + /> +
+
+
@@ -127,12 +141,13 @@ export function InviteForm({ )}
- +