From c00bab46e0842489cc42d0ad838c76d9f4afcf6d Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 26 May 2025 13:18:37 +0200 Subject: [PATCH 01/46] fix hydration issue for IDPs, i18n --- apps/login/locales/de.json | 1 + apps/login/locales/en.json | 1 + apps/login/locales/es.json | 1 + apps/login/locales/it.json | 1 + apps/login/locales/pl.json | 1 + apps/login/locales/ru.json | 1 + apps/login/locales/zh.json | 1 + apps/login/src/app/(login)/loginname/page.tsx | 10 ++++++---- apps/login/src/components/sign-in-with-idp.tsx | 4 ++++ apps/login/src/components/username-form.tsx | 3 --- 10 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 8b3d4b311e..7606ef1a0a 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -37,6 +37,7 @@ "idp": { "title": "Mit SSO anmelden", "description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden", + "orSignInWith": "oder melden Sie sich an mit", "signInWithApple": "Mit Apple anmelden", "signInWithGoogle": "Mit Google anmelden", "signInWithAzureAD": "Mit AzureAD anmelden", diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index daaaeba108..e2032ee21a 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -37,6 +37,7 @@ "idp": { "title": "Sign in with SSO", "description": "Select one of the following providers to sign in", + "orSignInWith": "or sign in with", "signInWithApple": "Sign in with Apple", "signInWithGoogle": "Sign in with Google", "signInWithAzureAD": "Sign in with AzureAD", diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index b7dd57b4c0..f5a0b05801 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -37,6 +37,7 @@ "idp": { "title": "Iniciar sesión con SSO", "description": "Selecciona uno de los siguientes proveedores para iniciar sesión", + "orSignInWith": "o iniciar sesión con", "signInWithApple": "Iniciar sesión con Apple", "signInWithGoogle": "Iniciar sesión con Google", "signInWithAzureAD": "Iniciar sesión con AzureAD", diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index f476da3402..b12d0e978d 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -37,6 +37,7 @@ "idp": { "title": "Accedi con SSO", "description": "Seleziona uno dei seguenti provider per accedere", + "orSignInWith": "o accedi con", "signInWithApple": "Accedi con Apple", "signInWithGoogle": "Accedi con Google", "signInWithAzureAD": "Accedi con AzureAD", diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 4dd607f3cb..6ce67bada9 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -37,6 +37,7 @@ "idp": { "title": "Zaloguj się za pomocą SSO", "description": "Wybierz jednego z poniższych dostawców, aby się zalogować", + "orSignInWith": "lub zaloguj się przez", "signInWithApple": "Zaloguj się przez Apple", "signInWithGoogle": "Zaloguj się przez Google", "signInWithAzureAD": "Zaloguj się przez AzureAD", diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index e8bbac212b..77d0314199 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -37,6 +37,7 @@ "idp": { "title": "Войти через SSO", "description": "Выберите одного из провайдеров для входа", + "orSignInWith": "или войти через", "signInWithApple": "Войти через Apple", "signInWithGoogle": "Войти через Google", "signInWithAzureAD": "Войти через AzureAD", diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 7bc4ecf68a..e03a9fd62d 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -37,6 +37,7 @@ "idp": { "title": "使用 SSO 登录", "description": "选择以下提供商中的一个进行登录", + "orSignInWith": "或使用以下方式登录", "signInWithApple": "用 Apple 登录", "signInWithGoogle": "用 Google 登录", "signInWithAzureAD": "用 AzureAD 登录", diff --git a/apps/login/src/app/(login)/loginname/page.tsx b/apps/login/src/app/(login)/loginname/page.tsx index 79372729c4..9a404d5068 100644 --- a/apps/login/src/app/(login)/loginname/page.tsx +++ b/apps/login/src/app/(login)/loginname/page.tsx @@ -74,15 +74,17 @@ export default async function Page(props: { suffix={suffix} submit={submit} allowRegister={!!loginSettings?.allowRegister} - > - {identityProviders && ( + > + + {identityProviders && ( +
- )} - +
+ )} ); diff --git a/apps/login/src/components/sign-in-with-idp.tsx b/apps/login/src/components/sign-in-with-idp.tsx index 7632a29cc1..7811c0bff3 100644 --- a/apps/login/src/components/sign-in-with-idp.tsx +++ b/apps/login/src/components/sign-in-with-idp.tsx @@ -6,6 +6,7 @@ import { IdentityProvider, IdentityProviderType, } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useTranslations } from "next-intl"; import { ReactNode, useActionState } from "react"; import { Alert } from "./alert"; import { SignInWithIdentityProviderProps } from "./idps/base-button"; @@ -31,6 +32,7 @@ export function SignInWithIdp({ linkOnly, }: Readonly) { const [state, action, _isPending] = useActionState(redirectToIdp, {}); + const t = useTranslations("idp"); const renderIDPButton = (idp: IdentityProvider, index: number) => { const { id, name, type } = idp; @@ -53,6 +55,7 @@ export function SignInWithIdp({ [IdentityProviderType.GITLAB]: SignInWithGitlab, [IdentityProviderType.GITLAB_SELF_HOSTED]: SignInWithGitlab, [IdentityProviderType.SAML]: SignInWithGeneric, + [IdentityProviderType.LDAP]: SignInWithGeneric, }; const Component = components[type]; @@ -74,6 +77,7 @@ export function SignInWithIdp({ return (
+

{t("orSignInWith")}

{!!identityProviders.length && identityProviders?.map(renderIDPButton)} {state?.error && (
diff --git a/apps/login/src/components/username-form.tsx b/apps/login/src/components/username-form.tsx index 6801f6b274..a239da3528 100644 --- a/apps/login/src/components/username-form.tsx +++ b/apps/login/src/components/username-form.tsx @@ -137,9 +137,6 @@ export function UsernameForm({ {error}
)} - -
{children}
-
From 2288e6ca92f622da833ebbe7346ae6600a32ca79 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 26 May 2025 13:41:18 +0200 Subject: [PATCH 02/46] cleanup routes --- apps/login/src/app/login/route.ts | 14 ++++++++++++++ apps/login/src/lib/idp.ts | 2 ++ apps/login/src/lib/server/idp.ts | 4 ++++ apps/login/src/lib/zitadel.ts | 1 - 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index 0e181b76f1..cdb25bae65 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -24,6 +24,7 @@ import { } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { DEFAULT_CSP } from "../../../constants/csp"; @@ -191,6 +192,19 @@ export async function GET(request: NextRequest) { const origin = request.nextUrl.origin; const identityProviderType = identityProviders[0].type; + + if (identityProviderType === IdentityProviderType.LDAP) { + const ldapUrl = constructUrl(request, "/ldap"); + if (authRequest.id) { + ldapUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); + } + if (organization) { + ldapUrl.searchParams.set("organization", organization); + } + + return NextResponse.redirect(ldapUrl); + } + let provider = idpTypeToSlug(identityProviderType); const params = new URLSearchParams(); diff --git a/apps/login/src/lib/idp.ts b/apps/login/src/lib/idp.ts index 1d4b82951a..66b3dfa594 100644 --- a/apps/login/src/lib/idp.ts +++ b/apps/login/src/lib/idp.ts @@ -24,6 +24,8 @@ export function idpTypeToSlug(idpType: IdentityProviderType) { return "oauth"; case IdentityProviderType.OIDC: return "oidc"; + case IdentityProviderType.LDAP: + return "ldap"; default: throw new Error("Unknown identity provider type"); } diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index 5cac537690..33e2990bdc 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -30,6 +30,10 @@ export async function redirectToIdp( if (requestId) params.set("requestId", requestId); if (organization) params.set("organization", organization); + if (provider === "ldap") { + redirect("/idp/ldap?linkOnly=" + linkOnly + "&" + params.toString()); + } + const response = await startIDPFlow({ idpId, successUrl: `/idp/${provider}/success?` + params.toString(), diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index d1fe83434d..b7f0f9a059 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -908,7 +908,6 @@ export async function startIdentityProviderFlow({ urls, }: { serviceUrl: string; - idpId: string; urls: RedirectURLsJson; }) { From 9d975bb39a9889eef504ae33d124d53dcc635986 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 26 May 2025 15:48:54 +0200 Subject: [PATCH 03/46] ldap components --- apps/login/src/app/(login)/password/page.tsx | 7 +- apps/login/src/components/login-passkey.tsx | 12 +- apps/login/src/components/password-form.tsx | 4 - .../src/components/username-password-form.tsx | 120 ++++++++++++++++++ 4 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 apps/login/src/components/username-password-form.tsx diff --git a/apps/login/src/app/(login)/password/page.tsx b/apps/login/src/app/(login)/password/page.tsx index 506454a275..29a49c61f2 100644 --- a/apps/login/src/app/(login)/password/page.tsx +++ b/apps/login/src/app/(login)/password/page.tsx @@ -10,7 +10,6 @@ import { getLoginSettings, } 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"; @@ -22,7 +21,7 @@ export default async function Page(props: { const t = await getTranslations({ locale, namespace: "password" }); const tError = await getTranslations({ locale, namespace: "error" }); - let { loginName, organization, requestId, alt } = searchParams; + let { loginName, organization, requestId } = searchParams; const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -93,10 +92,6 @@ export default async function Page(props: { requestId={requestId} organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user loginSettings={loginSettings} - promptPasswordless={ - loginSettings?.passkeysType == PasskeysType.ALLOWED - } - isAlternative={alt === "true"} /> )}
diff --git a/apps/login/src/components/login-passkey.tsx b/apps/login/src/components/login-passkey.tsx index b3f0b1212f..b364a9de45 100644 --- a/apps/login/src/components/login-passkey.tsx +++ b/apps/login/src/components/login-passkey.tsx @@ -210,26 +210,26 @@ export function LoginPasskey({ type="button" variant={ButtonVariants.Secondary} onClick={() => { - const params: any = { alt: "true" }; + const params = new URLSearchParams(); if (loginName) { - params.loginName = loginName; + params.append("loginName", loginName); } if (sessionId) { - params.sessionId = sessionId; + params.append("sessionId", sessionId); } if (requestId) { - params.requestId = requestId; + params.append("requestId", requestId); } if (organization) { - params.organization = organization; + params.append("organization", organization); } return router.push( - "/password?" + new URLSearchParams(params), // alt is set because password is requested as alternative auth method, so passwordless prompt can be escaped + "/password?" + params, // alt is set because password is requested as alternative auth method, so passwordless prompt can be escaped ); }} data-testid="password-button" diff --git a/apps/login/src/components/password-form.tsx b/apps/login/src/components/password-form.tsx index 17461644d8..a9b0a01316 100644 --- a/apps/login/src/components/password-form.tsx +++ b/apps/login/src/components/password-form.tsx @@ -23,8 +23,6 @@ type Props = { loginName: string; organization?: string; requestId?: string; - isAlternative?: boolean; // whether password was requested as alternative auth method - promptPasswordless?: boolean; }; export function PasswordForm({ @@ -32,8 +30,6 @@ export function PasswordForm({ loginName, organization, requestId, - promptPasswordless, - isAlternative, }: Props) { const t = useTranslations("password"); diff --git a/apps/login/src/components/username-password-form.tsx b/apps/login/src/components/username-password-form.tsx new file mode 100644 index 0000000000..fb6a98d8c3 --- /dev/null +++ b/apps/login/src/components/username-password-form.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { sendPassword } from "@/lib/server/password"; +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; + +type Inputs = { + loginName: string; + password: string; +}; + +type Props = { + loginSettings: LoginSettings | undefined; + loginName: string; + organization?: string; + requestId?: string; +}; + +export function UsernamePasswordForm({ + loginSettings, + loginName, + organization, + requestId, +}: Props) { + const t = useTranslations("password"); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + async function submitUsernamePassword(values: Inputs) { + setError(""); + setLoading(true); + + const response = await sendPassword({ + loginName, + organization, + checks: create(ChecksSchema, { + password: { password: values.password }, + }), + requestId, + }) + .catch(() => { + setError("Could not verify password"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + } + + return ( +
+ + +
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+ + ); +} From 7ea2103b57d6e31c6737d0afd592cff89f9af25c Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 28 May 2025 10:56:46 +0200 Subject: [PATCH 04/46] ldap page, start idp flow --- .../app/(login)/authenticator/set/page.tsx | 2 +- apps/login/src/app/(login)/idp/ldap/page.tsx | 55 ++++++++++++ .../src/components/username-password-form.tsx | 24 ++--- apps/login/src/lib/server/idp.ts | 90 +++++++++++++++---- apps/login/src/lib/zitadel.ts | 28 ++++++ 5 files changed, 167 insertions(+), 32 deletions(-) create mode 100644 apps/login/src/app/(login)/idp/ldap/page.tsx diff --git a/apps/login/src/app/(login)/authenticator/set/page.tsx b/apps/login/src/app/(login)/authenticator/set/page.tsx index 5a8dfe810d..82b494f200 100644 --- a/apps/login/src/app/(login)/authenticator/set/page.tsx +++ b/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -184,7 +184,7 @@ export default async function Page(props: { > )} - {loginSettings?.allowExternalIdp && identityProviders && ( + {loginSettings?.allowExternalIdp && !!identityProviders.length && ( <> {identityProviders.length && (
diff --git a/apps/login/src/app/(login)/idp/ldap/page.tsx b/apps/login/src/app/(login)/idp/ldap/page.tsx new file mode 100644 index 0000000000..6a54c2d648 --- /dev/null +++ b/apps/login/src/app/(login)/idp/ldap/page.tsx @@ -0,0 +1,55 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { UsernamePasswordForm } from "@/components/username-password-form"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; + params: Promise<{ provider: string }>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "ldap" }); + const { idpId, requestId, organization, link } = searchParams; + + if (!idpId) { + throw new Error("No idpId provided in 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 login failed if no linking or creation is allowed and no user was found + return ( + +
+

{t("title")}

+

{t("description")}

+ + +
+
+ ); +} diff --git a/apps/login/src/components/username-password-form.tsx b/apps/login/src/components/username-password-form.tsx index fb6a98d8c3..4c5543866d 100644 --- a/apps/login/src/components/username-password-form.tsx +++ b/apps/login/src/components/username-password-form.tsx @@ -1,9 +1,6 @@ "use client"; -import { sendPassword } from "@/lib/server/password"; -import { create } from "@zitadel/client"; -import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; -import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { createNewSessionForLDAP } from "@/lib/server/idp"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -20,17 +17,15 @@ type Inputs = { }; type Props = { - loginSettings: LoginSettings | undefined; - loginName: string; organization?: string; requestId?: string; + idpId: string; }; export function UsernamePasswordForm({ - loginSettings, - loginName, organization, requestId, + idpId, }: Props) { const t = useTranslations("password"); @@ -48,13 +43,10 @@ export function UsernamePasswordForm({ setError(""); setLoading(true); - const response = await sendPassword({ - loginName, - organization, - checks: create(ChecksSchema, { - password: { password: values.password }, - }), - requestId, + const response = await createNewSessionForLDAP({ + idpId: idpId, + username: values.loginName, + password: values.password, }) .catch(() => { setError("Could not verify password"); @@ -75,7 +67,7 @@ export function UsernamePasswordForm({ } return ( -
+ { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + if (!host) { + return { error: "Could not get host" }; + } + const params = new URLSearchParams(); const linkOnly = formData.get("linkOnly") === "true"; @@ -26,52 +34,54 @@ export async function redirectToIdp( const idpId = formData.get("id") as string; const provider = formData.get("provider") as string; + // const username = formData.get("username") as string; + // const password = formData.get("password") as string; + if (linkOnly) params.set("link", "true"); if (requestId) params.set("requestId", requestId); if (organization) params.set("organization", organization); + // redirect to LDAP page where username and password is requested if (provider === "ldap") { - redirect("/idp/ldap?linkOnly=" + linkOnly + "&" + params.toString()); + redirect(`/idp/ldap?` + params.toString()); } const response = await startIDPFlow({ + serviceUrl, + host, idpId, successUrl: `/idp/${provider}/success?` + params.toString(), failureUrl: `/idp/${provider}/failure?` + params.toString(), }); - if (response && "error" in response && response?.error) { - return { error: response.error }; + if (!response) { + return { error: "Could not start IDP flow" }; } if (response && "redirect" in response && response?.redirect) { redirect(response.redirect); } + + return { error: "Unexpected response from IDP flow" }; } export type StartIDPFlowCommand = { + serviceUrl: string; + host: string; idpId: string; successUrl: string; failureUrl: string; }; -export async function startIDPFlow(command: StartIDPFlowCommand) { - const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = _headers.get("host"); - - if (!host) { - return { error: "Could not get host" }; - } - +async function startIDPFlow(command: StartIDPFlowCommand) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; return startIdentityProviderFlow({ - serviceUrl, + serviceUrl: command.serviceUrl, idpId: command.idpId, urls: { - successUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}${command.successUrl}`, - failureUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}${command.failureUrl}`, + successUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.successUrl}`, + failureUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.failureUrl}`, }, }).then((response) => { if ( @@ -178,3 +188,53 @@ export async function createNewSessionFromIdpIntent( return { redirect: url }; } } + +type createNewSessionForLDAPCommand = { + username: string; + password: string; + idpId: string; +}; + +export async function createNewSessionForLDAP( + command: createNewSessionForLDAPCommand, +) { + const _headers = await headers(); + + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get domain" }; + } + + if (!command.username || !command.password) { + return { error: "No username or password provided" }; + } + + const response = await startLDAPIdentityProviderFlow({ + serviceUrl, + idpId: command.idpId, + username: command.username, + password: command.password, + }); + + if ( + !response || + response.nextStep.case !== "idpIntent" || + !response.nextStep.value + ) { + return { error: "Could not start LDAP identity provider flow" }; + } + + const { userId, idpIntentId, idpIntentToken } = response.nextStep.value; + + return { + redirect: + `/idp/ldap/success?` + + new URLSearchParams({ + userId, + id: idpIntentId, + token: idpIntentToken, + }).toString(), + }; +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index bdf46a5905..5cc4181e1f 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -910,6 +910,34 @@ export async function startIdentityProviderFlow({ }); } +export async function startLDAPIdentityProviderFlow({ + serviceUrl, + idpId, + username, + password, +}: { + serviceUrl: string; + idpId: string; + username: string; + password: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.startIdentityProviderIntent({ + idpId, + content: { + case: "ldap", + value: { + username, + password, + }, + }, + }); +} + export async function getAuthRequest({ serviceUrl, authRequestId, From 0818661496630e3355a4e5ccd4ecc2d64879a746 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 28 May 2025 11:47:42 +0200 Subject: [PATCH 05/46] ldap --- apps/login/locales/en.json | 7 +++++++ apps/login/src/app/(login)/idp/ldap/page.tsx | 6 +++--- ...e-password-form.tsx => ldap-username-password-form.tsx} | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) rename apps/login/src/components/{username-password-form.tsx => ldap-username-password-form.tsx} (98%) diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index e2032ee21a..81c93bb549 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -64,6 +64,13 @@ "description": "An error occurred while trying to link your account." } }, + "ldap": { + "title": "LDAP Login", + "description": "Enter your LDAP credentials.", + "username": "Username", + "password": "Password", + "submit": "Continue" + }, "mfa": { "verify": { "title": "Verify your identity", diff --git a/apps/login/src/app/(login)/idp/ldap/page.tsx b/apps/login/src/app/(login)/idp/ldap/page.tsx index 6a54c2d648..67e6bbe4a7 100644 --- a/apps/login/src/app/(login)/idp/ldap/page.tsx +++ b/apps/login/src/app/(login)/idp/ldap/page.tsx @@ -1,5 +1,5 @@ import { DynamicTheme } from "@/components/dynamic-theme"; -import { UsernamePasswordForm } from "@/components/username-password-form"; +import { LDAPUsernamePasswordForm } from "@/components/ldap-username-password-form"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; @@ -44,11 +44,11 @@ export default async function Page(props: {

{t("title")}

{t("description")}

- + >
); diff --git a/apps/login/src/components/username-password-form.tsx b/apps/login/src/components/ldap-username-password-form.tsx similarity index 98% rename from apps/login/src/components/username-password-form.tsx rename to apps/login/src/components/ldap-username-password-form.tsx index 4c5543866d..5350281053 100644 --- a/apps/login/src/components/username-password-form.tsx +++ b/apps/login/src/components/ldap-username-password-form.tsx @@ -22,7 +22,7 @@ type Props = { idpId: string; }; -export function UsernamePasswordForm({ +export function LDAPUsernamePasswordForm({ organization, requestId, idpId, From 2ba8311cd6f9603c1a796f4aece9ebd4ef4b800d Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 2 Jun 2025 11:00:05 +0200 Subject: [PATCH 06/46] idpId param, error msg --- apps/login/src/components/ldap-username-password-form.tsx | 2 +- apps/login/src/lib/server/idp.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/login/src/components/ldap-username-password-form.tsx b/apps/login/src/components/ldap-username-password-form.tsx index 5350281053..b3dcaabd58 100644 --- a/apps/login/src/components/ldap-username-password-form.tsx +++ b/apps/login/src/components/ldap-username-password-form.tsx @@ -49,7 +49,7 @@ export function LDAPUsernamePasswordForm({ password: values.password, }) .catch(() => { - setError("Could not verify password"); + setError("Could not start LDAP flow"); return; }) .finally(() => { diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index 1fb5173fe2..367824331f 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -43,6 +43,7 @@ export async function redirectToIdp( // redirect to LDAP page where username and password is requested if (provider === "ldap") { + params.set("idpId", idpId); redirect(`/idp/ldap?` + params.toString()); } From 07e5d548f06c020167cf605707d73aeef223d3a6 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 5 Jun 2025 09:20:05 +0200 Subject: [PATCH 07/46] register with idp intent --- .../(login)/idp/[provider]/success/page.tsx | 12 ++++++++++ apps/login/src/app/(login)/register/page.tsx | 22 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) 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..cf5f0c8637 100644 --- a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx @@ -24,6 +24,7 @@ import { } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +import { redirect } from "next/navigation"; const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; @@ -205,6 +206,7 @@ export default async function Page(props: { } } + // if addHumanUser is provided in the intent, expect that it can be created otherwise show an error if (addHumanUser) { let addHumanUserWithOrganization: AddHumanUserRequest; if (orgToRegisterOn) { @@ -241,6 +243,16 @@ export default async function Page(props: { : "Could not create user", ); } + } else { + // if no user was found, we will create a new user manually / redirect to the registration page + if (options.isCreationAllowed) { + const registerParams = new URLSearchParams({ + idpIntentId: id, + idpIntentToken: token, + organization: organization ?? "", + }); + return redirect(`/register?${registerParams})}`); + } } if (newUser) { diff --git a/apps/login/src/app/(login)/register/page.tsx b/apps/login/src/app/(login)/register/page.tsx index e50511edb1..d042b52c60 100644 --- a/apps/login/src/app/(login)/register/page.tsx +++ b/apps/login/src/app/(login)/register/page.tsx @@ -7,6 +7,7 @@ import { getLegalAndSupportSettings, getLoginSettings, getPasswordComplexitySettings, + retrieveIDPIntent, } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { getLocale, getTranslations } from "next-intl/server"; @@ -19,7 +20,15 @@ export default async function Page(props: { const locale = getLocale(); const t = await getTranslations({ locale, namespace: "register" }); - let { firstname, lastname, email, organization, requestId } = searchParams; + let { + firstname, + lastname, + email, + organization, + requestId, + idpIntentId, + idpIntentToken, + } = searchParams; const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -33,6 +42,17 @@ export default async function Page(props: { } } + let idpIntent; + if (idpIntentId && idpIntentToken) { + idpIntent = await retrieveIDPIntent({ + serviceUrl, + id: idpIntentId, + token: idpIntentToken, + }); + + const { idpInformation, userId } = idpIntent; + } + const legal = await getLegalAndSupportSettings({ serviceUrl, organization, From 738f1f04487881f2a75cf746c994f03aeb84ad7c Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 6 Jun 2025 13:46:25 +0200 Subject: [PATCH 08/46] conditionally hide options --- apps/login/locales/de.json | 2 ++ apps/login/locales/en.json | 2 ++ apps/login/locales/es.json | 2 ++ apps/login/locales/it.json | 2 ++ apps/login/locales/pl.json | 2 ++ apps/login/locales/ru.json | 2 ++ apps/login/locales/zh.json | 2 ++ apps/login/src/app/(login)/register/page.tsx | 28 ++++++++++++++++++++ apps/login/src/components/register-form.tsx | 11 +++++++- 9 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 9da622340b..bddc703771 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -149,11 +149,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..7bf71fd259 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -149,11 +149,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..225a5c84db 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -149,11 +149,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..effe09047a 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -149,11 +149,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..95f3cb2d2b 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -149,11 +149,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..9c34a1e6b1 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -149,11 +149,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..fae81906d2 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -149,11 +149,13 @@ }, "title": "注册", "description": "创建您的 ZITADEL 账户。", + "noMethodAvailableWarning": "没有可用的认证方法。请联系您的系统管理员。", "selectMethod": "选择您想使用的认证方法", "agreeTo": "注册即表示您同意条款和条件", "termsOfService": "服务条款", "privacyPolicy": "隐私政策", "submit": "继续", + "orUseIDP": "或使用身份提供者", "password": { "title": "设置密码", "description": "为您的账户设置密码", diff --git a/apps/login/src/app/(login)/register/page.tsx b/apps/login/src/app/(login)/register/page.tsx index d042b52c60..550a214102 100644 --- a/apps/login/src/app/(login)/register/page.tsx +++ b/apps/login/src/app/(login)/register/page.tsx @@ -1,7 +1,9 @@ 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, @@ -72,6 +74,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 ( @@ -91,6 +102,9 @@ export default async function Page(props: { {legal && passwordComplexitySettings && ( )} + + {loginSettings?.allowExternalIdp && !!identityProviders.length && ( + <> +
+

{t("orUseIDP")}

+
+ + + + )}
); diff --git a/apps/login/src/components/register-form.tsx b/apps/login/src/components/register-form.tsx index 09e3f0b89b..4ce4860b16 100644 --- a/apps/login/src/components/register-form.tsx +++ b/apps/login/src/components/register-form.tsx @@ -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, @@ -38,6 +38,7 @@ type Props = { 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"); @@ -178,11 +180,18 @@ export function RegisterForm({ )} + {(!loginSettings?.allowUsernamePassword || + loginSettings?.passkeysType != PasskeysType.ALLOWED) && + !idpCount && ( + {t("noMethodAvailableWarning")} + )} + {error && (
{error}
)} +
+
+ + ); +} diff --git a/apps/login/src/lib/server/cookie.ts b/apps/login/src/lib/server/cookie.ts index 88e0b48290..841fc06b3a 100644 --- a/apps/login/src/lib/server/cookie.ts +++ b/apps/login/src/lib/server/cookie.ts @@ -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 { + }; + requestId: string | undefined; + lifetime?: Duration; +}): Promise { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index 5cac537690..aaf5b77779 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -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" }; diff --git a/apps/login/src/lib/server/register.ts b/apps/login/src/lib/server/register.ts index 25bea33527..b08099817d 100644 --- a/apps/login/src/lib/server/register.ts +++ b/apps/login/src/lib/server/register.ts @@ -1,6 +1,9 @@ "use server"; -import { createSessionAndUpdateCookie } from "@/lib/server/cookie"; +import { + createSessionAndUpdateCookie, + createSessionForIdpAndUpdateCookie, +} from "@/lib/server/cookie"; import { addHumanUser, getLoginSettings, getUserByID } from "@/lib/zitadel"; import { create } from "@zitadel/client"; import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; @@ -133,3 +136,79 @@ 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; + }; + userId: 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, + }); + + // TODO: addIDPLink to addResponse + + const session = await createSessionForIdpAndUpdateCookie({ + requestId: command.requestId, + userId: command.userId, + 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 }; +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index a0e91a021c..c53d622d7d 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -387,7 +387,7 @@ export type AddHumanUserData = { firstName: string; lastName: string; email: string; - password: string | undefined; + password?: string; organization: string | undefined; }; From dbf458c685b131903bba65ab49a8dc95ebc00e6a Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 10 Jun 2025 14:02:35 +0200 Subject: [PATCH 10/46] idpIntent properties --- .../app/(login)/idp/[provider]/success/page.tsx | 1 + .../src/components/idps/pages/complete-idp.tsx | 6 ++++++ .../components/register-form-idp-incomplete.tsx | 14 ++++++++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) 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 3e4a7b253a..b56a425138 100644 --- a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx @@ -248,6 +248,7 @@ export default async function Page(props: { if (options?.isCreationAllowed) { return completeIDP({ branding, + idpIntent: { idpIntentId: id, idpIntentToken: token }, idpInformation, organization, requestId, diff --git a/apps/login/src/components/idps/pages/complete-idp.tsx b/apps/login/src/components/idps/pages/complete-idp.tsx index 4443a9317b..a2a89265c8 100644 --- a/apps/login/src/components/idps/pages/complete-idp.tsx +++ b/apps/login/src/components/idps/pages/complete-idp.tsx @@ -10,12 +10,17 @@ export async function completeIDP({ requestId, organization, branding, + idpIntent, }: { userId: string; idpInformation: IDPInformation; requestId?: string; organization?: string; branding?: BrandingSettings; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; }) { const locale = getLocale(); const t = await getTranslations({ locale, namespace: "idp" }); @@ -31,6 +36,7 @@ export async function completeIDP({ idpInformation={idpInformation} requestId={requestId} organization={organization} + idpIntent={idpIntent} /> diff --git a/apps/login/src/components/register-form-idp-incomplete.tsx b/apps/login/src/components/register-form-idp-incomplete.tsx index 35324be2e2..5fd7e43786 100644 --- a/apps/login/src/components/register-form-idp-incomplete.tsx +++ b/apps/login/src/components/register-form-idp-incomplete.tsx @@ -1,5 +1,6 @@ "use client"; +import { registerUserAndLinkToIDP } from "@/lib/server/register"; import { IDPInformation } from "@zitadel/proto/zitadel/user/v2/idp_pb"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; @@ -23,13 +24,20 @@ type Inputs = type Props = { organization?: string; requestId?: string; - idpInformation?: IDPInformation; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + idpInformation: IDPInformation; + userId: string; }; export function RegisterFormIDPIncomplete({ organization, requestId, + idpIntent, idpInformation, + userId, }: Props) { const t = useTranslations("register"); @@ -51,11 +59,13 @@ export function RegisterFormIDPIncomplete({ async function submitAndRegister(values: Inputs) { setLoading(true); const response = await registerUserAndLinkToIDP({ + userId: userId, email: values.email, firstName: values.firstname, lastName: values.lastname, organization: organization, requestId: requestId, + idpIntent: idpIntent, }) .catch(() => { setError("Could not register user"); @@ -130,7 +140,7 @@ export function RegisterFormIDPIncomplete({ - - - ); -} From 41170310dc17bd1402c0c3f6bc7e06eaef2eca13 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 11 Jun 2025 14:04:40 +0200 Subject: [PATCH 16/46] cleanup invite server code --- apps/login/src/lib/server/invite.ts | 58 ----------------------------- 1 file changed, 58 deletions(-) delete mode 100644 apps/login/src/lib/server/invite.ts diff --git a/apps/login/src/lib/server/invite.ts b/apps/login/src/lib/server/invite.ts deleted file mode 100644 index 40225d9916..0000000000 --- a/apps/login/src/lib/server/invite.ts +++ /dev/null @@ -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; -} From 3e777662e6d1955ca5e80a8121fab41247f43d6d Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 11 Jun 2025 14:09:56 +0200 Subject: [PATCH 17/46] require organization --- apps/login/src/app/(login)/register/password/page.tsx | 4 ++-- apps/login/src/components/set-register-password-form.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/set-register-password-form.tsx b/apps/login/src/components/set-register-password-form.tsx index 3f38a408d0..3e48c649c1 100644 --- a/apps/login/src/components/set-register-password-form.tsx +++ b/apps/login/src/components/set-register-password-form.tsx @@ -31,7 +31,7 @@ type Props = { email: string; firstname: string; lastname: string; - organization?: string; + organization: string; requestId?: string; }; From ea2b49479b15ed7f0eb61da538a2588999f1e1bf Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 11 Jun 2025 14:44:06 +0200 Subject: [PATCH 18/46] fix: use route handler to saml post, should close #411 --- apps/login/src/app/(login)/saml-post/page.tsx | 43 ------------------- apps/login/src/app/(login)/saml-post/route.ts | 30 +++++++++++++ 2 files changed, 30 insertions(+), 43 deletions(-) delete mode 100644 apps/login/src/app/(login)/saml-post/page.tsx create mode 100644 apps/login/src/app/(login)/saml-post/route.ts diff --git a/apps/login/src/app/(login)/saml-post/page.tsx b/apps/login/src/app/(login)/saml-post/page.tsx deleted file mode 100644 index d5765c8d56..0000000000 --- a/apps/login/src/app/(login)/saml-post/page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; -import { useEffect } from "react"; - -export default function SamlPost() { - const searchParams = useSearchParams(); - - const url = searchParams.get("url"); - const relayState = searchParams.get("RelayState"); - const samlResponse = searchParams.get("SAMLResponse"); - - useEffect(() => { - // Automatically submit the form after rendering - const form = document.getElementById("samlForm") as HTMLFormElement; - if (form) { - form.submit(); - } - }, []); - - if (!url || !relayState || !samlResponse) { - return ( -

Missing required parameters for SAML POST.

- ); - } - - return ( - - - - - Redirecting... - - -
- - -
-

Redirecting...

- - - ); -} diff --git a/apps/login/src/app/(login)/saml-post/route.ts b/apps/login/src/app/(login)/saml-post/route.ts new file mode 100644 index 0000000000..f2834f3884 --- /dev/null +++ b/apps/login/src/app/(login)/saml-post/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const url = searchParams.get("url"); + const relayState = searchParams.get("RelayState"); + const samlResponse = searchParams.get("SAMLResponse"); + + if (!url || !relayState || !samlResponse) { + return new NextResponse("Missing required parameters", { status: 400 }); + } + + // Respond with an HTML form that auto-submits via POST + const html = ` + + +
+ + + +
+ + + `; + return new NextResponse(html, { + headers: { "Content-Type": "text/html" }, + }); +} From 5513eb88412482a52efad9eed05a6ad137a85aa8 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 13 Jun 2025 09:06:54 +0200 Subject: [PATCH 19/46] custom header in middleware --- apps/login/src/i18n/request.ts | 14 +++++++++ apps/login/src/lib/zitadel.ts | 27 ++++++++++++++++ apps/login/src/middleware.ts | 49 ++++++++++++++++++++++------- packages/zitadel-proto/package.json | 2 +- 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/apps/login/src/i18n/request.ts b/apps/login/src/i18n/request.ts index 59c9da42cc..71ccaebae5 100644 --- a/apps/login/src/i18n/request.ts +++ b/apps/login/src/i18n/request.ts @@ -1,4 +1,6 @@ import { LANGS, LANGUAGE_COOKIE_NAME, LANGUAGE_HEADER_NAME } from "@/lib/i18n"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getHostedLoginTranslation } from "@/lib/zitadel"; import deepmerge from "deepmerge"; import { getRequestConfig } from "next-intl/server"; import { cookies, headers } from "next/headers"; @@ -9,6 +11,18 @@ export default getRequestConfig(async () => { let locale: string = fallback; + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const i18nOrganization = _headers.get("x-zitadel-i18n-organization") || ""; // You may need to set this header in middleware + + const translations = await getHostedLoginTranslation({ + serviceUrl, + organization: i18nOrganization, + }); + + translations. + const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); if (languageHeader) { const headerLocale = languageHeader.split(",")[0].split("-")[0]; // Extract the language code diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index d5045df041..f567350053 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -21,6 +21,7 @@ import { SessionService, } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { TranslationLevelType } from "@zitadel/proto/zitadel/settings/v2/settings_pb"; import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb"; import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb"; @@ -45,6 +46,7 @@ import { VerifyPasskeyRegistrationRequest, VerifyU2FRegistrationRequest, } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { getLocale } from "next-intl/server"; import { unstable_cacheLife as cacheLife } from "next/cache"; import { getUserAgent } from "./fingerprint"; import { createServiceForHost } from "./service"; @@ -58,6 +60,31 @@ async function cacheWrapper(callback: Promise) { return callback; } +export async function getHostedLoginTranslation({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const locale = await getLocale(); + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getHostedLoginTranslation( + { + level: TranslationLevelType.INSTANCE, + levelId: organization, + locale: locale, + }, + {}, + ) + .then((resp) => (resp.translations ? resp.translations : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + export async function getBrandingSettings({ serviceUrl, organization, diff --git a/apps/login/src/middleware.ts b/apps/login/src/middleware.ts index 4d66d0ab39..5c04a26d13 100644 --- a/apps/login/src/middleware.ts +++ b/apps/login/src/middleware.ts @@ -10,21 +10,52 @@ export const config = { "/oidc/:path*", "/idps/callback/:path*", "/saml/:path*", + // Add "/*" to match all routes for translation header injection + "/*", ], }; export async function middleware(request: NextRequest) { + // Add the original URL as a header to all requests + const requestHeaders = new Headers(request.headers); + + // Extract "organization" search param from the URL and set it as a header if available + const organization = request.nextUrl.searchParams.get("organization"); + if (organization) { + requestHeaders.set("x-zitadel-i18n-organization", organization); + } + + // Only run the rest of the logic for the original matcher paths + const matchedPaths = [ + "/.well-known/", + "/oauth/", + "/oidc/", + "/idps/callback/", + "/saml/", + ]; + + const isMatched = matchedPaths.some((prefix) => + request.nextUrl.pathname.startsWith(prefix), + ); + + if (!isMatched) { + // For all other routes, just add the header and continue + return NextResponse.next({ + request: { headers: requestHeaders }, + }); + } + // escape proxy if the environment is setup for multitenancy if (!process.env.ZITADEL_API_URL || !process.env.ZITADEL_SERVICE_USER_TOKEN) { - return NextResponse.next(); + return NextResponse.next({ + request: { headers: requestHeaders }, + }); } const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); // Call the /security route handler - // TODO check this on cloud run deployment const securityResponse = await fetch(`${request.nextUrl.origin}/security`); if (!securityResponse.ok) { @@ -32,7 +63,9 @@ export async function middleware(request: NextRequest) { "Failed to fetch security settings:", securityResponse.statusText, ); - return NextResponse.next(); // Fallback if the request fails + return NextResponse.next({ + request: { headers: requestHeaders }, + }); } const { settings: securitySettings } = await securityResponse.json(); @@ -41,13 +74,8 @@ export async function middleware(request: NextRequest) { .replace("https://", "") .replace("http://", ""); - const requestHeaders = new Headers(request.headers); - - // this is a workaround for the next.js server not forwarding the host header - // requestHeaders.set("x-zitadel-forwarded", `host="${request.nextUrl.host}"`); + // Add additional headers as before requestHeaders.set("x-zitadel-public-host", `${request.nextUrl.host}`); - - // this is a workaround for the next.js server not forwarding the host header requestHeaders.set("x-zitadel-instance-host", instanceHost); const responseHeaders = new Headers(); @@ -55,7 +83,6 @@ export async function middleware(request: NextRequest) { responseHeaders.set("Access-Control-Allow-Headers", "*"); if (securitySettings?.embeddedIframe?.enabled) { - securitySettings.embeddedIframe.allowedOrigins; responseHeaders.set( "Content-Security-Policy", `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, diff --git a/packages/zitadel-proto/package.json b/packages/zitadel-proto/package.json index 61ef296616..2fd912f4df 100644 --- a/packages/zitadel-proto/package.json +++ b/packages/zitadel-proto/package.json @@ -14,7 +14,7 @@ ], "sideEffects": false, "scripts": { - "generate": "buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel", + "generate": "buf generate https://github.com/zitadel/zitadel.git#branch=feat/9850-hosted-login-translation-api --path ./proto/zitadel", "clean": "rm -rf zitadel .turbo node_modules google protoc-gen-openapiv2 validate" }, "dependencies": { From 19236e19a8066db15212d9676ca9e157004a9d62 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 13 Jun 2025 09:15:22 +0200 Subject: [PATCH 20/46] deep merge with fallback --- apps/login/src/i18n/request.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/login/src/i18n/request.ts b/apps/login/src/i18n/request.ts index 71ccaebae5..36bf3f011e 100644 --- a/apps/login/src/i18n/request.ts +++ b/apps/login/src/i18n/request.ts @@ -21,7 +21,7 @@ export default getRequestConfig(async () => { organization: i18nOrganization, }); - translations. + console.log("Translations:", translations); const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); if (languageHeader) { @@ -38,12 +38,13 @@ export default getRequestConfig(async () => { } } - const userMessages = (await import(`../../locales/${locale}.json`)).default; + const customMessages = translations; + const localeMessages = (await import(`../../locales/${locale}.json`)).default; const fallbackMessages = (await import(`../../locales/${fallback}.json`)) .default; return { locale, - messages: deepmerge(fallbackMessages, userMessages), + messages: deepmerge(fallbackMessages, localeMessages, customMessages), }; }); From 3cf8e00e7923ae6a98bf068574f8a0f3987cc7ff Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 13 Jun 2025 09:21:41 +0200 Subject: [PATCH 21/46] locale context from intl-context --- apps/login/src/i18n/request.ts | 1 + apps/login/src/lib/zitadel.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/login/src/i18n/request.ts b/apps/login/src/i18n/request.ts index 36bf3f011e..edcae463d1 100644 --- a/apps/login/src/i18n/request.ts +++ b/apps/login/src/i18n/request.ts @@ -18,6 +18,7 @@ export default getRequestConfig(async () => { const translations = await getHostedLoginTranslation({ serviceUrl, + locale, organization: i18nOrganization, }); diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index f567350053..2308c2aff8 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -46,7 +46,6 @@ import { VerifyPasskeyRegistrationRequest, VerifyU2FRegistrationRequest, } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { getLocale } from "next-intl/server"; import { unstable_cacheLife as cacheLife } from "next/cache"; import { getUserAgent } from "./fingerprint"; import { createServiceForHost } from "./service"; @@ -63,11 +62,12 @@ async function cacheWrapper(callback: Promise) { export async function getHostedLoginTranslation({ serviceUrl, organization, + locale, }: { serviceUrl: string; organization?: string; + locale?: string; }) { - const locale = await getLocale(); const settingsService: Client = await createServiceForHost(SettingsService, serviceUrl); From 756bf7ec2623b8b41a7a4c88e268f59068ba0c39 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 18 Jun 2025 10:22:49 +0200 Subject: [PATCH 22/46] fix: update human on login --- .../(login)/idp/[provider]/success/page.tsx | 43 +++++++++++++------ apps/login/src/lib/zitadel.ts | 16 +++++++ 2 files changed, 47 insertions(+), 12 deletions(-) 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 a31aed4bb3..0822c6576b 100644 --- a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx @@ -16,6 +16,7 @@ import { getOrgsByDomain, listUsers, retrieveIDPIntent, + updateHuman, } from "@/lib/zitadel"; import { ConnectError, create } from "@zitadel/client"; import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; @@ -24,6 +25,7 @@ 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"; @@ -106,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"); } @@ -126,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 diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index d5045df041..8cc4efe0bc 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -41,6 +41,7 @@ import { SendEmailCodeRequestSchema, SetPasswordRequest, SetPasswordRequestSchema, + UpdateHumanUserRequest, UserService, VerifyPasskeyRegistrationRequest, VerifyU2FRegistrationRequest, @@ -455,6 +456,21 @@ export async function addHuman({ return userService.addHumanUser(request); } +export async function updateHuman({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: UpdateHumanUserRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.updateHumanUser(request); +} + export async function verifyTOTPRegistration({ serviceUrl, code, From 85cc98f68aca4d7313f9e647f58e6aa2e1d7e228 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 18 Jun 2025 14:23:48 +0200 Subject: [PATCH 23/46] fix: scoped branding for complete page --- .../src/app/(login)/idp/[provider]/success/page.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 0822c6576b..bfbde8b252 100644 --- a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx @@ -81,7 +81,7 @@ export default async function Page(props: { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const branding = await getBrandingSettings({ + let branding = await getBrandingSettings({ serviceUrl, organization, }); @@ -294,6 +294,13 @@ export default async function Page(props: { serviceUrl, }); + if (orgToRegisterOn) { + branding = await getBrandingSettings({ + serviceUrl, + organization: orgToRegisterOn, + }); + } + if (!orgToRegisterOn) { return loginFailed(branding, "No organization found for registration"); } From bf7bafe112e31a7a0584bbf8b89be96d13fdbc9b Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 10:22:59 +0200 Subject: [PATCH 24/46] update next, i18n api --- apps/login/package.json | 2 +- apps/login/src/i18n/request.ts | 16 +-- apps/login/src/lib/zitadel.ts | 17 ++- packages/zitadel-proto/package.json | 2 +- pnpm-lock.yaml | 159 +++++++++++----------------- 5 files changed, 85 insertions(+), 111 deletions(-) diff --git a/apps/login/package.json b/apps/login/package.json index b8afa2007f..3d0e740698 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -48,7 +48,7 @@ "jose": "^5.3.0", "lucide-react": "0.469.0", "moment": "^2.29.4", - "next": "15.4.0-canary.3", + "next": "15.4.0-canary.86", "next-intl": "^3.25.1", "next-themes": "^0.2.1", "nice-grpc": "2.0.1", diff --git a/apps/login/src/i18n/request.ts b/apps/login/src/i18n/request.ts index edcae463d1..5dade0873e 100644 --- a/apps/login/src/i18n/request.ts +++ b/apps/login/src/i18n/request.ts @@ -1,6 +1,5 @@ import { LANGS, LANGUAGE_COOKIE_NAME, LANGUAGE_HEADER_NAME } from "@/lib/i18n"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { getHostedLoginTranslation } from "@/lib/zitadel"; import deepmerge from "deepmerge"; import { getRequestConfig } from "next-intl/server"; import { cookies, headers } from "next/headers"; @@ -16,13 +15,13 @@ export default getRequestConfig(async () => { const i18nOrganization = _headers.get("x-zitadel-i18n-organization") || ""; // You may need to set this header in middleware - const translations = await getHostedLoginTranslation({ - serviceUrl, - locale, - organization: i18nOrganization, - }); + // const translations = await getHostedLoginTranslation({ + // serviceUrl, + // locale, + // organization: i18nOrganization, + // }); - console.log("Translations:", translations); + // console.log("Translations:", translations); const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); if (languageHeader) { @@ -39,7 +38,8 @@ export default getRequestConfig(async () => { } } - const customMessages = translations; + // const customMessages = translations; + const customMessages = {}; const localeMessages = (await import(`../../locales/${locale}.json`)).default; const fallbackMessages = (await import(`../../locales/${fallback}.json`)) .default; diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 3a6fd0b2b5..e148e7c0be 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -21,7 +21,6 @@ import { SessionService, } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; -import { TranslationLevelType } from "@zitadel/proto/zitadel/settings/v2/settings_pb"; import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb"; import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb"; @@ -75,13 +74,23 @@ export async function getHostedLoginTranslation({ const callback = settingsService .getHostedLoginTranslation( { - level: TranslationLevelType.INSTANCE, - levelId: organization, + level: organization + ? { + case: "organizationId", + value: organization, + } + : { + case: "instance", + value: true, + }, locale: locale, }, {}, ) - .then((resp) => (resp.translations ? resp.translations : undefined)); + .then((resp) => { + console.log(resp); + return resp.translations ? resp.translations : undefined; + }); return useCache ? cacheWrapper(callback) : callback; } diff --git a/packages/zitadel-proto/package.json b/packages/zitadel-proto/package.json index 2fd912f4df..61ef296616 100644 --- a/packages/zitadel-proto/package.json +++ b/packages/zitadel-proto/package.json @@ -14,7 +14,7 @@ ], "sideEffects": false, "scripts": { - "generate": "buf generate https://github.com/zitadel/zitadel.git#branch=feat/9850-hosted-login-translation-api --path ./proto/zitadel", + "generate": "buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel", "clean": "rm -rf zitadel .turbo node_modules google protoc-gen-openapiv2 validate" }, "dependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46a448c2f1..515cbd6384 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,7 +85,7 @@ importers: version: 0.5.7(tailwindcss@3.4.14) '@vercel/analytics': specifier: ^1.2.2 - version: 1.3.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) + version: 1.3.1(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) '@zitadel/client': specifier: workspace:* version: link:../../packages/zitadel-client @@ -111,14 +111,14 @@ importers: specifier: ^2.29.4 version: 2.30.1 next: - specifier: 15.4.0-canary.3 - version: 15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + specifier: 15.4.0-canary.86 + version: 15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) next-intl: specifier: ^3.25.1 - version: 3.25.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) + version: 3.26.5(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) next-themes: specifier: ^0.2.1 - version: 0.2.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 0.2.1(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nice-grpc: specifier: 2.0.1 version: 2.0.1 @@ -807,9 +807,6 @@ packages: '@formatjs/icu-skeleton-parser@1.8.8': resolution: {integrity: sha512-vHwK3piXwamFcx5YQdCdJxUQ1WdTl6ANclt5xba5zLGDv5Bsur7qz8AD7BevaKxITwpgDeU0u8My3AIibW9ywA==} - '@formatjs/intl-localematcher@0.5.4': - resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} - '@formatjs/intl-localematcher@0.5.8': resolution: {integrity: sha512-I+WDNWWJFZie+jkfkiK5Mp4hEDyRSEvmyfYadflOno/mmKJKcB17fEpEH0oJu/OWhhCJ8kJBDz2YMd/6cDl7Mg==} @@ -998,56 +995,56 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true - '@next/env@15.4.0-canary.3': - resolution: {integrity: sha512-lu4pB2e3Z/d+B0rxEm9YuQMb57Hd96iJUBZgVlcRNemlIryr0GByu17kvN6nBk3JjbWL8h+MW90stpGzGdhbqg==} + '@next/env@15.4.0-canary.86': + resolution: {integrity: sha512-WPrEvwqHnjeLx05ncJvqizbBJJFlQGRbxzOnL/pZWKzo19auM9x5Se87P27+E/D/d6jJS801l+thF85lfobAZQ==} '@next/eslint-plugin-next@14.2.18': resolution: {integrity: sha512-KyYTbZ3GQwWOjX3Vi1YcQbekyGP0gdammb7pbmmi25HBUCINzDReyrzCMOJIeZisK1Q3U6DT5Rlc4nm2/pQeXA==} - '@next/swc-darwin-arm64@15.4.0-canary.3': - resolution: {integrity: sha512-w9u8IpwLb/JS7HzHLt24smP4FxIYMgciOtYNUCognO1xh1XZfqqjDIrRAXDuuYDPKrc1i2EvI24R5eDTz7EYMQ==} + '@next/swc-darwin-arm64@15.4.0-canary.86': + resolution: {integrity: sha512-1ofBmzjPkmoMdM+dXvybZ/Roq8HRo0sFzcwXk7/FJNOufuwyK+QKdSpLE7pHlPR7ZREqfEMj61ONO+gAK+zOJw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.4.0-canary.3': - resolution: {integrity: sha512-5pL1hBRw8h1XeArzWYjCDERtRFIfrMAz1Nq9m1np8FrTuHclE7xitKKfOJqqmBbO9dWtnZIfA8lZl9bdlNEUZg==} + '@next/swc-darwin-x64@15.4.0-canary.86': + resolution: {integrity: sha512-WCKSrllvwzYi4TgrSdgxKSOF2nhieeaWWOeGucn0OXy50uOAamr0HwP5OaIBCx3oRar4w66gvs4IrdTdMedeJA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.4.0-canary.3': - resolution: {integrity: sha512-vx6cU4jKoecF2QZw3CQqJrzb+D0WhNzHHoWUN8O+YKPnX0oG4wEtAQWSWisxKjNrU1U4TiraOql0nOQBUOKwaQ==} + '@next/swc-linux-arm64-gnu@15.4.0-canary.86': + resolution: {integrity: sha512-8qn7DJVNFjhEIDo2ts0YCsO7g+vJjPWh8Ur8lBK3XspeX0BPsF4s+YmgidrpzRXeIfoo2uYLkkXcy/57CVDblw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.4.0-canary.3': - resolution: {integrity: sha512-7ig1sQHRRgTrj4QHt5l8OT1z2SJnEAHbnEY9SDP2HilwQIfgOAOxveFDBR+f/8AMdAKhCTSeMyrZsivpC0xTUA==} + '@next/swc-linux-arm64-musl@15.4.0-canary.86': + resolution: {integrity: sha512-8MTn6N4Ja25neMLu2Bra1lqW9AWPqsYg0BVs5M/cxL0QkcN3mak/8LLX1vbzz7GigMGSA+NLwg+ol8lglfgIGA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.4.0-canary.3': - resolution: {integrity: sha512-fML6pzNX9i3DlrCOdE6A1TbVL0aIQkIDDCjrbn/f37hOn88god1OrVd/d4J4w1YqLKQWpmJPnUn6Bkn8qXqbRw==} + '@next/swc-linux-x64-gnu@15.4.0-canary.86': + resolution: {integrity: sha512-hIhzDwWDQHnH0M0Pzaqs1c5fa4+LHiLLEBuPJQvhBxQfH+Eh86DWiWHDCaoNiURvdRPg6uCuF2MjwptrMplEkg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.4.0-canary.3': - resolution: {integrity: sha512-87/JPkbr3fgvASdWW2qBVuaXwcjSxgy+CTllj2DgYB7e7BEzT7QJEdj0HJZljBjVbN5oT1FOKwhaVRgRWuwYLQ==} + '@next/swc-linux-x64-musl@15.4.0-canary.86': + resolution: {integrity: sha512-FG6SBuSeRWYMNu6tsfaZ4iDzv3BLxlpRncO2xvKKQPeUdDSQ0cehuHYnx8fRte8IOAJ3rlbRd6NXvrDarqu92Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.4.0-canary.3': - resolution: {integrity: sha512-cTZh72h3ZX8z0lhdVs5m38uyy83mW5r0jz6hKagysPT06uTdOAypK6CRqG5CJSN7RM0n7CkfcO6ExjDqhkDhRA==} + '@next/swc-win32-arm64-msvc@15.4.0-canary.86': + resolution: {integrity: sha512-3HvZo4VuyINrNYplRhvC8ILdKwi/vFDHOcTN/I4ru039TFpu2eO6VtXsLBdOdJjGslSSSBYkX+6yRrghihAZDA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.4.0-canary.3': - resolution: {integrity: sha512-8oZKOKRGad4EVZ94L5Sz2EP59khHIeKGKg+/z8r5mCbBtupLPTXmWjrXoi1R55hHRXJjbW2D5NwcPfJn/ltZ3Q==} + '@next/swc-win32-x64-msvc@15.4.0-canary.86': + resolution: {integrity: sha512-UO9JzGGj7GhtSJFdI0Bl0dkIIBfgbhXLsgNVmq9Z/CsUsQB6J9RS/BMhsxfVwhO+RETk13nFpNutMAhAwcuD8w==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1824,10 +1821,6 @@ packages: peerDependencies: esbuild: '>=0.18' - busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1856,9 +1849,6 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001680: - resolution: {integrity: sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==} - caniuse-lite@1.0.30001715: resolution: {integrity: sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==} @@ -2159,10 +2149,6 @@ packages: engines: {node: '>=0.10'} hasBin: true - detect-libc@2.0.3: - resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} - engines: {node: '>=8'} - detect-libc@2.0.4: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} @@ -3389,11 +3375,11 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - next-intl@3.25.1: - resolution: {integrity: sha512-Z2dJWn5f/b1sb8EmuJcuDhbQTIp4RG1KBFAILgRt/y27W0ifU7Ll/os3liphUY4InyRH89uShTAk7ItAlpr0uA==} + next-intl@3.26.5: + resolution: {integrity: sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg==} peerDependencies: next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 next-themes@0.2.1: resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} @@ -3402,13 +3388,13 @@ packages: react: '*' react-dom: '*' - next@15.4.0-canary.3: - resolution: {integrity: sha512-OkwxAFNQeuE0vNL7tTwU+jm3nf3x3D5DHSmjRlFktsedGtxZiILZTq6UNExNaFBjttR+2Y6oGqRsFWXC4ob1Wg==} + next@15.4.0-canary.86: + resolution: {integrity: sha512-lGeO0sOvPZ7oFIklqRA863YzRL1bW+kT/OqU3N6RBquHldiucZwnZKQceZdn6WcHEFmWIHzZV+SMG1JEK7hZLg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.41.2 + '@playwright/test': ^1.51.1 babel-plugin-react-compiler: '*' react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 @@ -4170,10 +4156,6 @@ packages: stream-combiner@0.0.4: resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} - streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} - string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -4560,10 +4542,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - use-intl@3.25.1: - resolution: {integrity: sha512-Xeyl0+BjlBf6fJr2h5W/CESZ2IQAH7jzXYK4c/ao+qR26jNPW3FXBLjg7eLRxdeI6QaLcYGLtH3WYhC9I0+6Yg==} + use-intl@3.26.5: + resolution: {integrity: sha512-OdsJnC/znPvHCHLQH/duvQNXnP1w0hPfS+tkSi3mAbfjYBGh4JnyfdwkQBfIVf7t8gs9eSX/CntxUMvtKdG2MQ==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 use-sync-external-store@1.2.2: resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} @@ -5350,10 +5332,6 @@ snapshots: '@formatjs/ecma402-abstract': 2.2.4 tslib: 2.8.1 - '@formatjs/intl-localematcher@0.5.4': - dependencies: - tslib: 2.8.1 - '@formatjs/intl-localematcher@0.5.8': dependencies: tslib: 2.8.1 @@ -5538,34 +5516,34 @@ snapshots: - encoding - supports-color - '@next/env@15.4.0-canary.3': {} + '@next/env@15.4.0-canary.86': {} '@next/eslint-plugin-next@14.2.18': dependencies: glob: 10.3.10 - '@next/swc-darwin-arm64@15.4.0-canary.3': + '@next/swc-darwin-arm64@15.4.0-canary.86': optional: true - '@next/swc-darwin-x64@15.4.0-canary.3': + '@next/swc-darwin-x64@15.4.0-canary.86': optional: true - '@next/swc-linux-arm64-gnu@15.4.0-canary.3': + '@next/swc-linux-arm64-gnu@15.4.0-canary.86': optional: true - '@next/swc-linux-arm64-musl@15.4.0-canary.3': + '@next/swc-linux-arm64-musl@15.4.0-canary.86': optional: true - '@next/swc-linux-x64-gnu@15.4.0-canary.3': + '@next/swc-linux-x64-gnu@15.4.0-canary.86': optional: true - '@next/swc-linux-x64-musl@15.4.0-canary.3': + '@next/swc-linux-x64-musl@15.4.0-canary.86': optional: true - '@next/swc-win32-arm64-msvc@15.4.0-canary.3': + '@next/swc-win32-arm64-msvc@15.4.0-canary.86': optional: true - '@next/swc-win32-x64-msvc@15.4.0-canary.3': + '@next/swc-win32-x64-msvc@15.4.0-canary.86': optional: true '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': @@ -6027,11 +6005,11 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vercel/analytics@1.3.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0)': + '@vercel/analytics@1.3.1(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0)': dependencies: server-only: 0.0.1 optionalDependencies: - next: 15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + next: 15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) react: 19.1.0 '@vercel/git-hooks@1.0.0': {} @@ -6338,10 +6316,6 @@ snapshots: esbuild: 0.25.2 load-tsconfig: 0.2.5 - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - cac@6.7.14: {} cachedir@2.4.0: {} @@ -6368,8 +6342,6 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001680: {} - caniuse-lite@1.0.30001715: {} case-anything@2.1.13: {} @@ -6713,9 +6685,6 @@ snapshots: detect-libc@1.0.3: {} - detect-libc@2.0.3: - optional: true - detect-libc@2.0.4: {} didyoumean@1.2.2: {} @@ -8134,40 +8103,38 @@ snapshots: negotiator@1.0.0: {} - next-intl@3.25.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0): + next-intl@3.26.5(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0): dependencies: - '@formatjs/intl-localematcher': 0.5.4 + '@formatjs/intl-localematcher': 0.5.8 negotiator: 1.0.0 - next: 15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + next: 15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) react: 19.1.0 - use-intl: 3.25.1(react@19.1.0) + use-intl: 3.26.5(react@19.1.0) - next-themes@0.2.1(next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next-themes@0.2.1(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - next: 15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + next: 15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - next@15.4.0-canary.3(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0): + next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0): dependencies: - '@next/env': 15.4.0-canary.3 - '@swc/counter': 0.1.3 + '@next/env': 15.4.0-canary.86 '@swc/helpers': 0.5.15 - busboy: 1.6.0 - caniuse-lite: 1.0.30001680 + caniuse-lite: 1.0.30001715 postcss: 8.4.31 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) styled-jsx: 5.1.6(@babel/core@7.26.10)(react@19.1.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.4.0-canary.3 - '@next/swc-darwin-x64': 15.4.0-canary.3 - '@next/swc-linux-arm64-gnu': 15.4.0-canary.3 - '@next/swc-linux-arm64-musl': 15.4.0-canary.3 - '@next/swc-linux-x64-gnu': 15.4.0-canary.3 - '@next/swc-linux-x64-musl': 15.4.0-canary.3 - '@next/swc-win32-arm64-msvc': 15.4.0-canary.3 - '@next/swc-win32-x64-msvc': 15.4.0-canary.3 + '@next/swc-darwin-arm64': 15.4.0-canary.86 + '@next/swc-darwin-x64': 15.4.0-canary.86 + '@next/swc-linux-arm64-gnu': 15.4.0-canary.86 + '@next/swc-linux-arm64-musl': 15.4.0-canary.86 + '@next/swc-linux-x64-gnu': 15.4.0-canary.86 + '@next/swc-linux-x64-musl': 15.4.0-canary.86 + '@next/swc-win32-arm64-msvc': 15.4.0-canary.86 + '@next/swc-win32-x64-msvc': 15.4.0-canary.86 '@playwright/test': 1.52.0 sass: 1.87.0 sharp: 0.34.1 @@ -8737,7 +8704,7 @@ snapshots: sharp@0.34.1: dependencies: color: 4.2.3 - detect-libc: 2.0.3 + detect-libc: 2.0.4 semver: 7.7.1 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.1 @@ -8900,8 +8867,6 @@ snapshots: dependencies: duplexer: 0.1.2 - streamsearch@1.1.0: {} - string-argv@0.3.2: {} string-width@4.2.3: @@ -9309,7 +9274,7 @@ snapshots: dependencies: punycode: 2.3.1 - use-intl@3.25.1(react@19.1.0): + use-intl@3.26.5(react@19.1.0): dependencies: '@formatjs/fast-memoize': 2.2.3 intl-messageformat: 10.7.7 From d557cb51d7a862af03d3cf9e3a5e07355b652e23 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 13:47:35 +0200 Subject: [PATCH 25/46] cleanup packages, turbo, fix middleware matcher --- apps/login/next.config.mjs | 2 - apps/login/package.json | 4 +- apps/login/src/i18n/request.ts | 24 ++++++++---- apps/login/src/middleware.ts | 3 +- pnpm-lock.yaml | 72 ++++++++++++---------------------- 5 files changed, 43 insertions(+), 62 deletions(-) diff --git a/apps/login/next.config.mjs b/apps/login/next.config.mjs index edf5e54595..dea34d603b 100755 --- a/apps/login/next.config.mjs +++ b/apps/login/next.config.mjs @@ -26,8 +26,6 @@ const secureHeaders = [ key: "X-XSS-Protection", value: "1; mode=block", }, - // img-src vercel.com needed for deploy button, - // script-src va.vercel-scripts.com for analytics/vercel scripts { key: "Content-Security-Policy", value: `${DEFAULT_CSP} frame-ancestors 'none'`, diff --git a/apps/login/package.json b/apps/login/package.json index 3d0e740698..bc52864e4d 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev", "test": "concurrently --timings --kill-others-on-fail 'npm:test:unit' 'npm:test:integration'", "test:watch": "concurrently --kill-others 'npm:test:unit:watch' 'npm:test:integration:watch'", "test:unit": "vitest", @@ -45,7 +45,6 @@ "clsx": "1.2.1", "copy-to-clipboard": "^3.3.3", "deepmerge": "^4.3.1", - "jose": "^5.3.0", "lucide-react": "0.469.0", "moment": "^2.29.4", "next": "15.4.0-canary.86", @@ -56,7 +55,6 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "7.39.5", - "swr": "^2.2.0", "tinycolor2": "1.4.2", "uuid": "^11.1.0" }, diff --git a/apps/login/src/i18n/request.ts b/apps/login/src/i18n/request.ts index 5dade0873e..2d9fd4d688 100644 --- a/apps/login/src/i18n/request.ts +++ b/apps/login/src/i18n/request.ts @@ -1,5 +1,7 @@ import { LANGS, LANGUAGE_COOKIE_NAME, LANGUAGE_HEADER_NAME } from "@/lib/i18n"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getHostedLoginTranslation } from "@/lib/zitadel"; +import { JsonObject } from "@zitadel/client"; import deepmerge from "deepmerge"; import { getRequestConfig } from "next-intl/server"; import { cookies, headers } from "next/headers"; @@ -14,14 +16,22 @@ export default getRequestConfig(async () => { const { serviceUrl } = getServiceUrlFromHeaders(_headers); const i18nOrganization = _headers.get("x-zitadel-i18n-organization") || ""; // You may need to set this header in middleware + console.log("i18nOrganization:", i18nOrganization); + let translations: JsonObject | {} = {}; + try { + const i18nJSON = await getHostedLoginTranslation({ + serviceUrl, + locale, + organization: i18nOrganization, + }); - // const translations = await getHostedLoginTranslation({ - // serviceUrl, - // locale, - // organization: i18nOrganization, - // }); - - // console.log("Translations:", translations); + if (i18nJSON) { + translations = i18nJSON; + } + console.log("Translations:", translations); + } catch (error) { + console.warn("Error fetching custom translations:", error); + } const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); if (languageHeader) { diff --git a/apps/login/src/middleware.ts b/apps/login/src/middleware.ts index 5c04a26d13..fa287e2bdd 100644 --- a/apps/login/src/middleware.ts +++ b/apps/login/src/middleware.ts @@ -10,8 +10,7 @@ export const config = { "/oidc/:path*", "/idps/callback/:path*", "/saml/:path*", - // Add "/*" to match all routes for translation header injection - "/*", + "/:path*", ], }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 515cbd6384..fb7a635216 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,7 +85,7 @@ importers: version: 0.5.7(tailwindcss@3.4.14) '@vercel/analytics': specifier: ^1.2.2 - version: 1.3.1(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) + version: 1.3.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) '@zitadel/client': specifier: workspace:* version: link:../../packages/zitadel-client @@ -101,9 +101,6 @@ importers: deepmerge: specifier: ^4.3.1 version: 4.3.1 - jose: - specifier: ^5.3.0 - version: 5.8.0 lucide-react: specifier: 0.469.0 version: 0.469.0(react@19.1.0) @@ -112,13 +109,13 @@ importers: version: 2.30.1 next: specifier: 15.4.0-canary.86 - version: 15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + version: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) next-intl: specifier: ^3.25.1 - version: 3.26.5(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) + version: 3.26.5(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) next-themes: specifier: ^0.2.1 - version: 0.2.1(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 0.2.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) nice-grpc: specifier: 2.0.1 version: 2.0.1 @@ -134,9 +131,6 @@ importers: react-hook-form: specifier: 7.39.5 version: 7.39.5(react@19.1.0) - swr: - specifier: ^2.2.0 - version: 2.2.5(react@19.1.0) tinycolor2: specifier: 1.4.2 version: 1.4.2 @@ -4258,11 +4252,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - swr@2.2.5: - resolution: {integrity: sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -4547,11 +4536,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 - use-sync-external-store@1.2.2: - resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -6005,11 +5989,11 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vercel/analytics@1.3.1(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0)': + '@vercel/analytics@1.3.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0)': dependencies: server-only: 0.0.1 optionalDependencies: - next: 15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + next: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) react: 19.1.0 '@vercel/git-hooks@1.0.0': {} @@ -6077,7 +6061,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -6608,6 +6592,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.0: + dependencies: + ms: 2.1.3 + debug@4.4.0(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -7290,7 +7278,7 @@ snapshots: follow-redirects@1.15.9(debug@4.4.0): optionalDependencies: - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 for-each@0.3.3: dependencies: @@ -7550,7 +7538,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -7563,14 +7551,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 transitivePeerDependencies: - supports-color @@ -7919,7 +7907,7 @@ snapshots: dependencies: chalk: 5.4.1 commander: 13.1.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.2 @@ -8103,21 +8091,21 @@ snapshots: negotiator@1.0.0: {} - next-intl@3.26.5(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0): + next-intl@3.26.5(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0): dependencies: '@formatjs/intl-localematcher': 0.5.8 negotiator: 1.0.0 - next: 15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + next: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) react: 19.1.0 use-intl: 3.26.5(react@19.1.0) - next-themes@0.2.1(next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next-themes@0.2.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - next: 15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + next: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - next@15.4.0-canary.86(@babel/core@7.26.10)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0): + next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0): dependencies: '@next/env': 15.4.0-canary.86 '@swc/helpers': 0.5.15 @@ -8125,7 +8113,7 @@ snapshots: postcss: 8.4.31 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - styled-jsx: 5.1.6(@babel/core@7.26.10)(react@19.1.0) + styled-jsx: 5.1.6(react@19.1.0) optionalDependencies: '@next/swc-darwin-arm64': 15.4.0-canary.86 '@next/swc-darwin-x64': 15.4.0-canary.86 @@ -8849,7 +8837,7 @@ snapshots: arg: 5.0.2 bluebird: 3.7.2 check-more-types: 2.24.0 - debug: 4.4.0(supports-color@5.5.0) + debug: 4.4.0 execa: 5.1.1 lazy-ass: 1.6.0 ps-tree: 1.2.0 @@ -8955,12 +8943,10 @@ snapshots: strip-json-comments@3.1.1: {} - styled-jsx@5.1.6(@babel/core@7.26.10)(react@19.1.0): + styled-jsx@5.1.6(react@19.1.0): dependencies: client-only: 0.0.1 react: 19.1.0 - optionalDependencies: - '@babel/core': 7.26.10 sucrase@3.35.0: dependencies: @@ -8986,12 +8972,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - swr@2.2.5(react@19.1.0): - dependencies: - client-only: 0.0.1 - react: 19.1.0 - use-sync-external-store: 1.2.2(react@19.1.0) - symbol-tree@3.2.4: {} tabbable@6.2.0: {} @@ -9280,10 +9260,6 @@ snapshots: intl-messageformat: 10.7.7 react: 19.1.0 - use-sync-external-store@1.2.2(react@19.1.0): - dependencies: - react: 19.1.0 - util-deprecate@1.0.2: {} uuid@11.1.0: {} From 23062bcf2e16da0427b4da24e0aa05f2deccb34b Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 14:05:42 +0200 Subject: [PATCH 26/46] introduce translated component to give a hint on the used i18n key --- .../app/(login)/authenticator/set/page.tsx | 8 ++++++-- apps/login/src/app/(login)/error.tsx | 4 +++- apps/login/src/app/(login)/loginname/page.tsx | 12 ++++++----- apps/login/src/app/(login)/mfa/page.tsx | 8 ++++++-- apps/login/src/app/(login)/mfa/set/page.tsx | 14 ++++++++++--- .../src/app/(login)/otp/[method]/page.tsx | 6 ++++-- .../src/app/(login)/otp/[method]/set/page.tsx | 6 ++++-- apps/login/src/app/(login)/passkey/page.tsx | 8 ++++++-- .../src/app/(login)/passkey/set/page.tsx | 6 ++++-- .../src/app/(login)/password/change/page.tsx | 10 +++++++--- apps/login/src/app/(login)/password/page.tsx | 18 +++++++++-------- .../src/app/(login)/password/set/page.tsx | 10 +++++++--- apps/login/src/app/(login)/register/page.tsx | 8 ++++++-- apps/login/src/app/(login)/u2f/page.tsx | 8 ++++++-- apps/login/src/app/(login)/u2f/set/page.tsx | 6 ++++-- apps/login/src/app/(login)/verify/page.tsx | 6 ++++-- apps/login/src/app/global-error.tsx | 4 +++- apps/login/src/components/translated.tsx | 20 +++++++++++++++++++ 18 files changed, 118 insertions(+), 44 deletions(-) create mode 100644 apps/login/src/components/translated.tsx diff --git a/apps/login/src/app/(login)/authenticator/set/page.tsx b/apps/login/src/app/(login)/authenticator/set/page.tsx index 95b89af92d..25917715a8 100644 --- a/apps/login/src/app/(login)/authenticator/set/page.tsx +++ b/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -3,6 +3,7 @@ import { BackButton } from "@/components/back-button"; import { ChooseAuthenticatorToSetup } from "@/components/choose-authenticator-to-setup"; import { DynamicTheme } from "@/components/dynamic-theme"; import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; @@ -27,7 +28,6 @@ export default async function Page(props: { const searchParams = await props.searchParams; const locale = getLocale(); const t = await getTranslations({ locale, namespace: "authenticator" }); - const tError = await getTranslations({ locale, namespace: "error" }); const { loginName, requestId, organization, sessionId } = searchParams; @@ -99,7 +99,11 @@ export default async function Page(props: { !sessionWithData.factors || !sessionWithData.factors.user ) { - return {tError("unknownContext")}; + return ( + + + + ); } const branding = await getBrandingSettings({ diff --git a/apps/login/src/app/(login)/error.tsx b/apps/login/src/app/(login)/error.tsx index bee6516a59..37e7c5003d 100644 --- a/apps/login/src/app/(login)/error.tsx +++ b/apps/login/src/app/(login)/error.tsx @@ -19,7 +19,9 @@ export default function Error({ error, reset }: any) { Error: {error?.message}
- +
diff --git a/apps/login/src/app/(login)/loginname/page.tsx b/apps/login/src/app/(login)/loginname/page.tsx index adb6ec0eef..859fbae085 100644 --- a/apps/login/src/app/(login)/loginname/page.tsx +++ b/apps/login/src/app/(login)/loginname/page.tsx @@ -1,5 +1,6 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { Translated } from "@/components/translated"; import { UsernameForm } from "@/components/username-form"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { @@ -9,15 +10,12 @@ import { getLoginSettings, } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { searchParams: Promise>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "loginname" }); const loginName = searchParams?.loginName; const requestId = searchParams?.requestId; @@ -63,8 +61,12 @@ export default async function Page(props: { return (
-

{t("title")}

-

{t("description")}

+

+ +

+

+ +

)} - {!(loginName || sessionId) && {tError("unknownContext")}} + {!(loginName || sessionId) && ( + + + + )} {sessionFactors ? ( )} - {!(loginName || sessionId) && {tError("unknownContext")}} + {!(loginName || sessionId) && ( + + + + )} - {!valid && {tError("sessionExpired")}} + {!valid && ( + + + + )} {isSessionValid(sessionWithData).valid && loginSettings && diff --git a/apps/login/src/app/(login)/otp/[method]/page.tsx b/apps/login/src/app/(login)/otp/[method]/page.tsx index ee58420c42..08007c1819 100644 --- a/apps/login/src/app/(login)/otp/[method]/page.tsx +++ b/apps/login/src/app/(login)/otp/[method]/page.tsx @@ -1,6 +1,7 @@ import { Alert } from "@/components/alert"; import { DynamicTheme } from "@/components/dynamic-theme"; import { LoginOTP } from "@/components/login-otp"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; @@ -21,7 +22,6 @@ export default async function Page(props: { const searchParams = await props.searchParams; const locale = getLocale(); const t = await getTranslations({ locale, namespace: "otp" }); - const tError = await getTranslations({ locale, namespace: "error" }); const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -94,7 +94,9 @@ export default async function Page(props: { {!session && (
- {tError("unknownContext")} + + +
)} diff --git a/apps/login/src/app/(login)/otp/[method]/set/page.tsx b/apps/login/src/app/(login)/otp/[method]/set/page.tsx index d3fc4c89f7..79aa8c4786 100644 --- a/apps/login/src/app/(login)/otp/[method]/set/page.tsx +++ b/apps/login/src/app/(login)/otp/[method]/set/page.tsx @@ -3,6 +3,7 @@ import { BackButton } from "@/components/back-button"; import { Button, ButtonVariants } from "@/components/button"; import { DynamicTheme } from "@/components/dynamic-theme"; import { TotpRegister } from "@/components/totp-register"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; @@ -27,7 +28,6 @@ export default async function Page(props: { const searchParams = await props.searchParams; const locale = getLocale(); const t = await getTranslations({ locale, namespace: "otp" }); - const tError = await getTranslations({ locale, namespace: "error" }); const { loginName, organization, sessionId, requestId, checkAfter } = searchParams; @@ -131,7 +131,9 @@ export default async function Page(props: {

{t("set.title")}

{!session && (
- {tError("unknownContext")} + + +
)} diff --git a/apps/login/src/app/(login)/passkey/page.tsx b/apps/login/src/app/(login)/passkey/page.tsx index e24585e7e0..b6b199bec8 100644 --- a/apps/login/src/app/(login)/passkey/page.tsx +++ b/apps/login/src/app/(login)/passkey/page.tsx @@ -1,6 +1,7 @@ import { Alert } from "@/components/alert"; import { DynamicTheme } from "@/components/dynamic-theme"; import { LoginPasskey } from "@/components/login-passkey"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; @@ -15,7 +16,6 @@ export default async function Page(props: { const searchParams = await props.searchParams; const locale = getLocale(); const t = await getTranslations({ locale, namespace: "passkey" }); - const tError = await getTranslations({ locale, namespace: "error" }); const { loginName, altPassword, requestId, organization, sessionId } = searchParams; @@ -67,7 +67,11 @@ export default async function Page(props: { )}

{t("verify.description")}

- {!(loginName || sessionId) && {tError("unknownContext")}} + {!(loginName || sessionId) && ( + + + + )} {(loginName || sessionId) && ( - {tError("unknownContext")} + + +
)} diff --git a/apps/login/src/app/(login)/password/change/page.tsx b/apps/login/src/app/(login)/password/change/page.tsx index 05f8cd6a10..723591e4f6 100644 --- a/apps/login/src/app/(login)/password/change/page.tsx +++ b/apps/login/src/app/(login)/password/change/page.tsx @@ -1,6 +1,7 @@ import { Alert } from "@/components/alert"; import { ChangePasswordForm } from "@/components/change-password-form"; import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; @@ -21,7 +22,6 @@ export default async function Page(props: { const searchParams = await props.searchParams; const locale = getLocale(); const t = await getTranslations({ locale, namespace: "password" }); - const tError = await getTranslations({ locale, namespace: "error" }); const { loginName, organization, requestId } = searchParams; @@ -61,7 +61,9 @@ export default async function Page(props: { {(!sessionFactors || !loginName) && !loginSettings?.ignoreUnknownUsernames && (
- {tError("unknownContext")} + + +
)} @@ -86,7 +88,9 @@ export default async function Page(props: { /> ) : (
- {tError("failedLoading")} + + +
)} diff --git a/apps/login/src/app/(login)/password/page.tsx b/apps/login/src/app/(login)/password/page.tsx index 506454a275..56b0ee4473 100644 --- a/apps/login/src/app/(login)/password/page.tsx +++ b/apps/login/src/app/(login)/password/page.tsx @@ -1,6 +1,7 @@ import { Alert } from "@/components/alert"; import { DynamicTheme } from "@/components/dynamic-theme"; import { PasswordForm } from "@/components/password-form"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; @@ -11,17 +12,12 @@ import { } 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"; export default async function Page(props: { searchParams: Promise>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "password" }); - const tError = await getTranslations({ locale, namespace: "error" }); - let { loginName, organization, requestId, alt } = searchParams; const _headers = await headers(); @@ -66,15 +62,21 @@ export default async function Page(props: {

- {sessionFactors?.factors?.user?.displayName ?? t("verify.title")} + {sessionFactors?.factors?.user?.displayName ?? ( + + )}

-

{t("verify.description")}

+

+ +

{/* show error only if usernames should be shown to be unknown */} {(!sessionFactors || !loginName) && !loginSettings?.ignoreUnknownUsernames && (
- {tError("unknownContext")} + + +
)} diff --git a/apps/login/src/app/(login)/password/set/page.tsx b/apps/login/src/app/(login)/password/set/page.tsx index 26e065438c..ed461b748c 100644 --- a/apps/login/src/app/(login)/password/set/page.tsx +++ b/apps/login/src/app/(login)/password/set/page.tsx @@ -1,6 +1,7 @@ import { Alert, AlertType } from "@/components/alert"; import { DynamicTheme } from "@/components/dynamic-theme"; import { SetPasswordForm } from "@/components/set-password-form"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; @@ -21,7 +22,6 @@ export default async function Page(props: { const searchParams = await props.searchParams; const locale = getLocale(); const t = await getTranslations({ locale, namespace: "password" }); - const tError = await getTranslations({ locale, namespace: "error" }); const { userId, loginName, organization, requestId, code, initial } = searchParams; @@ -79,7 +79,9 @@ export default async function Page(props: { {/* show error only if usernames should be shown to be unknown */} {loginName && !session && !loginSettings?.ignoreUnknownUsernames && (
- {tError("unknownContext")} + + +
)} @@ -115,7 +117,9 @@ export default async function Page(props: { /> ) : (
- {tError("failedLoading")} + + +
)}
diff --git a/apps/login/src/app/(login)/register/page.tsx b/apps/login/src/app/(login)/register/page.tsx index be872b4dc7..543814072f 100644 --- a/apps/login/src/app/(login)/register/page.tsx +++ b/apps/login/src/app/(login)/register/page.tsx @@ -2,6 +2,7 @@ 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 { Translated } from "@/components/translated"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { getActiveIdentityProviders, @@ -22,7 +23,6 @@ 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; @@ -83,7 +83,11 @@ export default async function Page(props: {

{t("title")}

{t("description")}

- {!organization && {tError("unknownContext")}} + {!organization && ( + + + + )} {legal && passwordComplexitySettings && diff --git a/apps/login/src/app/(login)/u2f/page.tsx b/apps/login/src/app/(login)/u2f/page.tsx index b16dc88f4b..bac94b2ecc 100644 --- a/apps/login/src/app/(login)/u2f/page.tsx +++ b/apps/login/src/app/(login)/u2f/page.tsx @@ -1,6 +1,7 @@ import { Alert } from "@/components/alert"; import { DynamicTheme } from "@/components/dynamic-theme"; import { LoginPasskey } from "@/components/login-passkey"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; @@ -15,7 +16,6 @@ export default async function Page(props: { const searchParams = await props.searchParams; const locale = getLocale(); const t = await getTranslations({ locale, namespace: "u2f" }); - const tError = await getTranslations({ locale, namespace: "error" }); const { loginName, requestId, sessionId, organization } = searchParams; @@ -71,7 +71,11 @@ export default async function Page(props: { )}

{t("verify.description")}

- {!(loginName || sessionId) && {tError("unknownContext")}} + {!(loginName || sessionId) && ( + + + + )} {(loginName || sessionId) && ( - {tError("unknownContext")} + + + )} diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index 7634ff063a..17f9712401 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -1,5 +1,6 @@ import { Alert, AlertType } from "@/components/alert"; import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { VerifyForm } from "@/components/verify-form"; import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify"; @@ -14,7 +15,6 @@ export default async function Page(props: { searchParams: Promise }) { const searchParams = await props.searchParams; const locale = getLocale(); const t = await getTranslations({ locale, namespace: "verify" }); - const tError = await getTranslations({ locale, namespace: "error" }); const { userId, loginName, code, organization, requestId, invite, send } = searchParams; @@ -130,7 +130,9 @@ export default async function Page(props: { searchParams: Promise }) {

{t("verify.description")}

- {tError("unknownContext")} + + +
)} diff --git a/apps/login/src/app/global-error.tsx b/apps/login/src/app/global-error.tsx index 0fea5d0117..32a8ce824f 100644 --- a/apps/login/src/app/global-error.tsx +++ b/apps/login/src/app/global-error.tsx @@ -25,7 +25,9 @@ export default function GlobalError({ Error: {error?.message}
- +
diff --git a/apps/login/src/components/translated.tsx b/apps/login/src/components/translated.tsx new file mode 100644 index 0000000000..172c5b7af0 --- /dev/null +++ b/apps/login/src/components/translated.tsx @@ -0,0 +1,20 @@ +import { useTranslations } from "next-intl"; + +export function Translated({ + i18nKey, + children, + namespace, + ...props +}: { + i18nKey: string; + children?: React.ReactNode; + namespace?: string; +} & React.HTMLAttributes) { + const t = useTranslations(namespace); + + return ( + + {t(i18nKey)} + + ); +} From ed7f664b54c6bcf1472e1c0a9225d0886f199e8f Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 14:14:12 +0200 Subject: [PATCH 27/46] use --- apps/login/src/app/(login)/accounts/page.tsx | 16 +++++++++---- .../app/(login)/authenticator/set/page.tsx | 15 ++++++++---- apps/login/src/app/(login)/idp/page.tsx | 12 ++++++---- apps/login/src/app/(login)/logout/page.tsx | 12 ++++++---- apps/login/src/app/(login)/register/page.tsx | 23 +++++++++++++------ 5 files changed, 51 insertions(+), 27 deletions(-) diff --git a/apps/login/src/app/(login)/accounts/page.tsx b/apps/login/src/app/(login)/accounts/page.tsx index 32c88d17bf..a1e99401e2 100644 --- a/apps/login/src/app/(login)/accounts/page.tsx +++ b/apps/login/src/app/(login)/accounts/page.tsx @@ -1,5 +1,6 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { SessionsList } from "@/components/sessions-list"; +import { Translated } from "@/components/translated"; import { getAllSessionCookieIds } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { @@ -9,7 +10,7 @@ import { } from "@/lib/zitadel"; import { UserPlusIcon } from "@heroicons/react/24/outline"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; -import { getLocale, getTranslations } from "next-intl/server"; +import { getLocale } from "next-intl/server"; import { headers } from "next/headers"; import Link from "next/link"; @@ -33,7 +34,6 @@ export default async function Page(props: { }) { const searchParams = await props.searchParams; const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "accounts" }); const requestId = searchParams?.requestId; const organization = searchParams?.organization; @@ -71,8 +71,12 @@ export default async function Page(props: { return (
-

{t("title")}

-

{t("description")}

+

+ +

+

+ +

@@ -81,7 +85,9 @@ export default async function Page(props: {
- {t("addAnother")} + + +
diff --git a/apps/login/src/app/(login)/authenticator/set/page.tsx b/apps/login/src/app/(login)/authenticator/set/page.tsx index 25917715a8..a339426c89 100644 --- a/apps/login/src/app/(login)/authenticator/set/page.tsx +++ b/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -18,7 +18,7 @@ import { listAuthenticationMethodTypes, } from "@/lib/zitadel"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; -import { getLocale, getTranslations } from "next-intl/server"; +import { getLocale } from "next-intl/server"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -27,7 +27,6 @@ export default async function Page(props: { }) { const searchParams = await props.searchParams; const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "authenticator" }); const { loginName, requestId, organization, sessionId } = searchParams; @@ -169,9 +168,13 @@ export default async function Page(props: { return (
-

{t("title")}

+

+ +

-

{t("description")}

+

+ +

-

{t("linkWithIDP")}

+

+ +

>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "idp" }); const requestId = searchParams?.requestId; const organization = searchParams?.organization; @@ -33,8 +31,12 @@ export default async function Page(props: { return (
-

{t("title")}

-

{t("description")}

+

+ +

+

+ +

{identityProviders && ( >; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "logout" }); const organization = searchParams?.organization; const postLogoutRedirectUri = searchParams?.post_logout_redirect_uri; @@ -67,8 +65,12 @@ export default async function Page(props: { return (
-

{t("title")}

-

{t("description")}

+

+ +

+

+ +

-

{t("disabled.title")}

-

{t("disabled.description")}

+

+ +

+

+ +

); @@ -80,8 +83,12 @@ export default async function Page(props: { return (
-

{t("title")}

-

{t("description")}

+

+ +

+

+ +

{!organization && ( @@ -111,7 +118,9 @@ export default async function Page(props: { {loginSettings?.allowExternalIdp && !!identityProviders.length && ( <>
-

{t("orUseIDP")}

+

+ +

Date: Thu, 19 Jun 2025 14:20:04 +0200 Subject: [PATCH 28/46] improve i18nhelper --- apps/login/src/components/translated.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/login/src/components/translated.tsx b/apps/login/src/components/translated.tsx index 172c5b7af0..60cab58256 100644 --- a/apps/login/src/components/translated.tsx +++ b/apps/login/src/components/translated.tsx @@ -11,9 +11,10 @@ export function Translated({ namespace?: string; } & React.HTMLAttributes) { const t = useTranslations(namespace); + const helperKey = `${namespace ? `${namespace}.` : ""}${i18nKey}`; return ( - + {t(i18nKey)} ); From ef8931a17c56f27bebdd02a56e5e622a43d1ec57 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 14:39:52 +0200 Subject: [PATCH 29/46] translated component --- apps/login/src/app/(login)/error.tsx | 6 ++---- apps/login/src/app/global-error.tsx | 6 ++---- .../src/components/change-password-form.tsx | 7 +++---- .../choose-authenticator-to-setup.tsx | 17 ++++++++++++----- .../choose-second-factor-to-setup.tsx | 5 ++--- apps/login/src/components/consent.tsx | 7 ++++--- apps/login/src/components/device-code-form.tsx | 8 +++----- apps/login/src/components/login-otp.tsx | 12 +++++------- apps/login/src/components/login-passkey.tsx | 10 ++++------ apps/login/src/components/password-form.tsx | 10 ++++------ .../components/privacy-policy-checkboxes.tsx | 9 ++++----- .../register-form-idp-incomplete.tsx | 8 +++----- apps/login/src/components/register-form.tsx | 18 ++++++++++-------- apps/login/src/components/register-passkey.tsx | 10 ++++------ apps/login/src/components/register-u2f.tsx | 8 +++----- .../src/components/session-clear-item.tsx | 3 ++- .../src/components/sessions-clear-list.tsx | 11 +++++++---- apps/login/src/components/sessions-list.tsx | 7 ++++--- .../login/src/components/set-password-form.tsx | 7 ++++--- .../components/set-register-password-form.tsx | 5 +++-- 20 files changed, 85 insertions(+), 89 deletions(-) diff --git a/apps/login/src/app/(login)/error.tsx b/apps/login/src/app/(login)/error.tsx index 37e7c5003d..d14150a4b4 100644 --- a/apps/login/src/app/(login)/error.tsx +++ b/apps/login/src/app/(login)/error.tsx @@ -2,7 +2,7 @@ import { Boundary } from "@/components/boundary"; import { Button } from "@/components/button"; -import { useTranslations } from "next-intl"; +import { Translated } from "@/components/translated"; import { useEffect } from "react"; export default function Error({ error, reset }: any) { @@ -10,8 +10,6 @@ export default function Error({ error, reset }: any) { console.log("logging error:", error); }, [error]); - const t = useTranslations("error"); - return (
@@ -20,7 +18,7 @@ export default function Error({ error, reset }: any) {
diff --git a/apps/login/src/app/global-error.tsx b/apps/login/src/app/global-error.tsx index 32a8ce824f..5111a65e8d 100644 --- a/apps/login/src/app/global-error.tsx +++ b/apps/login/src/app/global-error.tsx @@ -3,7 +3,7 @@ import { Boundary } from "@/components/boundary"; import { Button } from "@/components/button"; import { ThemeWrapper } from "@/components/theme-wrapper"; -import { useTranslations } from "next-intl"; +import { Translated } from "@/components/translated"; export default function GlobalError({ error, @@ -12,8 +12,6 @@ export default function GlobalError({ error: Error & { digest?: string }; reset: () => void; }) { - const t = useTranslations("error"); - return ( // global-error must include html and body tags @@ -26,7 +24,7 @@ export default function GlobalError({
diff --git a/apps/login/src/components/change-password-form.tsx b/apps/login/src/components/change-password-form.tsx index 54aab7b3ca..00513d8dda 100644 --- a/apps/login/src/components/change-password-form.tsx +++ b/apps/login/src/components/change-password-form.tsx @@ -13,7 +13,6 @@ import { import { create } from "@zitadel/client"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { FieldValues, useForm } from "react-hook-form"; @@ -23,6 +22,7 @@ import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { PasswordComplexity } from "./password-complexity"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = | { @@ -46,7 +46,6 @@ export function ChangePasswordForm({ requestId, organization, }: Props) { - const t = useTranslations("password"); const router = useRouter(); const { register, handleSubmit, watch, formState } = useForm({ @@ -203,8 +202,8 @@ export function ChangePasswordForm({ onClick={handleSubmit(submitChange)} data-testid="submit-button" > - {loading && } - {t("change.submit")} + {loading && }{" "} +
diff --git a/apps/login/src/components/choose-authenticator-to-setup.tsx b/apps/login/src/components/choose-authenticator-to-setup.tsx index 9075e3286e..4aa4de720a 100644 --- a/apps/login/src/components/choose-authenticator-to-setup.tsx +++ b/apps/login/src/components/choose-authenticator-to-setup.tsx @@ -3,9 +3,9 @@ import { PasskeysType, } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { useTranslations } from "next-intl"; import { Alert, AlertType } from "./alert"; import { PASSKEYS, PASSWORD } from "./auth-methods"; +import { Translated } from "./translated"; type Props = { authMethods: AuthenticationMethodType[]; @@ -18,16 +18,23 @@ export function ChooseAuthenticatorToSetup({ params, loginSettings, }: Props) { - const t = useTranslations("authenticator"); - if (authMethods.length !== 0) { - return {t("allSetup")}; + return ( + + + + ); } else { return ( <> {loginSettings.passkeysType == PasskeysType.NOT_ALLOWED && !loginSettings.allowUsernamePassword && ( - {t("noMethodsAvailable")} + + + )}
diff --git a/apps/login/src/components/choose-second-factor-to-setup.tsx b/apps/login/src/components/choose-second-factor-to-setup.tsx index e56379e147..edd0ae2b61 100644 --- a/apps/login/src/components/choose-second-factor-to-setup.tsx +++ b/apps/login/src/components/choose-second-factor-to-setup.tsx @@ -6,9 +6,9 @@ import { SecondFactorType, } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { EMAIL, SMS, TOTP, U2F } from "./auth-methods"; +import { Translated } from "./translated"; type Props = { userId: string; @@ -37,7 +37,6 @@ export function ChooseSecondFactorToSetup({ emailVerified, force, }: Props) { - const t = useTranslations("mfa"); const router = useRouter(); const params = new URLSearchParams({}); @@ -112,7 +111,7 @@ export function ChooseSecondFactorToSetup({ type="button" data-testid="reset-button" > - {t("set.skip")} + )} diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index d3d30b3113..5b21ac2a9d 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -8,6 +8,7 @@ import { useState } from "react"; import { Alert } from "./alert"; import { Button, ButtonVariants } from "./button"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; export function ConsentScreen({ scope, @@ -50,7 +51,7 @@ export function ConsentScreen({
    {scopes?.length === 0 && ( - {t("device.scope.openid")} + )} {scopes?.map((s) => { @@ -91,7 +92,7 @@ export function ConsentScreen({ data-testid="deny-button" > {loading && } - {t("device.request.deny")} + @@ -102,7 +103,7 @@ export function ConsentScreen({ className="self-end" variant={ButtonVariants.Primary} > - {t("device.request.submit")} +
diff --git a/apps/login/src/components/device-code-form.tsx b/apps/login/src/components/device-code-form.tsx index e09adb1147..a1efc07207 100644 --- a/apps/login/src/components/device-code-form.tsx +++ b/apps/login/src/components/device-code-form.tsx @@ -2,7 +2,6 @@ import { Alert } from "@/components/alert"; import { getDeviceAuthorizationRequest } from "@/lib/server/oidc"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -10,14 +9,13 @@ import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = { userCode: string; }; export function DeviceCodeForm({ userCode }: { userCode?: string }) { - const t = useTranslations("verify"); - const router = useRouter(); const { register, handleSubmit, formState } = useForm({ @@ -87,8 +85,8 @@ export function DeviceCodeForm({ userCode }: { userCode?: string }) { onClick={handleSubmit(submitCodeAndContinue)} data-testid="submit-button" > - {loading && } - {t("verify.submit")} + {loading && }{" "} +
diff --git a/apps/login/src/components/login-otp.tsx b/apps/login/src/components/login-otp.tsx index f8500f6909..4ad6cced6a 100644 --- a/apps/login/src/components/login-otp.tsx +++ b/apps/login/src/components/login-otp.tsx @@ -6,7 +6,6 @@ import { create } from "@zitadel/client"; import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -15,6 +14,7 @@ import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; // either loginName or sessionId must be provided type Props = { @@ -42,8 +42,6 @@ export function LoginOTP({ code, loginSettings, }: Props) { - const t = useTranslations("otp"); - const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -223,7 +221,7 @@ export function LoginOTP({
- {t("verify.noCodeReceived")} +
@@ -277,8 +275,8 @@ export function LoginOTP({ })} data-testid="submit-button" > - {loading && } - {t("verify.submit")} + {loading && }{" "} + diff --git a/apps/login/src/components/login-passkey.tsx b/apps/login/src/components/login-passkey.tsx index b3f0b1212f..0b7ecd5a9e 100644 --- a/apps/login/src/components/login-passkey.tsx +++ b/apps/login/src/components/login-passkey.tsx @@ -9,13 +9,13 @@ import { UserVerificationRequirement, } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { Alert } from "./alert"; import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; // either loginName or sessionId must be provided type Props = { @@ -35,8 +35,6 @@ export function LoginPasskey({ organization, login = true, }: Props) { - const t = useTranslations("passkey"); - const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -234,7 +232,7 @@ export function LoginPasskey({ }} data-testid="password-button" > - {t("verify.usePassword")} + ) : ( @@ -273,8 +271,8 @@ export function LoginPasskey({ }} data-testid="submit-button" > - {loading && } - {t("verify.submit")} + {loading && }{" "} + diff --git a/apps/login/src/components/password-form.tsx b/apps/login/src/components/password-form.tsx index 17461644d8..c65a4049f8 100644 --- a/apps/login/src/components/password-form.tsx +++ b/apps/login/src/components/password-form.tsx @@ -4,7 +4,6 @@ import { resetPassword, sendPassword } from "@/lib/server/password"; import { create } from "@zitadel/client"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -13,6 +12,7 @@ import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = { password: string; @@ -35,8 +35,6 @@ export function PasswordForm({ promptPasswordless, isAlternative, }: Props) { - const t = useTranslations("password"); - const { register, handleSubmit, formState } = useForm({ mode: "onBlur", }); @@ -136,7 +134,7 @@ export function PasswordForm({ disabled={loading} data-testid="reset-button" > - {t("verify.resetPassword")} + )} @@ -173,8 +171,8 @@ export function PasswordForm({ onClick={handleSubmit(submitPassword)} data-testid="submit-button" > - {loading && } - {t("verify.submit")} + {loading && }{" "} + diff --git a/apps/login/src/components/privacy-policy-checkboxes.tsx b/apps/login/src/components/privacy-policy-checkboxes.tsx index 5ac0340bcb..4ab0e33222 100644 --- a/apps/login/src/components/privacy-policy-checkboxes.tsx +++ b/apps/login/src/components/privacy-policy-checkboxes.tsx @@ -1,9 +1,9 @@ "use client"; import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb"; -import { useTranslations } from "next-intl"; import Link from "next/link"; import { useState } from "react"; import { Checkbox } from "./checkbox"; +import { Translated } from "./translated"; type Props = { legal: LegalAndSupportSettings; @@ -16,7 +16,6 @@ type AcceptanceState = { }; export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) { - const t = useTranslations("register"); const [acceptanceState, setAcceptanceState] = useState({ tosAccepted: false, privacyPolicyAccepted: false, @@ -25,7 +24,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) { return ( <>

- {t("agreeTo")} + {legal?.helpLink && ( @@ -66,7 +65,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {

- {t("termsOfService")} +

@@ -95,7 +94,7 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) { className="underline" target="_blank" > - {t("privacyPolicy")} +

diff --git a/apps/login/src/components/register-form-idp-incomplete.tsx b/apps/login/src/components/register-form-idp-incomplete.tsx index 6194b34052..b8a7765c9c 100644 --- a/apps/login/src/components/register-form-idp-incomplete.tsx +++ b/apps/login/src/components/register-form-idp-incomplete.tsx @@ -1,7 +1,6 @@ "use client"; import { registerUserAndLinkToIDP } from "@/lib/server/register"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { FieldValues, useForm } from "react-hook-form"; @@ -10,6 +9,7 @@ import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = | { @@ -45,8 +45,6 @@ export function RegisterFormIDPIncomplete({ idpId, idpUserName, }: Props) { - const t = useTranslations("register"); - const { register, handleSubmit, formState } = useForm({ mode: "onBlur", defaultValues: { @@ -149,8 +147,8 @@ export function RegisterFormIDPIncomplete({ onClick={handleSubmit(submitAndRegister)} data-testid="submit-button" > - {loading && } - {t("submit")} + {loading && }{" "} + diff --git a/apps/login/src/components/register-form.tsx b/apps/login/src/components/register-form.tsx index c581131c8c..6217bbcbb9 100644 --- a/apps/login/src/components/register-form.tsx +++ b/apps/login/src/components/register-form.tsx @@ -6,7 +6,6 @@ import { LoginSettings, PasskeysType, } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { FieldValues, useForm } from "react-hook-form"; @@ -21,6 +20,7 @@ import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { PrivacyPolicyCheckboxes } from "./privacy-policy-checkboxes"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = | { @@ -51,8 +51,6 @@ export function RegisterForm({ loginSettings, idpCount = 0, }: Props) { - const t = useTranslations("register"); - const { register, handleSubmit, formState } = useForm({ mode: "onBlur", defaultValues: { @@ -173,7 +171,7 @@ export function RegisterForm({ loginSettings.passkeysType == PasskeysType.ALLOWED && ( <>

- {t("selectMethod")} +

@@ -184,12 +182,16 @@ export function RegisterForm({
)} - {!loginSettings?.allowUsernamePassword && - loginSettings?.passkeysType != PasskeysType.ALLOWED && + loginSettings?.passkeysType !== PasskeysType.ALLOWED && (!loginSettings?.allowExternalIdp || !idpCount) && (
- {t("noMethodAvailableWarning")} + + +
)} @@ -217,7 +219,7 @@ export function RegisterForm({ data-testid="submit-button" > {loading && } - {t("submit")} + diff --git a/apps/login/src/components/register-passkey.tsx b/apps/login/src/components/register-passkey.tsx index 8687312bbc..e21e1acdbb 100644 --- a/apps/login/src/components/register-passkey.tsx +++ b/apps/login/src/components/register-passkey.tsx @@ -5,7 +5,6 @@ import { registerPasskeyLink, verifyPasskeyRegistration, } from "@/lib/server/passkeys"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -13,6 +12,7 @@ import { Alert } from "./alert"; import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = {}; @@ -29,8 +29,6 @@ export function RegisterPasskey({ organization, requestId, }: Props) { - const t = useTranslations("passkey"); - const { handleSubmit, formState } = useForm({ mode: "onBlur", }); @@ -198,7 +196,7 @@ export function RegisterPasskey({ continueAndLogin(); }} > - {t("set.skip")} + ) : ( @@ -213,8 +211,8 @@ export function RegisterPasskey({ onClick={handleSubmit(submitRegisterAndContinue)} data-testid="submit-button" > - {loading && } - {t("set.submit")} + {loading && }{" "} + diff --git a/apps/login/src/components/register-u2f.tsx b/apps/login/src/components/register-u2f.tsx index 753eae017d..e72bf1fc69 100644 --- a/apps/login/src/components/register-u2f.tsx +++ b/apps/login/src/components/register-u2f.tsx @@ -5,13 +5,13 @@ import { getNextUrl } from "@/lib/client"; import { addU2F, verifyU2F } from "@/lib/server/u2f"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { RegisterU2FResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { Alert } from "./alert"; import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Props = { loginName?: string; @@ -30,8 +30,6 @@ export function RegisterU2f({ checkAfter, loginSettings, }: Props) { - const t = useTranslations("u2f"); - const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -218,8 +216,8 @@ export function RegisterU2f({ onClick={submitRegisterAndContinue} data-testid="submit-button" > - {loading && } - {t("set.submit")} + {loading && }{" "} + diff --git a/apps/login/src/components/session-clear-item.tsx b/apps/login/src/components/session-clear-item.tsx index 8bff33fee9..72cf51d6f5 100644 --- a/apps/login/src/components/session-clear-item.tsx +++ b/apps/login/src/components/session-clear-item.tsx @@ -9,6 +9,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { Avatar } from "./avatar"; import { isSessionValid } from "./session-item"; +import { Translated } from "./translated"; export function SessionClearItem({ session, @@ -89,7 +90,7 @@ export function SessionClearItem({
- {t("clear")} +
{valid ? ( diff --git a/apps/login/src/components/sessions-clear-list.tsx b/apps/login/src/components/sessions-clear-list.tsx index fe7a67f746..5989948725 100644 --- a/apps/login/src/components/sessions-clear-list.tsx +++ b/apps/login/src/components/sessions-clear-list.tsx @@ -3,11 +3,11 @@ import { clearSession } from "@/lib/server/session"; import { timestampDate } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; -import { useTranslations } from "next-intl"; import { redirect, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { Alert, AlertType } from "./alert"; import { SessionClearItem } from "./session-clear-item"; +import { Translated } from "./translated"; type Props = { sessions: Session[]; @@ -22,7 +22,6 @@ export function SessionsClearList({ postLogoutRedirectUri, organization, }: Props) { - const t = useTranslations("logout"); const [list, setList] = useState(sessions); const router = useRouter(); @@ -97,10 +96,14 @@ export function SessionsClearList({ ); })} {list.length === 0 && ( - {t("noResults")} + + + )}
) : ( - {t("noResults")} + + + ); } diff --git a/apps/login/src/components/sessions-list.tsx b/apps/login/src/components/sessions-list.tsx index 50f621a62d..a3a1f8ed94 100644 --- a/apps/login/src/components/sessions-list.tsx +++ b/apps/login/src/components/sessions-list.tsx @@ -2,10 +2,10 @@ import { timestampDate } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; -import { useTranslations } from "next-intl"; import { useState } from "react"; import { Alert } from "./alert"; import { SessionItem } from "./session-item"; +import { Translated } from "./translated"; type Props = { sessions: Session[]; @@ -13,7 +13,6 @@ type Props = { }; export function SessionsList({ sessions, requestId }: Props) { - const t = useTranslations("accounts"); const [list, setList] = useState(sessions); return sessions ? (
@@ -44,6 +43,8 @@ export function SessionsList({ sessions, requestId }: Props) { })}
) : ( - {t("noResults")} + + + ); } diff --git a/apps/login/src/components/set-password-form.tsx b/apps/login/src/components/set-password-form.tsx index 08f5c7c4ef..8bd2580fb8 100644 --- a/apps/login/src/components/set-password-form.tsx +++ b/apps/login/src/components/set-password-form.tsx @@ -24,6 +24,7 @@ import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { PasswordComplexity } from "./password-complexity"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = | { @@ -207,7 +208,7 @@ export function SetPasswordForm({ }} data-testid="resend-button" > - {t("set.resend")} + @@ -279,8 +280,8 @@ export function SetPasswordForm({ onClick={handleSubmit(submitPassword)} data-testid="submit-button" > - {loading && } - {t("set.submit")} + {loading && }{" "} + diff --git a/apps/login/src/components/set-register-password-form.tsx b/apps/login/src/components/set-register-password-form.tsx index 3e48c649c1..715be00ffa 100644 --- a/apps/login/src/components/set-register-password-form.tsx +++ b/apps/login/src/components/set-register-password-form.tsx @@ -18,6 +18,7 @@ import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { PasswordComplexity } from "./password-complexity"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = | { @@ -163,8 +164,8 @@ export function SetRegisterPasswordForm({ onClick={handleSubmit(submitRegister)} data-testid="submit-button" > - {loading && } - {t("password.submit")} + {loading && }{" "} + From 68b80193413e608c78c03c2dabe39ad920819871 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 14:45:08 +0200 Subject: [PATCH 30/46] translated --- .../src/app/(login)/otp/[method]/page.tsx | 19 +++++++++++++------ .../authentication-method-radio.tsx | 17 +++++++++++++---- apps/login/src/components/back-button.tsx | 5 ++--- .../src/components/set-password-form.tsx | 5 +---- apps/login/src/components/totp-register.tsx | 6 ++---- apps/login/src/components/username-form.tsx | 5 ++--- apps/login/src/components/verify-form.tsx | 10 ++++------ 7 files changed, 37 insertions(+), 30 deletions(-) diff --git a/apps/login/src/app/(login)/otp/[method]/page.tsx b/apps/login/src/app/(login)/otp/[method]/page.tsx index 08007c1819..2d9daac64f 100644 --- a/apps/login/src/app/(login)/otp/[method]/page.tsx +++ b/apps/login/src/app/(login)/otp/[method]/page.tsx @@ -11,7 +11,7 @@ import { getLoginSettings, getSession, } from "@/lib/zitadel"; -import { getLocale, getTranslations } from "next-intl/server"; +import { getLocale } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { @@ -21,7 +21,6 @@ export default async function Page(props: { const params = await props.params; const searchParams = await props.searchParams; const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "otp" }); const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -81,15 +80,23 @@ export default async function Page(props: { return (
-

{t("verify.title")}

+

+ +

{method === "time-based" && ( -

{t("verify.totpDescription")}

+

+ +

)} {method === "sms" && ( -

{t("verify.smsDescription")}

+

+ +

)} {method === "email" && ( -

{t("verify.emailDescription")}

+

+ +

)} {!session && ( diff --git a/apps/login/src/components/authentication-method-radio.tsx b/apps/login/src/components/authentication-method-radio.tsx index 1b2af2d167..c3b273ab46 100644 --- a/apps/login/src/components/authentication-method-radio.tsx +++ b/apps/login/src/components/authentication-method-radio.tsx @@ -1,7 +1,7 @@ "use client"; import { RadioGroup } from "@headlessui/react"; -import { useTranslations } from "next-intl"; +import { Translated } from "./translated"; export enum AuthenticationMethod { Passkey = "passkey", @@ -20,8 +20,6 @@ export function AuthenticationMethodRadio({ selected: any; selectionChanged: (value: any) => void; }) { - const t = useTranslations("register"); - return (
@@ -80,7 +78,18 @@ export function AuthenticationMethodRadio({ as="p" className={`font-medium ${checked ? "" : ""}`} > - {t(`methods.${method}`)} + {method === AuthenticationMethod.Passkey && ( + + )} + {method === AuthenticationMethod.Password && ( + + )}
diff --git a/apps/login/src/components/back-button.tsx b/apps/login/src/components/back-button.tsx index fe348af9c4..31d4a880ad 100644 --- a/apps/login/src/components/back-button.tsx +++ b/apps/login/src/components/back-button.tsx @@ -1,11 +1,10 @@ "use client"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { Button, ButtonVariants } from "./button"; +import { Translated } from "./translated"; export function BackButton() { - const t = useTranslations("common"); const router = useRouter(); return ( ); } diff --git a/apps/login/src/components/set-password-form.tsx b/apps/login/src/components/set-password-form.tsx index 8bd2580fb8..2c3db8dbf2 100644 --- a/apps/login/src/components/set-password-form.tsx +++ b/apps/login/src/components/set-password-form.tsx @@ -14,7 +14,6 @@ import { import { create } from "@zitadel/client"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { FieldValues, useForm } from "react-hook-form"; @@ -53,8 +52,6 @@ export function SetPasswordForm({ code, codeRequired, }: Props) { - const t = useTranslations("password"); - const { register, handleSubmit, watch, formState } = useForm({ mode: "onBlur", defaultValues: { @@ -196,7 +193,7 @@ export function SetPasswordForm({
- {t("set.noCodeReceived")} +
diff --git a/apps/login/src/components/username-form.tsx b/apps/login/src/components/username-form.tsx index 6801f6b274..6adc67fbb5 100644 --- a/apps/login/src/components/username-form.tsx +++ b/apps/login/src/components/username-form.tsx @@ -2,7 +2,6 @@ import { sendLoginname } from "@/lib/server/loginname"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { ReactNode, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -11,6 +10,7 @@ import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = { loginName: string; @@ -37,7 +37,6 @@ export function UsernameForm({ allowRegister, children, }: Props) { - const t = useTranslations("loginname"); const { register, handleSubmit, formState } = useForm({ mode: "onBlur", defaultValues: { @@ -127,7 +126,7 @@ export function UsernameForm({ disabled={loading} data-testid="register-button" > - {t("register")} + )}
diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 0933f598dd..dac4c91314 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -2,7 +2,6 @@ import { Alert, AlertType } from "@/components/alert"; import { resendVerification, sendVerification } from "@/lib/server/verify"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -10,6 +9,7 @@ import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = { code: string; @@ -32,8 +32,6 @@ export function VerifyForm({ code, isInvite, }: Props) { - const t = useTranslations("verify"); - const router = useRouter(); const { register, handleSubmit, formState } = useForm({ @@ -117,7 +115,7 @@ export function VerifyForm({
- {t("verify.noCodeReceived")} +
@@ -161,7 +159,7 @@ export function VerifyForm({ data-testid="submit-button" > {loading && } - {t("verify.submit")} +
From 996af6eea1d0253adecd4bcdd410d651e2c8111d Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 14:49:04 +0200 Subject: [PATCH 31/46] translated component --- .../src/app/(login)/passkey/set/page.tsx | 15 +++++----- .../app/(login)/register/password/page.tsx | 28 +++++++++++++------ .../choose-authenticator-to-login.tsx | 8 +++--- apps/login/src/components/username-form.tsx | 2 +- 4 files changed, 32 insertions(+), 21 deletions(-) diff --git a/apps/login/src/app/(login)/passkey/set/page.tsx b/apps/login/src/app/(login)/passkey/set/page.tsx index d302f0e693..3a3dccf8d7 100644 --- a/apps/login/src/app/(login)/passkey/set/page.tsx +++ b/apps/login/src/app/(login)/passkey/set/page.tsx @@ -6,15 +6,12 @@ import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings } from "@/lib/zitadel"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { searchParams: Promise>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "passkey" }); const { loginName, prompt, organization, requestId, userId } = searchParams; @@ -37,7 +34,9 @@ export default async function Page(props: { return (
-

{t("set.title")}

+

+ +

{session && ( )} -

{t("set.description")}

+

+ +

- {t("set.info.description")} + - {t("set.info.link")} + diff --git a/apps/login/src/app/(login)/register/password/page.tsx b/apps/login/src/app/(login)/register/password/page.tsx index d6a24fa47f..e9689f0f5e 100644 --- a/apps/login/src/app/(login)/register/password/page.tsx +++ b/apps/login/src/app/(login)/register/password/page.tsx @@ -1,5 +1,6 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { SetRegisterPasswordForm } from "@/components/set-register-password-form"; +import { Translated } from "@/components/translated"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { getBrandingSettings, @@ -9,15 +10,12 @@ import { getPasswordComplexitySettings, } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { searchParams: Promise>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "register" }); let { firstname, lastname, email, organization, requestId } = searchParams; @@ -57,15 +55,23 @@ export default async function Page(props: { return missingData ? (
-

{t("missingdata.title")}

-

{t("missingdata.description")}

+

+ +

+

+ +

) : loginSettings?.allowRegister && loginSettings.allowUsernamePassword ? (
-

{t("password.title")}

-

{t("description")}

+

+ +

+

+ +

{legal && passwordComplexitySettings && (
-

{t("disabled.title")}

-

{t("disabled.description")}

+

+ +

+

+ +

); diff --git a/apps/login/src/components/choose-authenticator-to-login.tsx b/apps/login/src/components/choose-authenticator-to-login.tsx index f7e3cdf8f8..0f5dd79134 100644 --- a/apps/login/src/components/choose-authenticator-to-login.tsx +++ b/apps/login/src/components/choose-authenticator-to-login.tsx @@ -3,8 +3,8 @@ import { PasskeysType, } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { useTranslations } from "next-intl"; import { PASSKEYS, PASSWORD } from "./auth-methods"; +import { Translated } from "./translated"; type Props = { authMethods: AuthenticationMethodType[]; @@ -17,13 +17,13 @@ export function ChooseAuthenticatorToLogin({ params, loginSettings, }: Props) { - const t = useTranslations("idp"); - return ( <> {authMethods.includes(AuthenticationMethodType.PASSWORD) && loginSettings?.allowUsernamePassword && ( -
Choose an alternative method to login
+
+ +
)}
{authMethods.includes(AuthenticationMethodType.PASSWORD) && diff --git a/apps/login/src/components/username-form.tsx b/apps/login/src/components/username-form.tsx index 6adc67fbb5..b16092bd9e 100644 --- a/apps/login/src/components/username-form.tsx +++ b/apps/login/src/components/username-form.tsx @@ -151,7 +151,7 @@ export function UsernameForm({ onClick={handleSubmit((e) => submitLoginName(e, organization))} > {loading && } - continue +
From cccec6ea1e82b8163ace873b50773b95181bf4b7 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 14:52:02 +0200 Subject: [PATCH 32/46] translated --- .../src/app/(login)/otp/[method]/set/page.tsx | 16 ++++++++++------ apps/login/src/app/(login)/passkey/page.tsx | 11 ++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/login/src/app/(login)/otp/[method]/set/page.tsx b/apps/login/src/app/(login)/otp/[method]/set/page.tsx index 79aa8c4786..f74093ce8e 100644 --- a/apps/login/src/app/(login)/otp/[method]/set/page.tsx +++ b/apps/login/src/app/(login)/otp/[method]/set/page.tsx @@ -15,7 +15,6 @@ import { registerTOTP, } from "@/lib/zitadel"; import { RegisterTOTPResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; import Link from "next/link"; import { redirect } from "next/navigation"; @@ -26,8 +25,6 @@ export default async function Page(props: { }) { const params = await props.params; const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "otp" }); const { loginName, organization, sessionId, requestId, checkAfter } = searchParams; @@ -128,7 +125,9 @@ export default async function Page(props: { return (
-

{t("set.title")}

+

+ +

{!session && (
@@ -154,7 +153,12 @@ export default async function Page(props: { {totpResponse && "uri" in totpResponse && "secret" in totpResponse ? ( <> -

{t("set.totpRegisterDescription")}

+

+ +

- {t("set.submit")} +
diff --git a/apps/login/src/app/(login)/passkey/page.tsx b/apps/login/src/app/(login)/passkey/page.tsx index b6b199bec8..bef71986f3 100644 --- a/apps/login/src/app/(login)/passkey/page.tsx +++ b/apps/login/src/app/(login)/passkey/page.tsx @@ -7,15 +7,12 @@ import { getSessionCookieById } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getSession } from "@/lib/zitadel"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { searchParams: Promise>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "passkey" }); const { loginName, altPassword, requestId, organization, sessionId } = searchParams; @@ -55,7 +52,9 @@ export default async function Page(props: { return (
-

{t("verify.title")}

+

+ +

{sessionFactors && ( )} -

{t("verify.description")}

+

+ +

{!(loginName || sessionId) && ( From 07f91f94b0f677211218612472c55449b56cf66a Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 14:55:16 +0200 Subject: [PATCH 33/46] idp translated --- apps/login/src/components/idps/sign-in-with-apple.tsx | 11 ++++++++--- .../src/components/idps/sign-in-with-azure-ad.tsx | 11 ++++++++--- .../login/src/components/idps/sign-in-with-github.tsx | 11 ++++++++--- .../login/src/components/idps/sign-in-with-gitlab.tsx | 11 ++++++++--- .../login/src/components/idps/sign-in-with-google.tsx | 11 ++++++++--- .../src/components/set-register-password-form.tsx | 3 --- 6 files changed, 40 insertions(+), 18 deletions(-) diff --git a/apps/login/src/components/idps/sign-in-with-apple.tsx b/apps/login/src/components/idps/sign-in-with-apple.tsx index 8b9419ae02..17e3fc43bb 100644 --- a/apps/login/src/components/idps/sign-in-with-apple.tsx +++ b/apps/login/src/components/idps/sign-in-with-apple.tsx @@ -1,7 +1,7 @@ "use client"; -import { useTranslations } from "next-intl"; import { forwardRef } from "react"; +import { Translated } from "../translated"; import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; export const SignInWithApple = forwardRef< @@ -9,7 +9,6 @@ export const SignInWithApple = forwardRef< SignInWithIdentityProviderProps >(function SignInWithApple(props, ref) { const { children, name, ...restProps } = props; - const t = useTranslations("idp"); return ( @@ -24,7 +23,13 @@ export const SignInWithApple = forwardRef< {children ? ( children ) : ( - {name ? name : t("signInWithApple")} + + {name ? ( + name + ) : ( + + )} + )} ); diff --git a/apps/login/src/components/idps/sign-in-with-azure-ad.tsx b/apps/login/src/components/idps/sign-in-with-azure-ad.tsx index a3a4c82272..3cd33708b6 100644 --- a/apps/login/src/components/idps/sign-in-with-azure-ad.tsx +++ b/apps/login/src/components/idps/sign-in-with-azure-ad.tsx @@ -1,7 +1,7 @@ "use client"; -import { useTranslations } from "next-intl"; import { forwardRef } from "react"; +import { Translated } from "../translated"; import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; export const SignInWithAzureAd = forwardRef< @@ -9,7 +9,6 @@ export const SignInWithAzureAd = forwardRef< SignInWithIdentityProviderProps >(function SignInWithAzureAd(props, ref) { const { children, name, ...restProps } = props; - const t = useTranslations("idp"); return ( @@ -30,7 +29,13 @@ export const SignInWithAzureAd = forwardRef< {children ? ( children ) : ( - {name ? name : t("signInWithAzureAD")} + + {name ? ( + name + ) : ( + + )} + )} ); diff --git a/apps/login/src/components/idps/sign-in-with-github.tsx b/apps/login/src/components/idps/sign-in-with-github.tsx index 45108d17f7..8800e66c3d 100644 --- a/apps/login/src/components/idps/sign-in-with-github.tsx +++ b/apps/login/src/components/idps/sign-in-with-github.tsx @@ -1,7 +1,7 @@ "use client"; -import { useTranslations } from "next-intl"; import { forwardRef } from "react"; +import { Translated } from "../translated"; import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; function GitHubLogo() { @@ -42,7 +42,6 @@ export const SignInWithGithub = forwardRef< SignInWithIdentityProviderProps >(function SignInWithGithub(props, ref) { const { children, name, ...restProps } = props; - const t = useTranslations("idp"); return ( @@ -52,7 +51,13 @@ export const SignInWithGithub = forwardRef< {children ? ( children ) : ( - {name ? name : t("signInWithGithub")} + + {name ? ( + name + ) : ( + + )} + )} ); diff --git a/apps/login/src/components/idps/sign-in-with-gitlab.tsx b/apps/login/src/components/idps/sign-in-with-gitlab.tsx index 8a1ed7d349..00f3712a90 100644 --- a/apps/login/src/components/idps/sign-in-with-gitlab.tsx +++ b/apps/login/src/components/idps/sign-in-with-gitlab.tsx @@ -1,7 +1,7 @@ "use client"; -import { useTranslations } from "next-intl"; import { forwardRef } from "react"; +import { Translated } from "../translated"; import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; export const SignInWithGitlab = forwardRef< @@ -9,7 +9,6 @@ export const SignInWithGitlab = forwardRef< SignInWithIdentityProviderProps >(function SignInWithGitlab(props, ref) { const { children, name, ...restProps } = props; - const t = useTranslations("idp"); return ( @@ -41,7 +40,13 @@ export const SignInWithGitlab = forwardRef< {children ? ( children ) : ( - {name ? name : t("signInWithGitlab")} + + {name ? ( + name + ) : ( + + )} + )} ); diff --git a/apps/login/src/components/idps/sign-in-with-google.tsx b/apps/login/src/components/idps/sign-in-with-google.tsx index 6162ef4a96..4759ad69c9 100644 --- a/apps/login/src/components/idps/sign-in-with-google.tsx +++ b/apps/login/src/components/idps/sign-in-with-google.tsx @@ -1,7 +1,7 @@ "use client"; -import { useTranslations } from "next-intl"; import { forwardRef } from "react"; +import { Translated } from "../translated"; import { BaseButton, SignInWithIdentityProviderProps } from "./base-button"; export const SignInWithGoogle = forwardRef< @@ -9,7 +9,6 @@ export const SignInWithGoogle = forwardRef< SignInWithIdentityProviderProps >(function SignInWithGoogle(props, ref) { const { children, name, ...restProps } = props; - const t = useTranslations("idp"); return ( @@ -54,7 +53,13 @@ export const SignInWithGoogle = forwardRef< {children ? ( children ) : ( - {name ? name : t("signInWithGoogle")} + + {name ? ( + name + ) : ( + + )} + )} ); diff --git a/apps/login/src/components/set-register-password-form.tsx b/apps/login/src/components/set-register-password-form.tsx index 715be00ffa..7660e60753 100644 --- a/apps/login/src/components/set-register-password-form.tsx +++ b/apps/login/src/components/set-register-password-form.tsx @@ -8,7 +8,6 @@ import { } from "@/helpers/validators"; import { registerUser } from "@/lib/server/register"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { FieldValues, useForm } from "react-hook-form"; @@ -44,8 +43,6 @@ export function SetRegisterPasswordForm({ organization, requestId, }: Props) { - const t = useTranslations("register"); - const { register, handleSubmit, watch, formState } = useForm({ mode: "onBlur", defaultValues: { From 0889d1e043043d4f25dee792f0f2bb535cc595f4 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 15:00:44 +0200 Subject: [PATCH 34/46] data prop for i18n --- apps/login/src/components/consent.tsx | 6 +++++- apps/login/src/components/session-clear-item.tsx | 11 +++++++---- apps/login/src/components/translated.tsx | 4 +++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index 5b21ac2a9d..e60ed2901b 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -74,7 +74,11 @@ export function ConsentScreen({

- {t("device.request.disclaimer", { appName: appName })} +

{error && ( diff --git a/apps/login/src/components/session-clear-item.tsx b/apps/login/src/components/session-clear-item.tsx index 72cf51d6f5..c219f42eb0 100644 --- a/apps/login/src/components/session-clear-item.tsx +++ b/apps/login/src/components/session-clear-item.tsx @@ -71,10 +71,13 @@ export function SessionClearItem({ {valid ? ( - {verifiedAt && - t("verfiedAt", { - time: moment(timestampDate(verifiedAt)).fromNow(), - })} + {verifiedAt && ( + + )} ) : ( verifiedAt && ( diff --git a/apps/login/src/components/translated.tsx b/apps/login/src/components/translated.tsx index 60cab58256..807ea18e8f 100644 --- a/apps/login/src/components/translated.tsx +++ b/apps/login/src/components/translated.tsx @@ -4,18 +4,20 @@ export function Translated({ i18nKey, children, namespace, + data, ...props }: { i18nKey: string; children?: React.ReactNode; namespace?: string; + data?: any; } & React.HTMLAttributes) { const t = useTranslations(namespace); const helperKey = `${namespace ? `${namespace}.` : ""}${i18nKey}`; return ( - {t(i18nKey)} + {t(i18nKey, data)} ); } From f2420ce5c6ddc3aebb34f98b2c895a031bb37d07 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 19 Jun 2025 15:17:02 +0200 Subject: [PATCH 35/46] i18n --- apps/login/src/app/(login)/verify/page.tsx | 30 +++++++++---------- .../components/idps/pages/complete-idp.tsx | 13 ++++---- .../components/idps/pages/linking-failed.tsx | 13 ++++---- .../components/idps/pages/linking-success.tsx | 13 ++++---- .../components/idps/pages/login-failed.tsx | 13 ++++---- .../components/idps/pages/login-success.tsx | 13 ++++---- .../src/components/session-clear-item.tsx | 4 +-- 7 files changed, 51 insertions(+), 48 deletions(-) diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index 17f9712401..a61d4e608c 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -8,13 +8,12 @@ import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getUserByID } from "@/lib/zitadel"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; -import { getLocale, getTranslations } from "next-intl/server"; +import { getLocale } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { searchParams: Promise }) { const searchParams = await props.searchParams; const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "verify" }); const { userId, loginName, code, organization, requestId, invite, send } = searchParams; @@ -121,25 +120,26 @@ export default async function Page(props: { searchParams: Promise }) { return (
-

{t("verify.title")}

-

{t("verify.description")}

+

+ +

+

+ +

{!id && ( - <> -

{t("verify.title")}

-

{t("verify.description")}

- -
- - - -
- +
+ + + +
)} {id && send && (
- {t("verify.codeSent")} + + +
)} diff --git a/apps/login/src/components/idps/pages/complete-idp.tsx b/apps/login/src/components/idps/pages/complete-idp.tsx index e1ed5e7401..2061a28e3e 100644 --- a/apps/login/src/components/idps/pages/complete-idp.tsx +++ b/apps/login/src/components/idps/pages/complete-idp.tsx @@ -1,8 +1,8 @@ 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"; +import { Translated } from "../../translated"; export async function completeIDP({ idpUserId, @@ -26,14 +26,15 @@ export async function completeIDP({ idpIntentToken: string; }; }) { - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "idp" }); - return (
-

{t("completeRegister.title")}

-

{t("completeRegister.description")}

+

+ +

+

+ +

-

{t("linkingError.title")}

-

{t("linkingError.description")}

+

+ +

+

+ +

{error && (
{{error}} diff --git a/apps/login/src/components/idps/pages/linking-success.tsx b/apps/login/src/components/idps/pages/linking-success.tsx index f4faa8e1bf..8d41cd8c32 100644 --- a/apps/login/src/components/idps/pages/linking-success.tsx +++ b/apps/login/src/components/idps/pages/linking-success.tsx @@ -1,7 +1,7 @@ import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; -import { getLocale, getTranslations } from "next-intl/server"; import { DynamicTheme } from "../../dynamic-theme"; import { IdpSignin } from "../../idp-signin"; +import { Translated } from "../../translated"; export async function linkingSuccess( userId: string, @@ -9,14 +9,15 @@ export async function linkingSuccess( requestId?: string, branding?: BrandingSettings, ) { - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "idp" }); - return (
-

{t("linkingSuccess.title")}

-

{t("linkingSuccess.description")}

+

+ +

+

+ +

-

{t("loginError.title")}

-

{t("loginError.description")}

+

+ +

+

+ +

{error && (
{{error}} diff --git a/apps/login/src/components/idps/pages/login-success.tsx b/apps/login/src/components/idps/pages/login-success.tsx index 6c884873f1..6beec160a9 100644 --- a/apps/login/src/components/idps/pages/login-success.tsx +++ b/apps/login/src/components/idps/pages/login-success.tsx @@ -1,7 +1,7 @@ import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; -import { getLocale, getTranslations } from "next-intl/server"; import { DynamicTheme } from "../../dynamic-theme"; import { IdpSignin } from "../../idp-signin"; +import { Translated } from "../../translated"; export async function loginSuccess( userId: string, @@ -9,14 +9,15 @@ export async function loginSuccess( requestId?: string, branding?: BrandingSettings, ) { - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "idp" }); - return (
-

{t("loginSuccess.title")}

-

{t("loginSuccess.description")}

+

+ +

+

+ +

void; }) { - const t = useTranslations("logout"); - const currentLocale = useLocale(); moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale); From f092894f1b7f203ccfdc4df3a4e541c5fd4fca41 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 20 Jun 2025 09:10:05 +0200 Subject: [PATCH 36/46] translated --- .../src/app/(login)/device/consent/page.tsx | 32 ++++++++++++------- apps/login/src/app/(login)/device/page.tsx | 12 ++++--- .../(login)/idp/[provider]/failure/page.tsx | 12 ++++--- .../(login)/idp/[provider]/success/page.tsx | 12 ++++--- .../src/app/(login)/logout/success/page.tsx | 12 ++++--- apps/login/src/app/(login)/mfa/page.tsx | 15 +++++---- apps/login/src/app/(login)/mfa/set/page.tsx | 11 ++++--- .../src/app/(login)/password/change/page.tsx | 11 ++++--- .../src/app/(login)/password/set/page.tsx | 19 ++++++++--- apps/login/src/app/(login)/signedin/page.tsx | 24 +++++++++----- apps/login/src/app/(login)/u2f/page.tsx | 11 ++++--- apps/login/src/app/(login)/u2f/set/page.tsx | 12 ++++--- .../src/app/(login)/verify/success/page.tsx | 12 ++++--- 13 files changed, 122 insertions(+), 73 deletions(-) diff --git a/apps/login/src/app/(login)/device/consent/page.tsx b/apps/login/src/app/(login)/device/consent/page.tsx index 75676cd7ff..9f257bca8c 100644 --- a/apps/login/src/app/(login)/device/consent/page.tsx +++ b/apps/login/src/app/(login)/device/consent/page.tsx @@ -1,5 +1,6 @@ import { ConsentScreen } from "@/components/consent"; import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { getBrandingSettings, @@ -7,22 +8,23 @@ import { getDeviceAuthorizationRequest, } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { searchParams: Promise>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale }); const userCode = searchParams?.user_code; const requestId = searchParams?.requestId; const organization = searchParams?.organization; if (!userCode || !requestId) { - return
{t("error.noUserCode")}
; + return ( +
+ +
+ ); } const _headers = await headers(); @@ -34,7 +36,11 @@ export default async function Page(props: { }); if (!deviceAuthorizationRequest) { - return
{t("error.noDeviceRequest")}
; + return ( +
+ +
+ ); } let defaultOrganization; @@ -66,15 +72,19 @@ export default async function Page(props: {

- {t("device.request.title", { - appName: deviceAuthorizationRequest?.appName, - })} +

- {t("device.request.description", { - appName: deviceAuthorizationRequest?.appName, - })} +

>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "device" }); const userCode = searchParams?.user_code; const organization = searchParams?.organization; @@ -37,8 +35,12 @@ export default async function Page(props: { return (
-

{t("usercode.title")}

-

{t("usercode.description")}

+

+ +

+

+ +

diff --git a/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx b/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx index de6ad858d9..f2b7a19b91 100644 --- a/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx @@ -1,6 +1,7 @@ import { Alert, AlertType } from "@/components/alert"; import { ChooseAuthenticatorToLogin } from "@/components/choose-authenticator-to-login"; import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { @@ -11,7 +12,6 @@ import { } from "@/lib/zitadel"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { @@ -19,8 +19,6 @@ export default async function Page(props: { params: Promise<{ provider: string }>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "idp" }); const { organization, userId } = searchParams; @@ -77,8 +75,12 @@ export default async function Page(props: { return (
-

{t("loginError.title")}

- {t("loginError.description")} +

+ +

+ + + {userId && authMethods.length && ( <> 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 bfbde8b252..ae9feff6b7 100644 --- a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx @@ -5,6 +5,7 @@ import { linkingFailed } from "@/components/idps/pages/linking-failed"; import { linkingSuccess } from "@/components/idps/pages/linking-success"; import { loginFailed } from "@/components/idps/pages/login-failed"; import { loginSuccess } from "@/components/idps/pages/login-success"; +import { Translated } from "@/components/translated"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { addHuman, @@ -27,7 +28,6 @@ import { 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 = /(?<=@)(.+)/; @@ -73,8 +73,6 @@ export default async function Page(props: { }) { const params = await props.params; const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "idp" }); let { id, token, requestId, organization, link } = searchParams; const { provider } = params; @@ -321,8 +319,12 @@ export default async function Page(props: { return (
-

{t("registerSuccess.title")}

-

{t("registerSuccess.description")}

+

+ +

+

+ +

}) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "logout" }); const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -33,8 +31,12 @@ export default async function Page(props: { searchParams: Promise }) { return (
-

{t("success.title")}

-

{t("success.description")}

+

+ +

+

+ +

); diff --git a/apps/login/src/app/(login)/mfa/page.tsx b/apps/login/src/app/(login)/mfa/page.tsx index 3ef7b1f519..5543cdf66f 100644 --- a/apps/login/src/app/(login)/mfa/page.tsx +++ b/apps/login/src/app/(login)/mfa/page.tsx @@ -12,15 +12,12 @@ import { getSession, listAuthenticationMethodTypes, } from "@/lib/zitadel"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { searchParams: Promise>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "mfa" }); const { loginName, requestId, organization, sessionId } = searchParams; @@ -90,9 +87,13 @@ export default async function Page(props: { return (
-

{t("verify.title")}

+

+ +

-

{t("verify.description")}

+

+ +

{sessionFactors && ( ) : ( - {t("verify.noResults")} + + + )}
diff --git a/apps/login/src/app/(login)/mfa/set/page.tsx b/apps/login/src/app/(login)/mfa/set/page.tsx index 3ae33f2f8b..ebfa358d6d 100644 --- a/apps/login/src/app/(login)/mfa/set/page.tsx +++ b/apps/login/src/app/(login)/mfa/set/page.tsx @@ -16,7 +16,6 @@ import { } from "@/lib/zitadel"; import { Timestamp, timestampDate } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; function isSessionValid(session: Partial): { @@ -39,8 +38,6 @@ export default async function Page(props: { searchParams: Promise>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "mfa" }); const { loginName, checkAfter, force, requestId, organization, sessionId } = searchParams; @@ -119,9 +116,13 @@ export default async function Page(props: { return (
-

{t("set.title")}

+

+ +

-

{t("set.description")}

+

+ +

{sessionWithData && (

- {sessionFactors?.factors?.user?.displayName ?? t("change.title")} + {sessionFactors?.factors?.user?.displayName ?? ( + + )}

-

{t("change.description")}

+

+ +

{/* show error only if usernames should be shown to be unknown */} {(!sessionFactors || !loginName) && diff --git a/apps/login/src/app/(login)/password/set/page.tsx b/apps/login/src/app/(login)/password/set/page.tsx index ed461b748c..b717fd5d96 100644 --- a/apps/login/src/app/(login)/password/set/page.tsx +++ b/apps/login/src/app/(login)/password/set/page.tsx @@ -13,7 +13,7 @@ import { } from "@/lib/zitadel"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; -import { getLocale, getTranslations } from "next-intl/server"; +import { getLocale } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { @@ -21,7 +21,6 @@ export default async function Page(props: { }) { const searchParams = await props.searchParams; const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "password" }); const { userId, loginName, organization, requestId, code, initial } = searchParams; @@ -73,8 +72,14 @@ export default async function Page(props: { return (
-

{session?.factors?.user?.displayName ?? t("set.title")}

-

{t("set.description")}

+

+ {session?.factors?.user?.displayName ?? ( + + )} +

+

+ +

{/* show error only if usernames should be shown to be unknown */} {loginName && !session && !loginSettings?.ignoreUnknownUsernames && ( @@ -101,7 +106,11 @@ export default async function Page(props: { > ) : null} - {!initial && {t("set.codeSent")}} + {!initial && ( + + + + )} {passwordComplexity && (loginName ?? user?.preferredLoginName) && diff --git a/apps/login/src/app/(login)/signedin/page.tsx b/apps/login/src/app/(login)/signedin/page.tsx index dd19096244..5b2ed5fbf4 100644 --- a/apps/login/src/app/(login)/signedin/page.tsx +++ b/apps/login/src/app/(login)/signedin/page.tsx @@ -1,6 +1,7 @@ import { Alert, AlertType } from "@/components/alert"; import { Button, ButtonVariants } from "@/components/button"; import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getMostRecentCookieWithLoginname, @@ -14,7 +15,6 @@ import { getLoginSettings, getSession, } from "@/lib/zitadel"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; import Link from "next/link"; @@ -37,8 +37,6 @@ async function loadSessionById( export default async function Page(props: { searchParams: Promise }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "signedin" }); const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -66,8 +64,12 @@ export default async function Page(props: { searchParams: Promise }) { return (
-

{t("error.title")}

-

{t("error.description")}

+

+ +

+

+ +

{err.message}
@@ -94,9 +96,15 @@ export default async function Page(props: { searchParams: Promise }) {

- {t("title", { user: sessionFactors?.factors?.user?.displayName })} +

-

{t("description")}

+

+ +

}) { className="self-end" variant={ButtonVariants.Primary} > - {t("continue")} +
diff --git a/apps/login/src/app/(login)/u2f/page.tsx b/apps/login/src/app/(login)/u2f/page.tsx index bac94b2ecc..7fba7be1be 100644 --- a/apps/login/src/app/(login)/u2f/page.tsx +++ b/apps/login/src/app/(login)/u2f/page.tsx @@ -7,7 +7,7 @@ import { getSessionCookieById } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getSession } from "@/lib/zitadel"; -import { getLocale, getTranslations } from "next-intl/server"; +import { getLocale } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { @@ -15,7 +15,6 @@ export default async function Page(props: { }) { const searchParams = await props.searchParams; const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "u2f" }); const { loginName, requestId, sessionId, organization } = searchParams; @@ -59,7 +58,9 @@ export default async function Page(props: { return (
-

{t("verify.title")}

+

+ +

{sessionFactors && ( )} -

{t("verify.description")}

+

+ +

{!(loginName || sessionId) && ( diff --git a/apps/login/src/app/(login)/u2f/set/page.tsx b/apps/login/src/app/(login)/u2f/set/page.tsx index 2d951d53fc..b73e902821 100644 --- a/apps/login/src/app/(login)/u2f/set/page.tsx +++ b/apps/login/src/app/(login)/u2f/set/page.tsx @@ -6,7 +6,7 @@ import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings } from "@/lib/zitadel"; -import { getLocale, getTranslations } from "next-intl/server"; +import { getLocale } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { @@ -14,7 +14,6 @@ export default async function Page(props: { }) { const searchParams = await props.searchParams; const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "u2f" }); const { loginName, organization, requestId, checkAfter } = searchParams; @@ -37,7 +36,9 @@ export default async function Page(props: { return (
-

{t("set.title")}

+

+ +

{sessionFactors && ( )} -

{t("set.description")}

+

+ {" "} + +

{!sessionFactors && (
diff --git a/apps/login/src/app/(login)/verify/success/page.tsx b/apps/login/src/app/(login)/verify/success/page.tsx index 1668e2e3fd..a0df0327c4 100644 --- a/apps/login/src/app/(login)/verify/success/page.tsx +++ b/apps/login/src/app/(login)/verify/success/page.tsx @@ -1,4 +1,5 @@ import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; @@ -8,13 +9,10 @@ import { getUserByID, } from "@/lib/zitadel"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; -import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { searchParams: Promise }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "verify" }); const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -65,8 +63,12 @@ export default async function Page(props: { searchParams: Promise }) { return (
-

{t("successTitle")}

-

{t("successDescription")}

+

+ +

+

+ +

{sessionFactors ? ( Date: Fri, 20 Jun 2025 15:27:53 +0200 Subject: [PATCH 37/46] missing i18n --- apps/login/locales/de.json | 3 ++- apps/login/locales/en.json | 3 ++- apps/login/locales/es.json | 3 ++- apps/login/locales/it.json | 3 ++- apps/login/locales/pl.json | 3 ++- apps/login/locales/ru.json | 3 ++- apps/login/locales/zh.json | 3 ++- 7 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 36dc145120..f7b0d064ba 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -22,7 +22,8 @@ "loginname": { "title": "Willkommen zurück!", "description": "Geben Sie Ihre Anmeldedaten ein.", - "register": "Neuen Benutzer registrieren" + "register": "Neuen Benutzer registrieren", + "submit": "Weiter" }, "password": { "verify": { diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 0b1cbeb472..6ce32d9833 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -22,7 +22,8 @@ "loginname": { "title": "Welcome back!", "description": "Enter your login data.", - "register": "Register new user" + "register": "Register new user", + "submit": "Continue" }, "password": { "verify": { diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 5cd40f764a..b9a4140bce 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -22,7 +22,8 @@ "loginname": { "title": "¡Bienvenido de nuevo!", "description": "Introduce tus datos de acceso.", - "register": "Registrar nuevo usuario" + "register": "Registrar nuevo usuario", + "submit": "Continuar" }, "password": { "verify": { diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index a19aa91cfb..109ab15b52 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -22,7 +22,8 @@ "loginname": { "title": "Bentornato!", "description": "Inserisci i tuoi dati di accesso.", - "register": "Registrati come nuovo utente" + "register": "Registrati come nuovo utente", + "submit": "Continua" }, "password": { "verify": { diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index b97e7e4b47..0c664101ae 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -22,7 +22,8 @@ "loginname": { "title": "Witamy ponownie!", "description": "Wprowadź dane logowania.", - "register": "Zarejestruj nowego użytkownika" + "register": "Zarejestruj nowego użytkownika", + "submit": "Kontynuuj" }, "password": { "verify": { diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 77ea8ba79e..c9fee46295 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -22,7 +22,8 @@ "loginname": { "title": "С возвращением!", "description": "Введите свои данные для входа.", - "register": "Зарегистрировать нового пользователя" + "register": "Зарегистрировать нового пользователя", + "submit": "Продолжить" }, "password": { "verify": { diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 0ad9c7e056..1601d7b7bd 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -22,7 +22,8 @@ "loginname": { "title": "欢迎回来!", "description": "请输入您的登录信息。", - "register": "注册新用户" + "register": "注册新用户", + "submit": "继续" }, "password": { "verify": { From e2754c1d169b1b3f3f5ebf003dda1e3da6a5a261 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 23 Jun 2025 11:40:43 +0200 Subject: [PATCH 38/46] fix deepmerge strategy --- apps/login/src/i18n/request.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/login/src/i18n/request.ts b/apps/login/src/i18n/request.ts index 2d9fd4d688..271d370f7c 100644 --- a/apps/login/src/i18n/request.ts +++ b/apps/login/src/i18n/request.ts @@ -28,7 +28,6 @@ export default getRequestConfig(async () => { if (i18nJSON) { translations = i18nJSON; } - console.log("Translations:", translations); } catch (error) { console.warn("Error fetching custom translations:", error); } @@ -48,14 +47,13 @@ export default getRequestConfig(async () => { } } - // const customMessages = translations; - const customMessages = {}; + const customMessages = translations; const localeMessages = (await import(`../../locales/${locale}.json`)).default; const fallbackMessages = (await import(`../../locales/${fallback}.json`)) .default; return { locale, - messages: deepmerge(fallbackMessages, localeMessages, customMessages), + messages: deepmerge.all([fallbackMessages, localeMessages, customMessages]), }; }); From bfb7928b6b24350a904fb6283f2810c1541b758e Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 25 Jun 2025 08:14:04 +0200 Subject: [PATCH 39/46] missing i18n, cleanup --- apps/login/locales/de.json | 7 +++++++ apps/login/locales/es.json | 7 +++++++ apps/login/locales/it.json | 7 +++++++ apps/login/locales/pl.json | 7 +++++++ apps/login/locales/ru.json | 7 +++++++ apps/login/locales/zh.json | 7 +++++++ apps/login/src/app/(login)/idp/ldap/page.tsx | 20 +++++++++---------- .../ldap-username-password-form.tsx | 16 ++++----------- 8 files changed, 55 insertions(+), 23 deletions(-) diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index ecb4740682..75897a628e 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -80,6 +80,13 @@ "description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen." } }, + "ldap": { + "title": "LDAP Login", + "description": "Geben Sie Ihre LDAP-Anmeldedaten ein.", + "username": "Benutzername", + "password": "Passwort", + "submit": "Weiter" + }, "mfa": { "verify": { "title": "Bestätigen Sie Ihre Identität", diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 40e3392e4a..fe88bb94c6 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -80,6 +80,13 @@ "description": "Para completar el registro, debes establecer una contraseña." } }, + "ldap": { + "title": "Iniciar sesión con LDAP", + "description": "Introduce tus credenciales LDAP.", + "username": "Nombre de usuario", + "password": "Contraseña", + "submit": "Continuar" + }, "mfa": { "verify": { "title": "Verifica tu identidad", diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 6f562f0974..1229a1a4c0 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -80,6 +80,13 @@ "description": "Completa la registrazione del tuo account." } }, + "ldap": { + "title": "Accedi con LDAP", + "description": "Inserisci le tue credenziali LDAP.", + "username": "Nome utente", + "password": "Password", + "submit": "Continua" + }, "mfa": { "verify": { "title": "Verifica la tua identità", diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 58c77b1d09..9fea6a19fa 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -80,6 +80,13 @@ "description": "Ukończ rejestrację swojego konta." } }, + "ldap": { + "title": "Zaloguj się przez LDAP", + "description": "Wprowadź swoje dane logowania LDAP.", + "username": "Nazwa użytkownika", + "password": "Hasło", + "submit": "Kontynuuj" + }, "mfa": { "verify": { "title": "Zweryfikuj swoją tożsamość", diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index b73f9b99c6..e745f1ae59 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -80,6 +80,13 @@ "description": "Завершите регистрацию вашего аккаунта." } }, + "ldap": { + "title": "Войти через LDAP", + "description": "Введите ваши учетные данные LDAP.", + "username": "Имя пользователя", + "password": "Пароль", + "submit": "Продолжить" + }, "mfa": { "verify": { "title": "Подтвердите вашу личность", diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 1023660882..5a9cb3a4eb 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -80,6 +80,13 @@ "description": "完成您的账户注册。" } }, + "ldap": { + "title": "使用 LDAP 登录", + "description": "请输入您的 LDAP 凭据。", + "username": "用户名", + "password": "密码", + "submit": "继续" + }, "mfa": { "verify": { "title": "验证您的身份", diff --git a/apps/login/src/app/(login)/idp/ldap/page.tsx b/apps/login/src/app/(login)/idp/ldap/page.tsx index 67e6bbe4a7..105c6832e6 100644 --- a/apps/login/src/app/(login)/idp/ldap/page.tsx +++ b/apps/login/src/app/(login)/idp/ldap/page.tsx @@ -1,9 +1,9 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { LDAPUsernamePasswordForm } from "@/components/ldap-username-password-form"; +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 { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export default async function Page(props: { @@ -11,9 +11,7 @@ export default async function Page(props: { params: Promise<{ provider: string }>; }) { const searchParams = await props.searchParams; - const locale = getLocale(); - const t = await getTranslations({ locale, namespace: "ldap" }); - const { idpId, requestId, organization, link } = searchParams; + const { idpId, organization, link } = searchParams; if (!idpId) { throw new Error("No idpId provided in searchParams"); @@ -41,14 +39,14 @@ export default async function Page(props: { return (
-

{t("title")}

-

{t("description")}

+

+ +

+

+ +

- +
); diff --git a/apps/login/src/components/ldap-username-password-form.tsx b/apps/login/src/components/ldap-username-password-form.tsx index b3dcaabd58..b7653c5782 100644 --- a/apps/login/src/components/ldap-username-password-form.tsx +++ b/apps/login/src/components/ldap-username-password-form.tsx @@ -1,7 +1,6 @@ "use client"; import { createNewSessionForLDAP } from "@/lib/server/idp"; -import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -10,6 +9,7 @@ import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { Spinner } from "./spinner"; +import { Translated } from "./translated"; type Inputs = { loginName: string; @@ -17,18 +17,10 @@ type Inputs = { }; type Props = { - organization?: string; - requestId?: string; idpId: string; }; -export function LDAPUsernamePasswordForm({ - organization, - requestId, - idpId, -}: Props) { - const t = useTranslations("password"); - +export function LDAPUsernamePasswordForm({ idpId }: Props) { const { register, handleSubmit, formState } = useForm({ mode: "onBlur", }); @@ -72,7 +64,7 @@ export function LDAPUsernamePasswordForm({ type="text" autoComplete="username" {...register("loginName", { required: "This field is required" })} - label={"Loginname"} + label="Loginname" data-testid="username-text-input" /> @@ -104,7 +96,7 @@ export function LDAPUsernamePasswordForm({ data-testid="submit-button" > {loading && } - {t("verify.submit")} +
From 94665022e5a3c0704679537fef9b8b496f1f0014 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 25 Jun 2025 08:19:16 +0200 Subject: [PATCH 40/46] link property for ldap --- apps/login/src/app/(login)/idp/ldap/page.tsx | 5 ++++- .../ldap-username-password-form.tsx | 4 +++- apps/login/src/lib/server/idp.ts | 22 ++++++++++--------- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/login/src/app/(login)/idp/ldap/page.tsx b/apps/login/src/app/(login)/idp/ldap/page.tsx index 105c6832e6..372c814525 100644 --- a/apps/login/src/app/(login)/idp/ldap/page.tsx +++ b/apps/login/src/app/(login)/idp/ldap/page.tsx @@ -46,7 +46,10 @@ export default async function Page(props: {

- +
); diff --git a/apps/login/src/components/ldap-username-password-form.tsx b/apps/login/src/components/ldap-username-password-form.tsx index b7653c5782..fe653ba077 100644 --- a/apps/login/src/components/ldap-username-password-form.tsx +++ b/apps/login/src/components/ldap-username-password-form.tsx @@ -18,9 +18,10 @@ type Inputs = { type Props = { idpId: string; + link?: boolean; }; -export function LDAPUsernamePasswordForm({ idpId }: Props) { +export function LDAPUsernamePasswordForm({ idpId, link }: Props) { const { register, handleSubmit, formState } = useForm({ mode: "onBlur", }); @@ -39,6 +40,7 @@ export function LDAPUsernamePasswordForm({ idpId }: Props) { idpId: idpId, username: values.loginName, password: values.password, + link: link, }) .catch(() => { setError("Could not start LDAP flow"); diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index 30ee0aa6fb..1925fe43d6 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -34,9 +34,6 @@ export async function redirectToIdp( const idpId = formData.get("id") as string; const provider = formData.get("provider") as string; - // const username = formData.get("username") as string; - // const password = formData.get("password") as string; - if (linkOnly) params.set("link", "true"); if (requestId) params.set("requestId", requestId); if (organization) params.set("organization", organization); @@ -194,6 +191,7 @@ type createNewSessionForLDAPCommand = { username: string; password: string; idpId: string; + link: boolean; }; export async function createNewSessionForLDAP( @@ -229,13 +227,17 @@ export async function createNewSessionForLDAP( const { userId, idpIntentId, idpIntentToken } = response.nextStep.value; + const params = new URLSearchParams({ + userId, + id: idpIntentId, + token: idpIntentToken, + }); + + if (command.link) { + params.set("link", "true"); + } + return { - redirect: - `/idp/ldap/success?` + - new URLSearchParams({ - userId, - id: idpIntentId, - token: idpIntentToken, - }).toString(), + redirect: `/idp/ldap/success?` + params.toString(), }; } From f33add736310eb8594662caca1bc555be089c6ce Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 25 Jun 2025 08:21:08 +0200 Subject: [PATCH 41/46] prop --- apps/login/src/components/ldap-username-password-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/login/src/components/ldap-username-password-form.tsx b/apps/login/src/components/ldap-username-password-form.tsx index fe653ba077..f7ea9aea0e 100644 --- a/apps/login/src/components/ldap-username-password-form.tsx +++ b/apps/login/src/components/ldap-username-password-form.tsx @@ -18,7 +18,7 @@ type Inputs = { type Props = { idpId: string; - link?: boolean; + link: boolean; }; export function LDAPUsernamePasswordForm({ idpId, link }: Props) { From 82cf493fe17f8070f46fd9a0401c50eae6543e77 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 25 Jun 2025 08:34:41 +0200 Subject: [PATCH 42/46] i18n --- apps/login/src/components/sign-in-with-idp.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/login/src/components/sign-in-with-idp.tsx b/apps/login/src/components/sign-in-with-idp.tsx index 13b179071c..ec9cfb36f8 100644 --- a/apps/login/src/components/sign-in-with-idp.tsx +++ b/apps/login/src/components/sign-in-with-idp.tsx @@ -6,7 +6,6 @@ import { IdentityProvider, IdentityProviderType, } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; -import { useTranslations } from "next-intl"; import { ReactNode, useActionState } from "react"; import { Alert } from "./alert"; import { SignInWithIdentityProviderProps } from "./idps/base-button"; @@ -16,6 +15,7 @@ import { SignInWithGeneric } from "./idps/sign-in-with-generic"; import { SignInWithGithub } from "./idps/sign-in-with-github"; import { SignInWithGitlab } from "./idps/sign-in-with-gitlab"; import { SignInWithGoogle } from "./idps/sign-in-with-google"; +import { Translated } from "./translated"; export interface SignInWithIDPProps { children?: ReactNode; @@ -32,7 +32,6 @@ export function SignInWithIdp({ linkOnly, }: Readonly) { const [state, action, _isPending] = useActionState(redirectToIdp, {}); - const t = useTranslations("idp"); const renderIDPButton = (idp: IdentityProvider, index: number) => { const { id, name, type } = idp; @@ -78,7 +77,9 @@ export function SignInWithIdp({ return (
-

{t("orSignInWith")}

+

+ +

{!!identityProviders.length && identityProviders?.map(renderIDPButton)} {state?.error && (
From f8be2afc4124b225356b860c4c1f7863e1aaebca Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 26 Jun 2025 09:26:22 +0200 Subject: [PATCH 43/46] chore: readme --- .github/custom-i18n.png | Bin 0 -> 85028 bytes README.md | 11 +++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .github/custom-i18n.png diff --git a/.github/custom-i18n.png b/.github/custom-i18n.png new file mode 100644 index 0000000000000000000000000000000000000000..2306e62f8709d5b6f756b773410eca411b3b5c2e GIT binary patch literal 85028 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 z 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 Date: Thu, 26 Jun 2025 09:56:42 +0200 Subject: [PATCH 44/46] fix: ldap translations --- .../login/src/components/ldap-username-password-form.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/login/src/components/ldap-username-password-form.tsx b/apps/login/src/components/ldap-username-password-form.tsx index f7ea9aea0e..2f9824dff2 100644 --- a/apps/login/src/components/ldap-username-password-form.tsx +++ b/apps/login/src/components/ldap-username-password-form.tsx @@ -1,6 +1,7 @@ "use client"; import { createNewSessionForLDAP } from "@/lib/server/idp"; +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -26,6 +27,8 @@ export function LDAPUsernamePasswordForm({ idpId, link }: Props) { mode: "onBlur", }); + const t = useTranslations("ldap"); + const [error, setError] = useState(""); const [loading, setLoading] = useState(false); @@ -66,7 +69,7 @@ export function LDAPUsernamePasswordForm({ idpId, link }: Props) { type="text" autoComplete="username" {...register("loginName", { required: "This field is required" })} - label="Loginname" + label={t("username")} data-testid="username-text-input" /> @@ -75,7 +78,7 @@ export function LDAPUsernamePasswordForm({ idpId, link }: Props) { type="password" autoComplete="password" {...register("password", { required: "This field is required" })} - label="Password" + label={t("password")} data-testid="password-text-input" />

@@ -98,7 +101,7 @@ export function LDAPUsernamePasswordForm({ idpId, link }: Props) { data-testid="submit-button" > {loading && } - +
From 871c2d5b52eeb82735a6dc0f3b1c98588297728f Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Thu, 26 Jun 2025 11:24:56 +0200 Subject: [PATCH 45/46] test pipeline --- .github/workflows/test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 19468a4ccb..5c858cc03a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,9 +15,6 @@ on: jobs: quality: name: Ensure Quality - if: github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.repository_owner != 'zitadel') || - (github.event_name == 'pull_request' && github.repository_owner != 'zitadel') runs-on: ubuntu-22.04 timeout-minutes: 30 permissions: From bea341513d5bfe11b0b40f66dad9344d0be6b7b2 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Thu, 26 Jun 2025 12:12:25 +0200 Subject: [PATCH 46/46] make login_quality --- Makefile | 2 +- apps/login/package.json | 1 + apps/login/turbo.json | 1 + docker-bake.hcl | 2 +- dockerfiles/login-client.Dockerfile | 3 ++- dockerfiles/login-test-unit.Dockerfile | 2 +- package.json | 1 + 7 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index c1280810bc..e7b863dc72 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ export LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG := login-test-acceptance-samlidp:${DOCK export POSTGRES_TAG := postgres:17.0-alpine3.19 export GOLANG_TAG := golang:1.24-alpine export ZITADEL_TAG ?= ghcr.io/zitadel/zitadel:latest -export CORE_MOCK_TAG := login-core-mock:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_CORE_MOCK_TAG := login-core-mock:${DOCKER_METADATA_OUTPUT_VERSION} login_help: @echo "Makefile for the login service" diff --git a/apps/login/package.json b/apps/login/package.json index 49c06d8c3a..f498b912c2 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "pnpm next dev --turbopack", "test:unit": "pnpm vitest", + "test:unit:standalone": "pnpm test:unit", "test:unit:watch": "pnpm test:unit --watch", "lint": "pnpm exec next lint && pnpm exec prettier --check .", "lint:fix": "pnpm exec prettier --write .", diff --git a/apps/login/turbo.json b/apps/login/turbo.json index 030d45d581..bc63a2dbc4 100644 --- a/apps/login/turbo.json +++ b/apps/login/turbo.json @@ -14,6 +14,7 @@ "test:unit": { "dependsOn": ["@zitadel/client#build"] }, + "test:unit:standalone": {}, "test:watch": { "dependsOn": ["@zitadel/client#build"] } diff --git a/docker-bake.hcl b/docker-bake.hcl index b5f3ea7406..3d98c09b2c 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -82,7 +82,7 @@ target "login-client" { } variable "LOGIN_CORE_MOCK_TAG" { - default = "core-mock:local" + default = "login-core-mock:local" } # the core-mock context must not be overwritten, so we don't prefix it with login-. diff --git a/dockerfiles/login-client.Dockerfile b/dockerfiles/login-client.Dockerfile index 783bc92062..107c9e1846 100644 --- a/dockerfiles/login-client.Dockerfile +++ b/dockerfiles/login-client.Dockerfile @@ -1,7 +1,8 @@ FROM typescript-proto-client AS login-client COPY packages/zitadel-tsconfig packages/zitadel-tsconfig COPY packages/zitadel-client/package.json ./packages/zitadel-client/ +RUN ls -la RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile --workspace-root --filter ./packages/zitadel-client -COPY packages/zitadel-client packages/zitadel-client +COPY packages/zitadel-client ./packages/zitadel-client RUN pnpm build:client:standalone diff --git a/dockerfiles/login-test-unit.Dockerfile b/dockerfiles/login-test-unit.Dockerfile index b0cfdbd086..d456a4fac4 100644 --- a/dockerfiles/login-test-unit.Dockerfile +++ b/dockerfiles/login-test-unit.Dockerfile @@ -3,4 +3,4 @@ COPY apps/login/package.json ./apps/login/ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile --workspace-root --filter ./apps/login COPY apps/login ./apps/login -RUN cd apps/login && pnpm test:unit +RUN pnpm test:unit:standalone diff --git a/package.json b/package.json index 8b33aee194..ce844c4b2c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "start": "pnpm exec turbo run start", "start:built": "pnpm exec turbo run start:built", "test:unit": "pnpm exec turbo run test:unit -- --passWithNoTests", + "test:unit:standalone": "pnpm exec turbo run test:unit:standalone -- --passWithNoTests", "test:integration": "cd apps/login-test-integration && pnpm test:integration", "test:integration:setup": "NODE_ENV=test pnpm exec turbo run test:integration:setup", "test:acceptance": "cd apps/login-test-acceptance && pnpm test:acceptance",