Merge branch 'main' into saml-post

This commit is contained in:
Max Peintner
2025-06-20 15:47:01 +02:00
committed by GitHub
20 changed files with 556 additions and 241 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "Установите пароль для вашего аккаунта",

View File

@@ -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": "为您的账户设置密码",

View File

@@ -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<string | undefined> {
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<Record<string | number | symbol, string | undefined>>;
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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("registerSuccess.title")}</h1>
<p className="ztdl-p">{t("registerSuccess.description")}</p>
<IdpSignin
userId={newUser.userId}
idpIntent={{ idpIntentId: id, idpIntentToken: token }}
requestId={requestId}
/>
</div>
</DynamicTheme>
} 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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("registerSuccess.title")}</h1>
<p className="ztdl-p">{t("registerSuccess.description")}</p>
<IdpSignin
userId={newUser.userId}
idpIntent={{ idpIntentId: id, idpIntentToken: token }}
requestId={requestId}
/>
</div>
</DynamicTheme>
);
}
// return login failed if no linking or creation is allowed and no user was found

View File

@@ -75,7 +75,7 @@ export default async function Page(props: {
submit={submit}
allowRegister={!!loginSettings?.allowRegister}
>
{identityProviders && (
{identityProviders && loginSettings?.allowExternalIdp && (
<SignInWithIdp
identityProviders={identityProviders}
requestId={requestId}

View File

@@ -1,7 +1,10 @@
import { Alert } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme";
import { RegisterForm } from "@/components/register-form";
import { SignInWithIdp } from "@/components/sign-in-with-idp";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import {
getActiveIdentityProviders,
getBrandingSettings,
getDefaultOrg,
getLegalAndSupportSettings,
@@ -9,6 +12,7 @@ import {
getPasswordComplexitySettings,
} from "@/lib/zitadel";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
@@ -18,6 +22,7 @@ export default async function Page(props: {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "register" });
const tError = await getTranslations({ locale, namespace: "error" });
let { firstname, lastname, email, organization, requestId } = searchParams;
@@ -52,6 +57,15 @@ export default async function Page(props: {
organization,
});
const identityProviders = await getActiveIdentityProviders({
serviceUrl,
orgId: organization,
}).then((resp) => {
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 (
<DynamicTheme branding={branding}>
@@ -69,16 +83,39 @@ export default async function Page(props: {
<h1>{t("title")}</h1>
<p className="ztdl-p">{t("description")}</p>
{legal && passwordComplexitySettings && (
<RegisterForm
legal={legal}
organization={organization}
firstname={firstname}
lastname={lastname}
email={email}
requestId={requestId}
loginSettings={loginSettings}
></RegisterForm>
{!organization && <Alert>{tError("unknownContext")}</Alert>}
{legal &&
passwordComplexitySettings &&
organization &&
(loginSettings.allowUsernamePassword ||
loginSettings.passkeysType == PasskeysType.ALLOWED) && (
<RegisterForm
idpCount={
!loginSettings?.allowExternalIdp ? 0 : identityProviders.length
}
legal={legal}
organization={organization}
firstname={firstname}
lastname={lastname}
email={email}
requestId={requestId}
loginSettings={loginSettings}
></RegisterForm>
)}
{loginSettings?.allowExternalIdp && !!identityProviders.length && (
<>
<div className="py-3 flex flex-col items-center">
<p className="ztdl-p text-center">{t("orUseIDP")}</p>
</div>
<SignInWithIdp
identityProviders={identityProviders}
requestId={requestId}
organization={organization}
></SignInWithIdp>
</>
)}
</div>
</DynamicTheme>

View File

@@ -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}
></SetRegisterPasswordForm>
)}

View File

@@ -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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("completeRegister.title")}</h1>
<p className="ztdl-p">{t("completeRegister.description")}</p>
<RegisterFormIDPIncomplete
idpUserId={idpUserId}
idpId={idpId}
idpUserName={idpUserName}
defaultValues={{
email: addHumanUser?.email?.email || "",
firstname: addHumanUser?.profile?.givenName || "",
lastname: addHumanUser?.profile?.familyName || "",
}}
requestId={requestId}
organization={organization}
idpIntent={idpIntent}
/>
</div>
</DynamicTheme>
);
}

View File

@@ -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<Inputs>({
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 (
<form className="w-full">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="col-span-2">
<TextInput
type="email"
autoComplete="email"
required
{...register("email", { required: "This field is required" })}
label="E-mail"
error={errors.email?.message as string}
/>
</div>
<div className="">
<TextInput
type="firstname"
@@ -106,6 +107,7 @@ export function InviteForm({
{...register("firstname", { required: "This field is required" })}
label="First name"
error={errors.firstname?.message as string}
data-testid="firstname-text-input"
/>
</div>
<div className="">
@@ -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"
/>
</div>
<div className="col-span-2">
<TextInput
type="email"
autoComplete="email"
required
{...register("email", { required: "This field is required" })}
label="E-mail"
error={errors.email?.message as string}
data-testid="email-text-input"
/>
</div>
</div>
@@ -127,12 +141,13 @@ export function InviteForm({
)}
<div className="mt-8 flex w-full flex-row items-center justify-between">
<BackButton />
<BackButton data-testid="back-button" />
<Button
type="submit"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(submitAndContinue)}
onClick={handleSubmit(submitAndRegister)}
data-testid="submit-button"
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
{t("submit")}

View File

@@ -10,7 +10,7 @@ import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert";
import { Alert, AlertType } from "./alert";
import {
AuthenticationMethod,
AuthenticationMethodRadio,
@@ -35,9 +35,10 @@ type Props = {
firstname?: string;
lastname?: string;
email?: string;
organization?: string;
organization: string;
requestId?: string;
loginSettings?: LoginSettings;
idpCount: number;
};
export function RegisterForm({
@@ -48,6 +49,7 @@ export function RegisterForm({
organization,
requestId,
loginSettings,
idpCount = 0,
}: Props) {
const t = useTranslations("register");
@@ -165,16 +167,29 @@ export function RegisterForm({
onChange={setTosAndPolicyAccepted}
/>
)}
<p className="mt-4 ztdl-p mb-6 block text-left">{t("selectMethod")}</p>
{/* show chooser if both methods are allowed */}
{loginSettings &&
loginSettings.allowUsernamePassword &&
loginSettings.passkeysType == PasskeysType.ALLOWED && (
<div className="pb-4">
<AuthenticationMethodRadio
selected={selected}
selectionChanged={setSelected}
/>
<>
<p className="mt-4 ztdl-p mb-6 block text-left">
{t("selectMethod")}
</p>
<div className="pb-4">
<AuthenticationMethodRadio
selected={selected}
selectionChanged={setSelected}
/>
</div>
</>
)}
{!loginSettings?.allowUsernamePassword &&
loginSettings?.passkeysType != PasskeysType.ALLOWED &&
(!loginSettings?.allowExternalIdp || !idpCount) && (
<div className="py-4">
<Alert type={AlertType.INFO}>{t("noMethodAvailableWarning")}</Alert>
</div>
)}
@@ -183,6 +198,7 @@ export function RegisterForm({
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center justify-between">
<BackButton data-testid="back-button" />
<Button

View File

@@ -31,7 +31,7 @@ type Props = {
email: string;
firstname: string;
lastname: string;
organization?: string;
organization: string;
requestId?: string;
};

View File

@@ -109,15 +109,20 @@ export async function createSessionAndUpdateCookie(command: {
}
}
export async function createSessionForIdpAndUpdateCookie(
userId: string,
export async function createSessionForIdpAndUpdateCookie({
userId,
idpIntent,
requestId,
lifetime,
}: {
userId: string;
idpIntent: {
idpIntentId?: string | undefined;
idpIntentToken?: string | undefined;
},
requestId: string | undefined,
lifetime?: Duration,
): Promise<Session> {
};
requestId: string | undefined;
lifetime?: Duration;
}): Promise<Session> {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);

View File

@@ -122,12 +122,12 @@ export async function createNewSessionFromIdpIntent(
organization: userResponse.user.details?.resourceOwner,
});
const session = await createSessionForIdpAndUpdateCookie(
command.userId,
command.idpIntent,
command.requestId,
loginSettings?.externalLoginCheckLifetime,
);
const session = await createSessionForIdpAndUpdateCookie({
userId: command.userId,
idpIntent: command.idpIntent,
requestId: command.requestId,
lifetime: loginSettings?.externalLoginCheckLifetime,
});
if (!session || !session.factors?.user) {
return { error: "Could not create session" };

View File

@@ -1,58 +0,0 @@
"use server";
import { addHumanUser, createInviteCode } from "@/lib/zitadel";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { headers } from "next/headers";
import { getServiceUrlFromHeaders } from "../service-url";
type InviteUserCommand = {
email: string;
firstName: string;
lastName: string;
password?: string;
organization?: string;
requestId?: string;
};
export type RegisterUserResponse = {
userId: string;
sessionId: string;
factors: Factors | undefined;
};
export async function inviteUser(command: InviteUserCommand) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const host = _headers.get("host");
if (!host) {
return { error: "Could not get domain" };
}
const human = await addHumanUser({
serviceUrl,
email: command.email,
firstName: command.firstName,
lastName: command.lastName,
password: command.password ? command.password : undefined,
organization: command.organization,
});
if (!human) {
return { error: "Could not create user" };
}
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const codeResponse = await createInviteCode({
serviceUrl,
urlTemplate: `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true`,
userId: human.userId,
});
if (!codeResponse || !human) {
return { error: "Could not create invite code" };
}
return human.userId;
}

View File

@@ -1,7 +1,15 @@
"use server";
import { createSessionAndUpdateCookie } from "@/lib/server/cookie";
import { addHumanUser, getLoginSettings, getUserByID } from "@/lib/zitadel";
import {
createSessionAndUpdateCookie,
createSessionForIdpAndUpdateCookie,
} from "@/lib/server/cookie";
import {
addHumanUser,
addIDPLink,
getLoginSettings,
getUserByID,
} from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
import {
@@ -18,7 +26,7 @@ type RegisterUserCommand = {
firstName: string;
lastName: string;
password?: string;
organization?: string;
organization: string;
requestId?: string;
};
@@ -133,3 +141,93 @@ export async function registerUser(command: RegisterUserCommand) {
return { redirect: url };
}
}
type RegisterUserAndLinkToIDPommand = {
email: string;
firstName: string;
lastName: string;
organization: string;
requestId?: string;
idpIntent: {
idpIntentId: string;
idpIntentToken: string;
};
idpUserId: string;
idpId: string;
idpUserName: string;
};
export type registerUserAndLinkToIDPResponse = {
userId: string;
sessionId: string;
factors: Factors | undefined;
};
export async function registerUserAndLinkToIDP(
command: RegisterUserAndLinkToIDPommand,
) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const host = _headers.get("host");
if (!host || typeof host !== "string") {
throw new Error("No host found");
}
const addResponse = await addHumanUser({
serviceUrl,
email: command.email,
firstName: command.firstName,
lastName: command.lastName,
organization: command.organization,
});
if (!addResponse) {
return { error: "Could not create user" };
}
const loginSettings = await getLoginSettings({
serviceUrl,
organization: command.organization,
});
const idpLink = await addIDPLink({
serviceUrl,
idp: {
id: command.idpId,
userId: command.idpUserId,
userName: command.idpUserName,
},
userId: addResponse.userId,
});
if (!idpLink) {
return { error: "Could not link IDP to user" };
}
const session = await createSessionForIdpAndUpdateCookie({
requestId: command.requestId,
userId: addResponse.userId, // the user we just created
idpIntent: command.idpIntent,
lifetime: loginSettings?.externalLoginCheckLifetime,
});
if (!session || !session.factors?.user) {
return { error: "Could not create session" };
}
const url = await getNextUrl(
command.requestId && session.id
? {
sessionId: session.id,
requestId: command.requestId,
organization: session.factors.user.organizationId,
}
: {
loginName: session.factors.user.loginName,
organization: session.factors.user.organizationId,
},
loginSettings?.defaultRedirectUri,
);
return { redirect: url };
}

View File

@@ -1,7 +1,10 @@
import { Client, create, Duration } from "@zitadel/client";
import { makeReqCtx } from "@zitadel/client/v2";
import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb";
import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb";
import {
OrganizationSchema,
TextQueryMethod,
} from "@zitadel/proto/zitadel/object/v2/object_pb";
import {
CreateCallbackRequest,
OIDCService,
@@ -32,11 +35,13 @@ import {
import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb";
import {
AddHumanUserRequest,
AddHumanUserRequestSchema,
ResendEmailCodeRequest,
ResendEmailCodeRequestSchema,
SendEmailCodeRequestSchema,
SetPasswordRequest,
SetPasswordRequestSchema,
UpdateHumanUserRequest,
UserService,
VerifyPasskeyRegistrationRequest,
VerifyU2FRegistrationRequest,
@@ -387,8 +392,8 @@ export type AddHumanUserData = {
firstName: string;
lastName: string;
email: string;
password: string | undefined;
organization: string | undefined;
password?: string;
organization: string;
};
export async function addHumanUser({
@@ -404,23 +409,36 @@ export async function addHumanUser({
serviceUrl,
);
return userService.addHumanUser({
email: {
email,
verification: {
case: "isVerified",
value: false,
let addHumanUserRequest: AddHumanUserRequest = create(
AddHumanUserRequestSchema,
{
email: {
email,
verification: {
case: "isVerified",
value: false,
},
},
username: email,
profile: { givenName: firstName, familyName: lastName },
passwordType: password
? { case: "password", value: { password } }
: undefined,
},
username: email,
profile: { givenName: firstName, familyName: lastName },
organization: organization
? { org: { case: "orgId", value: organization } }
: undefined,
passwordType: password
? { case: "password", value: { password } }
: undefined,
});
);
if (organization) {
const organizationSchema = create(OrganizationSchema, {
org: { case: "orgId", value: organization },
});
addHumanUserRequest = {
...addHumanUserRequest,
organization: organizationSchema,
};
}
return userService.addHumanUser(addHumanUserRequest);
}
export async function addHuman({
@@ -438,6 +456,21 @@ export async function addHuman({
return userService.addHumanUser(request);
}
export async function updateHuman({
serviceUrl,
request,
}: {
serviceUrl: string;
request: UpdateHumanUserRequest;
}) {
const userService: Client<typeof UserService> = await createServiceForHost(
UserService,
serviceUrl,
);
return userService.updateHumanUser(request);
}
export async function verifyTOTPRegistration({
serviceUrl,
code,