From 8d58adcc185e64b0f5fb566ebb7673c2e0c5b5e4 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 14 Aug 2025 15:42:33 +0200 Subject: [PATCH 01/21] fix(login): idp redirect (#10482) This PR fixes an issue where a user was not redirected to an IDP correctly if the user has entered the loginname and has an IDP as single auth method --- src/lib/server/loginname.ts | 86 +++++++++++-------------------------- 1 file changed, 26 insertions(+), 60 deletions(-) diff --git a/src/lib/server/loginname.ts b/src/lib/server/loginname.ts index dee740bf4..451c3201e 100644 --- a/src/lib/server/loginname.ts +++ b/src/lib/server/loginname.ts @@ -201,35 +201,21 @@ export async function sendLoginname(command: SendLoginnameCommand) { }); // compare with the concatenated suffix when set - const concatLoginname = command.suffix - ? `${command.loginName}@${command.suffix}` - : command.loginName; + const concatLoginname = command.suffix ? `${command.loginName}@${command.suffix}` : command.loginName; - const humanUser = - potentialUsers[0].type.case === "human" - ? potentialUsers[0].type.value - : undefined; + const humanUser = potentialUsers[0].type.case === "human" ? potentialUsers[0].type.value : undefined; // recheck login settings after user discovery, as the search might have been done without org scope - if ( - userLoginSettings?.disableLoginWithEmail && - userLoginSettings?.disableLoginWithPhone - ) { + if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) { if (user.preferredLoginName !== concatLoginname) { return { error: "User not found in the system!" }; } } else if (userLoginSettings?.disableLoginWithEmail) { - if ( - user.preferredLoginName !== concatLoginname || - humanUser?.phone?.phone !== command.loginName - ) { + if (user.preferredLoginName !== concatLoginname || humanUser?.phone?.phone !== command.loginName) { return { error: "User not found in the system!" }; } } else if (userLoginSettings?.disableLoginWithPhone) { - if ( - user.preferredLoginName !== concatLoginname || - humanUser?.email?.email !== command.loginName - ) { + if (user.preferredLoginName !== concatLoginname || humanUser?.email?.email !== command.loginName) { return { error: "User not found in the system!" }; } } @@ -270,11 +256,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { } if (command.organization || session.factors?.user?.organizationId) { - params.append( - "organization", - command.organization ?? - (session.factors?.user?.organizationId as string), - ); + params.append("organization", command.organization ?? (session.factors?.user?.organizationId as string)); } return { redirect: `/verify?` + params }; @@ -286,8 +268,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { case AuthenticationMethodType.PASSWORD: // user has only password as auth method if (!userLoginSettings?.allowUsernamePassword) { return { - error: - "Username Password not allowed! Contact your administrator for more information.", + error: "Username Password not allowed! Contact your administrator for more information.", }; } @@ -298,10 +279,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { // TODO: does this have to be checked in loginSettings.allowDomainDiscovery if (command.organization || session.factors?.user?.organizationId) { - paramsPassword.append( - "organization", - command.organization ?? session.factors?.user?.organizationId, - ); + paramsPassword.append("organization", command.organization ?? session.factors?.user?.organizationId); } if (command.requestId) { @@ -315,8 +293,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY if (userLoginSettings?.passkeysType === PasskeysType.NOT_ALLOWED) { return { - error: - "Passkeys not allowed! Contact your administrator for more information.", + error: "Passkeys not allowed! Contact your administrator for more information.", }; } @@ -328,13 +305,19 @@ export async function sendLoginname(command: SendLoginnameCommand) { } if (command.organization || session.factors?.user?.organizationId) { - paramsPasskey.append( - "organization", - command.organization ?? session.factors?.user?.organizationId, - ); + paramsPasskey.append("organization", command.organization ?? session.factors?.user?.organizationId); } return { redirect: "/passkey?" + paramsPasskey }; + + case AuthenticationMethodType.IDP: + const resp = await redirectUserToIDP(userId); + + if (resp?.error) { + return { error: resp.error }; + } + + return resp; } } else { // prefer passkey in favor of other methods @@ -349,20 +332,13 @@ export async function sendLoginname(command: SendLoginnameCommand) { } if (command.organization || session.factors?.user?.organizationId) { - passkeyParams.append( - "organization", - command.organization ?? session.factors?.user?.organizationId, - ); + passkeyParams.append("organization", command.organization ?? session.factors?.user?.organizationId); } return { redirect: "/passkey?" + passkeyParams }; - } else if ( - methods.authMethodTypes.includes(AuthenticationMethodType.IDP) - ) { + } else if (methods.authMethodTypes.includes(AuthenticationMethodType.IDP)) { return redirectUserToIDP(userId); - } else if ( - methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) - ) { + } else if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)) { // user has no passkey setup and login settings allow passkeys const paramsPasswordDefault = new URLSearchParams({ loginName: session.factors?.user?.loginName, @@ -373,10 +349,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { } if (command.organization || session.factors?.user?.organizationId) { - paramsPasswordDefault.append( - "organization", - command.organization ?? session.factors?.user?.organizationId, - ); + paramsPasswordDefault.append("organization", command.organization ?? session.factors?.user?.organizationId); } return { @@ -387,19 +360,13 @@ export async function sendLoginname(command: SendLoginnameCommand) { } // user not found, check if register is enabled on instance / organization context - if ( - loginSettingsByContext?.allowRegister && - !loginSettingsByContext?.allowUsernamePassword - ) { + if (loginSettingsByContext?.allowRegister && !loginSettingsByContext?.allowUsernamePassword) { const resp = await redirectUserToSingleIDPIfAvailable(); if (resp) { return resp; } return { error: "User not found in the system" }; - } else if ( - loginSettingsByContext?.allowRegister && - loginSettingsByContext?.allowUsernamePassword - ) { + } else if (loginSettingsByContext?.allowRegister && loginSettingsByContext?.allowUsernamePassword) { let orgToRegisterOn: string | undefined = command.organization; if ( @@ -416,8 +383,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { serviceUrl, domain: suffix, }); - const orgToCheckForDiscovery = - orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; const orgLoginSettings = await getLoginSettings({ serviceUrl, From f41d828ae7a3c51eb34f77ee164f2dde3bc3509a Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 15 Aug 2025 10:30:46 +0200 Subject: [PATCH 02/21] fix(login): user discovery - ignore case for loginname, email (#10475) # Which Problems Are Solved The new login UI user case sensitive matching for usernames and email addresses. This is different from the v1 login and not expected by customers, leading to not found user errors. # How the Problems Are Solved The user search is changed to case insensitive matching. # Additional Changes None # Additional Context - reported by a customer - requires backport to 4.x --------- Co-authored-by: Livio Spring --- src/lib/zitadel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/zitadel.ts b/src/lib/zitadel.ts index 5e4583f8a..0fa8eb75f 100644 --- a/src/lib/zitadel.ts +++ b/src/lib/zitadel.ts @@ -771,7 +771,7 @@ const LoginNameQuery = (searchValue: string) => case: "loginNameQuery", value: { loginName: searchValue, - method: TextQueryMethod.EQUALS, + method: TextQueryMethod.EQUALS_IGNORE_CASE, }, }, }); @@ -782,7 +782,7 @@ const EmailQuery = (searchValue: string) => case: "emailQuery", value: { emailAddress: searchValue, - method: TextQueryMethod.EQUALS, + method: TextQueryMethod.EQUALS_IGNORE_CASE, }, }, }); From 8cd5f9eb296ed0291f91ec42ab107eea066acb68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Badst=C3=BCbner?= Date: Fri, 15 Aug 2025 14:00:16 +0200 Subject: [PATCH 03/21] fix(loginV2): hide sign-in-with-idp if none are configured (#10402) # Which Problems Are Solved Don't show the external IdP section, if none are configured. # How the Problems Are Solved - Checks if the length of `identityProviders` is non-empty. # Additional Changes - Added 2 additional null-checks for `identityProviders` # Additional Context - Closes #10401 Co-authored-by: Max Peintner Co-authored-by: Livio Spring --- src/app/(login)/idp/page.tsx | 2 +- src/app/(login)/loginname/page.tsx | 2 +- src/components/sign-in-with-idp.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/(login)/idp/page.tsx b/src/app/(login)/idp/page.tsx index ab16e897e..51b4f71bb 100644 --- a/src/app/(login)/idp/page.tsx +++ b/src/app/(login)/idp/page.tsx @@ -38,7 +38,7 @@ export default async function Page(props: {

- {identityProviders && ( + {!!identityProviders?.length && ( - {identityProviders && loginSettings?.allowExternalIdp && ( + {loginSettings?.allowExternalIdp && !!identityProviders?.length && (

- {!!identityProviders.length && identityProviders?.map(renderIDPButton)} + {!!identityProviders?.length && identityProviders?.map(renderIDPButton)} {state?.error && (
{state?.error} From f4c1bbbfbf43b91c7c5cca3dd4fdc809b3130199 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 18 Aug 2025 09:38:37 +0200 Subject: [PATCH 04/21] fix(login): use `/logout/done` as success page, accept `post_logout_redirect ` param as post logout uri (#10500) Closes #10413 This PR changes the logout success page of the V2 login to `/logout/done` and accepts both `post_logout_redirect` as well as `post_logout_redirect_uri` as a param for the post logout url. # Which Problems Are Solved The new Login V2 aligns with the login V1 now. Accepts `post_logout_redirect` as well as `post_logout_redirect_uri` as a param for the post logout url. # How the Problems Are Solved Both search params are now accepted. --- .../(login)/logout/{success => done}/page.tsx | 0 src/app/(login)/logout/page.tsx | 12 +++--------- src/components/sessions-clear-list.tsx | 17 ++++------------- 3 files changed, 7 insertions(+), 22 deletions(-) rename src/app/(login)/logout/{success => done}/page.tsx (100%) diff --git a/src/app/(login)/logout/success/page.tsx b/src/app/(login)/logout/done/page.tsx similarity index 100% rename from src/app/(login)/logout/success/page.tsx rename to src/app/(login)/logout/done/page.tsx diff --git a/src/app/(login)/logout/page.tsx b/src/app/(login)/logout/page.tsx index 71371fc12..c96675e85 100644 --- a/src/app/(login)/logout/page.tsx +++ b/src/app/(login)/logout/page.tsx @@ -3,11 +3,7 @@ import { SessionsClearList } from "@/components/sessions-clear-list"; import { Translated } from "@/components/translated"; import { getAllSessionCookieIds } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { - getBrandingSettings, - getDefaultOrg, - listSessions, -} from "@/lib/zitadel"; +import { getBrandingSettings, getDefaultOrg, listSessions } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { headers } from "next/headers"; @@ -26,13 +22,11 @@ async function loadSessions({ serviceUrl }: { serviceUrl: string }) { } } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; const organization = searchParams?.organization; - const postLogoutRedirectUri = searchParams?.post_logout_redirect_uri; + const postLogoutRedirectUri = searchParams?.post_logout_redirect || searchParams?.post_logout_redirect_uri; const logoutHint = searchParams?.logout_hint; // TODO implement with new translation service // const UILocales = searchParams?.ui_locales; diff --git a/src/components/sessions-clear-list.tsx b/src/components/sessions-clear-list.tsx index 598994872..ba00b3ec3 100644 --- a/src/components/sessions-clear-list.tsx +++ b/src/components/sessions-clear-list.tsx @@ -16,12 +16,7 @@ type Props = { organization?: string; }; -export function SessionsClearList({ - sessions, - logoutHint, - postLogoutRedirectUri, - organization, -}: Props) { +export function SessionsClearList({ sessions, logoutHint, postLogoutRedirectUri, organization }: Props) { const [list, setList] = useState(sessions); const router = useRouter(); @@ -54,7 +49,7 @@ export function SessionsClearList({ params.set("organization", organization); } - return router.push("/logout/success?" + params); + return router.push("/logout/done?" + params); } else { console.warn(`No session found for login hint: ${logoutHint}`); } @@ -72,12 +67,8 @@ export function SessionsClearList({ .filter((session) => session?.factors?.user?.loginName) // sort by change date descending .sort((a, b) => { - const dateA = a.changeDate - ? timestampDate(a.changeDate).getTime() - : 0; - const dateB = b.changeDate - ? timestampDate(b.changeDate).getTime() - : 0; + const dateA = a.changeDate ? timestampDate(a.changeDate).getTime() : 0; + const dateB = b.changeDate ? timestampDate(b.changeDate).getTime() : 0; return dateB - dateA; }) // TODO: add sorting to move invalid sessions to the bottom From beb33fff27b1d32ec274642ae40fd9a59a95731e Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 22 Aug 2025 12:59:13 +0200 Subject: [PATCH 05/21] fix(login): use translation `title` key prop to set page title (#10537) This PR sets the page title to the same title as the respective pages and introduces a default title ("Login with Zitadel"). Closes #10282 # Which Problems Are Solved Missing page title on pages. # How the Problems Are Solved Using the hosted translation service, we load and merge properties to set the page title --------- Co-authored-by: Livio Spring --- locales/de.json | 3 ++- locales/en.json | 3 ++- locales/es.json | 3 ++- locales/it.json | 3 ++- locales/pl.json | 3 ++- locales/ru.json | 3 ++- locales/zh.json | 3 ++- src/app/(login)/accounts/page.tsx | 7 +++++++ src/app/(login)/authenticator/set/page.tsx | 7 +++++++ src/app/(login)/device/page.tsx | 7 +++++++ src/app/(login)/idp/page.tsx | 7 +++++++ src/app/(login)/layout.tsx | 8 ++++++++ src/app/(login)/loginname/page.tsx | 7 +++++++ src/app/(login)/logout/page.tsx | 7 +++++++ src/app/(login)/mfa/page.tsx | 7 +++++++ src/app/(login)/mfa/set/page.tsx | 7 +++++++ src/app/(login)/otp/[method]/page.tsx | 7 +++++++ src/app/(login)/passkey/page.tsx | 7 +++++++ src/app/(login)/passkey/set/page.tsx | 7 +++++++ src/app/(login)/password/change/page.tsx | 7 +++++++ src/app/(login)/password/page.tsx | 7 +++++++ src/app/(login)/password/set/page.tsx | 7 +++++++ src/app/(login)/register/page.tsx | 7 +++++++ src/app/(login)/signedin/page.tsx | 7 +++++++ src/app/(login)/u2f/page.tsx | 7 +++++++ src/app/(login)/u2f/set/page.tsx | 7 +++++++ src/app/(login)/verify/page.tsx | 7 +++++++ 27 files changed, 155 insertions(+), 7 deletions(-) diff --git a/locales/de.json b/locales/de.json index 7b2a507fe..5d5a4b592 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1,6 +1,7 @@ { "common": { - "back": "Zurück" + "back": "Zurück", + "title": "Anmelden mit Zitadel" }, "accounts": { "title": "Konten", diff --git a/locales/en.json b/locales/en.json index e1b7e4e82..606065bd9 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,6 +1,7 @@ { "common": { - "back": "Back" + "back": "Back", + "title": "Login with Zitadel" }, "accounts": { "title": "Accounts", diff --git a/locales/es.json b/locales/es.json index b1c63583a..34e9bf700 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1,6 +1,7 @@ { "common": { - "back": "Atrás" + "back": "Atrás", + "title": "Iniciar sesión con Zitadel" }, "accounts": { "title": "Cuentas", diff --git a/locales/it.json b/locales/it.json index a71b48ed0..8e71997e0 100644 --- a/locales/it.json +++ b/locales/it.json @@ -1,6 +1,7 @@ { "common": { - "back": "Indietro" + "back": "Indietro", + "title": "Accedi con Zitadel" }, "accounts": { "title": "Account", diff --git a/locales/pl.json b/locales/pl.json index 3e8562a22..8fce97170 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -1,6 +1,7 @@ { "common": { - "back": "Powrót" + "back": "Powrót", + "title": "Zaloguj się za pomocą Zitadel" }, "accounts": { "title": "Konta", diff --git a/locales/ru.json b/locales/ru.json index 6ba2917e1..d5b8575f9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1,6 +1,7 @@ { "common": { - "back": "Назад" + "back": "Назад", + "title": "Войти с Zitadel" }, "accounts": { "title": "Аккаунты", diff --git a/locales/zh.json b/locales/zh.json index fe5f2d186..2f8f18324 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -1,6 +1,7 @@ { "common": { - "back": "返回" + "back": "返回", + "title": "使用 Zitadel 登录" }, "accounts": { "title": "账户", diff --git a/src/app/(login)/accounts/page.tsx b/src/app/(login)/accounts/page.tsx index e4e6b387d..50407e996 100644 --- a/src/app/(login)/accounts/page.tsx +++ b/src/app/(login)/accounts/page.tsx @@ -10,10 +10,17 @@ import { } from "@/lib/zitadel"; import { UserPlusIcon } from "@heroicons/react/24/outline"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; // import { getLocale } from "next-intl/server"; import { headers } from "next/headers"; import Link from "next/link"; +export async function generateMetadata(): Promise { + const t = await getTranslations("accounts"); + return { title: t('title')}; +} + async function loadSessions({ serviceUrl }: { serviceUrl: string }) { const cookieIds = await getAllSessionCookieIds(); diff --git a/src/app/(login)/authenticator/set/page.tsx b/src/app/(login)/authenticator/set/page.tsx index e08367f58..1f8912d95 100644 --- a/src/app/(login)/authenticator/set/page.tsx +++ b/src/app/(login)/authenticator/set/page.tsx @@ -19,9 +19,16 @@ import { } from "@/lib/zitadel"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; // import { getLocale } from "next-intl/server"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; +export async function generateMetadata(): Promise { + const t = await getTranslations("authenticator"); + return { title: t('title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/device/page.tsx b/src/app/(login)/device/page.tsx index e8761d25d..0164640a4 100644 --- a/src/app/(login)/device/page.tsx +++ b/src/app/(login)/device/page.tsx @@ -4,8 +4,15 @@ import { Translated } from "@/components/translated"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("device"); + return { title: t('usercode.title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/idp/page.tsx b/src/app/(login)/idp/page.tsx index 51b4f71bb..839efada0 100644 --- a/src/app/(login)/idp/page.tsx +++ b/src/app/(login)/idp/page.tsx @@ -3,8 +3,15 @@ import { SignInWithIdp } from "@/components/sign-in-with-idp"; import { Translated } from "@/components/translated"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { getActiveIdentityProviders, getBrandingSettings } from "@/lib/zitadel"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("idp"); + return { title: t('title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/layout.tsx b/src/app/(login)/layout.tsx index dbce9804c..a28ad843c 100644 --- a/src/app/(login)/layout.tsx +++ b/src/app/(login)/layout.tsx @@ -9,17 +9,25 @@ import * as Tooltip from "@radix-ui/react-tooltip"; import { Analytics } from "@vercel/analytics/react"; import { Lato } from "next/font/google"; import { ReactNode, Suspense } from "react"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; const lato = Lato({ weight: ["400", "700", "900"], subsets: ["latin"], }); +export async function generateMetadata(): Promise { + const t = await getTranslations("common"); + return { title: t('title')}; +} + export default async function RootLayout({ children, }: { children: ReactNode; }) { + return ( diff --git a/src/app/(login)/loginname/page.tsx b/src/app/(login)/loginname/page.tsx index f19d9986d..7694e43b1 100644 --- a/src/app/(login)/loginname/page.tsx +++ b/src/app/(login)/loginname/page.tsx @@ -10,8 +10,15 @@ import { getLoginSettings, } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("loginname"); + return { title: t('title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/logout/page.tsx b/src/app/(login)/logout/page.tsx index c96675e85..d20489a3a 100644 --- a/src/app/(login)/logout/page.tsx +++ b/src/app/(login)/logout/page.tsx @@ -5,8 +5,15 @@ import { getAllSessionCookieIds } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { getBrandingSettings, getDefaultOrg, listSessions } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("logout"); + return { title: t('title')}; +} + async function loadSessions({ serviceUrl }: { serviceUrl: string }) { const cookieIds = await getAllSessionCookieIds(); diff --git a/src/app/(login)/mfa/page.tsx b/src/app/(login)/mfa/page.tsx index 5543cdf66..35af05474 100644 --- a/src/app/(login)/mfa/page.tsx +++ b/src/app/(login)/mfa/page.tsx @@ -12,8 +12,15 @@ import { getSession, listAuthenticationMethodTypes, } from "@/lib/zitadel"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("mfa"); + return { title: t('verify.title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/mfa/set/page.tsx b/src/app/(login)/mfa/set/page.tsx index ebfa358d6..0203ba341 100644 --- a/src/app/(login)/mfa/set/page.tsx +++ b/src/app/(login)/mfa/set/page.tsx @@ -16,8 +16,15 @@ import { } from "@/lib/zitadel"; import { Timestamp, timestampDate } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("mfa"); + return { title: t('set.title')}; +} + function isSessionValid(session: Partial): { valid: boolean; verifiedAt?: Timestamp; diff --git a/src/app/(login)/otp/[method]/page.tsx b/src/app/(login)/otp/[method]/page.tsx index 1b1356315..d39c9ffda 100644 --- a/src/app/(login)/otp/[method]/page.tsx +++ b/src/app/(login)/otp/[method]/page.tsx @@ -11,8 +11,15 @@ import { getLoginSettings, getSession, } from "@/lib/zitadel"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("otp"); + return { title: t('verify.title')}; +} + export default async function Page(props: { searchParams: Promise>; params: Promise>; diff --git a/src/app/(login)/passkey/page.tsx b/src/app/(login)/passkey/page.tsx index bef71986f..7a287d5ac 100644 --- a/src/app/(login)/passkey/page.tsx +++ b/src/app/(login)/passkey/page.tsx @@ -7,8 +7,15 @@ import { getSessionCookieById } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getSession } from "@/lib/zitadel"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("passkey"); + return { title: t('verify.title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/passkey/set/page.tsx b/src/app/(login)/passkey/set/page.tsx index 52c195e6c..47b13a3fc 100644 --- a/src/app/(login)/passkey/set/page.tsx +++ b/src/app/(login)/passkey/set/page.tsx @@ -6,8 +6,15 @@ import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings } from "@/lib/zitadel"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("passkey"); + return { title: t('set.title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/password/change/page.tsx b/src/app/(login)/password/change/page.tsx index 78ba88d28..fe920cda8 100644 --- a/src/app/(login)/password/change/page.tsx +++ b/src/app/(login)/password/change/page.tsx @@ -10,8 +10,15 @@ import { getLoginSettings, getPasswordComplexitySettings, } from "@/lib/zitadel"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("password"); + return { title: t('change.title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/password/page.tsx b/src/app/(login)/password/page.tsx index a9ab4091f..97be81e7b 100644 --- a/src/app/(login)/password/page.tsx +++ b/src/app/(login)/password/page.tsx @@ -11,8 +11,15 @@ import { getLoginSettings, } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("password"); + return { title: t('verify.title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/password/set/page.tsx b/src/app/(login)/password/set/page.tsx index c47305929..738b68df2 100644 --- a/src/app/(login)/password/set/page.tsx +++ b/src/app/(login)/password/set/page.tsx @@ -13,8 +13,15 @@ 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 { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("password"); + return { title: t('set.title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/register/page.tsx b/src/app/(login)/register/page.tsx index 221679ef0..5a11ca1f8 100644 --- a/src/app/(login)/register/page.tsx +++ b/src/app/(login)/register/page.tsx @@ -14,8 +14,15 @@ 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 { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("register"); + return { title: t('title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/signedin/page.tsx b/src/app/(login)/signedin/page.tsx index 5b2ed5fbf..a9b0660e2 100644 --- a/src/app/(login)/signedin/page.tsx +++ b/src/app/(login)/signedin/page.tsx @@ -15,9 +15,16 @@ import { getLoginSettings, getSession, } from "@/lib/zitadel"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; import Link from "next/link"; +export async function generateMetadata(): Promise { + const t = await getTranslations("signedin"); + return { title: t('title', { user: '' })}; +} + async function loadSessionById( serviceUrl: string, sessionId: string, diff --git a/src/app/(login)/u2f/page.tsx b/src/app/(login)/u2f/page.tsx index c54b45103..5bf892846 100644 --- a/src/app/(login)/u2f/page.tsx +++ b/src/app/(login)/u2f/page.tsx @@ -7,8 +7,15 @@ import { getSessionCookieById } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getSession } from "@/lib/zitadel"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("u2f"); + return { title: t('verify.title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/u2f/set/page.tsx b/src/app/(login)/u2f/set/page.tsx index 79f64bf67..455b917a2 100644 --- a/src/app/(login)/u2f/set/page.tsx +++ b/src/app/(login)/u2f/set/page.tsx @@ -6,8 +6,15 @@ import { UserAvatar } from "@/components/user-avatar"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings } from "@/lib/zitadel"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("u2f"); + return { title: t('set.title')}; +} + export default async function Page(props: { searchParams: Promise>; }) { diff --git a/src/app/(login)/verify/page.tsx b/src/app/(login)/verify/page.tsx index 749769822..67166ed37 100644 --- a/src/app/(login)/verify/page.tsx +++ b/src/app/(login)/verify/page.tsx @@ -8,8 +8,15 @@ 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 { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; +export async function generateMetadata(): Promise { + const t = await getTranslations("verify"); + return { title: t('verify.title')}; +} + export default async function Page(props: { searchParams: Promise }) { const searchParams = await props.searchParams; From 5b0f32617365bc48152fa789e5e492c0532d1f8d Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 25 Aug 2025 13:39:37 +0200 Subject: [PATCH 06/21] fix(login): add email verification check before callback (#10516) Closes https://github.com/zitadel/typescript/issues/539 This PR adds an additional email verification check before completing an auth flow, if the environment configuration `EMAIL_VERIFICATION` asks for it. # Which Problems Are Solved https://github.com/zitadel/typescript/issues/539 # How the Problems Are Solved Adds an additional check before completing an auth flow --- src/lib/session.ts | 83 ++++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 47 deletions(-) diff --git a/src/lib/session.ts b/src/lib/session.ts index 8c2548b8f..f250da64c 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -5,11 +5,7 @@ import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { GetSessionResponse } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { getMostRecentCookieWithLoginname } from "./cookies"; -import { - getLoginSettings, - getSession, - listAuthenticationMethodTypes, -} from "./zitadel"; +import { getLoginSettings, getSession, getUserByID, listAuthenticationMethodTypes } from "./zitadel"; type LoadMostRecentSessionParams = { serviceUrl: string; @@ -39,13 +35,7 @@ export async function loadMostRecentSession({ * mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.) * to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId); **/ -export async function isSessionValid({ - serviceUrl, - session, -}: { - serviceUrl: string; - session: Session; -}): Promise { +export async function isSessionValid({ serviceUrl, session }: { serviceUrl: string; session: Session }): Promise { // session can't be checked without user if (!session.factors?.user) { console.warn("Session has no user"); @@ -63,43 +53,22 @@ export async function isSessionValid({ if (authMethods && authMethods.includes(AuthenticationMethodType.TOTP)) { mfaValid = !!session.factors.totp?.verifiedAt; if (!mfaValid) { - console.warn( - "Session has no valid totpEmail factor", - session.factors.totp?.verifiedAt, - ); + console.warn("Session has no valid totpEmail factor", session.factors.totp?.verifiedAt); } - } else if ( - authMethods && - authMethods.includes(AuthenticationMethodType.OTP_EMAIL) - ) { + } else if (authMethods && authMethods.includes(AuthenticationMethodType.OTP_EMAIL)) { mfaValid = !!session.factors.otpEmail?.verifiedAt; if (!mfaValid) { - console.warn( - "Session has no valid otpEmail factor", - session.factors.otpEmail?.verifiedAt, - ); + console.warn("Session has no valid otpEmail factor", session.factors.otpEmail?.verifiedAt); } - } else if ( - authMethods && - authMethods.includes(AuthenticationMethodType.OTP_SMS) - ) { + } else if (authMethods && authMethods.includes(AuthenticationMethodType.OTP_SMS)) { mfaValid = !!session.factors.otpSms?.verifiedAt; if (!mfaValid) { - console.warn( - "Session has no valid otpSms factor", - session.factors.otpSms?.verifiedAt, - ); + console.warn("Session has no valid otpSms factor", session.factors.otpSms?.verifiedAt); } - } else if ( - authMethods && - authMethods.includes(AuthenticationMethodType.U2F) - ) { + } else if (authMethods && authMethods.includes(AuthenticationMethodType.U2F)) { mfaValid = !!session.factors.webAuthN?.verifiedAt; if (!mfaValid) { - console.warn( - "Session has no valid u2f factor", - session.factors.webAuthN?.verifiedAt, - ); + console.warn("Session has no valid u2f factor", session.factors.webAuthN?.verifiedAt); } } else { // only check settings if no auth methods are available, as this would require a setup @@ -128,22 +97,42 @@ export async function isSessionValid({ const validPasskey = session?.factors?.webAuthN?.verifiedAt; const validIDP = session?.factors?.intent?.verifiedAt; - const stillValid = session.expirationDate - ? timestampDate(session.expirationDate).getTime() > new Date().getTime() - : true; + const stillValid = session.expirationDate ? timestampDate(session.expirationDate).getTime() > new Date().getTime() : true; if (!stillValid) { console.warn( "Session is expired", - session.expirationDate - ? timestampDate(session.expirationDate).toDateString() - : "no expiration date", + session.expirationDate ? timestampDate(session.expirationDate).toDateString() : "no expiration date", ); + return false; } const validChecks = !!(validPassword || validPasskey || validIDP); - return stillValid && validChecks && mfaValid; + if (!validChecks) { + return false; + } + + if (!mfaValid) { + return false; + } + + // Check email verification if EMAIL_VERIFICATION environment variable is enabled + if (process.env.EMAIL_VERIFICATION === "true") { + const userResponse = await getUserByID({ + serviceUrl, + userId: session.factors.user.id, + }); + + const humanUser = userResponse?.user?.type.case === "human" ? userResponse?.user.type.value : undefined; + + if (humanUser && !humanUser.email?.isVerified) { + console.warn("Session invalid: Email not verified and EMAIL_VERIFICATION is enabled", session.factors.user.id); + return false; + } + } + + return true; } export async function findValidSession({ From e59baf5104974732950805c11174e786b52c69dd Mon Sep 17 00:00:00 2001 From: Nils <20240901+Tolsto@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:53:21 +0300 Subject: [PATCH 07/21] fix(loginV2): Disable image optimization (#10508) # Which Problems Are Solved Next.js's Image Optimization feature requires that hostnames for remote images be explicitly defined in the `next.config.js` file via `remotePatterns`. This configuration is static and evaluated at **build time**. However, the `ZITADEL_API_URL`, which is supposed to be used for additional whitelisted hostnames, is a dynamic environment variable only known at **run time**. This creates a fundamental conflict, making it impossible to add the user-provided URL to the configuration when building the public Docker image. Consequently, images like instance logos fail to load. The existing workaround uses a permissive wildcard pattern (`*.zitadel.*`). This is a significant security risk, as it could allow malicious actors to abuse the server as an open image-resizing proxy, leading to potential denial-of-service (DDoS) attacks or excessive costs. # How the Problems Are Solved This change disables the Next.js Image Optimization feature entirely by setting `unoptimized: true` in the `images` configuration. By doing this, Next.js will no longer attempt to optimize, cache, or validate remote image sources. Instead, it will pass the original image URL directly to the client. This approach resolves the issue by: 1. **Eliminating the need for `remotePatterns`**, which bypasses the build-time vs. run-time configuration conflict. 2. **Improving security** by removing the overly permissive wildcard pattern. 3. **Ensuring functionality**, as remote images now load correctly. The trade-off is the loss of performance benefits from Next.js image optimization, but I see this as an acceptable compromise to restore essential functionality and secure the application. Fixes #10456 Co-authored-by: Max Peintner --- next.config.mjs | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index b84f11a23..3ac2e59a1 100755 --- a/next.config.mjs +++ b/next.config.mjs @@ -33,30 +33,6 @@ const secureHeaders = [ { key: "X-Frame-Options", value: "deny" }, ]; -const imageRemotePatterns = [ - { - protocol: "http", - hostname: "localhost", - port: "8080", - pathname: "/**", - }, - { - protocol: "https", - hostname: "*.zitadel.*", - port: "", - pathname: "/**", - }, -]; - -if (process.env.ZITADEL_API_URL) { - imageRemotePatterns.push({ - protocol: "https", - hostname: process.env.ZITADEL_API_URL?.replace("https://", "") || "", - port: "", - pathname: "/**", - }); -} - const nextConfig = { basePath: process.env.NEXT_PUBLIC_BASE_PATH, output: process.env.NEXT_OUTPUT_MODE || undefined, @@ -65,7 +41,7 @@ const nextConfig = { dynamicIO: true, }, images: { - remotePatterns: imageRemotePatterns, + unoptimized: true }, eslint: { ignoreDuringBuilds: true, From cf349572bed8aa499f13c2b76bc200630b66affb Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 27 Aug 2025 10:38:15 +0200 Subject: [PATCH 08/21] fix(login): CSP img-src to allow instance assets (#10579) Fix CSP img-src to allow ZITADEL instance assets # Which Problems Are Solved Login app was failing to load images (logos, branding assets) from ZITADEL instances due to Content Security Policy restrictions. The CSP img-src directive only allowed 'self' and https://vercel.com, blocking images from ZITADEL domains like https://login-*.zitadel.app. # How the Problems Are Solved - Dynamic CSP configuration: Extract hostname from ZITADEL_API_URL environment variable - Fallback support: Use *.zitadel.cloud wildcard when no specific URL is configured - Environment-aware: Works across dev/staging/prod without hardcoded domains --- constants/csp.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/constants/csp.js b/constants/csp.js index 5cc1e254f..ac1b738ca 100644 --- a/constants/csp.js +++ b/constants/csp.js @@ -1,2 +1,6 @@ +const ZITADEL_DOMAIN = process.env.ZITADEL_API_URL + ? new URL(process.env.ZITADEL_API_URL).hostname + : '*.zitadel.cloud'; + export const DEFAULT_CSP = - "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com;"; + `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com https://${ZITADEL_DOMAIN};`; From 28624863366bb20c025475bd752290feaada1037 Mon Sep 17 00:00:00 2001 From: Adam Kida <122802098+jmblab-adam@users.noreply.github.com> Date: Fri, 29 Aug 2025 07:12:12 +0200 Subject: [PATCH 09/21] feat(typescript): add i18n for input labels in Login V2 (#10233) # Which Problems Are Solved - Most inputs have hardcoded label # How the Problems Are Solved - add usage of i18n library for every label - add labels to i18n translation files # Additional Changes - fixed key used in `device-code-form.tsx` by submit button - `v2-default.json` was update and contains all values from login app not only newly added key for labels. # Additional Context N.A --------- Co-authored-by: David Skewis Co-authored-by: Max Peintner --- locales/de.json | 47 +++++++++++++++++-- locales/en.json | 45 +++++++++++++++++- locales/es.json | 45 +++++++++++++++++- locales/it.json | 45 +++++++++++++++++- locales/pl.json | 45 +++++++++++++++++- locales/ru.json | 45 +++++++++++++++++- locales/zh.json | 45 +++++++++++++++++- src/components/change-password-form.tsx | 4 +- src/components/device-code-form.tsx | 4 +- .../ldap-username-password-form.tsx | 4 +- src/components/login-otp.tsx | 2 +- src/components/password-form.tsx | 2 +- .../register-form-idp-incomplete.tsx | 6 +-- src/components/register-form.tsx | 6 +-- src/components/set-password-form.tsx | 6 +-- src/components/set-register-password-form.tsx | 4 +- src/components/totp-register.tsx | 2 +- src/components/username-form.tsx | 8 ++-- src/components/verify-form.tsx | 2 +- 19 files changed, 327 insertions(+), 40 deletions(-) diff --git a/locales/de.json b/locales/de.json index 5d5a4b592..05183374b 100644 --- a/locales/de.json +++ b/locales/de.json @@ -27,6 +27,12 @@ "description": "Geben Sie Ihre Anmeldedaten ein.", "register": "Neuen Benutzer registrieren", "submit": "Weiter", + "labels": { + "loginname": "Loginname", + "username": "Benutzername", + "usernameOrPhoneNumber": "Benutzername oder Telefonnummer", + "usernameOrEmail": "Benutzername oder E-Mail" + }, "required": { "loginName": "Dieses Feld ist erforderlich" } @@ -37,6 +43,9 @@ "description": "Geben Sie Ihr Passwort ein.", "resetPassword": "Passwort zurücksetzen", "submit": "Weiter", + "labels": { + "password": "Passwort" + }, "required": { "password": "Dieses Feld ist erforderlich" } @@ -48,6 +57,11 @@ "noCodeReceived": "Keinen Code erhalten?", "resend": "Erneut senden", "submit": "Weiter", + "labels": { + "code": "Code", + "newPassword": "Neues Passwort", + "confirmPassword": "Neues Passwort wiederholen" + }, "required": { "code": "Dieses Feld ist erforderlich", "newPassword": "Bitte geben Sie ein Passwort ein!", @@ -58,6 +72,10 @@ "title": "Passwort ändern", "description": "Legen Sie das Passwort für Ihr Konto fest", "submit": "Weiter", + "labels": { + "newPassword": "Neues Passwort", + "confirmPassword": "Neues Passwort wiederholen" + }, "required": { "newPassword": "Bitte geben Sie ein neues Passwort ein!", "confirmPassword": "Dieses Feld ist erforderlich" @@ -101,9 +119,11 @@ "ldap": { "title": "LDAP Login", "description": "Geben Sie Ihre LDAP-Anmeldedaten ein.", - "username": "Benutzername", - "password": "Passwort", "submit": "Weiter", + "labels": { + "username": "Benutzername", + "password": "Passwort" + }, "required": { "username": "Dieses Feld ist erforderlich", "password": "Dieses Feld ist erforderlich" @@ -130,6 +150,9 @@ "noCodeReceived": "Keinen Code erhalten?", "resendCode": "Code erneut senden", "submit": "Weiter", + "labels": { + "code": "Code" + }, "required": { "code": "Dieses Feld ist erforderlich" } @@ -141,6 +164,9 @@ "emailDescription": "Geben Sie Ihre E-Mail-Adresse ein, um einen Code per E-Mail zu erhalten.", "totpRegisterDescription": "Scannen Sie den QR-Code oder navigieren Sie manuell zur URL.", "submit": "Weiter", + "labels": { + "code": "Code" + }, "required": { "code": "Dieses Feld ist erforderlich" } @@ -178,7 +204,7 @@ "register": { "methods": { "passkey": "Passkey", - "password": "Password" + "password": "Passwort" }, "disabled": { "title": "Registrierung deaktiviert", @@ -201,11 +227,20 @@ "title": "Passwort festlegen", "description": "Legen Sie das Passwort für Ihr Konto fest", "submit": "Weiter", + "labels": { + "password": "Passwort", + "confirmPassword": "Neues Passwort wiederholen" + }, "required": { "password": "Bitte geben Sie ein Passwort ein!", "confirmPassword": "Dieses Feld ist erforderlich" } }, + "labels": { + "firstname": "Vorname", + "lastname": "Nachname", + "email": "E-Mail" + }, "required": { "firstname": "Dieses Feld ist erforderlich", "lastname": "Dieses Feld ist erforderlich", @@ -247,6 +282,9 @@ "resendCode": "Code erneut senden", "codeSent": "Ein Code wurde gerade an Ihre E-Mail-Adresse gesendet.", "submit": "Weiter", + "labels": { + "code": "Code" + }, "required": { "code": "Dieses Feld ist erforderlich" } @@ -264,6 +302,9 @@ "title": "Gerätecode", "description": "Geben Sie den Code ein.", "submit": "Weiter", + "labels": { + "code": "Code" + }, "required": { "code": "Dieses Feld ist erforderlich" } diff --git a/locales/en.json b/locales/en.json index 606065bd9..5c47e8e64 100644 --- a/locales/en.json +++ b/locales/en.json @@ -27,6 +27,12 @@ "description": "Enter your login data.", "register": "Register new user", "submit": "Continue", + "labels": { + "loginname": "Loginname", + "username": "Username", + "usernameOrPhoneNumber": "Username or phone number", + "usernameOrEmail": "Username or email" + }, "required": { "loginName": "This field is required" } @@ -37,6 +43,9 @@ "description": "Enter your password.", "resetPassword": "Reset Password", "submit": "Continue", + "labels": { + "password": "Password" + }, "required": { "password": "This field is required" } @@ -48,6 +57,11 @@ "noCodeReceived": "Didn't receive a code?", "resend": "Resend code", "submit": "Continue", + "labels": { + "code": "Code", + "newPassword": "New Password", + "confirmPassword": "Confirm Password" + }, "required": { "code": "This field is required", "newPassword": "You have to provide a password!", @@ -58,6 +72,10 @@ "title": "Change Password", "description": "Set the password for your account", "submit": "Continue", + "labels": { + "newPassword": "New Password", + "confirmPassword": "Confirm Password" + }, "required": { "newPassword": "You have to provide a new password!", "confirmPassword": "This field is required" @@ -101,9 +119,11 @@ "ldap": { "title": "LDAP Login", "description": "Enter your LDAP credentials.", - "username": "Username", - "password": "Password", "submit": "Continue", + "labels": { + "username": "Username", + "password": "Password" + }, "required": { "username": "This field is required", "password": "This field is required" @@ -130,6 +150,9 @@ "noCodeReceived": "Didn't receive a code?", "resendCode": "Resend code", "submit": "Continue", + "labels": { + "code": "Code" + }, "required": { "code": "This field is required" } @@ -141,6 +164,9 @@ "emailDescription": "Enter your email address to receive a code via email.", "totpRegisterDescription": "Scan the QR Code or navigate to the URL manually.", "submit": "Continue", + "labels": { + "code": "Code" + }, "required": { "code": "This field is required" } @@ -201,11 +227,20 @@ "title": "Set Password", "description": "Set the password for your account", "submit": "Continue", + "labels": { + "password": "Password", + "confirmPassword": "Confirm Password" + }, "required": { "password": "You have to provide a password!", "confirmPassword": "This field is required" } }, + "labels": { + "firstname": "First name", + "lastname": "Last name", + "email": "E-mail" + }, "required": { "firstname": "This field is required", "lastname": "This field is required", @@ -247,6 +282,9 @@ "resendCode": "Resend code", "codeSent": "A code has just been sent to your email address.", "submit": "Continue", + "labels": { + "code": "Code" + }, "required": { "code": "This field is required" } @@ -264,6 +302,9 @@ "title": "Device code", "description": "Enter the code displayed on your app or device.", "submit": "Continue", + "labels": { + "code": "Code" + }, "required": { "code": "This field is required" } diff --git a/locales/es.json b/locales/es.json index 34e9bf700..1d44cb53c 100644 --- a/locales/es.json +++ b/locales/es.json @@ -27,6 +27,12 @@ "description": "Introduce tus datos de acceso.", "register": "Registrar nuevo usuario", "submit": "Continuar", + "labels": { + "loginname": "Nombre de inicio de sesión", + "username": "Nombre de usuario", + "usernameOrPhoneNumber": "Nombre de usuario o número de teléfono", + "usernameOrEmail": "Nombre de usuario o correo electrónico" + }, "required": { "loginName": "Este campo es obligatorio" } @@ -37,6 +43,9 @@ "description": "Introduce tu contraseña.", "resetPassword": "Restablecer contraseña", "submit": "Continuar", + "labels": { + "password": "Contraseña" + }, "required": { "password": "Este campo es obligatorio" } @@ -48,6 +57,11 @@ "noCodeReceived": "¿No recibiste un código?", "resend": "Reenviar código", "submit": "Continuar", + "labels": { + "code": "Código", + "newPassword": "Nueva contraseña", + "confirmPassword": "Confirmar contraseña" + }, "required": { "code": "Este campo es obligatorio", "newPassword": "¡Debes proporcionar una contraseña!", @@ -58,6 +72,10 @@ "title": "Cambiar Contraseña", "description": "Establece la contraseña para tu cuenta", "submit": "Continuar", + "labels": { + "newPassword": "Nueva contraseña", + "confirmPassword": "Confirmar contraseña" + }, "required": { "newPassword": "¡Debes proporcionar una nueva contraseña!", "confirmPassword": "Este campo es obligatorio" @@ -101,9 +119,11 @@ "ldap": { "title": "Iniciar sesión con LDAP", "description": "Introduce tus credenciales LDAP.", - "username": "Nombre de usuario", - "password": "Contraseña", "submit": "Continuar", + "labels": { + "username": "Nombre de usuario", + "password": "Contraseña" + }, "required": { "username": "Este campo es obligatorio", "password": "Este campo es obligatorio" @@ -130,6 +150,9 @@ "noCodeReceived": "¿No recibiste un código?", "resendCode": "Reenviar código", "submit": "Continuar", + "labels": { + "code": "Código" + }, "required": { "code": "Este campo es obligatorio" } @@ -141,6 +164,9 @@ "emailDescription": "Introduce tu dirección de correo electrónico para recibir un código por correo electrónico.", "totpRegisterDescription": "Escanea el código QR o navega manualmente a la URL.", "submit": "Continuar", + "labels": { + "code": "Código" + }, "required": { "code": "Este campo es obligatorio" } @@ -201,11 +227,20 @@ "title": "Establecer Contraseña", "description": "Establece la contraseña para tu cuenta", "submit": "Continuar", + "labels": { + "password": "Contraseña", + "confirmPassword": "Confirmar contraseña" + }, "required": { "password": "¡Debes proporcionar una contraseña!", "confirmPassword": "Este campo es obligatorio" } }, + "labels": { + "firstname": "Nombre", + "lastname": "Apellidos", + "email": "Correo electrónico" + }, "required": { "firstname": "Este campo es obligatorio", "lastname": "Este campo es obligatorio", @@ -247,6 +282,9 @@ "resendCode": "Reenviar código", "codeSent": "Se ha enviado un código a tu dirección de correo electrónico.", "submit": "Continuar", + "labels": { + "code": "Código" + }, "required": { "code": "Este campo es obligatorio" } @@ -264,6 +302,9 @@ "title": "Código del dispositivo", "description": "Introduce el código.", "submit": "Continuar", + "labels": { + "code": "Código" + }, "required": { "code": "Este campo es obligatorio" } diff --git a/locales/it.json b/locales/it.json index 8e71997e0..40f59cd9e 100644 --- a/locales/it.json +++ b/locales/it.json @@ -27,6 +27,12 @@ "description": "Inserisci i tuoi dati di accesso.", "register": "Registrati come nuovo utente", "submit": "Continua", + "labels": { + "loginname": "Nome di accesso", + "username": "Nome utente", + "usernameOrPhoneNumber": "Nome utente o numero di telefono", + "usernameOrEmail": "Nome utente o e-mail" + }, "required": { "loginName": "Questo campo è obbligatorio" } @@ -37,6 +43,9 @@ "description": "Inserisci la tua password.", "resetPassword": "Reimposta Password", "submit": "Continua", + "labels": { + "password": "Password" + }, "required": { "password": "Questo campo è obbligatorio" } @@ -48,6 +57,11 @@ "noCodeReceived": "Non hai ricevuto un codice?", "resend": "Invia di nuovo", "submit": "Continua", + "labels": { + "code": "Codice", + "newPassword": "Nuova password", + "confirmPassword": "Conferma password" + }, "required": { "code": "Questo campo è obbligatorio", "newPassword": "Devi fornire una password!", @@ -58,6 +72,10 @@ "title": "Cambia Password", "description": "Imposta la password per il tuo account", "submit": "Continua", + "labels": { + "newPassword": "Nuova password", + "confirmPassword": "Conferma password" + }, "required": { "newPassword": "Devi fornire una nuova password!", "confirmPassword": "Questo campo è obbligatorio" @@ -101,9 +119,11 @@ "ldap": { "title": "Accedi con LDAP", "description": "Inserisci le tue credenziali LDAP.", - "username": "Nome utente", - "password": "Password", "submit": "Continua", + "labels": { + "username": "Nome utente", + "password": "Password" + }, "required": { "username": "Questo campo è obbligatorio", "password": "Questo campo è obbligatorio" @@ -130,6 +150,9 @@ "noCodeReceived": "Non hai ricevuto un codice?", "resendCode": "Invia di nuovo il codice", "submit": "Continua", + "labels": { + "code": "Codice" + }, "required": { "code": "Questo campo è obbligatorio" } @@ -141,6 +164,9 @@ "emailDescription": "Inserisci il tuo indirizzo email per ricevere un codice via email.", "totpRegisterDescription": "Scansiona il codice QR o naviga manualmente all'URL.", "submit": "Continua", + "labels": { + "code": "Codice" + }, "required": { "code": "Questo campo è obbligatorio" } @@ -201,11 +227,20 @@ "title": "Imposta Password", "description": "Imposta la password per il tuo account", "submit": "Continua", + "labels": { + "password": "Password", + "confirmPassword": "Conferma password" + }, "required": { "password": "Devi fornire una password!", "confirmPassword": "Questo campo è obbligatorio" } }, + "labels": { + "firstname": "Nome", + "lastname": "Cognome", + "email": "E-mail" + }, "required": { "firstname": "Questo campo è obbligatorio", "lastname": "Questo campo è obbligatorio", @@ -247,6 +282,9 @@ "resendCode": "Invia di nuovo il codice", "codeSent": "Un codice è stato appena inviato al tuo indirizzo email.", "submit": "Continua", + "labels": { + "code": "Codice" + }, "required": { "code": "Questo campo è obbligatorio" } @@ -264,6 +302,9 @@ "title": "Codice dispositivo", "description": "Inserisci il codice.", "submit": "Continua", + "labels": { + "code": "Codice" + }, "required": { "code": "Questo campo è obbligatorio" } diff --git a/locales/pl.json b/locales/pl.json index 8fce97170..5cb69ad20 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -27,6 +27,12 @@ "description": "Wprowadź dane logowania.", "register": "Zarejestruj nowego użytkownika", "submit": "Kontynuuj", + "labels": { + "loginname": "Login", + "username": "Nazwa użytkownika", + "usernameOrPhoneNumber": "Nazwa użytkownika lub numer telefonu", + "usernameOrEmail": "Nazwa użytkownika lub e-mail" + }, "required": { "loginName": "To pole jest wymagane" } @@ -37,6 +43,9 @@ "description": "Wprowadź swoje hasło.", "resetPassword": "Zresetuj hasło", "submit": "Kontynuuj", + "labels": { + "password": "Hasło" + }, "required": { "password": "To pole jest wymagane" } @@ -48,6 +57,11 @@ "noCodeReceived": "Nie otrzymałeś kodu?", "resend": "Wyślij kod ponownie", "submit": "Kontynuuj", + "labels": { + "code": "Kod", + "newPassword": "Nowe hasło", + "confirmPassword": "Potwierdź nowe hasło" + }, "required": { "code": "To pole jest wymagane", "newPassword": "Musisz podać hasło!", @@ -58,6 +72,10 @@ "title": "Zmień hasło", "description": "Ustaw nowe hasło dla swojego konta", "submit": "Kontynuuj", + "labels": { + "newPassword": "Nowe hasło", + "confirmPassword": "Potwierdź nowe hasło" + }, "required": { "newPassword": "Musisz podać nowe hasło!", "confirmPassword": "To pole jest wymagane" @@ -101,9 +119,11 @@ "ldap": { "title": "Zaloguj się przez LDAP", "description": "Wprowadź swoje dane logowania LDAP.", - "username": "Nazwa użytkownika", - "password": "Hasło", "submit": "Kontynuuj", + "labels": { + "username": "Nazwa użytkownika", + "password": "Hasło" + }, "required": { "username": "To pole jest wymagane", "password": "To pole jest wymagane" @@ -130,6 +150,9 @@ "noCodeReceived": "Nie otrzymałeś kodu?", "resendCode": "Wyślij kod ponownie", "submit": "Kontynuuj", + "labels": { + "code": "Kod" + }, "required": { "code": "To pole jest wymagane" } @@ -141,6 +164,9 @@ "emailDescription": "Wprowadź swój adres e-mail, aby otrzymać kod e-mailem.", "totpRegisterDescription": "Zeskanuj kod QR lub otwórz adres URL ręcznie.", "submit": "Kontynuuj", + "labels": { + "code": "Kod" + }, "required": { "code": "To pole jest wymagane" } @@ -201,11 +227,20 @@ "title": "Ustaw hasło", "description": "Ustaw hasło dla swojego konta", "submit": "Kontynuuj", + "labels": { + "password": "Hasło", + "confirmPassword": "Potwierdź hasło" + }, "required": { "password": "Musisz podać hasło!", "confirmPassword": "To pole jest wymagane" } }, + "labels": { + "firstname": "Imię", + "lastname": "Nazwisko", + "email": "E-mail" + }, "required": { "firstname": "To pole jest wymagane", "lastname": "To pole jest wymagane", @@ -247,6 +282,9 @@ "resendCode": "Wyślij kod ponownie", "codeSent": "Kod został właśnie wysłany na twój adres e-mail.", "submit": "Kontynuuj", + "labels": { + "code": "Kod" + }, "required": { "code": "To pole jest wymagane" } @@ -264,6 +302,9 @@ "title": "Kod urządzenia", "description": "Wprowadź kod.", "submit": "Kontynuuj", + "labels": { + "code": "Kod" + }, "required": { "code": "To pole jest wymagane" } diff --git a/locales/ru.json b/locales/ru.json index d5b8575f9..caf1366cf 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -27,6 +27,12 @@ "description": "Введите свои данные для входа.", "register": "Зарегистрировать нового пользователя", "submit": "Продолжить", + "labels": { + "loginname": "Логин", + "username": "Имя пользователя", + "usernameOrPhoneNumber": "Имя пользователя или номер телефона", + "usernameOrEmail": "Имя пользователя или электронная почта" + }, "required": { "loginName": "Это поле обязательно для заполнения" } @@ -37,6 +43,9 @@ "description": "Введите ваш пароль.", "resetPassword": "Сбросить пароль", "submit": "Продолжить", + "labels": { + "password": "Пароль" + }, "required": { "password": "Это поле обязательно для заполнения" } @@ -48,6 +57,11 @@ "noCodeReceived": "Не получили код?", "resend": "Отправить код повторно", "submit": "Продолжить", + "labels": { + "code": "Код", + "newPassword": "Новый пароль", + "confirmPassword": "Подтвердите пароль" + }, "required": { "code": "Это поле обязательно для заполнения", "newPassword": "Вы должны указать пароль!", @@ -58,6 +72,10 @@ "title": "Изменить пароль", "description": "Установите пароль для вашего аккаунта", "submit": "Продолжить", + "labels": { + "newPassword": "Новый пароль", + "confirmPassword": "Подтвердите пароль" + }, "required": { "newPassword": "Вы должны указать новый пароль!", "confirmPassword": "Это поле обязательно для заполнения" @@ -101,9 +119,11 @@ "ldap": { "title": "Войти через LDAP", "description": "Введите ваши учетные данные LDAP.", - "username": "Имя пользователя", - "password": "Пароль", "submit": "Продолжить", + "labels": { + "username": "Имя пользователя", + "password": "Пароль" + }, "required": { "username": "Это поле обязательно для заполнения", "password": "Это поле обязательно для заполнения" @@ -130,6 +150,9 @@ "noCodeReceived": "Не получили код?", "resendCode": "Отправить код повторно", "submit": "Продолжить", + "labels": { + "code": "Код" + }, "required": { "code": "Это поле обязательно для заполнения" } @@ -141,6 +164,9 @@ "emailDescription": "Введите email для получения кода.", "totpRegisterDescription": "Отсканируйте QR-код или перейдите по ссылке вручную.", "submit": "Продолжить", + "labels": { + "code": "Код" + }, "required": { "code": "Это поле обязательно для заполнения" } @@ -201,11 +227,20 @@ "title": "Установить пароль", "description": "Установите пароль для вашего аккаунта", "submit": "Продолжить", + "labels": { + "password": "Пароль", + "confirmPassword": "Подтвердите пароль" + }, "required": { "password": "Вы должны указать пароль!", "confirmPassword": "Это поле обязательно для заполнения" } }, + "labels": { + "firstname": "Имя", + "lastname": "Фамилия", + "email": "Электронная почта" + }, "required": { "firstname": "Это поле обязательно для заполнения", "lastname": "Это поле обязательно для заполнения", @@ -247,6 +282,9 @@ "resendCode": "Отправить код повторно", "codeSent": "Код отправлен на ваш email.", "submit": "Продолжить", + "labels": { + "code": "Код" + }, "required": { "code": "Это поле обязательно для заполнения" } @@ -264,6 +302,9 @@ "title": "Код устройства", "description": "Введите код.", "submit": "Продолжить", + "labels": { + "code": "Код" + }, "required": { "code": "Это поле обязательно для заполнения" } diff --git a/locales/zh.json b/locales/zh.json index 2f8f18324..5d87b5d8d 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -27,6 +27,12 @@ "description": "请输入您的登录信息。", "register": "注册新用户", "submit": "继续", + "labels": { + "loginname": "登录名", + "username": "用户名", + "usernameOrPhoneNumber": "用户名或电话号码", + "usernameOrEmail": "用户名或电子邮箱" + }, "required": { "loginName": "此字段为必填项" } @@ -37,6 +43,9 @@ "description": "请输入您的密码。", "resetPassword": "重置密码", "submit": "继续", + "labels": { + "password": "密码" + }, "required": { "password": "此字段为必填项" } @@ -48,6 +57,11 @@ "noCodeReceived": "没有收到验证码?", "resend": "重发验证码", "submit": "继续", + "labels": { + "code": "验证码", + "newPassword": "新密码", + "confirmPassword": "确认密码" + }, "required": { "code": "此字段为必填项", "newPassword": "必须提供密码!", @@ -58,6 +72,10 @@ "title": "更改密码", "description": "为您的账户设置密码", "submit": "继续", + "labels": { + "newPassword": "新密码", + "confirmPassword": "确认密码" + }, "required": { "newPassword": "必须提供新密码!", "confirmPassword": "此字段为必填项" @@ -101,9 +119,11 @@ "ldap": { "title": "使用 LDAP 登录", "description": "请输入您的 LDAP 凭据。", - "username": "用户名", - "password": "密码", "submit": "继续", + "labels": { + "username": "用户名", + "password": "密码" + }, "required": { "username": "此字段为必填项", "password": "此字段为必填项" @@ -130,6 +150,9 @@ "noCodeReceived": "没有收到验证码?", "resendCode": "重发验证码", "submit": "继续", + "labels": { + "code": "验证码" + }, "required": { "code": "此字段为必填项" } @@ -141,6 +164,9 @@ "emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。", "totpRegisterDescription": "扫描二维码或手动导航到URL。", "submit": "继续", + "labels": { + "code": "验证码" + }, "required": { "code": "此字段为必填项" } @@ -201,11 +227,20 @@ "title": "设置密码", "description": "为您的账户设置密码", "submit": "继续", + "labels": { + "password": "密码", + "confirmPassword": "确认密码" + }, "required": { "password": "必须提供密码!", "confirmPassword": "此字段为必填项" } }, + "labels": { + "firstname": "名字", + "lastname": "姓氏", + "email": "电子邮箱" + }, "required": { "firstname": "此字段为必填项", "lastname": "此字段为必填项", @@ -247,6 +282,9 @@ "resendCode": "重发验证码", "codeSent": "刚刚发送了一封包含验证码的电子邮件。", "submit": "继续", + "labels": { + "code": "验证码" + }, "required": { "code": "此字段为必填项" } @@ -264,6 +302,9 @@ "title": "设备代码", "description": "输入代码。", "submit": "继续", + "labels": { + "code": "验证码" + }, "required": { "code": "此字段为必填项" } diff --git a/src/components/change-password-form.tsx b/src/components/change-password-form.tsx index 6124812e2..394d0a663 100644 --- a/src/components/change-password-form.tsx +++ b/src/components/change-password-form.tsx @@ -161,7 +161,7 @@ export function ChangePasswordForm({ {...register("password", { required: t("change.required.newPassword"), })} - label="New Password" + label={t("change.labels.newPassword")} error={errors.password?.message as string} data-testid="password-change-text-input" /> @@ -174,7 +174,7 @@ export function ChangePasswordForm({ {...register("confirmPassword", { required: t("change.required.confirmPassword"), })} - label="Confirm Password" + label={t("change.labels.confirmPassword")} error={errors.confirmPassword?.message as string} data-testid="password-change-confirm-text-input" /> diff --git a/src/components/device-code-form.tsx b/src/components/device-code-form.tsx index 9c5a28ca4..108c1a352 100644 --- a/src/components/device-code-form.tsx +++ b/src/components/device-code-form.tsx @@ -66,7 +66,7 @@ export function DeviceCodeForm({ userCode }: { userCode?: string }) { type="text" autoComplete="one-time-code" {...register("userCode", { required: t("usercode.required.code") })} - label="Code" + label={t("usercode.labels.code")} data-testid="code-text-input" />
@@ -89,7 +89,7 @@ export function DeviceCodeForm({ userCode }: { userCode?: string }) { data-testid="submit-button" > {loading && }{" "} - +
diff --git a/src/components/ldap-username-password-form.tsx b/src/components/ldap-username-password-form.tsx index cc9071875..476d7b13e 100644 --- a/src/components/ldap-username-password-form.tsx +++ b/src/components/ldap-username-password-form.tsx @@ -69,7 +69,7 @@ export function LDAPUsernamePasswordForm({ idpId, link }: Props) { type="text" autoComplete="username" {...register("loginName", { required: t("required.username") })} - label={t("username")} + label={t("labels.username")} data-testid="username-text-input" /> @@ -78,7 +78,7 @@ export function LDAPUsernamePasswordForm({ idpId, link }: Props) { type="password" autoComplete="password" {...register("password", { required: t("required.password") })} - label={t("password")} + label={t("labels.password")} data-testid="password-text-input" /> diff --git a/src/components/login-otp.tsx b/src/components/login-otp.tsx index ffaa5320b..abd482979 100644 --- a/src/components/login-otp.tsx +++ b/src/components/login-otp.tsx @@ -253,7 +253,7 @@ export function LoginOTP({ diff --git a/src/components/password-form.tsx b/src/components/password-form.tsx index c8a10a990..1bf63e996 100644 --- a/src/components/password-form.tsx +++ b/src/components/password-form.tsx @@ -122,7 +122,7 @@ export function PasswordForm({ type="password" autoComplete="password" {...register("password", { required: t("verify.required.password") })} - label="Password" + label={t("verify.labels.password")} data-testid="password-text-input" /> {!loginSettings?.hidePasswordReset && ( diff --git a/src/components/register-form-idp-incomplete.tsx b/src/components/register-form-idp-incomplete.tsx index 10e766be7..9cefe8d3a 100644 --- a/src/components/register-form-idp-incomplete.tsx +++ b/src/components/register-form-idp-incomplete.tsx @@ -106,7 +106,7 @@ export function RegisterFormIDPIncomplete({ autoComplete="firstname" required {...register("firstname", { required: t("required.firstname") })} - label="First name" + label={t("labels.firstname")} error={errors.firstname?.message as string} data-testid="firstname-text-input" /> @@ -117,7 +117,7 @@ export function RegisterFormIDPIncomplete({ autoComplete="lastname" required {...register("lastname", { required: t("required.lastname") })} - label="Last name" + label={t("labels.lastname")} error={errors.lastname?.message as string} data-testid="lastname-text-input" /> @@ -128,7 +128,7 @@ export function RegisterFormIDPIncomplete({ autoComplete="email" required {...register("email", { required: t("required.email") })} - label="E-mail" + label={t("labels.email")} error={errors.email?.message as string} data-testid="email-text-input" /> diff --git a/src/components/register-form.tsx b/src/components/register-form.tsx index 7b4254b0b..bf44ad286 100644 --- a/src/components/register-form.tsx +++ b/src/components/register-form.tsx @@ -134,7 +134,7 @@ export function RegisterForm({ autoComplete="firstname" required {...register("firstname", { required: t("required.firstname") })} - label="First name" + label={t("labels.firstname")} error={errors.firstname?.message as string} data-testid="firstname-text-input" /> @@ -145,7 +145,7 @@ export function RegisterForm({ autoComplete="lastname" required {...register("lastname", { required: t("required.lastname") })} - label="Last name" + label={t("labels.lastname")} error={errors.lastname?.message as string} data-testid="lastname-text-input" /> @@ -156,7 +156,7 @@ export function RegisterForm({ autoComplete="email" required {...register("email", { required: t("required.email") })} - label="E-mail" + label={t("labels.email")} error={errors.email?.message as string} data-testid="email-text-input" /> diff --git a/src/components/set-password-form.tsx b/src/components/set-password-form.tsx index 83e884912..ab09c9c19 100644 --- a/src/components/set-password-form.tsx +++ b/src/components/set-password-form.tsx @@ -221,7 +221,7 @@ export function SetPasswordForm({ {...register("code", { required: t("set.required.code"), })} - label="Code" + label={t("set.labels.code")} autoComplete="one-time-code" error={errors.code?.message as string} data-testid="code-text-input" @@ -236,7 +236,7 @@ export function SetPasswordForm({ {...register("password", { required: t("set.required.newPassword"), })} - label="New Password" + label={t("set.labels.newPassword")} error={errors.password?.message as string} data-testid="password-set-text-input" /> @@ -249,7 +249,7 @@ export function SetPasswordForm({ {...register("confirmPassword", { required: t("set.required.confirmPassword"), })} - label="Confirm Password" + label={t("set.labels.confirmPassword")} error={errors.confirmPassword?.message as string} data-testid="password-set-confirm-text-input" /> diff --git a/src/components/set-register-password-form.tsx b/src/components/set-register-password-form.tsx index dc61c6731..8d17f1dfc 100644 --- a/src/components/set-register-password-form.tsx +++ b/src/components/set-register-password-form.tsx @@ -120,7 +120,7 @@ export function SetRegisterPasswordForm({ {...register("password", { required: t("password.required.password"), })} - label="Password" + label={t("password.labels.password")} error={errors.password?.message as string} data-testid="password-text-input" /> @@ -133,7 +133,7 @@ export function SetRegisterPasswordForm({ {...register("confirmPassword", { required: t("password.required.confirmPassword"), })} - label="Confirm Password" + label={t("password.labels.confirmPassword")} error={errors.confirmPassword?.message as string} data-testid="password-confirm-text-input" /> diff --git a/src/components/totp-register.tsx b/src/components/totp-register.tsx index ec9f630f9..eaef41db1 100644 --- a/src/components/totp-register.tsx +++ b/src/components/totp-register.tsx @@ -126,7 +126,7 @@ export function TotpRegister({ diff --git a/src/components/username-form.tsx b/src/components/username-form.tsx index 38eeffff9..f5a8a721d 100644 --- a/src/components/username-form.tsx +++ b/src/components/username-form.tsx @@ -86,16 +86,16 @@ export function UsernameForm({ } }, []); - let inputLabel = "Loginname"; + let inputLabel = t("labels.loginname"); if ( loginSettings?.disableLoginWithEmail && loginSettings?.disableLoginWithPhone ) { - inputLabel = "Username"; + inputLabel = t("labels.username"); } else if (loginSettings?.disableLoginWithEmail) { - inputLabel = "Username or phone number"; + inputLabel = t("labels.usernameOrPhoneNumber"); } else if (loginSettings?.disableLoginWithPhone) { - inputLabel = "Username or email"; + inputLabel = t("labels.usernameOrEmail"); } return ( diff --git a/src/components/verify-form.tsx b/src/components/verify-form.tsx index ada750119..575f401e3 100644 --- a/src/components/verify-form.tsx +++ b/src/components/verify-form.tsx @@ -139,7 +139,7 @@ export function VerifyForm({ type="text" autoComplete="one-time-code" {...register("code", { required: t("verify.required.code") })} - label="Code" + label={t("verify.labels.code")} data-testid="code-text-input" /> From 31aad766ad883d669a37a1e3c03efdd01f980be7 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 1 Sep 2025 16:16:18 +0200 Subject: [PATCH 10/21] fix(login): integration tests failing due to React 19 SSR errors (#10613) # Which Problems Are Solved Integration tests were failing with Minified React error 419 caused by React 19 Suspense boundary issues during server-side rendering (SSR) to client-side rendering (CSR) transitions. # How the Problems Are Solved The fix handles infrastructure-level SSR errors gracefully while maintaining proper error detection for actual application issues. - Added Cypress error handling for React 19 SSR hydration errors that don't affect functionality # Additional Changes Enhanced Next.js configuration with React 19 compatibility optimizations: - `optimizePackageImports`: @radix-ui/react-tooltip and @heroicons/react can have large bundle sizes if not optimized. Such packages are suggested to be optimized in https://nextjs.org/docs/app/api-reference/config/next-config-js/optimizePackageImports - `poweredByHeader`: Not that important. Benefits are smaller HTTP headers, Tiny bandwidth savings, and more professional appearance due to cleaner response headers, added it as a "security best practice". # Additional Context - Replaces #10611 --- integration/integration/invite.cy.ts | 2 +- integration/support/e2e.ts | 20 ++++++++++++++++++++ next.config.mjs | 11 ++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/integration/integration/invite.cy.ts b/integration/integration/invite.cy.ts index 4a370be54..344950db4 100644 --- a/integration/integration/invite.cy.ts +++ b/integration/integration/invite.cy.ts @@ -89,7 +89,7 @@ describe("verify invite", () => { }); }); - it.only("shows authenticators after successful invite verification", () => { + it("shows authenticators after successful invite verification", () => { stub("zitadel.user.v2.UserService", "VerifyInviteCode"); cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); diff --git a/integration/support/e2e.ts b/integration/support/e2e.ts index 58056c973..40b93bea8 100644 --- a/integration/support/e2e.ts +++ b/integration/support/e2e.ts @@ -1,3 +1,23 @@ +// Handle React 19 SSR hydration errors that don't affect functionality +Cypress.on('uncaught:exception', (err, runnable) => { + // React error #419 is specifically about SSR Suspense boundary issues + // This doesn't affect the actual functionality, just the SSR/CSR transition + if (err.message.includes('Minified React error #419')) { + console.warn('Cypress: Suppressed React SSR error #419 (Suspense boundary issue):', err.message); + return false; + } + // Other hydration mismatches that are common with React 19 + Next.js 15 + if (err.message.includes('server could not finish this Suspense boundary') || + err.message.includes('Switched to client rendering') || + err.message.includes('Hydration failed') || + err.message.includes('Text content does not match server-rendered HTML')) { + console.warn('Cypress: Suppressed React hydration error (non-functional):', err.message); + return false; + } + // Let other errors fail the test as they should + return true; +}); + const url = Cypress.env("CORE_MOCK_STUBS_URL") || "http://localhost:22220/v1/stubs"; function removeStub(service: string, method: string) { diff --git a/next.config.mjs b/next.config.mjs index 3ac2e59a1..e4f2de05e 100755 --- a/next.config.mjs +++ b/next.config.mjs @@ -36,9 +36,11 @@ const secureHeaders = [ const nextConfig = { basePath: process.env.NEXT_PUBLIC_BASE_PATH, output: process.env.NEXT_OUTPUT_MODE || undefined, - reactStrictMode: true, // Recommended for the `pages` directory, default in `app`. + reactStrictMode: true, experimental: { dynamicIO: true, + // Add React 19 compatibility optimizations + optimizePackageImports: ['@radix-ui/react-tooltip', '@heroicons/react'], }, images: { unoptimized: true @@ -46,6 +48,13 @@ const nextConfig = { eslint: { ignoreDuringBuilds: true, }, + // Improve SSR stability - not actually needed for React 19 SSR issues + // onDemandEntries: { + // maxInactiveAge: 25 * 1000, + // pagesBufferLength: 2, + // }, + // Better error handling for production builds + poweredByHeader: false, async headers() { return [ { From 777552941cfd38c6c081397b09e32a37ff76c34f Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 9 Sep 2025 09:37:32 +0200 Subject: [PATCH 11/21] fix: Registration Form Legal Checkbox Logic (#10597) Closes #10498 The registration form's legal checkboxes had incorrect validation logic that prevented users from completing registration when only one legal document (ToS or Privacy Policy) was configured, or when no legal documents were required. additionally removes a duplicate description for "or use Identity Provider" # Which Problems Are Solved Having only partial legal documents was blocking users to register. The logic now conditionally renders checkboxes and checks if all provided documents are accepted. # How the Problems Are Solved - Fixed checkbox validation: Now properly validates based on which legal documents are actually available - acceptance logic: Only requires acceptance of checkboxes that are shown - No legal docs support: Users can proceed when no legal documents are configured - Proper state management: Fixed checkbox state tracking and mixed-up test IDs --------- Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> --- locales/de.json | 1 - locales/en.json | 1 - locales/es.json | 1 - locales/it.json | 1 - locales/pl.json | 1 - locales/ru.json | 1 - locales/zh.json | 1 - src/app/(login)/register/page.tsx | 19 ++---- src/components/privacy-policy-checkboxes.tsx | 44 ++++++++----- src/components/register-form.tsx | 69 +++++++------------- 10 files changed, 56 insertions(+), 83 deletions(-) diff --git a/locales/de.json b/locales/de.json index 05183374b..55a635512 100644 --- a/locales/de.json +++ b/locales/de.json @@ -222,7 +222,6 @@ "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/locales/en.json b/locales/en.json index 5c47e8e64..8d8c11ec3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -222,7 +222,6 @@ "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/locales/es.json b/locales/es.json index 1d44cb53c..82f1e9335 100644 --- a/locales/es.json +++ b/locales/es.json @@ -222,7 +222,6 @@ "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/locales/it.json b/locales/it.json index 40f59cd9e..815efb5a3 100644 --- a/locales/it.json +++ b/locales/it.json @@ -222,7 +222,6 @@ "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/locales/pl.json b/locales/pl.json index 5cb69ad20..ae81ba7f3 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -222,7 +222,6 @@ "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/locales/ru.json b/locales/ru.json index caf1366cf..f5e91066d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -222,7 +222,6 @@ "termsOfService": "Условия использования", "privacyPolicy": "Политика конфиденциальности", "submit": "Продолжить", - "orUseIDP": "или используйте Identity Provider", "password": { "title": "Установить пароль", "description": "Установите пароль для вашего аккаунта", diff --git a/locales/zh.json b/locales/zh.json index 5d87b5d8d..3ec2ec31e 100644 --- a/locales/zh.json +++ b/locales/zh.json @@ -222,7 +222,6 @@ "termsOfService": "服务条款", "privacyPolicy": "隐私政策", "submit": "继续", - "orUseIDP": "或使用身份提供者", "password": { "title": "设置密码", "description": "为您的账户设置密码", diff --git a/src/app/(login)/register/page.tsx b/src/app/(login)/register/page.tsx index 5a11ca1f8..2168813c4 100644 --- a/src/app/(login)/register/page.tsx +++ b/src/app/(login)/register/page.tsx @@ -20,12 +20,10 @@ import { headers } from "next/headers"; export async function generateMetadata(): Promise { const t = await getTranslations("register"); - return { title: t('title')}; + return { title: t("title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; let { firstname, lastname, email, organization, requestId } = searchParams; @@ -104,12 +102,9 @@ export default async function Page(props: { {legal && passwordComplexitySettings && organization && - (loginSettings.allowUsernamePassword || - loginSettings.passkeysType == PasskeysType.ALLOWED) && ( + (loginSettings.allowUsernamePassword || loginSettings.passkeysType == PasskeysType.ALLOWED) && ( -
-

- -

-
- { + const hasTosLink = !!legal?.tosLink; + const hasPrivacyLink = !!legal?.privacyPolicyLink; + + // Check that all required checkboxes are accepted + return ( + (!hasTosLink || newState.tosAccepted) && + (!hasPrivacyLink || newState.privacyPolicyAccepted) + ); + }; + return ( <>

@@ -50,16 +62,17 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {

{ - setAcceptanceState({ + const newState = { ...acceptanceState, tosAccepted: checked, - }); - onChange(checked && acceptanceState.privacyPolicyAccepted); + }; + setAcceptanceState(newState); + onChange(checkAllAccepted(newState)); }} - data-testid="privacy-policy-checkbox" + data-testid="tos-checkbox" />
@@ -75,25 +88,22 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
{ - setAcceptanceState({ + const newState = { ...acceptanceState, privacyPolicyAccepted: checked, - }); - onChange(checked && acceptanceState.tosAccepted); + }; + setAcceptanceState(newState); + onChange(checkAllAccepted(newState)); }} - data-testid="tos-checkbox" + data-testid="privacy-policy-checkbox" />

- +

diff --git a/src/components/register-form.tsx b/src/components/register-form.tsx index bf44ad286..a8f8b0962 100644 --- a/src/components/register-form.tsx +++ b/src/components/register-form.tsx @@ -2,20 +2,13 @@ import { registerUser } from "@/lib/server/register"; import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb"; -import { - LoginSettings, - PasskeysType, -} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { LoginSettings, PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useTranslations } from "next-intl"; import { FieldValues, useForm } from "react-hook-form"; import { Alert, AlertType } from "./alert"; -import { - AuthenticationMethod, - AuthenticationMethodRadio, - methods, -} from "./authentication-method-radio"; +import { AuthenticationMethod, AuthenticationMethodRadio, methods } from "./authentication-method-radio"; import { BackButton } from "./back-button"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; @@ -98,10 +91,7 @@ export function RegisterForm({ return response; } - async function submitAndContinue( - value: Inputs, - withPassword: boolean = false, - ) { + async function submitAndContinue(value: Inputs, withPassword: boolean = false) { const registerParams: any = value; if (organization) { @@ -114,9 +104,7 @@ export function RegisterForm({ // redirect user to /register/password if password is chosen if (withPassword) { - return router.push( - `/register/password?` + new URLSearchParams(registerParams), - ); + return router.push(`/register/password?` + new URLSearchParams(registerParams)); } else { return submitAndRegister(value); } @@ -125,6 +113,11 @@ export function RegisterForm({ const { errors } = formState; const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false); + + // Check if legal acceptance is required + const isLegalAcceptanceRequired = !!(legal?.tosLink || legal?.privacyPolicyLink); + const canSubmit = formState.isValid && (!isLegalAcceptanceRequired || tosAndPolicyAccepted); + return (
@@ -162,38 +155,27 @@ export function RegisterForm({ />
- {legal && ( - + {(legal?.tosLink || legal?.privacyPolicyLink) && ( + )} {/* show chooser if both methods are allowed */} - {loginSettings && - loginSettings.allowUsernamePassword && - loginSettings.passkeysType == PasskeysType.ALLOWED && ( - <> -

- -

- -
- -
- - )} + {loginSettings && loginSettings.allowUsernamePassword && loginSettings.passkeysType == PasskeysType.ALLOWED && ( + <> +

+ +

+ +
+ +
+ + )} {!loginSettings?.allowUsernamePassword && loginSettings?.passkeysType !== PasskeysType.ALLOWED && (!loginSettings?.allowExternalIdp || !idpCount) && (
- +
)} @@ -209,11 +191,10 @@ export function RegisterForm({
); diff --git a/src/components/logo.tsx b/src/components/logo.tsx index 09819f2ac..b56d7a0a6 100644 --- a/src/components/logo.tsx +++ b/src/components/logo.tsx @@ -1,5 +1,3 @@ -import Image from "next/image"; - type Props = { darkSrc?: string; lightSrc?: string; @@ -12,24 +10,12 @@ export function Logo({ lightSrc, darkSrc, height = 40, width = 147.5 }: Props) { <> {darkSrc && (
- logo + logo
)} {lightSrc && (
- logo + logo
)} diff --git a/src/components/zitadel-logo.tsx b/src/components/zitadel-logo.tsx index 105665fbb..472e87962 100644 --- a/src/components/zitadel-logo.tsx +++ b/src/components/zitadel-logo.tsx @@ -1,4 +1,3 @@ -import Image from "next/image"; type Props = { height?: number; width?: number; @@ -10,22 +9,10 @@ export function ZitadelLogo({ height = 40, width = 147.5 }: Props) {
{/* */} - zitadel logo + zitadel logo
- zitadel logo + zitadel logo
); From b71b25dd7d88c1cb3f64fd081b4e29db377c5b94 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 22 Sep 2025 18:09:20 +0200 Subject: [PATCH 16/21] chore(login): Extract auth flow utilities and eliminate RSC request interference (#10644) The /login route was experiencing issues with React Server Component (RSC) requests interfering with one-time authentication callbacks. When users navigated to /login via client-side routing (router.push()), Next.js automatically triggered _rsc requests that could consume single-use createCallback tokens, breaking OIDC and SAML authentication flows. # Which Problems Are Solved When users attempt to log in, Next.js automatically makes requests with the `_rsc=1` query parameter for React Server Components. The current implementation treats these as server errors: ```typescript // Before if (_rsc) { return NextResponse.json({ error: "No _rsc supported" }, { status: 500 }); } ``` This results in: - Spurious 500 error logs polluting monitoring systems - False alerts for server failures - Difficulty distinguishing real issues from benign RSC requests # How the Problems Are Solved This PR implements a comprehensive refactoring that: - Eliminates RSC interference by providing server actions for internal auth flow completion - Separates concerns between external flow initiation and internal flow completion - Extracts shared utilities to improve code maintainability and reusability - Maintains full backward compatibility for external applications # Additional Context ## New Architecture - auth-flow.ts: Shared utilities for auth flow completion with RSC protection - flow-initiation.ts: Extracted OIDC/SAML flow initiation logic (~400 lines) - auth.ts: Server actions for internal components ## Route Handler Simplification - route.ts: Reduced from ~350 lines to ~75 lines - External-only focus: Now handles only flow initiation for external applications - Removed completion logic: External apps use their own callback URLs - Enhanced validation: Early RSC blocking and parameter validation ## Flow Logic Improvements - Early return patterns: Guard clauses eliminate deep nesting - Better error handling: Specific error messages for different failure modes - Fixed SAML flow: Addressed incomplete logic - Consistent session handling: Unified approach across OIDC and SAML --- src/app/(login)/password/change/page.tsx | 2 +- src/app/login/route.ts | 571 ++---------------- .../choose-second-factor-to-setup.tsx | 50 +- src/components/login-otp.tsx | 56 +- src/components/password-form.tsx | 24 +- .../register-form-idp-incomplete.tsx | 12 +- src/components/register-u2f.tsx | 100 ++- src/components/session-item.tsx | 6 +- src/components/totp-register.tsx | 74 +-- src/lib/auth-utils.ts | 23 + src/lib/client.ts | 73 ++- src/lib/oidc.ts | 62 +- src/lib/saml.ts | 70 +-- src/lib/server/auth-flow.ts | 83 +++ src/lib/server/flow-initiation.ts | 450 ++++++++++++++ src/lib/server/idp.ts | 73 ++- src/lib/server/passkeys.ts | 75 +-- src/lib/server/password.ts | 109 ++-- src/lib/server/register.ts | 40 +- src/lib/server/session.ts | 82 ++- src/lib/server/verify.ts | 43 +- src/lib/session.test.ts | 306 +++++++++- src/lib/session.ts | 100 +-- 23 files changed, 1336 insertions(+), 1148 deletions(-) create mode 100644 src/lib/auth-utils.ts create mode 100644 src/lib/server/auth-flow.ts create mode 100644 src/lib/server/flow-initiation.ts diff --git a/src/app/(login)/password/change/page.tsx b/src/app/(login)/password/change/page.tsx index fe920cda8..690c1dcb3 100644 --- a/src/app/(login)/password/change/page.tsx +++ b/src/app/(login)/password/change/page.tsx @@ -62,7 +62,7 @@ export default async function Page(props: { )}

- +

{/* show error only if usernames should be shown to be unknown */} diff --git a/src/app/login/route.ts b/src/app/login/route.ts index 7b57e1a5e..2c1d94746 100644 --- a/src/app/login/route.ts +++ b/src/app/login/route.ts @@ -1,66 +1,24 @@ import { getAllSessions } from "@/lib/cookies"; -import { idpTypeToSlug } from "@/lib/idp"; -import { loginWithOIDCAndSession } from "@/lib/oidc"; -import { loginWithSAMLAndSession } from "@/lib/saml"; -import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; -import { constructUrl, getServiceUrlFromHeaders } from "@/lib/service-url"; -import { findValidSession } from "@/lib/session"; -import { - createCallback, - createResponse, - getActiveIdentityProviders, - getAuthRequest, - getOrgsByDomain, - getSAMLRequest, - getSecuritySettings, - listSessions, - startIdentityProviderFlow, -} from "@/lib/zitadel"; -import { create } from "@zitadel/client"; -import { Prompt } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; -import { - CreateCallbackRequestSchema, - SessionSchema, -} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; -import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + validateAuthRequest, + isRSCRequest +} from "@/lib/auth-utils"; +import { + handleOIDCFlowInitiation, + handleSAMLFlowInitiation, + FlowInitiationParams +} from "@/lib/server/flow-initiation"; +import { listSessions } from "@/lib/zitadel"; 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"; export const dynamic = "force-dynamic"; export const revalidate = false; export const fetchCache = "default-no-store"; -const gotoAccounts = ({ - request, - requestId, - organization, -}: { - request: NextRequest; - requestId: string; - organization?: string; -}): NextResponse => { - const accountsUrl = constructUrl(request, "/accounts"); - - if (requestId) { - accountsUrl.searchParams.set("requestId", requestId); - } - if (organization) { - accountsUrl.searchParams.set("organization", organization); - } - - return NextResponse.redirect(accountsUrl); -}; - -async function loadSessions({ - serviceUrl, - ids, -}: { - serviceUrl: string; - ids: string[]; -}): Promise { +async function loadSessions({ serviceUrl, ids }: { serviceUrl: string; ids: string[] }): Promise { const response = await listSessions({ serviceUrl, ids: ids.filter((id: string | undefined) => !!id), @@ -69,34 +27,24 @@ async function loadSessions({ return response?.sessions ?? []; } -const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/; -const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/; // TODO: check regex for all domain character options -const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/; - export async function GET(request: NextRequest) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); const searchParams = request.nextUrl.searchParams; - const oidcRequestId = searchParams.get("authRequest"); // oidc initiated request - const samlRequestId = searchParams.get("samlRequest"); // saml initiated request - - // internal request id which combines authRequest and samlRequest with the prefix oidc_ or saml_ - let requestId = - searchParams.get("requestId") ?? - (oidcRequestId - ? `oidc_${oidcRequestId}` - : samlRequestId - ? `saml_${samlRequestId}` - : undefined); - - const sessionId = searchParams.get("sessionId"); + // Defensive check: block RSC requests early + if (isRSCRequest(searchParams)) { + return NextResponse.json({ error: "RSC requests not supported" }, { status: 400 }); + } - // TODO: find a better way to handle _rsc (react server components) requests and block them to avoid conflicts when creating oidc callback - const _rsc = searchParams.get("_rsc"); - if (_rsc) { - return NextResponse.json({ error: "No _rsc supported" }, { status: 500 }); + // Early validation: if no valid request parameters, return error immediately + const requestId = validateAuthRequest(searchParams); + if (!requestId) { + return NextResponse.json( + { error: "No valid authentication request found" }, + { status: 400 }, + ); } const sessionCookies = await getAllSessions(); @@ -106,460 +54,29 @@ export async function GET(request: NextRequest) { sessions = await loadSessions({ serviceUrl, ids }); } - // complete flow if session and request id are provided - if (requestId && sessionId) { - if (requestId.startsWith("oidc_")) { - // this finishes the login process for OIDC - return loginWithOIDCAndSession({ - serviceUrl, - authRequest: requestId.replace("oidc_", ""), - sessionId, - sessions, - sessionCookies, - request, - }); - } else if (requestId.startsWith("saml_")) { - // this finishes the login process for SAML - return loginWithSAMLAndSession({ - serviceUrl, - samlRequest: requestId.replace("saml_", ""), - sessionId, - sessions, - sessionCookies, - request, - }); - } - } - - // continue with OIDC - if (requestId && requestId.startsWith("oidc_")) { - const { authRequest } = await getAuthRequest({ - serviceUrl, - authRequestId: requestId.replace("oidc_", ""), - }); - - let organization = ""; - let suffix = ""; - let idpId = ""; - - if (authRequest?.scope) { - const orgScope = authRequest.scope.find((s: string) => - ORG_SCOPE_REGEX.test(s), - ); - - const idpScope = authRequest.scope.find((s: string) => - IDP_SCOPE_REGEX.test(s), - ); - - if (orgScope) { - const matched = ORG_SCOPE_REGEX.exec(orgScope); - organization = matched?.[1] ?? ""; - } else { - const orgDomainScope = authRequest.scope.find((s: string) => - ORG_DOMAIN_SCOPE_REGEX.test(s), - ); - - if (orgDomainScope) { - const matched = ORG_DOMAIN_SCOPE_REGEX.exec(orgDomainScope); - const orgDomain = matched?.[1] ?? ""; - if (orgDomain) { - const orgs = await getOrgsByDomain({ - serviceUrl, - domain: orgDomain, - }); - if (orgs.result && orgs.result.length === 1) { - organization = orgs.result[0].id ?? ""; - suffix = orgDomain; - } - } - } - } - - if (idpScope) { - const matched = IDP_SCOPE_REGEX.exec(idpScope); - idpId = matched?.[1] ?? ""; - - const identityProviders = await getActiveIdentityProviders({ - serviceUrl, - orgId: organization ? organization : undefined, - }).then((resp) => { - return resp.identityProviders; - }); - - const idp = identityProviders.find((idp) => idp.id === idpId); - - if (idp) { - 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(); - - if (requestId) { - params.set("requestId", requestId); - } - - if (organization) { - params.set("organization", organization); - } - - let url: string | null = await startIdentityProviderFlow({ - serviceUrl, - idpId, - urls: { - successUrl: - `${origin}/idp/${provider}/success?` + - new URLSearchParams(params), - failureUrl: - `${origin}/idp/${provider}/failure?` + - new URLSearchParams(params), - }, - }); - - if (!url) { - return NextResponse.json( - { error: "Could not start IDP flow" }, - { status: 500 }, - ); - } - - if (url.startsWith("/")) { - // if the url is a relative path, construct the absolute url - url = constructUrl(request, url).toString(); - } - - return NextResponse.redirect(url); - } - } - } - - if (authRequest && authRequest.prompt.includes(Prompt.CREATE)) { - const registerUrl = constructUrl(request, "/register"); - if (authRequest.id) { - registerUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); - } - if (organization) { - registerUrl.searchParams.set("organization", organization); - } - - return NextResponse.redirect(registerUrl); - } - - // use existing session and hydrate it for oidc - if (authRequest && sessions.length) { - // if some accounts are available for selection and select_account is set - if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) { - return gotoAccounts({ - request, - requestId: `oidc_${authRequest.id}`, - organization, - }); - } else if (authRequest.prompt.includes(Prompt.LOGIN)) { - /** - * The login prompt instructs the authentication server to prompt the user for re-authentication, regardless of whether the user is already authenticated - */ - - // if a hint is provided, skip loginname page and jump to the next page - if (authRequest.loginHint) { - try { - let command: SendLoginnameCommand = { - loginName: authRequest.loginHint, - requestId: authRequest.id, - }; - - if (organization) { - command = { ...command, organization }; - } - - const res = await sendLoginname(command); - - if (res && "redirect" in res && res?.redirect) { - const absoluteUrl = constructUrl(request, res.redirect); - return NextResponse.redirect(absoluteUrl.toString()); - } - } catch (error) { - console.error("Failed to execute sendLoginname:", error); - } - } - - const loginNameUrl = constructUrl(request, "/loginname"); - if (authRequest.id) { - loginNameUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); - } - if (authRequest.loginHint) { - loginNameUrl.searchParams.set("loginName", authRequest.loginHint); - } - if (organization) { - loginNameUrl.searchParams.set("organization", organization); - } - if (suffix) { - loginNameUrl.searchParams.set("suffix", suffix); - } - return NextResponse.redirect(loginNameUrl); - } else if (authRequest.prompt.includes(Prompt.NONE)) { - /** - * With an OIDC none prompt, the authentication server must not display any authentication or consent user interface pages. - * This means that the user should not be prompted to enter their password again. - * Instead, the server attempts to silently authenticate the user using an existing session or other authentication mechanisms that do not require user interaction - **/ - const securitySettings = await getSecuritySettings({ - serviceUrl, - }); - - const selectedSession = await findValidSession({ - serviceUrl, - sessions, - authRequest, - }); - - const noSessionResponse = NextResponse.json( - { error: "No active session found" }, - { status: 400 }, - ); - - if (securitySettings?.embeddedIframe?.enabled) { - securitySettings.embeddedIframe.allowedOrigins; - noSessionResponse.headers.set( - "Content-Security-Policy", - `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, - ); - noSessionResponse.headers.delete("X-Frame-Options"); - } - - if (!selectedSession || !selectedSession.id) { - return noSessionResponse; - } - - const cookie = sessionCookies.find( - (cookie) => cookie.id === selectedSession.id, - ); - - if (!cookie || !cookie.id || !cookie.token) { - return noSessionResponse; - } - - const session = { - sessionId: cookie.id, - sessionToken: cookie.token, - }; - - const { callbackUrl } = await createCallback({ - serviceUrl, - req: create(CreateCallbackRequestSchema, { - authRequestId: requestId.replace("oidc_", ""), - callbackKind: { - case: "session", - value: create(SessionSchema, session), - }, - }), - }); - - const callbackResponse = NextResponse.redirect(callbackUrl); - - if (securitySettings?.embeddedIframe?.enabled) { - securitySettings.embeddedIframe.allowedOrigins; - callbackResponse.headers.set( - "Content-Security-Policy", - `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, - ); - callbackResponse.headers.delete("X-Frame-Options"); - } - - return callbackResponse; - } else { - // check for loginHint, userId hint and valid sessions - let selectedSession = await findValidSession({ - serviceUrl, - sessions, - authRequest, - }); - - if (!selectedSession || !selectedSession.id) { - return gotoAccounts({ - request, - requestId: `oidc_${authRequest.id}`, - organization, - }); - } - - const cookie = sessionCookies.find( - (cookie) => cookie.id === selectedSession.id, - ); - - if (!cookie || !cookie.id || !cookie.token) { - return gotoAccounts({ - request, - requestId: `oidc_${authRequest.id}`, - organization, - }); - } - - const session = { - sessionId: cookie.id, - sessionToken: cookie.token, - }; - - try { - const { callbackUrl } = await createCallback({ - serviceUrl, - req: create(CreateCallbackRequestSchema, { - authRequestId: requestId.replace("oidc_", ""), - callbackKind: { - case: "session", - value: create(SessionSchema, session), - }, - }), - }); - if (callbackUrl) { - return NextResponse.redirect(callbackUrl); - } else { - console.log( - "could not create callback, redirect user to choose other account", - ); - return gotoAccounts({ - request, - organization, - requestId: `oidc_${authRequest.id}`, - }); - } - } catch (error) { - console.error(error); - return gotoAccounts({ - request, - requestId: `oidc_${authRequest.id}`, - organization, - }); - } - } - } else { - const loginNameUrl = constructUrl(request, "/loginname"); - - loginNameUrl.searchParams.set("requestId", requestId); - if (authRequest?.loginHint) { - loginNameUrl.searchParams.set("loginName", authRequest.loginHint); - loginNameUrl.searchParams.set("submit", "true"); // autosubmit - } - - if (organization) { - loginNameUrl.searchParams.append("organization", organization); - // loginNameUrl.searchParams.set("organization", organization); - } - - return NextResponse.redirect(loginNameUrl); - } - } - // continue with SAML - else if (requestId && requestId.startsWith("saml_")) { - const { samlRequest } = await getSAMLRequest({ - serviceUrl, - samlRequestId: requestId.replace("saml_", ""), - }); - - if (!samlRequest) { - return NextResponse.json( - { error: "No samlRequest found" }, - { status: 400 }, - ); - } - - let selectedSession = await findValidSession({ - serviceUrl, - sessions, - samlRequest, - }); - - if (!selectedSession || !selectedSession.id) { - return gotoAccounts({ - request, - requestId: `saml_${samlRequest.id}`, - }); - } - - const cookie = sessionCookies.find( - (cookie) => cookie.id === selectedSession.id, + // Flow initiation - delegate to appropriate handler + const flowParams: FlowInitiationParams = { + serviceUrl, + requestId, + sessions, + sessionCookies, + request, + }; + + if (requestId.startsWith("oidc_")) { + return handleOIDCFlowInitiation(flowParams); + } else if (requestId.startsWith("saml_")) { + return handleSAMLFlowInitiation(flowParams); + } else if (requestId.startsWith("device_")) { + // Device Authorization does not need to start here as it is handled on the /device endpoint + return NextResponse.json( + { error: "Device authorization should use /device endpoint" }, + { status: 400 } ); - - if (!cookie || !cookie.id || !cookie.token) { - return gotoAccounts({ - request, - requestId: `saml_${samlRequest.id}`, - // organization, - }); - } - - const session = { - sessionId: cookie.id, - sessionToken: cookie.token, - }; - - try { - const { url, binding } = await createResponse({ - serviceUrl, - req: create(CreateResponseRequestSchema, { - samlRequestId: requestId.replace("saml_", ""), - responseKind: { - case: "session", - value: session, - }, - }), - }); - if (url && binding.case === "redirect") { - return NextResponse.redirect(url); - } else if (url && binding.case === "post") { - // Create HTML form that auto-submits via POST and escape the SAML cookie - const html = ` - - - - - - - - - - `; - - return new NextResponse(html, { - headers: { "Content-Type": "text/html" }, - }); - } else { - console.log( - "could not create response, redirect user to choose other account", - ); - return gotoAccounts({ - request, - requestId: `saml_${samlRequest.id}`, - }); - } - } catch (error) { - console.error(error); - return gotoAccounts({ - request, - requestId: `saml_${samlRequest.id}`, - }); - } - } - // Device Authorization does not need to start here as it is handled on the /device endpoint - else { + } else { return NextResponse.json( - { error: "No authRequest nor samlRequest provided" }, - { status: 500 }, + { error: "Invalid request ID format" }, + { status: 400 } ); } } diff --git a/src/components/choose-second-factor-to-setup.tsx b/src/components/choose-second-factor-to-setup.tsx index 38599053f..d636cb722 100644 --- a/src/components/choose-second-factor-to-setup.tsx +++ b/src/components/choose-second-factor-to-setup.tsx @@ -1,14 +1,13 @@ "use client"; import { skipMFAAndContinueWithNextUrl } from "@/lib/server/session"; -import { - LoginSettings, - SecondFactorType, -} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { LoginSettings, SecondFactorType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { useRouter } from "next/navigation"; import { EMAIL, SMS, TOTP, U2F } from "./auth-methods"; import { Translated } from "./translated"; +import { useState } from "react"; +import { Alert } from "./alert"; type Props = { userId: string; @@ -40,6 +39,8 @@ export function ChooseSecondFactorToSetup({ const router = useRouter(); const params = new URLSearchParams({}); + const [error, setError] = useState(""); + if (loginName) { params.append("loginName", loginName); } @@ -62,31 +63,15 @@ export function ChooseSecondFactorToSetup({ {loginSettings.secondFactors.map((factor) => { switch (factor) { case SecondFactorType.OTP: - return TOTP( - userMethods.includes(AuthenticationMethodType.TOTP), - "/otp/time-based/set?" + params, - ); + return TOTP(userMethods.includes(AuthenticationMethodType.TOTP), "/otp/time-based/set?" + params); case SecondFactorType.U2F: - return U2F( - userMethods.includes(AuthenticationMethodType.U2F), - "/u2f/set?" + params, - ); + return U2F(userMethods.includes(AuthenticationMethodType.U2F), "/u2f/set?" + params); case SecondFactorType.OTP_EMAIL: return ( - emailVerified && - EMAIL( - userMethods.includes(AuthenticationMethodType.OTP_EMAIL), - "/otp/email/set?" + params, - ) + emailVerified && EMAIL(userMethods.includes(AuthenticationMethodType.OTP_EMAIL), "/otp/email/set?" + params) ); case SecondFactorType.OTP_SMS: - return ( - phoneVerified && - SMS( - userMethods.includes(AuthenticationMethodType.OTP_SMS), - "/otp/sms/set?" + params, - ) - ); + return phoneVerified && SMS(userMethods.includes(AuthenticationMethodType.OTP_SMS), "/otp/sms/set?" + params); default: return null; } @@ -96,7 +81,7 @@ export function ChooseSecondFactorToSetup({ )} + {error && ( +
+ {error} +
+ )} ); } diff --git a/src/components/login-otp.tsx b/src/components/login-otp.tsx index abd482979..d4fa0fccb 100644 --- a/src/components/login-otp.tsx +++ b/src/components/login-otp.tsx @@ -1,6 +1,6 @@ "use client"; -import { getNextUrl } from "@/lib/client"; +import { completeFlowOrGetUrl } from "@/lib/client"; import { updateSession } from "@/lib/server/session"; import { create } from "@zitadel/client"; import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; @@ -33,16 +33,7 @@ type Inputs = { code: string; }; -export function LoginOTP({ - host, - loginName, - sessionId, - requestId, - organization, - method, - code, - loginSettings, -}: Props) { +export function LoginOTP({ host, loginName, sessionId, requestId, organization, method, code, loginSettings }: Props) { const t = useTranslations("otp"); const [error, setError] = useState(""); @@ -190,29 +181,33 @@ export function LoginOTP({ // Wait for 2 seconds to avoid eventual consistency issues with an OTP code being verified in the /login endpoint await new Promise((resolve) => setTimeout(resolve, 2000)); - const url = - requestId && response.sessionId - ? await getNextUrl( - { + // Use unified approach that handles both OIDC/SAML and regular flows + if (response.factors?.user) { + const callbackResponse = await completeFlowOrGetUrl( + requestId && response.sessionId + ? { sessionId: response.sessionId, requestId: requestId, organization: response.factors?.user?.organizationId, + } + : { + loginName: response.factors.user.loginName, + organization: response.factors?.user?.organizationId, }, - loginSettings?.defaultRedirectUri, - ) - : response.factors?.user - ? await getNextUrl( - { - loginName: response.factors.user.loginName, - organization: response.factors?.user?.organizationId, - }, - loginSettings?.defaultRedirectUri, - ) - : null; + loginSettings?.defaultRedirectUri, + ); + setLoading(false); - setLoading(false); - if (url) { - router.push(url); + if ("error" in callbackResponse) { + setError(callbackResponse.error); + return; + } + + if ("redirect" in callbackResponse) { + return router.push(callbackResponse.redirect); + } + } else { + setLoading(false); } } }); @@ -278,8 +273,7 @@ export function LoginOTP({ })} data-testid="submit-button" > - {loading && }{" "} - + {loading && }
diff --git a/src/components/password-form.tsx b/src/components/password-form.tsx index 1bf63e996..1b4c73b95 100644 --- a/src/components/password-form.tsx +++ b/src/components/password-form.tsx @@ -26,16 +26,11 @@ type Props = { requestId?: string; }; -export function PasswordForm({ - loginSettings, - loginName, - organization, - requestId, -}: Props) { +export function PasswordForm({ loginSettings, loginName, organization, requestId }: Props) { const { register, handleSubmit, formState } = useForm({ mode: "onBlur", }); - + const t = useTranslations("password"); const [info, setInfo] = useState(""); @@ -57,8 +52,9 @@ export function PasswordForm({ }), requestId, }) - .catch(() => { + .catch((error) => { setError("Could not verify password"); + console.error("Error verifying password:", error); return; }) .finally(() => { @@ -137,14 +133,7 @@ export function PasswordForm({ )} - {loginName && ( - - )} + {loginName && }
{info && ( @@ -170,8 +159,7 @@ export function PasswordForm({ onClick={handleSubmit(submitPassword)} data-testid="submit-button" > - {loading && }{" "} - + {loading && } diff --git a/src/components/register-form-idp-incomplete.tsx b/src/components/register-form-idp-incomplete.tsx index 9cefe8d3a..d6bd128bb 100644 --- a/src/components/register-form-idp-incomplete.tsx +++ b/src/components/register-form-idp-incomplete.tsx @@ -1,7 +1,6 @@ "use client"; import { registerUserAndLinkToIDP } from "@/lib/server/register"; -import { useRouter } from "next/navigation"; import { useState } from "react"; import { useTranslations } from "next-intl"; import { FieldValues, useForm } from "react-hook-form"; @@ -60,8 +59,6 @@ export function RegisterFormIDPIncomplete({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const router = useRouter(); - async function submitAndRegister(values: Inputs) { setLoading(true); const response = await registerUserAndLinkToIDP({ @@ -88,11 +85,7 @@ export function RegisterFormIDPIncomplete({ return; } - if (response && "redirect" in response && response.redirect) { - return router.push(response.redirect); - } - - return response; + // If no error, the function has already handled the redirect } const { errors } = formState; @@ -150,8 +143,7 @@ export function RegisterFormIDPIncomplete({ onClick={handleSubmit(submitAndRegister)} data-testid="submit-button" > - {loading && }{" "} - + {loading && } diff --git a/src/components/register-u2f.tsx b/src/components/register-u2f.tsx index ebaabb839..44276304b 100644 --- a/src/components/register-u2f.tsx +++ b/src/components/register-u2f.tsx @@ -1,7 +1,7 @@ "use client"; import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64"; -import { getNextUrl } from "@/lib/client"; +import { completeFlowOrGetUrl } 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"; @@ -22,26 +22,14 @@ type Props = { loginSettings?: LoginSettings; }; -export function RegisterU2f({ - loginName, - sessionId, - organization, - requestId, - checkAfter, - loginSettings, -}: Props) { +export function RegisterU2f({ loginName, sessionId, organization, requestId, checkAfter, loginSettings }: Props) { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const router = useRouter(); - async function submitVerify( - u2fId: string, - passkeyName: string, - publicKeyCredential: any, - sessionId: string, - ) { + async function submitVerify(u2fId: string, passkeyName: string, publicKeyCredential: any, sessionId: string) { setError(""); setLoading(true); const response = await verifyU2F({ @@ -94,24 +82,14 @@ export function RegisterU2f({ const u2fId = u2fResponse.u2fId; const options: CredentialCreationOptions = - (u2fResponse?.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? - {}; + (u2fResponse?.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? {}; if (options.publicKey) { - options.publicKey.challenge = coerceToArrayBuffer( - options.publicKey.challenge, - "challenge", - ); - options.publicKey.user.id = coerceToArrayBuffer( - options.publicKey.user.id, - "userid", - ); + options.publicKey.challenge = coerceToArrayBuffer(options.publicKey.challenge, "challenge"); + options.publicKey.user.id = coerceToArrayBuffer(options.publicKey.user.id, "userid"); if (options.publicKey.excludeCredentials) { options.publicKey.excludeCredentials.map((cred: any) => { - cred.id = coerceToArrayBuffer( - cred.id as string, - "excludeCredentials.id", - ); + cred.id = coerceToArrayBuffer(cred.id as string, "excludeCredentials.id"); return cred; }); } @@ -137,10 +115,7 @@ export function RegisterU2f({ rawId: coerceToBase64Url(rawId, "rawId"), type: resp.type, response: { - attestationObject: coerceToBase64Url( - attestationObject, - "attestationObject", - ), + attestationObject: coerceToBase64Url(attestationObject, "attestationObject"), clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"), }, }; @@ -170,27 +145,41 @@ export function RegisterU2f({ return router.push(`/u2f?` + paramsToContinue); } else { - const url = - requestId && sessionId - ? await getNextUrl( - { - sessionId: sessionId, - requestId: requestId, - organization: organization, - }, - loginSettings?.defaultRedirectUri, - ) - : loginName - ? await getNextUrl( - { - loginName: loginName, - organization: organization, - }, - loginSettings?.defaultRedirectUri, - ) - : null; - if (url) { - return router.push(url); + if (requestId && sessionId) { + const callbackResponse = await completeFlowOrGetUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ); + + if ("error" in callbackResponse) { + setError(callbackResponse.error); + return; + } + + if ("redirect" in callbackResponse) { + return router.push(callbackResponse.redirect); + } + } else if (loginName) { + const callbackResponse = await completeFlowOrGetUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ); + + if ("error" in callbackResponse) { + setError(callbackResponse.error); + return; + } + + if ("redirect" in callbackResponse) { + return router.push(callbackResponse.redirect); + } } } } @@ -216,8 +205,7 @@ export function RegisterU2f({ onClick={submitRegisterAndContinue} data-testid="submit-button" > - {loading && }{" "} - + {loading && } diff --git a/src/components/session-item.tsx b/src/components/session-item.tsx index 41948282a..3ae425d0b 100644 --- a/src/components/session-item.tsx +++ b/src/components/session-item.tsx @@ -73,14 +73,10 @@ export function SessionItem({ + + + + + `; + + return new NextResponse(html, { + headers: { "Content-Type": "text/html" }, + }); + } + } catch (error) { + console.error("SAML createResponse failed:", error); + } + + // Final fallback: SAML response creation failed - show account selection + return gotoAccounts({ + request, + requestId, + }); +} \ No newline at end of file diff --git a/src/lib/server/idp.ts b/src/lib/server/idp.ts index 87f88a7c3..50630dc50 100644 --- a/src/lib/server/idp.ts +++ b/src/lib/server/idp.ts @@ -3,22 +3,20 @@ import { getLoginSettings, getUserByID, + listAuthenticationMethodTypes, startIdentityProviderFlow, startLDAPIdentityProviderFlow, } from "@/lib/zitadel"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; -import { getNextUrl } from "../client"; +import { completeFlowOrGetUrl } from "../client"; import { getServiceUrlFromHeaders } from "../service-url"; -import { checkEmailVerification } from "../verify-helper"; +import { checkEmailVerification, checkMFAFactors } from "../verify-helper"; import { createSessionForIdpAndUpdateCookie } from "./cookie"; export type RedirectToIdpState = { error?: string | null } | undefined; -export async function redirectToIdp( - prevState: RedirectToIdpState, - formData: FormData, -): Promise { +export async function redirectToIdp(prevState: RedirectToIdpState, formData: FormData): Promise { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); const host = _headers.get("host"); @@ -102,9 +100,7 @@ type CreateNewSessionCommand = { requestId?: string; }; -export async function createNewSessionFromIdpIntent( - command: CreateNewSessionCommand, -) { +export async function createNewSessionFromIdpIntent(command: CreateNewSessionCommand) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -143,30 +139,43 @@ export async function createNewSessionFromIdpIntent( return { error: "Could not create session" }; } - const humanUser = - userResponse.user.type.case === "human" - ? userResponse.user.type.value - : undefined; + const humanUser = userResponse.user.type.case === "human" ? userResponse.user.type.value : undefined; // check to see if user was verified - const emailVerificationCheck = checkEmailVerification( - session, - humanUser, - command.organization, - command.requestId, - ); + const emailVerificationCheck = checkEmailVerification(session, humanUser, command.organization, command.requestId); if (emailVerificationCheck?.redirect) { return emailVerificationCheck; } - // TODO: check if user has MFA methods - // const mfaFactorCheck = checkMFAFactors(session, loginSettings, authMethods, organization, requestId); - // if (mfaFactorCheck?.redirect) { - // return mfaFactorCheck; - // } + // check if user has MFA methods + let authMethods; + if (session.factors?.user?.id) { + const response = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + if (response.authMethodTypes && response.authMethodTypes.length) { + authMethods = response.authMethodTypes; + } + } + + if (authMethods) { + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, + session, + loginSettings, + authMethods, + command.organization, + command.requestId, + ); + + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + } - const url = await getNextUrl( + return completeFlowOrGetUrl( command.requestId && session.id ? { sessionId: session.id, @@ -179,10 +188,6 @@ export async function createNewSessionFromIdpIntent( }, loginSettings?.defaultRedirectUri, ); - - if (url) { - return { redirect: url }; - } } type createNewSessionForLDAPCommand = { @@ -192,9 +197,7 @@ type createNewSessionForLDAPCommand = { link: boolean; }; -export async function createNewSessionForLDAP( - command: createNewSessionForLDAPCommand, -) { +export async function createNewSessionForLDAP(command: createNewSessionForLDAPCommand) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -215,11 +218,7 @@ export async function createNewSessionForLDAP( password: command.password, }); - if ( - !response || - response.nextStep.case !== "idpIntent" || - !response.nextStep.value - ) { + if (!response || response.nextStep.case !== "idpIntent" || !response.nextStep.value) { return { error: "Could not start LDAP identity provider flow" }; } diff --git a/src/lib/server/passkeys.ts b/src/lib/server/passkeys.ts index ca603471a..57ab71361 100644 --- a/src/lib/server/passkeys.ts +++ b/src/lib/server/passkeys.ts @@ -18,17 +18,10 @@ import { } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { headers } from "next/headers"; import { userAgent } from "next/server"; -import { getNextUrl } from "../client"; -import { - getMostRecentSessionCookie, - getSessionCookieById, - getSessionCookieByLoginName, -} from "../cookies"; +import { completeFlowOrGetUrl } from "../client"; +import { getMostRecentSessionCookie, getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; import { getServiceUrlFromHeaders } from "../service-url"; -import { - checkEmailVerification, - checkUserVerification, -} from "../verify-helper"; +import { checkEmailVerification, checkUserVerification } from "../verify-helper"; import { setSessionAndUpdateCookie } from "./cookie"; type VerifyPasskeyCommand = { @@ -48,9 +41,7 @@ function isSessionValid(session: Partial): { } { const validPassword = session?.factors?.password?.verifiedAt; const validPasskey = session?.factors?.webAuthN?.verifiedAt; - const stillValid = session.expirationDate - ? timestampDate(session.expirationDate) > new Date() - : true; + const stillValid = session.expirationDate ? timestampDate(session.expirationDate) > new Date() : true; const verifiedAt = validPassword || validPasskey; const valid = !!((validPassword || validPasskey) && stillValid); @@ -93,15 +84,12 @@ export async function registerPasskeyLink( // if the user has no authmethods set, we need to check if the user was verified if (authmethods.authMethodTypes.length !== 0) { return { - error: - "You have to authenticate or have a valid User Verification Check", + error: "You have to authenticate or have a valid User Verification Check", }; } // check if a verification was done earlier - const hasValidUserVerificationCheck = await checkUserVerification( - session.session.factors.user.id, - ); + const hasValidUserVerificationCheck = await checkUserVerification(session.session.factors.user.id); if (!hasValidUserVerificationCheck) { return { error: "User Verification Check has to be done" }; @@ -246,41 +234,30 @@ export async function sendPasskey(command: SendPasskeyCommand) { return { error: "User not found in the system" }; } - const humanUser = - userResponse.user.type.case === "human" - ? userResponse.user.type.value - : undefined; + const humanUser = userResponse.user.type.case === "human" ? userResponse.user.type.value : undefined; - const emailVerificationCheck = checkEmailVerification( - session, - humanUser, - organization, - requestId, - ); + const emailVerificationCheck = checkEmailVerification(session, humanUser, organization, requestId); if (emailVerificationCheck?.redirect) { return emailVerificationCheck; } - const url = - requestId && session.id - ? await getNextUrl( - { - sessionId: session.id, - requestId: requestId, - organization: organization, - }, - loginSettings?.defaultRedirectUri, - ) - : session?.factors?.user?.loginName - ? await getNextUrl( - { - loginName: session.factors.user.loginName, - organization: organization, - }, - loginSettings?.defaultRedirectUri, - ) - : null; - - return { redirect: url }; + if (requestId && session.id) { + return completeFlowOrGetUrl( + { + sessionId: session.id, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ); + } else if (session?.factors?.user?.loginName) { + return completeFlowOrGetUrl( + { + loginName: session.factors.user.loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ); + } } diff --git a/src/lib/server/password.ts b/src/lib/server/password.ts index 013255d06..be60d0f00 100644 --- a/src/lib/server/password.ts +++ b/src/lib/server/password.ts @@ -1,9 +1,6 @@ "use server"; -import { - createSessionAndUpdateCookie, - setSessionAndUpdateCookie, -} from "@/lib/server/cookie"; +import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "@/lib/server/cookie"; import { getLockoutSettings, getLoginSettings, @@ -18,18 +15,12 @@ import { } from "@/lib/zitadel"; import { ConnectError, create, Duration } from "@zitadel/client"; import { createUserServiceClient } from "@zitadel/client/v2"; -import { - Checks, - ChecksSchema, -} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; -import { - AuthenticationMethodType, - SetPasswordRequestSchema, -} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { AuthenticationMethodType, SetPasswordRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { headers } from "next/headers"; -import { getNextUrl } from "../client"; +import { completeFlowOrGetUrl } from "../client"; import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; import { getServiceUrlFromHeaders } from "../service-url"; import { @@ -61,11 +52,7 @@ export async function resetPassword(command: ResetPasswordCommand) { organizationId: command.organization, }); - if ( - !users.details || - users.details.totalResult !== BigInt(1) || - !users.result[0].userId - ) { + if (!users.details || users.details.totalResult !== BigInt(1) || !users.result[0].userId) { return { error: "Could not send Password Reset Link" }; } const userId = users.result[0].userId; @@ -88,7 +75,7 @@ export type UpdateSessionCommand = { requestId?: string; }; -export async function sendPassword(command: UpdateSessionCommand) { +export async function sendPassword(command: UpdateSessionCommand): Promise<{ error: string } | { redirect: string }> { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -139,18 +126,17 @@ export async function sendPassword(command: UpdateSessionCommand) { return { error: `Failed to authenticate. You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.` + - (lockoutSettings?.maxPasswordAttempts && - error.failedAttempts >= lockoutSettings?.maxPasswordAttempts + (lockoutSettings?.maxPasswordAttempts && error.failedAttempts >= lockoutSettings?.maxPasswordAttempts ? "Contact your administrator to unlock your account" : ""), }; } return { error: "Could not create session for user" }; } + } else { + // this is a fake error message to hide that the user does not even exist + return { error: "Could not verify password" }; } - - // this is a fake error message to hide that the user does not even exist - return { error: "Could not verify password" }; } else { loginSettings = await getLoginSettings({ serviceUrl, @@ -188,8 +174,7 @@ export async function sendPassword(command: UpdateSessionCommand) { return { error: `Failed to authenticate. You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.` + - (lockoutSettings?.maxPasswordAttempts && - error.failedAttempts >= lockoutSettings?.maxPasswordAttempts + (lockoutSettings?.maxPasswordAttempts && error.failedAttempts >= lockoutSettings?.maxPasswordAttempts ? " Contact your administrator to unlock your account" : ""), }; @@ -216,12 +201,11 @@ export async function sendPassword(command: UpdateSessionCommand) { if (!loginSettings) { loginSettings = await getLoginSettings({ serviceUrl, - organization: - command.organization ?? session.factors?.user?.organizationId, + organization: command.organization ?? session.factors?.user?.organizationId, }); } - if (!session?.factors?.user?.id || !sessionCookie) { + if (!session?.factors?.user?.id) { return { error: "Could not create session for user" }; } @@ -251,12 +235,7 @@ export async function sendPassword(command: UpdateSessionCommand) { } // check to see if user was verified - const emailVerificationCheck = checkEmailVerification( - session, - humanUser, - command.organization, - command.requestId, - ); + const emailVerificationCheck = checkEmailVerification(session, humanUser, command.organization, command.requestId); if (emailVerificationCheck?.redirect) { return emailVerificationCheck; @@ -292,36 +271,49 @@ export async function sendPassword(command: UpdateSessionCommand) { } if (command.requestId && session.id) { - const nextUrl = await getNextUrl( + // OIDC/SAML flow - use completeFlowOrGetUrl for proper handling + console.log("Password auth: OIDC/SAML flow with requestId:", command.requestId, "sessionId:", session.id); + const result = await completeFlowOrGetUrl( { sessionId: session.id, requestId: command.requestId, - organization: - command.organization ?? session.factors?.user?.organizationId, + organization: command.organization ?? session.factors?.user?.organizationId, }, loginSettings?.defaultRedirectUri, ); + console.log("Password auth: OIDC/SAML flow result:", result); - return { redirect: nextUrl }; + // Safety net - ensure we always return a valid object + if (!result || typeof result !== "object" || (!("redirect" in result) && !("error" in result))) { + console.error("Password auth: Invalid result from completeFlowOrGetUrl (OIDC/SAML):", result); + return { error: "Authentication completed but navigation failed" }; + } + + return result; } - const url = await getNextUrl( + // Regular flow (no requestId) - return URL for client-side navigation + console.log("Password auth: Regular flow with loginName:", session.factors.user.loginName); + const result = await completeFlowOrGetUrl( { loginName: session.factors.user.loginName, organization: session.factors?.user?.organizationId, }, loginSettings?.defaultRedirectUri, ); + console.log("Password auth: Regular flow result:", result); + + // Safety net - ensure we always return a valid object + if (!result || typeof result !== "object" || (!("redirect" in result) && !("error" in result))) { + console.error("Password auth: Invalid result from completeFlowOrGetUrl:", result); + return { error: "Authentication completed but navigation failed" }; + } - return { redirect: url }; + return result; } // this function lets users with code set a password or users with valid User Verification Check -export async function changePassword(command: { - code?: string; - userId: string; - password: string; -}) { +export async function changePassword(command: { code?: string; userId: string; password: string }) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -350,15 +342,12 @@ export async function changePassword(command: { // if the user has no authmethods set, we need to check if the user was verified if (authmethods.authMethodTypes.length !== 0) { return { - error: - "You have to provide a code or have a valid User Verification Check", + error: "You have to provide a code or have a valid User Verification Check", }; } // check if a verification was done earlier - const hasValidUserVerificationCheck = await checkUserVerification( - user.userId, - ); + const hasValidUserVerificationCheck = await checkUserVerification(user.userId); if (!hasValidUserVerificationCheck) { return { error: "User Verification Check has to be done" }; @@ -378,10 +367,7 @@ type CheckSessionAndSetPasswordCommand = { password: string; }; -export async function checkSessionAndSetPassword({ - sessionId, - password, -}: CheckSessionAndSetPasswordCommand) { +export async function checkSessionAndSetPassword({ sessionId, password }: CheckSessionAndSetPasswordCommand) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -421,18 +407,14 @@ export async function checkSessionAndSetPassword({ AuthenticationMethodType.U2F, ]; - const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every( - (method) => !authmethods.authMethodTypes.includes(method), - ); + const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every((method) => !authmethods.authMethodTypes.includes(method)); const loginSettings = await getLoginSettings({ serviceUrl, organization: session.factors.user.organizationId, }); - const forceMfa = !!( - loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly - ); + const forceMfa = !!(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly); // if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user if (forceMfa && hasNoMFAMethods) { @@ -454,10 +436,7 @@ export async function checkSessionAndSetPassword({ return createUserServiceClient(transportPromise); }; - const selfService = await myUserService( - serviceUrl, - `${sessionCookie.token}`, - ); + const selfService = await myUserService(serviceUrl, `${sessionCookie.token}`); return selfService .setPassword( diff --git a/src/lib/server/register.ts b/src/lib/server/register.ts index f84b4c8d5..a41d6a468 100644 --- a/src/lib/server/register.ts +++ b/src/lib/server/register.ts @@ -1,23 +1,12 @@ "use server"; -import { - createSessionAndUpdateCookie, - createSessionForIdpAndUpdateCookie, -} from "@/lib/server/cookie"; -import { - addHumanUser, - addIDPLink, - getLoginSettings, - getUserByID, -} from "@/lib/zitadel"; +import { createSessionAndUpdateCookie, createSessionForIdpAndUpdateCookie } from "@/lib/server/cookie"; +import { addHumanUser, addIDPLink, getLoginSettings, getUserByID } from "@/lib/zitadel"; import { create } from "@zitadel/client"; import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; -import { - ChecksJson, - ChecksSchema, -} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { ChecksJson, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { headers } from "next/headers"; -import { getNextUrl } from "../client"; +import { completeFlowOrGetUrl } from "../client"; import { getServiceUrlFromHeaders } from "../service-url"; import { checkEmailVerification } from "../verify-helper"; @@ -78,9 +67,7 @@ export async function registerUser(command: RegisterUserCommand) { const session = await createSessionAndUpdateCookie({ checks, requestId: command.requestId, - lifetime: command.password - ? loginSettings?.passwordCheckLifetime - : undefined, + lifetime: command.password ? loginSettings?.passwordCheckLifetime : undefined, }); if (!session || !session.factors?.user) { @@ -108,10 +95,7 @@ export async function registerUser(command: RegisterUserCommand) { return { error: "User not found in the system" }; } - const humanUser = - userResponse.user.type.case === "human" - ? userResponse.user.type.value - : undefined; + const humanUser = userResponse.user.type.case === "human" ? userResponse.user.type.value : undefined; const emailVerificationCheck = checkEmailVerification( session, @@ -124,7 +108,7 @@ export async function registerUser(command: RegisterUserCommand) { return emailVerificationCheck; } - const url = await getNextUrl( + return completeFlowOrGetUrl( command.requestId && session.id ? { sessionId: session.id, @@ -137,8 +121,6 @@ export async function registerUser(command: RegisterUserCommand) { }, loginSettings?.defaultRedirectUri, ); - - return { redirect: url }; } } @@ -162,9 +144,7 @@ export type registerUserAndLinkToIDPResponse = { sessionId: string; factors: Factors | undefined; }; -export async function registerUserAndLinkToIDP( - command: RegisterUserAndLinkToIDPommand, -) { +export async function registerUserAndLinkToIDP(command: RegisterUserAndLinkToIDPommand) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); const host = _headers.get("host"); @@ -215,7 +195,7 @@ export async function registerUserAndLinkToIDP( return { error: "Could not create session" }; } - const url = await getNextUrl( + return completeFlowOrGetUrl( command.requestId && session.id ? { sessionId: session.id, @@ -228,6 +208,4 @@ export async function registerUserAndLinkToIDP( }, loginSettings?.defaultRedirectUri, ); - - return { redirect: url }; } diff --git a/src/lib/server/session.ts b/src/lib/server/session.ts index 7f8e9e46e..de045534c 100644 --- a/src/lib/server/session.ts +++ b/src/lib/server/session.ts @@ -13,7 +13,7 @@ import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_p import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { headers } from "next/headers"; -import { getNextUrl } from "../client"; +import { completeFlowOrGetUrl } from "../client"; import { getMostRecentSessionCookie, getSessionCookieById, @@ -34,7 +34,7 @@ export async function skipMFAAndContinueWithNextUrl({ sessionId?: string; requestId?: string; organization?: string; -}) { +}): Promise<{ redirect: string } | { error: string }> { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -45,28 +45,26 @@ export async function skipMFAAndContinueWithNextUrl({ await humanMFAInitSkipped({ serviceUrl, userId }); - const url = - requestId && sessionId - ? await getNextUrl( - { - sessionId: sessionId, - requestId: requestId, - organization: organization, - }, - loginSettings?.defaultRedirectUri, - ) - : loginName - ? await getNextUrl( - { - loginName: loginName, - organization: organization, - }, - loginSettings?.defaultRedirectUri, - ) - : null; - if (url) { - return { redirect: url }; + if (requestId && sessionId) { + return completeFlowOrGetUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ); + } else if (loginName) { + return completeFlowOrGetUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ); } + + return { error: "Could not skip MFA and continue" }; } export async function continueWithSession({ requestId, ...session }: Session & { requestId?: string }) { @@ -78,27 +76,23 @@ export async function continueWithSession({ requestId, ...session }: Session & { organization: session.factors?.user?.organizationId, }); - const url = - requestId && session.id && session.factors?.user - ? await getNextUrl( - { - sessionId: session.id, - requestId: requestId, - organization: session.factors.user.organizationId, - }, - loginSettings?.defaultRedirectUri, - ) - : session.factors?.user - ? await getNextUrl( - { - loginName: session.factors.user.loginName, - organization: session.factors.user.organizationId, - }, - loginSettings?.defaultRedirectUri, - ) - : null; - if (url) { - return { redirect: url }; + if (requestId && session.id && session.factors?.user) { + return completeFlowOrGetUrl( + { + sessionId: session.id, + requestId: requestId, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + } else if (session.factors?.user) { + return completeFlowOrGetUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); } } diff --git a/src/lib/server/verify.ts b/src/lib/server/verify.ts index cf60f739b..2adc08c1e 100644 --- a/src/lib/server/verify.ts +++ b/src/lib/server/verify.ts @@ -17,7 +17,7 @@ import { create } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { cookies, headers } from "next/headers"; -import { getNextUrl } from "../client"; +import { completeFlowOrGetUrl } from "../client"; import { getSessionCookieByLoginName } from "../cookies"; import { getOrSetFingerprintId } from "../fingerprint"; import { getServiceUrlFromHeaders } from "../service-url"; @@ -25,11 +25,7 @@ import { loadMostRecentSession } from "../session"; import { checkMFAFactors } from "../verify-helper"; import { createSessionAndUpdateCookie } from "./cookie"; -export async function verifyTOTP( - code: string, - loginName?: string, - organization?: string, -) { +export async function verifyTOTP(code: string, loginName?: string, organization?: string) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -104,8 +100,7 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { const user = userResponse.user; const sessionCookie = await getSessionCookieByLoginName({ - loginName: - "loginName" in command ? command.loginName : user.preferredLoginName, + loginName: "loginName" in command ? command.loginName : user.preferredLoginName, organization: command.organization, }).catch((error) => { console.warn("Ignored error:", error); // checked later @@ -134,11 +129,7 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { } // if no authmethods are found on the user, redirect to set one up - if ( - authMethodResponse && - authMethodResponse.authMethodTypes && - authMethodResponse.authMethodTypes.length == 0 - ) { + if (authMethodResponse && authMethodResponse.authMethodTypes && authMethodResponse.authMethodTypes.length == 0) { if (!sessionCookie) { const checks = create(ChecksSchema, { user: { @@ -171,10 +162,7 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { const cookiesList = await cookies(); const userAgentId = await getOrSetFingerprintId(); - const verificationCheck = crypto - .createHash("sha256") - .update(`${user.userId}:${userAgentId}`) - .digest("hex"); + const verificationCheck = crypto.createHash("sha256").update(`${user.userId}:${userAgentId}`).digest("hex"); await cookiesList.set({ name: "verificationCheck", @@ -196,15 +184,10 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { verifySuccessParams.set("userId", command.userId); } - if ( - ("loginName" in command && command.loginName) || - user.preferredLoginName - ) { + if (("loginName" in command && command.loginName) || user.preferredLoginName) { verifySuccessParams.set( "loginName", - "loginName" in command && command.loginName - ? command.loginName - : user.preferredLoginName, + "loginName" in command && command.loginName ? command.loginName : user.preferredLoginName, ); } if (command.requestId) { @@ -238,28 +221,24 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { // login user if no additional steps are required if (command.requestId && session.id) { - const nextUrl = await getNextUrl( + return completeFlowOrGetUrl( { sessionId: session.id, requestId: command.requestId, - organization: - command.organization ?? session.factors?.user?.organizationId, + organization: command.organization ?? session.factors?.user?.organizationId, }, loginSettings?.defaultRedirectUri, ); - - return { redirect: nextUrl }; } - const url = await getNextUrl( + // Regular flow - return URL for client-side navigation + return completeFlowOrGetUrl( { loginName: session.factors.user.loginName, organization: session.factors?.user?.organizationId, }, loginSettings?.defaultRedirectUri, ); - - return { redirect: url }; } type resendVerifyEmailCommand = { diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts index 80ca32854..2be64a658 100644 --- a/src/lib/session.test.ts +++ b/src/lib/session.test.ts @@ -1,6 +1,6 @@ /** * Unit tests for the isSessionValid function. - * + * * This test suite covers the comprehensive session validation logic including: * - Session expiration checks * - User presence validation @@ -113,10 +113,7 @@ describe("isSessionValid", () => { const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( - "Session is expired", - expect.any(String) - ); + expect(consoleSpy).toHaveBeenCalledWith("Session is expired", expect.any(String)); consoleSpy.mockRestore(); }); }); @@ -147,7 +144,7 @@ describe("isSessionValid", () => { }); describe("MFA validation with configured authentication methods", () => { - test("should return true when TOTP is configured and verified", async () => { + test("should return true when TOTP is configured and verified with MFA required", async () => { const verifiedTimestamp = createMockTimestamp(); const session = createMockSession({ factors: { @@ -167,8 +164,9 @@ describe("isSessionValid", () => { }, }); - vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ - authMethodTypes: [AuthenticationMethodType.TOTP], + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: true, + forceMfaLocalOnly: false, } as any); const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); @@ -176,7 +174,35 @@ describe("isSessionValid", () => { expect(result).toBe(true); }); - test("should return false when TOTP is configured but not verified", async () => { + test("should return true when TOTP is configured but not verified and MFA is not required", async () => { + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + // No TOTP verification + }, + }); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + expect(result).toBe(true); + }); + + test("should return false when TOTP is configured but not verified and MFA is required", async () => { const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const verifiedTimestamp = createMockTimestamp(); const session = createMockSession({ @@ -195,28 +221,19 @@ describe("isSessionValid", () => { }, }); - vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ - authMethodTypes: [AuthenticationMethodType.TOTP], + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: true, + forceMfaLocalOnly: false, } as any); const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( - "Session has no valid MFA factor. Configured methods:", - [AuthenticationMethodType.TOTP], - "Session factors:", - expect.objectContaining({ - totp: undefined, - otpEmail: undefined, - otpSms: undefined, - webAuthN: undefined, - }) - ); + expect(consoleSpy).toHaveBeenCalledWith("Session has no valid multifactor", expect.any(Object)); consoleSpy.mockRestore(); }); - test("should return true when OTP Email is configured and verified", async () => { + test("should return true when OTP Email is configured and verified with MFA required", async () => { const verifiedTimestamp = createMockTimestamp(); const session = createMockSession({ factors: { @@ -274,7 +291,7 @@ describe("isSessionValid", () => { expect(result).toBe(true); }); - test("should return true when multiple auth methods are configured and one is verified", async () => { + test("should return true when multiple auth methods are configured and one is verified with MFA required", async () => { const verifiedTimestamp = createMockTimestamp(); const session = createMockSession({ factors: { @@ -295,6 +312,11 @@ describe("isSessionValid", () => { }, }); + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: true, + forceMfaLocalOnly: false, + } as any); + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ authMethodTypes: [AuthenticationMethodType.TOTP, AuthenticationMethodType.OTP_EMAIL], } as any); @@ -303,6 +325,195 @@ describe("isSessionValid", () => { expect(result).toBe(true); }); + + test("should return true when session has only password and MFA is not required by policy", async () => { + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + // No MFA factors verified + }, + }); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + expect(result).toBe(true); + }); + + test("should return true when user has PASSWORD and TOTP configured but only password verified and MFA not required", async () => { + // This test specifically covers the original bug scenario: + // - User has PASSWORD and TOTP configured (would show up in listAuthenticationMethodTypes) + // - User has only verified password, not TOTP + // - MFA is not required by policy + // - Session should be valid (this was the bug - it was returning false) + + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + // TOTP is configured but NOT verified - this is the key part + // totp: undefined (no verifiedAt) + }, + }); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + expect(result).toBe(true); + }); + + test("should return false when user has PASSWORD and TOTP configured but only password verified and MFA IS required", async () => { + // This is the counterpart test to ensure MFA is still enforced when required + + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + // TOTP is configured but NOT verified + // totp: undefined (no verifiedAt) + }, + }); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: true, + forceMfaLocalOnly: false, + } as any); + + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.TOTP], + } as any); + + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + expect(result).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + "Session has no valid MFA factor. Configured methods:", + [AuthenticationMethodType.TOTP], + "Session factors:", + expect.objectContaining({ + totp: undefined, + otpEmail: undefined, + otpSms: undefined, + webAuthN: undefined, + }), + ); + consoleSpy.mockRestore(); + }); + + test("REGRESSION TEST: user with only PASSWORD factor should be valid when MFA not required", async () => { + // This test specifically verifies the original bug is fixed + // Original bug: A user with only PASSWORD authentication would be invalid + // because the code checked if authMethods.length > 0 (which included PASSWORD) + // and then required MFA verification even when MFA was not required by policy + + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + // Explicitly no MFA factors at all + totp: undefined, + otpEmail: undefined, + otpSms: undefined, + webAuthN: undefined, + intent: undefined, + }, + }); + + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + // This should be true - if it's false, the original bug still exists + expect(result).toBe(true); + }); + + test("DEMONSTRATION: how the original bug would manifest with old logic", async () => { + // This test demonstrates the original problematic scenario: + // 1. listAuthenticationMethodTypes returns [PASSWORD, TOTP] + // 2. Old logic would check authMethods.length > 0 (true because PASSWORD is included) + // 3. Old logic would then require MFA verification regardless of policy + // 4. User has only password verified, no TOTP + // 5. Session would be marked invalid even though MFA is not required + + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + password: { + verifiedAt: verifiedTimestamp, + }, + // User has TOTP configured but not verified + totp: undefined, + }, + }); + + // MFA is NOT required by policy + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: false, + forceMfaLocalOnly: false, + } as any); + + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + // With our fix, this should be true (session is valid) + // With the old logic, this would have been false (bug) + expect(result).toBe(true); + }); }); describe("MFA validation with login settings (no configured auth methods)", () => { @@ -368,10 +579,7 @@ describe("isSessionValid", () => { const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); expect(result).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( - "Session has no valid multifactor", - expect.any(Object) - ); + expect(consoleSpy).toHaveBeenCalledWith("Session has no valid multifactor", expect.any(Object)); consoleSpy.mockRestore(); }); @@ -493,7 +701,7 @@ describe("isSessionValid", () => { expect(result).toBe(false); expect(consoleSpy).toHaveBeenCalledWith( "Session invalid: Email not verified and EMAIL_VERIFICATION is enabled", - mockUserId + mockUserId, ); consoleSpy.mockRestore(); }); @@ -647,6 +855,44 @@ describe("isSessionValid", () => { expect(result).toBe(true); }); + + test("should return true when authenticated with IDP intent even with forced MFA", async () => { + const verifiedTimestamp = createMockTimestamp(); + const session = createMockSession({ + factors: { + user: { + id: mockUserId, + organizationId: mockOrganizationId, + loginName: "test@example.com", + displayName: "Test User", + verifiedAt: verifiedTimestamp, + }, + intent: { + verifiedAt: verifiedTimestamp, + }, + // No password factor, no MFA factors + }, + }); + + // Organization enforces MFA + vi.mocked(zitadelModule.getLoginSettings).mockResolvedValue({ + forceMfa: true, + forceMfaLocalOnly: false, + } as any); + + // User has MFA methods configured but none verified + vi.mocked(zitadelModule.listAuthenticationMethodTypes).mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.TOTP, AuthenticationMethodType.OTP_EMAIL], + } as any); + + // Should still return true because IDP bypasses MFA requirements + const result = await isSessionValid({ serviceUrl: mockServiceUrl, session }); + + expect(result).toBe(true); + // Verify that getLoginSettings was not called since IDP should bypass MFA check entirely + expect(zitadelModule.getLoginSettings).not.toHaveBeenCalled(); + expect(zitadelModule.listAuthenticationMethodTypes).not.toHaveBeenCalled(); + }); }); describe("edge cases", () => { @@ -682,4 +928,4 @@ describe("isSessionValid", () => { expect(result).toBe(true); }); }); -}); \ No newline at end of file +}); diff --git a/src/lib/session.ts b/src/lib/session.ts index 45c3274c5..77bf59616 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -44,55 +44,76 @@ export async function isSessionValid({ serviceUrl, session }: { serviceUrl: stri let mfaValid = true; - const authMethodTypes = await listAuthenticationMethodTypes({ - serviceUrl, - userId: session.factors.user.id, - }); - - const authMethods = authMethodTypes.authMethodTypes; - if (authMethods && authMethods.length > 0) { - // Check if any of the configured authentication methods have been verified - const totpValid = authMethods.includes(AuthenticationMethodType.TOTP) && !!session.factors.totp?.verifiedAt; - const otpEmailValid = authMethods.includes(AuthenticationMethodType.OTP_EMAIL) && !!session.factors.otpEmail?.verifiedAt; - const otpSmsValid = authMethods.includes(AuthenticationMethodType.OTP_SMS) && !!session.factors.otpSms?.verifiedAt; - const u2fValid = authMethods.includes(AuthenticationMethodType.U2F) && !!session.factors.webAuthN?.verifiedAt; - - mfaValid = totpValid || otpEmailValid || otpSmsValid || u2fValid; - - if (!mfaValid) { - console.warn("Session has no valid MFA factor. Configured methods:", authMethods, "Session factors:", { - totp: session.factors.totp?.verifiedAt, - otpEmail: session.factors.otpEmail?.verifiedAt, - otpSms: session.factors.otpSms?.verifiedAt, - webAuthN: session.factors.webAuthN?.verifiedAt - }); - } + // Check if user authenticated via IDP - if so, skip MFA validation entirely + const validIDP = session?.factors?.intent?.verifiedAt; + if (validIDP) { + // IDP authentication bypasses MFA requirements + mfaValid = true; } else { - // only check settings if no auth methods are available, as this would require a setup + // Get login settings to determine if MFA is actually required by policy const loginSettings = await getLoginSettings({ serviceUrl, organization: session.factors?.user?.organizationId, }); - if (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) { - const otpEmail = session.factors.otpEmail?.verifiedAt; - const otpSms = session.factors.otpSms?.verifiedAt; - const totp = session.factors.totp?.verifiedAt; - const webAuthN = session.factors.webAuthN?.verifiedAt; - const idp = session.factors.intent?.verifiedAt; // TODO: forceMFA should not consider this as valid factor - - // must have one single check - mfaValid = !!(otpEmail || otpSms || totp || webAuthN || idp); - if (!mfaValid) { - console.warn("Session has no valid multifactor", session.factors); + + const isMfaRequired = loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly; + + // Only enforce MFA validation if MFA is required by policy + if (isMfaRequired) { + const authMethodTypes = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + + const authMethods = authMethodTypes.authMethodTypes; + // Filter to only MFA methods (exclude PASSWORD and PASSKEY) + const mfaMethods = authMethods?.filter( + (method) => + method === AuthenticationMethodType.TOTP || + method === AuthenticationMethodType.OTP_EMAIL || + method === AuthenticationMethodType.OTP_SMS || + method === AuthenticationMethodType.U2F, + ); + + if (mfaMethods && mfaMethods.length > 0) { + // Check if any of the configured MFA methods have been verified + const totpValid = mfaMethods.includes(AuthenticationMethodType.TOTP) && !!session.factors.totp?.verifiedAt; + const otpEmailValid = + mfaMethods.includes(AuthenticationMethodType.OTP_EMAIL) && !!session.factors.otpEmail?.verifiedAt; + const otpSmsValid = mfaMethods.includes(AuthenticationMethodType.OTP_SMS) && !!session.factors.otpSms?.verifiedAt; + const u2fValid = mfaMethods.includes(AuthenticationMethodType.U2F) && !!session.factors.webAuthN?.verifiedAt; + + mfaValid = totpValid || otpEmailValid || otpSmsValid || u2fValid; + + if (!mfaValid) { + console.warn("Session has no valid MFA factor. Configured methods:", mfaMethods, "Session factors:", { + totp: session.factors.totp?.verifiedAt, + otpEmail: session.factors.otpEmail?.verifiedAt, + otpSms: session.factors.otpSms?.verifiedAt, + webAuthN: session.factors.webAuthN?.verifiedAt, + }); + } + } else { + // No specific MFA methods configured, but MFA is forced - check for any verified MFA factors + // (excluding IDP which should be handled separately) + const otpEmail = session.factors.otpEmail?.verifiedAt; + const otpSms = session.factors.otpSms?.verifiedAt; + const totp = session.factors.totp?.verifiedAt; + const webAuthN = session.factors.webAuthN?.verifiedAt; + // Note: Removed IDP (session.factors.intent?.verifiedAt) as requested + + mfaValid = !!(otpEmail || otpSms || totp || webAuthN); + if (!mfaValid) { + console.warn("Session has no valid multifactor", session.factors); + } } - } else { - mfaValid = true; } } + // If MFA is not required by policy, mfaValid remains true const validPassword = session?.factors?.password?.verifiedAt; const validPasskey = session?.factors?.webAuthN?.verifiedAt; - const validIDP = session?.factors?.intent?.verifiedAt; + // validIDP already declared above for IDP bypass logic const stillValid = session.expirationDate ? timestampDate(session.expirationDate).getTime() > new Date().getTime() : true; @@ -151,7 +172,8 @@ export async function findValidSession({ return s.factors?.user?.loginName === authRequest.loginHint; } if (samlRequest) { - // TODO: do whatever + // SAML requests don't contain user hints like OIDC (hintUserId/loginHint) + // so we return all sessions for further processing return true; } return true; From d7fb2102540738b00d6717bb5266d7d329e2616e Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 23 Sep 2025 18:21:01 +0200 Subject: [PATCH 17/21] fix(login): host utility to provide correct host behind proxies (#10770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved When deploying the login application behind proxies or using Vercel rewrites (e.g., `zitadel.com/login` → `login-zitadel-qa.vercel.app`), the application was using the internal rewritten host instead of the original user-facing host. This caused several issues: 1. **Broken Password Reset Emails**: Email links contained internal hosts like `login-zitadel-qa.vercel.app` instead of `zitadel.com` 2. **Inconsistent User Experience**: Users would see different domains in various parts of the flow 3. **Security Concerns**: Internal infrastructure details were exposed to end users 4. **Scattered Logic**: Host detection logic was duplicated across multiple files with inconsistent error handling # How the Problems Are Solved Created comprehensive host detection utilities in `/lib/server/host.ts` and `/lib/client/host.ts`: **Server-side utilities:** - `getOriginalHost()` - Returns the original user-facing host - `getOriginalHostWithProtocol()` - Returns host with proper protocol (http/https) --- .../(login)/idp/[provider]/success/page.tsx | 45 +- src/app/(login)/mfa/set/page.tsx | 59 +- src/app/(login)/otp/[method]/page.tsx | 19 +- src/app/(login)/u2f/page.tsx | 19 +- src/app/(login)/verify/page.tsx | 22 +- src/app/login/route.ts | 28 +- src/components/idp-signin.tsx | 28 +- src/components/session-item.tsx | 42 +- src/lib/oidc.ts | 1 + src/lib/server/host.test.ts | 297 ++++++++++ src/lib/server/host.ts | 48 ++ src/lib/server/idp.ts | 42 +- src/lib/server/loginname.ts | 18 +- src/lib/server/passkeys.ts | 7 +- src/lib/server/password.ts | 9 +- src/lib/server/register.ts | 67 ++- src/lib/server/session.ts | 7 +- src/lib/server/u2f.ts | 13 +- src/lib/server/verify.ts | 11 +- src/lib/session.test.ts | 236 +++++++- src/lib/session.ts | 121 ++-- src/lib/verify-helper.test.ts | 322 ++++++++++ src/lib/verify-helper.ts | 162 +++--- src/lib/zitadel.ts | 549 ++++-------------- 24 files changed, 1328 insertions(+), 844 deletions(-) create mode 100644 src/lib/server/host.test.ts create mode 100644 src/lib/server/host.ts create mode 100644 src/lib/verify-helper.test.ts diff --git a/src/app/(login)/idp/[provider]/success/page.tsx b/src/app/(login)/idp/[provider]/success/page.tsx index ae9feff6b..1acf2a39c 100644 --- a/src/app/(login)/idp/[provider]/success/page.tsx +++ b/src/app/(login)/idp/[provider]/success/page.tsx @@ -51,8 +51,7 @@ async function resolveOrganizationForUser({ serviceUrl, domain: suffix, }); - const orgToCheckForDiscovery = - orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; if (orgToCheckForDiscovery) { const orgLoginSettings = await getLoginSettings({ @@ -141,12 +140,7 @@ export default async function Page(props: { } } - return loginSuccess( - userId, - { idpIntentId: id, idpIntentToken: token }, - requestId, - branding, - ); + return loginSuccess(userId, { idpIntentId: id, idpIntentToken: token }, requestId, branding); } if (link) { @@ -174,12 +168,7 @@ export default async function Page(props: { if (!idpLink) { return linkingFailed(branding); } else { - return linkingSuccess( - userId, - { idpIntentId: id, idpIntentToken: token }, - requestId, - branding, - ); + return linkingSuccess(userId, { idpIntentId: id, idpIntentToken: token }, requestId, branding); } } @@ -230,12 +219,7 @@ export default async function Page(props: { if (!idpLink) { return linkingFailed(branding); } else { - return linkingSuccess( - foundUser.userId, - { idpIntentId: id, idpIntentToken: token }, - requestId, - branding, - ); + return linkingSuccess(foundUser.userId, { idpIntentId: id, idpIntentToken: token }, requestId, branding); } } } @@ -260,10 +244,7 @@ export default async function Page(props: { organization: organizationSchema, }); } else { - addHumanUserWithOrganization = create( - AddHumanUserRequestSchema, - addHumanUser, - ); + addHumanUserWithOrganization = create(AddHumanUserRequestSchema, addHumanUser); } try { @@ -272,16 +253,10 @@ export default async function Page(props: { request: addHumanUserWithOrganization, }); } catch (error: unknown) { - console.error( - "An error occurred while creating the user:", - error, - addHumanUser, - ); + console.error("An error occurred while creating the user:", error, addHumanUser); return loginFailed( branding, - (error as ConnectError).message - ? (error as ConnectError).message - : "Could not create user", + (error as ConnectError).message ? (error as ConnectError).message : "Could not create user", ); } } else if (options?.isCreationAllowed) { @@ -325,11 +300,7 @@ export default async function Page(props: {

- + ); diff --git a/src/app/(login)/mfa/set/page.tsx b/src/app/(login)/mfa/set/page.tsx index 0203ba341..c966a4d88 100644 --- a/src/app/(login)/mfa/set/page.tsx +++ b/src/app/(login)/mfa/set/page.tsx @@ -22,7 +22,7 @@ import { headers } from "next/headers"; export async function generateMetadata(): Promise { const t = await getTranslations("mfa"); - return { title: t('set.title')}; + return { title: t("set.title") }; } function isSessionValid(session: Partial): { @@ -31,23 +31,19 @@ function isSessionValid(session: Partial): { } { const validPassword = session?.factors?.password?.verifiedAt; const validPasskey = session?.factors?.webAuthN?.verifiedAt; - const stillValid = session.expirationDate - ? timestampDate(session.expirationDate) > new Date() - : true; + const validIDP = session?.factors?.intent?.verifiedAt; + const stillValid = session.expirationDate ? timestampDate(session.expirationDate) > new Date() : true; - const verifiedAt = validPassword || validPasskey; - const valid = !!((validPassword || validPasskey) && stillValid); + const verifiedAt = validPassword || validPasskey || validIDP; + const valid = !!((validPassword || validPasskey || validIDP) && stillValid); return { valid, verifiedAt }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; - const { loginName, checkAfter, force, requestId, organization, sessionId } = - searchParams; + const { loginName, checkAfter, force, requestId, organization, sessionId } = searchParams; const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -68,8 +64,7 @@ export default async function Page(props: { userId, }).then((methods) => { return getUserByID({ serviceUrl, userId }).then((user) => { - const humanUser = - user.user?.type.case === "human" ? user.user?.type.value : undefined; + const humanUser = user.user?.type.case === "human" ? user.user?.type.value : undefined; return { id: session.id, @@ -83,10 +78,7 @@ export default async function Page(props: { }); } - async function loadSessionByLoginname( - loginName?: string, - organization?: string, - ) { + async function loadSessionByLoginname(loginName?: string, organization?: string) { return loadMostRecentSession({ serviceUrl, sessionParams: { @@ -152,24 +144,21 @@ export default async function Page(props: { )} - {isSessionValid(sessionWithData).valid && - loginSettings && - sessionWithData && - sessionWithData.factors?.user?.id && ( - - )} + {valid && loginSettings && sessionWithData && sessionWithData.factors?.user?.id && ( + + )}
diff --git a/src/app/(login)/otp/[method]/page.tsx b/src/app/(login)/otp/[method]/page.tsx index d39c9ffda..4e8bfe860 100644 --- a/src/app/(login)/otp/[method]/page.tsx +++ b/src/app/(login)/otp/[method]/page.tsx @@ -4,20 +4,17 @@ import { LoginOTP } from "@/components/login-otp"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getSessionCookieById } from "@/lib/cookies"; +import { getOriginalHost } from "@/lib/server/host"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; -import { - getBrandingSettings, - getLoginSettings, - getSession, -} from "@/lib/zitadel"; +import { getBrandingSettings, getLoginSettings, getSession } from "@/lib/zitadel"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; export async function generateMetadata(): Promise { const t = await getTranslations("otp"); - return { title: t('verify.title')}; + return { title: t("verify.title") }; } export default async function Page(props: { @@ -29,11 +26,7 @@ export default async function Page(props: { 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 host = await getOriginalHost(); const { loginName, // send from password page @@ -120,9 +113,7 @@ export default async function Page(props: { loginName={loginName ?? session.factors?.user?.loginName} sessionId={sessionId} requestId={requestId} - organization={ - organization ?? session?.factors?.user?.organizationId - } + organization={organization ?? session?.factors?.user?.organizationId} method={method} loginSettings={loginSettings} host={host} diff --git a/src/app/(login)/u2f/page.tsx b/src/app/(login)/u2f/page.tsx index 5bf892846..558314e5b 100644 --- a/src/app/(login)/u2f/page.tsx +++ b/src/app/(login)/u2f/page.tsx @@ -13,23 +13,16 @@ import { headers } from "next/headers"; export async function generateMetadata(): Promise { const t = await getTranslations("u2f"); - return { title: t('verify.title')}; + return { title: t("verify.title") }; } -export default async function Page(props: { - searchParams: Promise>; -}) { +export default async function Page(props: { searchParams: Promise> }) { const searchParams = await props.searchParams; const { loginName, requestId, sessionId, organization } = searchParams; 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 branding = await getBrandingSettings({ serviceUrl, @@ -37,17 +30,13 @@ export default async function Page(props: { }); const sessionFactors = sessionId - ? await loadSessionById(serviceUrl, sessionId, organization) + ? await loadSessionById(sessionId, organization) : await loadMostRecentSession({ serviceUrl, sessionParams: { loginName, organization }, }); - async function loadSessionById( - host: string, - sessionId: string, - organization?: string, - ) { + async function loadSessionById(sessionId: string, organization?: string) { const recent = await getSessionCookieById({ sessionId, organization }); return getSession({ serviceUrl, diff --git a/src/app/(login)/verify/page.tsx b/src/app/(login)/verify/page.tsx index 67166ed37..e1aa91698 100644 --- a/src/app/(login)/verify/page.tsx +++ b/src/app/(login)/verify/page.tsx @@ -4,6 +4,7 @@ import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { VerifyForm } from "@/components/verify-form"; import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify"; +import { getOriginalHostWithProtocol } from "@/lib/server/host"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getUserByID } from "@/lib/zitadel"; @@ -14,14 +15,13 @@ import { headers } from "next/headers"; export async function generateMetadata(): Promise { const t = await getTranslations("verify"); - return { title: t('verify.title')}; + return { title: t("verify.title") }; } export default async function Page(props: { searchParams: Promise }) { const searchParams = await props.searchParams; - const { userId, loginName, code, organization, requestId, invite, send } = - searchParams; + const { userId, loginName, code, organization, requestId, invite, send } = searchParams; const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); @@ -41,17 +41,13 @@ export default async function Page(props: { searchParams: Promise }) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; async function sendEmail(userId: string) { - const host = _headers.get("host"); - - if (!host || typeof host !== "string") { - throw new Error("No host found"); - } + const hostWithProtocol = await getOriginalHostWithProtocol(); if (invite === "true") { await sendInviteEmailCode({ userId, urlTemplate: - `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + + `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + (requestId ? `&requestId=${requestId}` : ""), }).catch((error) => { console.error("Could not send invitation email", error); @@ -61,7 +57,7 @@ export default async function Page(props: { searchParams: Promise }) { await sendEmailCode({ userId, urlTemplate: - `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + (requestId ? `&requestId=${requestId}` : ""), }).catch((error) => { console.error("Could not send verification email", error); @@ -157,11 +153,7 @@ export default async function Page(props: { searchParams: Promise }) { > ) : ( user && ( - + ) )} diff --git a/src/app/login/route.ts b/src/app/login/route.ts index 2c1d94746..ed61c43c3 100644 --- a/src/app/login/route.ts +++ b/src/app/login/route.ts @@ -1,14 +1,7 @@ import { getAllSessions } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { - validateAuthRequest, - isRSCRequest -} from "@/lib/auth-utils"; -import { - handleOIDCFlowInitiation, - handleSAMLFlowInitiation, - FlowInitiationParams -} from "@/lib/server/flow-initiation"; +import { validateAuthRequest, isRSCRequest } from "@/lib/auth-utils"; +import { handleOIDCFlowInitiation, handleSAMLFlowInitiation, FlowInitiationParams } from "@/lib/server/flow-initiation"; import { listSessions } from "@/lib/zitadel"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { headers } from "next/headers"; @@ -17,6 +10,8 @@ import { NextRequest, NextResponse } from "next/server"; export const dynamic = "force-dynamic"; export const revalidate = false; export const fetchCache = "default-no-store"; +// Add this to prevent RSC requests +export const runtime = "nodejs"; async function loadSessions({ serviceUrl, ids }: { serviceUrl: string; ids: string[] }): Promise { const response = await listSessions({ @@ -41,10 +36,7 @@ export async function GET(request: NextRequest) { // Early validation: if no valid request parameters, return error immediately const requestId = validateAuthRequest(searchParams); if (!requestId) { - return NextResponse.json( - { error: "No valid authentication request found" }, - { status: 400 }, - ); + return NextResponse.json({ error: "No valid authentication request found" }, { status: 400 }); } const sessionCookies = await getAllSessions(); @@ -69,14 +61,8 @@ export async function GET(request: NextRequest) { return handleSAMLFlowInitiation(flowParams); } else if (requestId.startsWith("device_")) { // Device Authorization does not need to start here as it is handled on the /device endpoint - return NextResponse.json( - { error: "Device authorization should use /device endpoint" }, - { status: 400 } - ); + return NextResponse.json({ error: "Device authorization should use /device endpoint" }, { status: 400 }); } else { - return NextResponse.json( - { error: "Invalid request ID format" }, - { status: 400 } - ); + return NextResponse.json({ error: "Invalid request ID format" }, { status: 400 }); } } diff --git a/src/components/idp-signin.tsx b/src/components/idp-signin.tsx index a7c938e90..ec7da3fc8 100644 --- a/src/components/idp-signin.tsx +++ b/src/components/idp-signin.tsx @@ -1,8 +1,8 @@ "use client"; -import { createNewSessionFromIdpIntent } from "@/lib/server/idp"; +import { CreateNewSessionCommand, createNewSessionFromIdpIntent } from "@/lib/server/idp"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Alert } from "./alert"; import { Spinner } from "./spinner"; @@ -16,25 +16,33 @@ type Props = { requestId?: string; }; -export function IdpSignin({ - userId, - idpIntent: { idpIntentId, idpIntentToken }, - requestId, -}: Props) { +export function IdpSignin({ userId, idpIntent: { idpIntentId, idpIntentToken }, requestId }: Props) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const executedRef = useRef(false); const router = useRouter(); useEffect(() => { - createNewSessionFromIdpIntent({ + // Prevent double execution in React Strict Mode + if (executedRef.current) { + return; + } + + executedRef.current = true; + let request: CreateNewSessionCommand = { userId, idpIntent: { idpIntentId, idpIntentToken, }, - requestId, - }) + }; + + if (requestId) { + request = { ...request, requestId: requestId }; + } + + createNewSessionFromIdpIntent(request) .then((response) => { if (response && "error" in response && response?.error) { setError(response?.error); diff --git a/src/components/session-item.tsx b/src/components/session-item.tsx index 3ae425d0b..c74f52ff1 100644 --- a/src/components/session-item.tsx +++ b/src/components/session-item.tsx @@ -1,7 +1,7 @@ "use client"; import { sendLoginname } from "@/lib/server/loginname"; -import { clearSession, continueWithSession } from "@/lib/server/session"; +import { clearSession, continueWithSession, ContinueWithSessionCommand } from "@/lib/server/session"; import { XCircleIcon } from "@heroicons/react/24/outline"; import * as Tooltip from "@radix-ui/react-tooltip"; import { Timestamp, timestampDate } from "@zitadel/client"; @@ -21,9 +21,7 @@ export function isSessionValid(session: Partial): { const validPasskey = session?.factors?.webAuthN?.verifiedAt; const validIDP = session?.factors?.intent?.verifiedAt; - const stillValid = session.expirationDate - ? timestampDate(session.expirationDate) > new Date() - : true; + const stillValid = session.expirationDate ? timestampDate(session.expirationDate) > new Date() : true; const verifiedAt = validPassword || validPasskey || validIDP; const valid = !!((validPassword || validPasskey || validIDP) && stillValid); @@ -31,15 +29,7 @@ export function isSessionValid(session: Partial): { return { valid, verifiedAt }; } -export function SessionItem({ - session, - reload, - requestId, -}: { - session: Session; - reload: () => void; - requestId?: string; -}) { +export function SessionItem({ session, reload, requestId }: { session: Session; reload: () => void; requestId?: string }) { const currentLocale = useLocale(); moment.locale(currentLocale === "zh" ? "zh-cn" : currentLocale); @@ -73,10 +63,21 @@ export function SessionItem({
diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index 212414a63..0bf8ac390 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -23,15 +23,15 @@ type SessionCookie = Cookie & T; async function setSessionHttpOnlyCookie(sessions: SessionCookie[], iFrameEnabled: boolean = false) { const cookiesList = await cookies(); - // Use "none" for iframe compatibility, otherwise "strict" as default + // "none" is required for iframe embedding (with secure flag) let resolvedSameSite: "lax" | "strict" | "none"; if (iFrameEnabled) { // When embedded in iframe, must use "none" with secure flag resolvedSameSite = "none"; } else { - // Production and other environments: use strict for better security - resolvedSameSite = "strict"; + // This allows cookies during top-level navigation while blocking cross-origin requests + resolvedSameSite = "lax"; } return cookiesList.set({ @@ -273,6 +273,7 @@ export async function getAllSessions(cleanup: boolean = false): Promise { const { serviceUrl, requestId, sessions, sessionCookies, request } = params; - + const { authRequest } = await getAuthRequest({ serviceUrl, authRequestId: requestId.replace("oidc_", ""), @@ -85,11 +82,14 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr if (orgDomainScope) { const matched = ORG_DOMAIN_SCOPE_REGEX.exec(orgDomainScope); const orgDomain = matched?.[1] ?? ""; + + console.log("Extracted org domain:", orgDomain); if (orgDomain) { const orgs = await getOrgsByDomain({ serviceUrl, domain: orgDomain, }); + if (orgs.result && orgs.result.length === 1) { organization = orgs.result[0].id ?? ""; suffix = orgDomain; @@ -347,6 +347,10 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr loginNameUrl.searchParams.append("organization", organization); } + if (suffix) { + loginNameUrl.searchParams.append("suffix", suffix); + } + return NextResponse.redirect(loginNameUrl); } } @@ -356,7 +360,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr */ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Promise { const { serviceUrl, requestId, sessions, sessionCookies, request } = params; - + const { samlRequest } = await getSAMLRequest({ serviceUrl, samlRequestId: requestId.replace("saml_", ""), @@ -416,7 +420,7 @@ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Pr }, }), }); - + if (url && binding.case === "redirect") { return NextResponse.redirect(url); } else if (url && binding.case === "post") { @@ -447,4 +451,4 @@ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Pr request, requestId, }); -} \ No newline at end of file +} diff --git a/src/lib/server/passkeys.ts b/src/lib/server/passkeys.ts index 4082cfc77..83a354a27 100644 --- a/src/lib/server/passkeys.ts +++ b/src/lib/server/passkeys.ts @@ -11,29 +11,33 @@ import { } from "@/lib/zitadel"; import { create, Duration, Timestamp, timestampDate } from "@zitadel/client"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; -import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { Checks, ChecksSchema, GetSessionResponse } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { RegisterPasskeyResponse, VerifyPasskeyRegistrationRequestSchema, } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { headers } from "next/headers"; import { userAgent } from "next/server"; -import { completeFlowOrGetUrl } from "../client"; import { getMostRecentSessionCookie, getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; import { getServiceUrlFromHeaders } from "../service-url"; import { checkEmailVerification, checkUserVerification } from "../verify-helper"; -import { setSessionAndUpdateCookie } from "./cookie"; +import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "./cookie"; import { getOriginalHost } from "./host"; +import { completeFlowOrGetUrl } from "../client"; type VerifyPasskeyCommand = { passkeyId: string; passkeyName?: string; publicKeyCredential: any; - sessionId: string; + sessionId?: string; + userId?: string; }; type RegisterPasskeyCommand = { - sessionId: string; + sessionId?: string; + userId?: string; + code?: string; + codeId?: string; }; function isSessionValid(session: Partial): { @@ -53,73 +57,126 @@ function isSessionValid(session: Partial): { export async function registerPasskeyLink( command: RegisterPasskeyCommand, ): Promise { - const { sessionId } = command; + if (!command.sessionId && !command.userId) { + return { error: "Either sessionId or userId must be provided" }; + } const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); const host = await getOriginalHost(); - const sessionCookie = await getSessionCookieById({ sessionId }); - const session = await getSession({ - serviceUrl, - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }); + let session: GetSessionResponse | undefined; + let createdSession: Session | undefined; + let currentUserId: string | undefined = undefined; + let registerCode: { id: string; code: string } | undefined = undefined; - if (!session?.session?.factors?.user?.id) { - return { error: "Could not determine user from session" }; - } + if (command.sessionId) { + // Session-based flow (existing logic) + const sessionCookie = await getSessionCookieById({ sessionId: command.sessionId }); + session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session?.session?.factors?.user?.id) { + return { error: "Could not determine user from session" }; + } + + currentUserId = session.session.factors.user.id; - const sessionValid = isSessionValid(session.session); + const sessionValid = isSessionValid(session.session); - if (!sessionValid) { - const authmethods = await listAuthenticationMethodTypes({ + if (!sessionValid.valid) { + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: currentUserId, + }); + + // if the user has no authmethods set, we need to check if the user was verified + if (authmethods.authMethodTypes.length !== 0) { + return { + error: "You have to authenticate or have a valid User Verification Check", + }; + } + + // check if a verification was done earlier + const hasValidUserVerificationCheck = await checkUserVerification(currentUserId); + + console.log("hasValidUserVerificationCheck", hasValidUserVerificationCheck); + if (!hasValidUserVerificationCheck) { + return { error: "User Verification Check has to be done" }; + } + + if (!command.code) { + // request a new code if no code is provided + const codeResponse = await createPasskeyRegistrationLink({ + serviceUrl, + userId: currentUserId, + }); + + if (!codeResponse?.code?.code) { + return { error: "Could not create registration link" }; + } + + registerCode = codeResponse.code; + } + } + } else if (command.userId && command.code && command.codeId) { + currentUserId = command.userId; + registerCode = { + id: command.codeId, + code: command.code, + }; + + // Check if user exists + const userResponse = await getUserByID({ serviceUrl, - userId: session.session.factors.user.id, + userId: currentUserId, }); - // if the user has no authmethods set, we need to check if the user was verified - if (authmethods.authMethodTypes.length !== 0) { - return { - error: "You have to authenticate or have a valid User Verification Check", - }; + if (!userResponse || !userResponse.user) { + return { error: "User not found" }; } - // check if a verification was done earlier - const hasValidUserVerificationCheck = await checkUserVerification(session.session.factors.user.id); + // Create a session for the user to continue the flow after passkey registration + const checks = create(ChecksSchema, { + user: { + search: { + case: "loginName", + value: userResponse.user.preferredLoginName, + }, + }, + }); + + createdSession = await createSessionAndUpdateCookie({ + checks, + requestId: undefined, // No requestId in passkey registration context, TODO: consider if needed + }); - if (!hasValidUserVerificationCheck) { - return { error: "User Verification Check has to be done" }; + if (!createdSession) { + return { error: "Could not create session" }; } } + if (!registerCode) { + throw new Error("Missing code in response"); + } + const [hostname] = host.split(":"); if (!hostname) { throw new Error("Could not get hostname"); } - const userId = session?.session?.factors?.user?.id; - - if (!userId) { - throw new Error("Could not get session"); - } - // TODO: add org context - - // use session token to add the passkey - const registerLink = await createPasskeyRegistrationLink({ - serviceUrl, - userId, - }); - - if (!registerLink.code) { - throw new Error("Missing code in response"); + if (!currentUserId) { + throw new Error("Could not determine user"); } return registerPasskey({ serviceUrl, - userId, - code: registerLink.code, + userId: currentUserId, + code: registerCode, domain: hostname, }); } @@ -128,6 +185,10 @@ export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); + if (!command.sessionId && !command.userId) { + throw new Error("Either sessionId or userId must be provided"); + } + // if no name is provided, try to generate one from the user agent let passkeyName = command.passkeyName; if (!passkeyName) { @@ -140,18 +201,38 @@ export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) { }${os.name}${os.name ? ", " : ""}${browser.name}`; } - const sessionCookie = await getSessionCookieById({ - sessionId: command.sessionId, - }); - const session = await getSession({ - serviceUrl, - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }); - const userId = session?.session?.factors?.user?.id; + let currentUserId: string; - if (!userId) { - throw new Error("Could not get session"); + if (command.sessionId) { + // Session-based flow + const sessionCookie = await getSessionCookieById({ + sessionId: command.sessionId, + }); + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + throw new Error("Could not get session"); + } + + currentUserId = userId; + } else { + // UserId-based flow + currentUserId = command.userId!; + + // Verify user exists + const userResponse = await getUserByID({ + serviceUrl, + userId: currentUserId, + }); + + if (!userResponse || !userResponse.user) { + throw new Error("User not found"); + } } return zitadelVerifyPasskeyRegistration({ @@ -160,7 +241,7 @@ export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) { passkeyId: command.passkeyId, publicKeyCredential: command.publicKeyCredential, passkeyName, - userId, + userId: currentUserId, }), }); } diff --git a/src/lib/server/password.ts b/src/lib/server/password.ts index 5212aa2ec..166a11d5f 100644 --- a/src/lib/server/password.ts +++ b/src/lib/server/password.ts @@ -18,7 +18,7 @@ import { createUserServiceClient } from "@zitadel/client/v2"; import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; -import { AuthenticationMethodType, SetPasswordRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { SetPasswordRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { headers } from "next/headers"; import { completeFlowOrGetUrl } from "../client"; import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; @@ -370,13 +370,26 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const sessionCookie = await getSessionCookieById({ sessionId }); + let sessionCookie; + try { + sessionCookie = await getSessionCookieById({ sessionId }); + } catch (error) { + console.error("Error getting session cookie:", error); + return { error: "Could not load session cookie" }; + } - const { session } = await getSession({ - serviceUrl, - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }); + let session; + try { + const sessionResponse = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + session = sessionResponse.session; + } catch (error) { + console.error("Error getting session:", error); + return { error: "Could not load session" }; + } if (!session || !session.factors?.user?.id) { return { error: "Could not load session" }; @@ -390,40 +403,43 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS }); // check if the user has no password set in order to set a password - const authmethods = await listAuthenticationMethodTypes({ - serviceUrl, - userId: session.factors.user.id, - }); + let authmethods; + try { + authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + } catch (error) { + console.error("Error getting auth methods:", error); + return { error: "Could not load auth methods" }; + } if (!authmethods) { return { error: "Could not load auth methods" }; } - const requiredAuthMethodsForForceMFA = [ - AuthenticationMethodType.OTP_EMAIL, - AuthenticationMethodType.OTP_SMS, - AuthenticationMethodType.TOTP, - AuthenticationMethodType.U2F, - ]; - - const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every((method) => !authmethods.authMethodTypes.includes(method)); - - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: session.factors.user.organizationId, - }); + let loginSettings; + try { + loginSettings = await getLoginSettings({ + serviceUrl, + organization: session.factors.user.organizationId, + }); + } catch (error) { + console.error("Error getting login settings:", error); + return { error: "Could not load login settings" }; + } const forceMfa = !!(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly); // if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user - if (forceMfa && hasNoMFAMethods) { + if (forceMfa) { + console.log("Set password using service account due to enforced MFA without existing MFA methods"); return setPassword({ serviceUrl, payload }).catch((error) => { // throw error if failed precondition (ex. User is not yet initialized) if (error.code === 9 && error.message) { return { error: "Failed precondition" }; - } else { - throw error; } + return { error: "Could not set password" }; }); } else { const transport = async (serviceUrl: string, token: string) => { @@ -450,7 +466,7 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS if (error.code === 7) { return { error: "Session is not valid." }; } - throw error; + return { error: "Could not set the password" }; }); } } diff --git a/src/lib/server/register.ts b/src/lib/server/register.ts index af392afcc..74647a387 100644 --- a/src/lib/server/register.ts +++ b/src/lib/server/register.ts @@ -5,10 +5,12 @@ import { addHumanUser, addIDPLink, getLoginSettings, getUserByID, listAuthentica import { create } from "@zitadel/client"; import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { ChecksJson, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; -import { headers } from "next/headers"; +import { cookies, headers } from "next/headers"; +import crypto from "crypto"; import { completeFlowOrGetUrl } from "../client"; import { getServiceUrlFromHeaders } from "../service-url"; import { checkEmailVerification, checkMFAFactors } from "../verify-helper"; +import { getOrSetFingerprintId } from "../fingerprint"; type RegisterUserCommand = { email: string; @@ -79,6 +81,21 @@ export async function registerUser(command: RegisterUserCommand) { params.append("requestId", command.requestId); } + // Set verification cookie for users registering with passkey (no password) + // This allows them to proceed with passkey registration without additional verification + const cookiesList = await cookies(); + const userAgentId = await getOrSetFingerprintId(); + + const verificationCheck = crypto.createHash("sha256").update(`${session.factors.user.id}:${userAgentId}`).digest("hex"); + + await cookiesList.set({ + name: "verificationCheck", + value: verificationCheck, + httpOnly: true, + path: "/", + maxAge: 300, // 5 minutes + }); + return { redirect: "/passkey/set?" + params }; } else { const userResponse = await getUserByID({ diff --git a/src/lib/zitadel.ts b/src/lib/zitadel.ts index d3067b8ea..ae2a12770 100644 --- a/src/lib/zitadel.ts +++ b/src/lib/zitadel.ts @@ -687,7 +687,7 @@ export async function searchUsers({ serviceUrl, searchValue, loginSettings, orga } if (organizationId) { - queries.push( + emailAndPhoneQueries.push( create(SearchQuerySchema, { query: { case: "organizationIdQuery", From 7b164cdbb13884b26f99da7edb0063a07e9f4e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 1 Oct 2025 12:47:04 +0300 Subject: [PATCH 19/21] feat(rt): project repository (#10789) # Which Problems Are Solved Add projects to the relational tables # How the Problems Are Solved - Define table migrations - Define and implement Project and Project Role repositories. - Provide projection handlers to populate the relational tables. # Additional Changes - Statement Builder now has a constructor which allows setting of a base query with arguments. - Certain operations, like Get, Update and Delete require the Primary Key to be set as conditions. However, this requires knowledge of the implementation and table definition. This PR proposes an additional condition for repositories: `PrimaryKeyCondition`. This gives clarity on the required IDs for these operations. - Added couple of helpers to the repository package, to allow more DRY code. - getOne / getMany: generic functions for query execution and scanning. - checkRestrictingColumns, checkPkCondition: simplify condition checking, instead of using ladders of conditionals. - Added a couple of helpers to the repository test package: - Transaction, savepoint and rollback helpers. - Create instance and organization helpers for objects that depend on them (like projects). # Additional Context - after https://github.com/zitadel/zitadel/pull/10809 - closes #10765 --- acceptance/oidcrp/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/acceptance/oidcrp/main.go b/acceptance/oidcrp/main.go index 72ae5f57e..83efa12a7 100644 --- a/acceptance/oidcrp/main.go +++ b/acceptance/oidcrp/main.go @@ -20,7 +20,6 @@ import ( "github.com/google/uuid" "github.com/sirupsen/logrus" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/client/rp" httphelper "github.com/zitadel/oidc/v3/pkg/http" From 4f60af1bbdf22fbbf7b7f427a60bb31d3b006b03 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 7 Oct 2025 07:47:58 +0200 Subject: [PATCH 20/21] fix(login): Redirect to IDP flow when password auth is disabled (#10839) Closes #10671 # Which Problems Are Solved Users with only password authentication method were immediately shown an error "Username Password not allowed" when `loginSettings.allowUsernamePassword` was set to false. However, the IDP flow could potentially allow the user to register a new account or link an existing account, providing a better user experience than a dead-end error. # How the Problems Are Solved - Modified single password method case to attempt IDP redirect before showing error - This allows users to potentially register or link accounts through the IDP flow instead of hitting an immediate error - Only show error as last resort when no IDP alternative is available --- src/lib/server/loginname.test.ts | 548 +++++++++++++++++++++++++++++++ src/lib/server/loginname.ts | 17 +- 2 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 src/lib/server/loginname.test.ts diff --git a/src/lib/server/loginname.test.ts b/src/lib/server/loginname.test.ts new file mode 100644 index 000000000..a394e4805 --- /dev/null +++ b/src/lib/server/loginname.test.ts @@ -0,0 +1,548 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; +import { sendLoginname } from "./loginname"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getIDPByID } from "../zitadel"; + +// Mock all the dependencies +vi.mock("next/headers", () => ({ + headers: vi.fn(), +})); + +vi.mock("@zitadel/client", () => ({ + create: vi.fn(), +})); + +vi.mock("../service-url", () => ({ + getServiceUrlFromHeaders: vi.fn(), +})); + +vi.mock("../idp", () => ({ + idpTypeToIdentityProviderType: vi.fn(), + idpTypeToSlug: vi.fn(), +})); + +vi.mock("../zitadel", () => ({ + getActiveIdentityProviders: vi.fn(), + getIDPByID: vi.fn(), + getLoginSettings: vi.fn(), + getOrgsByDomain: vi.fn(), + listAuthenticationMethodTypes: vi.fn(), + listIDPLinks: vi.fn(), + searchUsers: vi.fn(), + startIdentityProviderFlow: vi.fn(), +})); + +vi.mock("./cookie", () => ({ + createSessionAndUpdateCookie: vi.fn(), +})); + +vi.mock("./host", () => ({ + getOriginalHost: vi.fn(), +})); + +describe("sendLoginname", () => { + // Mock modules + let mockHeaders: any; + let mockCreate: any; + let mockGetServiceUrlFromHeaders: any; + let mockGetLoginSettings: any; + let mockSearchUsers: any; + let mockCreateSessionAndUpdateCookie: any; + let mockListAuthenticationMethodTypes: any; + let mockListIDPLinks: any; + let mockGetOriginalHost: any; + let mockStartIdentityProviderFlow: any; + let mockGetActiveIdentityProviders: any; + let mockGetIDPByID: any; + let mockIdpTypeToSlug: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Import mocked modules + const { headers } = await import("next/headers"); + const { create } = await import("@zitadel/client"); + const { getServiceUrlFromHeaders } = await import("../service-url"); + const { + getLoginSettings, + searchUsers, + listAuthenticationMethodTypes, + listIDPLinks, + startIdentityProviderFlow, + getActiveIdentityProviders, + } = await import("../zitadel"); + const { createSessionAndUpdateCookie } = await import("./cookie"); + const { getOriginalHost } = await import("./host"); + const { idpTypeToSlug } = await import("../idp"); + + // Setup mocks + mockHeaders = vi.mocked(headers); + mockCreate = vi.mocked(create); + mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders); + mockGetLoginSettings = vi.mocked(getLoginSettings); + mockSearchUsers = vi.mocked(searchUsers); + mockCreateSessionAndUpdateCookie = vi.mocked(createSessionAndUpdateCookie); + mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes); + mockListIDPLinks = vi.mocked(listIDPLinks); + mockGetOriginalHost = vi.mocked(getOriginalHost); + mockStartIdentityProviderFlow = vi.mocked(startIdentityProviderFlow); + mockGetActiveIdentityProviders = vi.mocked(getActiveIdentityProviders); + mockGetIDPByID = vi.mocked(getIDPByID); + mockIdpTypeToSlug = vi.mocked(idpTypeToSlug); + + // Default mock implementations + mockHeaders.mockResolvedValue({} as any); + mockGetServiceUrlFromHeaders.mockReturnValue({ serviceUrl: "https://api.example.com" }); + mockGetOriginalHost.mockResolvedValue("example.com"); + mockIdpTypeToSlug.mockReturnValue("google"); + mockGetIDPByID.mockResolvedValue({ + id: "idp123", + name: "Google", + type: "GOOGLE", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Error cases", () => { + test("should return error when login settings cannot be retrieved", async () => { + mockGetLoginSettings.mockResolvedValue(null); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "Could not get login settings" }); + }); + + test("should return error when user search fails", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ error: "Search failed" }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "Search failed" }); + }); + + test("should return error when search result has no result field", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({}); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "Could not search users" }); + }); + + test("should return error when more than one user found", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ + result: [ + { userId: "user1", preferredLoginName: "user1@example.com" }, + { userId: "user2", preferredLoginName: "user2@example.com" }, + ], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "More than one user found. Provide a unique identifier." }); + }); + }); + + describe("Single user found - authentication method handling", () => { + const mockUser = { + userId: "user123", + preferredLoginName: "user@example.com", + details: { resourceOwner: "org123" }, + type: { case: "human", value: { email: { email: "user@example.com" } } }, + state: UserState.ACTIVE, + }; + + const mockSession = { + factors: { + user: { + id: "user123", + loginName: "user@example.com", + organizationId: "org123", + }, + }, + }; + + beforeEach(() => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockCreate.mockReturnValue({}); + mockCreateSessionAndUpdateCookie.mockResolvedValue(mockSession); + }); + + test("should redirect to verify when user has no authentication methods", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ authMethodTypes: [] }); + + const result = await sendLoginname({ + loginName: "user@example.com", + requestId: "req123", + }); + + expect(result).toHaveProperty("redirect"); + expect((result as any).redirect).toMatch(/^\/verify\?/); + expect((result as any).redirect).toContain("loginName=user%40example.com"); + expect((result as any).redirect).toContain("send=true"); + expect((result as any).redirect).toContain("invite=true"); + expect((result as any).redirect).toContain("requestId=req123"); + }); + + describe("Single authentication method", () => { + test("should redirect to password when user has only password method and it's allowed", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + requestId: "req123", + }); + + expect(result).toHaveProperty("redirect"); + expect((result as any).redirect).toMatch(/^\/password\?/); + expect((result as any).redirect).toContain("loginName=user%40example.com"); + expect((result as any).redirect).toContain("requestId=req123"); + }); + + test("should attempt IDP redirect when password is not allowed but user has IDP links", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: false }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + mockListIDPLinks.mockResolvedValue({ + result: [{ idpId: "idp123" }], + }); + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ redirect: "https://idp.example.com/auth" }); + expect(mockListIDPLinks).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + userId: "user123", + }); + }); + + test("should return error when password not allowed and no IDP links available", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: false }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + mockListIDPLinks.mockResolvedValue({ result: [] }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ + error: "Username Password not allowed! Contact your administrator for more information.", + }); + }); + + test("should redirect to passkey when user has only passkey method and it's allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ passkeysType: PasskeysType.ALLOWED }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSKEY], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + requestId: "req123", + }); + + expect(result).toHaveProperty("redirect"); + expect((result as any).redirect).toMatch(/^\/passkey\?/); + expect((result as any).redirect).toContain("loginName=user%40example.com"); + expect((result as any).redirect).toContain("requestId=req123"); + }); + + test("should return error when passkeys are not allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ passkeysType: PasskeysType.NOT_ALLOWED }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSKEY], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ + error: "Passkeys not allowed! Contact your administrator for more information.", + }); + }); + + test("should redirect to IDP when user has only IDP method", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.IDP], + }); + mockListIDPLinks.mockResolvedValue({ + result: [{ idpId: "idp123" }], + }); + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ redirect: "https://idp.example.com/auth" }); + }); + }); + + describe("Multiple authentication methods", () => { + test("should prefer passkey when multiple methods available", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.PASSKEY], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toHaveProperty("redirect"); + expect((result as any).redirect).toMatch(/^\/passkey\?/); + expect((result as any).redirect).toContain("altPassword=true"); // password is allowed + }); + + test("should not show password alternative when password is not allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: false }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.PASSKEY], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/passkey\?/); + expect(result?.redirect).toContain("altPassword=false"); // password is not allowed + }); + + test("should redirect to IDP when no passkey but IDP available", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.IDP], + }); + mockListIDPLinks.mockResolvedValue({ + result: [{ idpId: "idp123" }], + }); + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ redirect: "https://idp.example.com/auth" }); + }); + + test("should redirect to password when no passkey or IDP, only password available and allowed", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/password\?/); + }); + + test("should return error when password is only method in multi-method scenario but not allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: false }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + mockListIDPLinks.mockResolvedValue({ result: [] }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ + error: "Username Password not allowed! Contact your administrator for more information.", + }); + }); + }); + }); + + describe("User not found scenarios", () => { + beforeEach(() => { + mockSearchUsers.mockResolvedValue({ result: [] }); + }); + + test("should redirect to single IDP when register allowed but password not allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ + allowRegister: true, + allowUsernamePassword: false, + }); + mockGetActiveIdentityProviders.mockResolvedValue({ + identityProviders: [{ id: "idp123", type: "OIDC" }], + }); + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ redirect: "https://idp.example.com/auth" }); + }); + + test("should redirect to register when both register and password allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ + allowRegister: true, + allowUsernamePassword: true, + ignoreUnknownUsernames: false, + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + organization: "org123", + requestId: "req123", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/register\?/); + expect(result?.redirect).toContain("organization=org123"); + expect(result?.redirect).toContain("requestId=req123"); + expect(result?.redirect).toContain("email=user%40example.com"); + }); + + test("should redirect to password when ignoreUnknownUsernames is true", async () => { + mockGetLoginSettings.mockResolvedValue({ + ignoreUnknownUsernames: true, + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + requestId: "req123", + organization: "org123", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/password\?/); + expect(result?.redirect).toContain("loginName=user%40example.com"); + expect(result?.redirect).toContain("requestId=req123"); + expect(result?.redirect).toContain("organization=org123"); + }); + + test("should return error when user not found and no registration allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ + allowRegister: false, + allowUsernamePassword: true, + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "User not found in the system" }); + }); + }); + + describe("Edge cases", () => { + test("should handle session creation failure", async () => { + const mockUser = { + userId: "user123", + preferredLoginName: "user@example.com", + details: { resourceOwner: "org123" }, + type: { case: "human", value: { email: { email: "user@example.com" } } }, + state: UserState.ACTIVE, + }; + + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockCreate.mockReturnValue({}); + mockCreateSessionAndUpdateCookie.mockResolvedValue({ factors: {} }); // No user in session + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "Could not create session for user" }); + }); + + test("should handle initial user state", async () => { + const mockUser = { + userId: "user123", + preferredLoginName: "user@example.com", + details: { resourceOwner: "org123" }, + type: { case: "human", value: { email: { email: "user@example.com" } } }, + state: UserState.INITIAL, + }; + + const mockSession = { + factors: { + user: { + id: "user123", + loginName: "user@example.com", + organizationId: "org123", + }, + }, + }; + + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockCreate.mockReturnValue({}); + mockCreateSessionAndUpdateCookie.mockResolvedValue(mockSession); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "Initial User not supported" }); + }); + + test("should handle organization parameter in all redirects", async () => { + const mockUser = { + userId: "user123", + preferredLoginName: "user@example.com", + details: { resourceOwner: "org123" }, + type: { case: "human", value: { email: { email: "user@example.com" } } }, + state: UserState.ACTIVE, + }; + + const mockSession = { + factors: { + user: { + id: "user123", + loginName: "user@example.com", + organizationId: "org123", + }, + }, + }; + + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockCreate.mockReturnValue({}); + mockCreateSessionAndUpdateCookie.mockResolvedValue(mockSession); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + organization: "custom-org", + requestId: "req123", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toContain("organization=custom-org"); + expect(result?.redirect).toContain("requestId=req123"); + }); + }); +}); diff --git a/src/lib/server/loginname.ts b/src/lib/server/loginname.ts index c69589212..9f243a86d 100644 --- a/src/lib/server/loginname.ts +++ b/src/lib/server/loginname.ts @@ -255,6 +255,12 @@ export async function sendLoginname(command: SendLoginnameCommand) { switch (method) { case AuthenticationMethodType.PASSWORD: // user has only password as auth method if (!userLoginSettings?.allowUsernamePassword) { + // Check if user has IDPs available as alternative, that could eventually be used to register/link. + const idpResp = await redirectUserToIDP(userId); + if (idpResp?.redirect) { + return idpResp; + } + return { error: "Username Password not allowed! Contact your administrator for more information.", }; @@ -312,7 +318,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY)) { const passkeyParams = new URLSearchParams({ loginName: session.factors?.user?.loginName, - altPassword: `${methods.authMethodTypes.includes(1)}`, // show alternative password option + altPassword: `${methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) && userLoginSettings?.allowUsernamePassword}`, // show alternative password option only if allowed }); if (command.requestId) { @@ -327,7 +333,14 @@ export async function sendLoginname(command: SendLoginnameCommand) { } else if (methods.authMethodTypes.includes(AuthenticationMethodType.IDP)) { return redirectUserToIDP(userId); } else if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)) { - // user has no passkey setup and login settings allow passkeys + // Check if password authentication is allowed + if (!userLoginSettings?.allowUsernamePassword) { + return { + error: "Username Password not allowed! Contact your administrator for more information.", + }; + } + + // user has no passkey setup and login settings allow passwords const paramsPasswordDefault = new URLSearchParams({ loginName: session.factors?.user?.loginName, }); From 77182de9b07bfc3f78729caae967dbd6020f3772 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 8 Oct 2025 10:27:02 +0200 Subject: [PATCH 21/21] chore: rehaul DevX (#10571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Replaces Turbo by Nx and lays the foundation for the next CI improvements. It enables using Nx Cloud to speed the up the pipelines that affect any node package. It streamlines the dev experience for frontend and backend developers by providing the following commands: | Task | Command | Notes | |------|---------|--------| | **Production** | `nx run PROJECT:prod` | Production server | | **Develop** | `nx run PROJECT:dev` | Hot reloading development server | | **Test** | `nx run PROJECT:test` | Run all tests | | **Lint** | `nx run PROJECT:lint` | Check code style | | **Lint Fix** | `nx run PROJECT:lint-fix` | Auto-fix style issues | The following values can be used for PROJECT: - @zitadel/zitadel (root commands) - @zitadel/api, - @zitadel/login, - @zitadel/console, - @zitadel/docs, - @zitadel/client - @zitadel/proto The project names and folders are streamlined: | Old Folder | New Folder | | --- | --- | | ./e2e | ./tests/functional-ui | | ./load-test | ./benchmark | | ./build/zitadel | ./apps/api | | ./console | ./apps/console (postponed so the PR is reviewable) | Also, all references to the TypeScript repo are removed so we can archive it. # How the Problems Are Solved - Ran `npx nx@latest init` - Replaced all turbo.json by project.json and fixed the target configs - Removed Turbo dependency - All JavaScript related code affected by a PRs changes is quality-checked using the `nx affected` command - We move PR checks that are runnable using Nx into the `check` workflow. For workflows where we don't use Nx, yet, we restore previously built dependency artifacts from Nx. - We only use a single and easy to understand dev container - The CONTRIBUTING.md is streamlined - The setup with a generated client pat is orchestrated with Nx - Everything related to the TypeScript repo is updated or removed. A **Deploy with Vercel** button is added to the docs and the CONTRIBUTING.md. # Additional Changes - NPM package names have a consistent pattern. - Docker bake is removed. The login container is built and released like the core container. - The integration tests build the login container before running, so they don't rely on the login container action anymore. This fixes consistently failing checks on PRs from forks. - The docs build in GitHub actions is removed, as we already build on Vercel. # Additional Context - Internal discussion: https://zitadel.slack.com/archives/C087ADF8LRX/p1756277884928169 - Workflow dispatch test: https://github.com/zitadel/zitadel/actions/runs/17760122959 --------- Co-authored-by: Florian Forster Co-authored-by: Tim Möhlmann Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .env | 5 + .env.prod | 1 + .env.test | 6 - .env.test-integration | 2 + .env.test-integration-run-login | 4 + .github/ISSUE_TEMPLATE/bug.yaml | 63 ----- .github/ISSUE_TEMPLATE/config.yml | 4 - .github/ISSUE_TEMPLATE/docs.yaml | 30 --- .github/ISSUE_TEMPLATE/improvement.yaml | 54 ----- .github/ISSUE_TEMPLATE/proposal.yaml | 54 ----- .github/custom-i18n.png | Bin 85028 -> 0 bytes .github/dependabot.example.yml | 21 -- .github/pull_request_template.md | 13 -- .github/workflows/close_pr.yml | 39 ---- .github/workflows/issues.yml | 41 ---- .github/workflows/release.yml | 32 --- .github/workflows/test.yml | 67 ------ .gitignore | 1 - CODE_OF_CONDUCT.md | 128 ---------- CONTRIBUTING.md | 218 ------------------ Dockerfile | 37 +-- Dockerfile.dockerignore | 22 -- cypress.config.ts | 10 +- docker-bake-release.hcl | 3 - docker-bake.hcl | 25 -- .../{core-mock => api-mock}/Dockerfile | 4 +- .../zitadel.settings.v2.SettingsService.json | 0 .../mocked-services.cfg | 0 integration/api-mock/project.json | 19 ++ integration/support/e2e.ts | 2 +- next-env-vars.d.ts | 3 + next.config.mjs | 3 +- package.json | 36 ++- project.json | 156 +++++++++++++ scripts/entrypoint.sh | 21 +- tsconfig.json | 10 +- turbo.json | 57 ----- vercel.json | 4 + 38 files changed, 248 insertions(+), 947 deletions(-) create mode 100644 .env create mode 100644 .env.prod delete mode 100644 .env.test create mode 100644 .env.test-integration create mode 100644 .env.test-integration-run-login delete mode 100644 .github/ISSUE_TEMPLATE/bug.yaml delete mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/docs.yaml delete mode 100644 .github/ISSUE_TEMPLATE/improvement.yaml delete mode 100644 .github/ISSUE_TEMPLATE/proposal.yaml delete mode 100644 .github/custom-i18n.png delete mode 100644 .github/dependabot.example.yml delete mode 100644 .github/pull_request_template.md delete mode 100644 .github/workflows/close_pr.yml delete mode 100644 .github/workflows/issues.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/test.yml delete mode 100644 CODE_OF_CONDUCT.md delete mode 100644 CONTRIBUTING.md delete mode 100644 Dockerfile.dockerignore delete mode 100644 docker-bake-release.hcl delete mode 100644 docker-bake.hcl rename integration/{core-mock => api-mock}/Dockerfile (86%) rename integration/{core-mock => api-mock}/initial-stubs/zitadel.settings.v2.SettingsService.json (100%) rename integration/{core-mock => api-mock}/mocked-services.cfg (100%) create mode 100644 integration/api-mock/project.json create mode 100644 project.json delete mode 100644 turbo.json create mode 100644 vercel.json diff --git a/.env b/.env new file mode 100644 index 000000000..941bd7206 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +NEXT_PUBLIC_BASE_PATH=/ui/v2/login +EMAIL_VERIFICATION=false +DEBUG=true +ZITADEL_API_URL=http://localhost:8080 +ZITADEL_SERVICE_USER_TOKEN_FILE=../../login-client.pat diff --git a/.env.prod b/.env.prod new file mode 100644 index 000000000..454804b07 --- /dev/null +++ b/.env.prod @@ -0,0 +1 @@ +ZITADEL_SERVICE_USER_TOKEN_FILE=../../../../login-client.pat diff --git a/.env.test b/.env.test deleted file mode 100644 index c0344ebf0..000000000 --- a/.env.test +++ /dev/null @@ -1,6 +0,0 @@ -ZITADEL_API_URL=http://localhost:22222 -ZITADEL_SERVICE_USER_TOKEN="yolo" -EMAIL_VERIFICATION=true -DEBUG=true -PORT=3001 -NEXT_PUBLIC_BASE_PATH=/ui/v2/login diff --git a/.env.test-integration b/.env.test-integration new file mode 100644 index 000000000..136bf21f2 --- /dev/null +++ b/.env.test-integration @@ -0,0 +1,2 @@ +API_MOCK_STUBS_HOST=${DEVCONTAINER_LOGIN_API_MOCK_HOST:-localhost} +API_MOCK_STUBS_URL=http://${API_MOCK_STUBS_HOST}:22220/v1/stubs diff --git a/.env.test-integration-run-login b/.env.test-integration-run-login new file mode 100644 index 000000000..8eb5c685d --- /dev/null +++ b/.env.test-integration-run-login @@ -0,0 +1,4 @@ +ZITADEL_API_URL=http://${DEVCONTAINER_LOGIN_API_MOCK_HOST:-localhost}:22222 +ZITADEL_SERVICE_USER_TOKEN=none +ZITADEL_SERVICE_USER_TOKEN_FILE="" +PORT=3001 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml deleted file mode 100644 index 2764c1a36..000000000 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ /dev/null @@ -1,63 +0,0 @@ -name: 🐛 Bug Report -description: "Create a bug report to help us improve ZITADEL Typescript Library." -title: "[Bug]: " -labels: ["bug"] -body: -- type: markdown - attributes: - value: | - Thanks for taking the time to fill out this bug report! -- type: checkboxes - id: preflight - attributes: - label: Preflight Checklist - options: - - label: - I could not find a solution in the documentation, the existing issues or discussions - required: true - - label: - I have joined the [ZITADEL chat](https://zitadel.com/chat) - validations: - required: true -- type: input - id: version - attributes: - label: Version - description: Which version of ZITADEL Typescript Library are you using. -- type: textarea - id: impact - attributes: - label: Describe the problem caused by this bug - description: A clear and concise description of the problem you have and what the bug is. - validations: - required: true -- type: textarea - id: reproduce - attributes: - label: To reproduce - description: Steps to reproduce the behaviour - placeholder: | - Steps to reproduce the behavior: - 1. ... - validations: - required: true -- type: textarea - id: screenshots - attributes: - label: Screenshots - description: If applicable, add screenshots to help explain your problem. -- type: textarea - id: expected - attributes: - label: Expected behavior - description: A clear and concise description of what you expected to happen. -- type: textarea - id: config - attributes: - label: Relevant Configuration - description: Add any relevant configurations that could help us. Make sure to redact any sensitive information. -- type: textarea - id: additional - attributes: - label: Additional Context - description: Please add any other infos that could be useful. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 7e690b934..000000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,4 +0,0 @@ -blank_issues_enabled: true -contact_links: - - name: 💬 ZITADEL Community Chat - url: https://zitadel.com/chat diff --git a/.github/ISSUE_TEMPLATE/docs.yaml b/.github/ISSUE_TEMPLATE/docs.yaml deleted file mode 100644 index 04c1c0cdb..000000000 --- a/.github/ISSUE_TEMPLATE/docs.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: 📄 Documentation -description: Create an issue for missing or wrong documentation. -labels: ["docs"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this issue. - - type: checkboxes - id: preflight - attributes: - label: Preflight Checklist - options: - - label: - I could not find a solution in the existing issues, docs, nor discussions - required: true - - label: - I have joined the [ZITADEL chat](https://zitadel.com/chat) - - type: textarea - id: docs - attributes: - label: Describe the docs your are missing or that are wrong - placeholder: As a [type of user], I want [some goal] so that [some reason]. - validations: - required: true - - type: textarea - id: additional - attributes: - label: Additional Context - description: Please add any other infos that could be useful. diff --git a/.github/ISSUE_TEMPLATE/improvement.yaml b/.github/ISSUE_TEMPLATE/improvement.yaml deleted file mode 100644 index cfe79d407..000000000 --- a/.github/ISSUE_TEMPLATE/improvement.yaml +++ /dev/null @@ -1,54 +0,0 @@ -name: 🛠️ Improvement -description: "Create an new issue for an improvment in ZITADEL" -labels: ["improvement"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this improvement request - - type: checkboxes - id: preflight - attributes: - label: Preflight Checklist - options: - - label: - I could not find a solution in the existing issues, docs, nor discussions - required: true - - label: - I have joined the [ZITADEL chat](https://zitadel.com/chat) - - type: textarea - id: problem - attributes: - label: Describe your problem - description: Please describe your problem this improvement is supposed to solve. - placeholder: Describe the problem you have - validations: - required: true - - type: textarea - id: solution - attributes: - label: Describe your ideal solution - description: Which solution do you propose? - placeholder: As a [type of user], I want [some goal] so that [some reason]. - validations: - required: true - - type: input - id: version - attributes: - label: Version - description: Which version of the typescript library are you using. - - type: dropdown - id: environment - attributes: - label: Environment - description: How do you use ZITADEL? - options: - - ZITADEL Cloud - - Self-hosted - validations: - required: true - - type: textarea - id: additional - attributes: - label: Additional Context - description: Please add any other infos that could be useful. diff --git a/.github/ISSUE_TEMPLATE/proposal.yaml b/.github/ISSUE_TEMPLATE/proposal.yaml deleted file mode 100644 index cd9ff6697..000000000 --- a/.github/ISSUE_TEMPLATE/proposal.yaml +++ /dev/null @@ -1,54 +0,0 @@ -name: 💡 Proposal / Feature request -description: "Create an issue for a feature request/proposal." -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Thanks for taking the time to fill out this proposal / feature reqeust - - type: checkboxes - id: preflight - attributes: - label: Preflight Checklist - options: - - label: - I could not find a solution in the existing issues, docs, nor discussions - required: true - - label: - I have joined the [ZITADEL chat](https://zitadel.com/chat) - - type: textarea - id: problem - attributes: - label: Describe your problem - description: Please describe your problem this proposal / feature is supposed to solve. - placeholder: Describe the problem you have. - validations: - required: true - - type: textarea - id: solution - attributes: - label: Describe your ideal solution - description: Which solution do you propose? - placeholder: As a [type of user], I want [some goal] so that [some reason]. - validations: - required: true - - type: input - id: version - attributes: - label: Version - description: Which version of the Typescript Library are you using. - - type: dropdown - id: environment - attributes: - label: Environment - description: How do you use ZITADEL? - options: - - ZITADEL Cloud - - Self-hosted - validations: - required: true - - type: textarea - id: additional - attributes: - label: Additional Context - description: Please add any other infos that could be useful. diff --git a/.github/custom-i18n.png b/.github/custom-i18n.png deleted file mode 100644 index 2306e62f8709d5b6f756b773410eca411b3b5c2e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85028 zcmd42V|b-amnaK`bRw4#KiwH;^$^zq21f-zpZ)1pPh+%+IQ80o7F?2sS zS40705kHmC{=g0!3G2F;D4zP%<`q;%o*1dG2ZOYp(r#p4WO9GJ9sK4$$l`Xozy~?h z^)6?_oCgreCoo1L+?&bD$r;9bf`A@^qD4WSdk5%@hQ}a6)xiJqW2~+Q6T6Eg0G|??mq-*#-RMELfW4yy~|OmgEYh;@q?Si!dX?DRl{dh zAGh;lz3DKw#X;icpS(3GKyZBG%+R4(@)7aH;IhbsY5_+4VLsQ_rqINCfLerx0H>Mc z=hNG2XO@qWbiRU-1G5+dRl1-MJfak7rzfZPN$1y#gw!8cIej>4cXXN(l`N)dzr;CN zdy_Qqw|^zv4pW6Sf(kg)+E3|)r)x&U0g`zwN#mRhCqPiQC9hY%t^)~accFp>t)U&h zlX0+k50_90;}taoJPFhGKefAu-4Xpn9s%F^ZXGjb+K)KtMK3(K2Y)61R3}K7^qh;% z34@o+SCUP>jc{Zf6QX$~2~;LVqykck2~~nR6!B0pSPxS)GDYHYT9YdG(-_zKYfvNr zUVhxxUc+lDoW}X+=6HW~J#|k>!{iKR+%p>pEprUvMY)7}3#~QqA%USZ)DS1j#pA<^ zR6qlkMc`9LlEFC85G*F{zYJ9jZoAH`9uj-4>L=k=?v4}1q+<3X#L^FkMk*J_rb^rt z?x^~1CojKFnJ4o<@&OT>i9l-n!4{mEz`ubzh}$VMwkzKOK>6YVWQf2Jg@0d$74;Cn zv=0UdU2=<%5D{V@@GsGVLBxHqMW{M%Bg?tCfM5&zNG0Q4Vr}yPAsx3mj1(kUwgpMt z0oYgkJllOV{>;0*6{jB$uS_o?TLKi`VT$mR!v#p%DB$6s{Xiq&GzLevk3Ay~p5B{* z%iwbJkUgIMDNHO(b}&@%k6?!0@uO{$WhzEe0n9lhoKgAb#iyRCyb{{;STA9hvM5#u z@J;`=ZjZ0cj9JU*b*sc($(n}IGw<2}ecpUx?g&(f;;8&bMQgFxxtdvfd_~3m=abhm zZ&|_-4!0t++t=0VI>um|C3ERh?NcIGNbZ2_KCN1c?%o)~aSd))k#KRf)xsWh4j%2( zJbq3Ev)hcP>u<^FN!;_?cVB%}?)D;04`?|^-M%}=$YiA_Pi}WJeh(W+gvJ}H&C1Tw z1y%6%Q>p%?&K$IX9=!Y>qJ51EvOWX}3F-UX8$tkX(8cXdj`!ZXT(eawir`-#3IVGb z5Yd;63FhrIwRJuVHQ0@})a{-XfO`q%O9TuKqyi!(^spWJVCWpMFv0x>j}(NV0{_wN zsQ}Xyc$Now3JN8FY7Rl!J-Nx#4E;0kkNur<&_NzjJ0u|xKupXQ9B@cV5k_tl`<2wX z9|}gKH(rAbTT++>8KyvBpIADM{%8Mjf0^dl^u51L!mE=MsZz`zJ-CWs>`CN0+RiIqH_6+B(#jTJh=Nr)*SciII zfkpAi{H_(lROJj$rH5|4eq=toAgvUwyj6=@_5x>B=?a+*?U{g0zH9Bh>sjIo5;j&} zR*wlZmNXVORw?!xmk7QUevVv|T>1n-HexoPu8l6{n#kI6t7@ybb6XnLY{ZE1K8rdF z5>qwvMl3T!9 zRh?8_EKON^+nBDX{L!f;U#YWkvgY`s*C2eRb5?g+amuh%cyD`e^fG>*y#u|&GblNz z8rycBci!zlWnbpjd@<|{??&fn?dZ+CZsa}sIKML)^>Mm;oOIIAcXSUpgULsYplFbJ zoL!#K+|=3gHX*Swn(7Zh&7|yfu609hv36+L8nutM*F9d@e;yfMligp~T-dSQ%GvK6 zdE?R!#UIz3;5!k#ynN6&oIbvqm>p-`xbZ(A)I-$c)N|33r__{3kzHXv%Kw$lvEh~J zmWnh+Iey@k%b>5U-)Yx)0nYElpT8m3MTw^^_r;9kAfzj?>-R_82kuWwH@*jV5DXxm z0?rh28Gag21KXW(n2w6IjIWGwiQV1y@(L&#tlLc2P7W3g9j$@uKsm#rgtCkVQQ)pGV6<-1y z-svdzuzsu*Cm`t zccj?9VSK2)+6>i&k9}cy(V?MxEB;+x*?zj?*mJGlnR%%@;08|1qUK!btleUQ?vVU# zzHz?uht-046}8Tdy-okhpi8Ao@yWXuzt3BXW9oGkZW%5bZYxbMZ3c&ywNB%ev5DH= zJk3W%M_-4Ka z9|~==24>3>@@-^ot(Hbsa`UZ=3k%o_Rh8l==y$wr%5M6rs5PZ6J^6-z^!C^5d<=4p zHtOFjUaIk-xu;y?d}_AD#|Sy_jqqq}L~st+?%7S)UDzo(+z1ePR~NSD9SI1B^q4o@1qBA999yu4-&N|C}Wz%Ik5WJ5}sBFCEqML%n%l!yo*v ztzS3Op7hA!euaBUExEBh!2P592kGtj`6M6lSIi*XIc5f-_MK>_wEHYt1>H9Vg!}2WK-|IP}+Pn(wym3%G;1i{02SQZ`uI)!#DD?7Z4m zTVqy{nm68w&U?;614AbXM+kjR8GL2Dbl&@SCC`)(N~?GUc`?{o?nISMlQ6v z>^u!`wuks%-_0*FR~O%u?j1KL_o{F8&z{Hjs+-;q-@U4D^!(hFpVdBW#<}m?ST7RS z`Jb3hNRNyMDF4XQ=l1b!U=QPB#a)}c_UMDcQ^kJ426=6P1JPmufp@npl2gn^f`TjH znnfhJ%&j?w=hRpQG57ztW$w7V*yjRyaRo7exH~Eb5t#)EdjL7?4<~hbq~mZuSHU3R z!S&`pQ})#3dgKswp?Dj;n5I{#X!V~y>jGJ42oeui!>#?iU__W`NSeyZg3x^Cp+Nwk zI3N(8Ind9@2a5Y&c`;Bb5b%G>!9YMlEkFSO0VDUB{ta=T@h{H5Qt*WDAW)zGP(Gu3 z9@u|E1Bmj#|1!6TLH;*_N-B|E ze$rpGP|t%&h}i4 zjBajj3~sCpc8+F@%$%H@j7%(yEG+b&5cEzSw$6s`^tMi<|043AbVN*?j2$iPoh|Ha ziT=_xG_rGX<|QHd%jmztzuIZyZt-tUwod=itU1aWM*RjAE^JW>i>(X>SW?5Y-jVy)0ywz z^YtHm|9jHNu zK7ap8%HQ-e)`Ol8XA6LU2!Ke62&%Y)p6Wulqb*|a39OK#q_s|MH&vYT6huYZU`7;#O8nk0hHZrr_{Fe#%~;A{!X2Y+-^T7`Rg-V zK6n#KRcaF$1457u09c@6{{MFpU@by~gbG{^V{jXXW&(=>A?^OZ5-?B-qDR61or1VO zOqg|O8r1&=@fRKYf2$0z{(qwn+d#Xwcm$@lVK#HN1ZHRRYG`TfH&Nqc$tyXckdgHZ zf0IF5jckT`Ai2P;9E13$;ky)oH2+~)x1fXG|IvxKD(FYd`%o0oh@hY<;ij0x#OxvR z!JcGXbz(9$GRn}s+J6Rz3H!69GT-Uk*lOyU=F=q{XO!Kqwp--*%)Su>@OXgG{k}eS zPD;8KqP(z~tYKRm*jm0xy?libzm>gMKj?!R`3+A<5z;QQl#tuuH5m^ECdcx3K;uZE zo4>XQ%+DkfcSZidVq_pj?7ax-3o3&S3y%#beI&Rm2}>?U|GLZ_5c-4r(f!FkC_6$X z+!qUCC`i7yFD=w=wD^(-8&qr)UwE^Mm?+~u6UsJZw6|)Lx5jQnA?VliNFj8?qft4P zK5scEkF5KRZ=TI5o?(9!FV|wT-H!Lt)dL~dlbw}VElpLv=MTI7J$wyzoL+`gjHA{y z-@w2DtBBic=DV$dnGhDdgRQ1NMs~+qqVs;$`wu?O7;9_jo>bYYg zZCx>aZ_Q*L&tinq9!ov;?P7%DR_x0t$!YLa`vl7Rsa2wUOUsN3J`AW`7i}=KldJKi z-faHe{TRi0deg5s#qvry!a>-f?ALYA{fu1*bk8*fhfVYCJC}zJj%r9fZ_Hp(-P~dR z*~Qp%K6ja}9QV{&ov1$ogw)=07Y^BiC_C3f^3;m83fPg6Fn{X#PjMS!pVB|D)1IEe znM9;2q9Q}d;|JGp|8m?A@euo*g3Afg%LO2C9Et%l6^wjyB3Zb`tH&USBjs2@H(?%i zBb4$=5%Oez0c^(i(R9@G?zYlKrShRu)~_cn9}gP{8V=7t zxpGmTkq@L5I%JVYTB@fnAvhuRi+(mAr29 z9|QL1R-*n5bt$et0M2A}x0c_wt+J7j-fc-Pz1mn5=7%J8UhfV?4m)=nAc2}!ep-cn z@Htc?p;m^ATT3k}M!Z3~`zxmNqpCqhouUJ;X~Dydzrra8q1$Jn+noTx9Y2uRK6FF{ zDqmXI$#@sOoow`%8m{UO5!vyv8i4gWgBiIFT`LTZ5fD)FhcYhc`9~sc&-wt&InwoB zCD$>)3d|aXE;S8^9b`9bS^-dYJi!OwaN}Z5@fDQKrI|qE5fwZm_$&6oeZYL2g8eTY z>H@o3RnY1mK^k3+a`|Ri3+F2R_{w2_x~9}@_4+(oEH=)8$?f3)B($wQ1m}GZ`2CM^HzAVC-<*eT-GgFt4jp0&&Ztvn#Uw?2w^=zU8q47UvE-rNV3SF+mHz z`Htl#KkiK9c1e!dgHh;q@V~w7|Kl#7?hi6t2H8KjK2OA-p=X|{Q*@Wr9u4SEGbPVc zbl|9EmE+zWghd4ok`!w*Qo=y5P0vmkf|1yGI|@^y0$a8i+S{CnawW7q)uC|N#z-qN zqA;CkL8pt-O{dyZYI)z%s~oVy&0DL`NEC?ZdU%EDogoUJ||2xDW`7p>(M z0niVeg7fkEKQ5!?-3!ygw-48QaLpd3nPr*igbcRp3awhgm(*I2r}#I0ePP`8=fkee zj`so?Yq-(^yCa#HQtB2U17q_5vqi9iN$z~iEze74XwKXw1kdsa7ot2%aC-+vXvQ4g zptR73t(lPy`>S1oASb7X`=D;IY~n6)VkXvK!Oi0FK-W98tVcWWqHh6(rBP+B39XJh zDCgTI;S}$>K}JSUA4^qOOW#c~m2C59%88Rrm0_T@s8kqt0ef&?1&A?mp}iilV!jU? zE{)E>>Ao4!3~3aFJDg0Ta^m0BvQ)IT<}V8i&9Hv8XLaJvVWYhE61-My4BR}i2H(9g z91Kv17blF(DcaN_ET3#dEkOZ-65K1auWlWn;!7bM^S!sgH z)O!ovx^^pLzPnxQMofs4mg^K3ra$(wtU#QNtEioLc0SUUQ=xoYu_25)(^#P+)3dCC zVr@;cJqO}Kjy%HI{pkSQ<3ojSJv$>5ZCOBxl!;hDcZ#f%{9Q3e68Q<*Y+)b$Gd9f_>JHR3xNVz7yei6OhL4a#MLlg z_z%5sv@>UhuZh^Ax;FVJy!DUm=?7zQhOq>FDUrzbi~dLGTY9L~LH72w%;HrQfUo{I zqH}WEuWP6Mcl=@UflguS5d9@`$e33hWUmYPPh+eD^|B>O>~A_E9Jg&GmRfWmefbF{}Hdmc+5LUd@iJ*vb@ARyGhbA3Q5HTv55e5VT z*_L1DW%zc++Wuea=8Yqe`@9z2r-4}RX_>isICMH3k(@`7W94wnkv zgmSRQ^Gi*3Kgpy*USPLB$h9X!ZTM3wZ$KDhcgSsbdePNc8#R)+@dxfTHyQ;mgu#Zg81n?{vC?E~Y}r-t-imNy zIR5NH(b(v8AINgY_nibiQ;<)mLGRU$Icv4z+A5CHj<0NEhl7leEqjxkKOE}0-ibPI z5h|D?0sYQPZaO`bj}VCXGbHbB@rURdIIS31Ja9#+bWG%uei4{cO4d`S*w`4!>bzd? z0xX-ZVSP6)^a03*w|k+M_&x-fmTdpV0_$B^$Nnz=fvJ6TFR#0X&;h8Y^e#xndNk^V zy}YFUiN(y0?Rm%c8~a1ja`1=BkJ#^mcaZqb*VLp(79>$`bT`c=!Txy=`#JA4P|2Q@ z4>V2}cjj0zCD70PXkdYQ_8;czh^uF+a6Tq};zEQyHRGwoF#36thoUa?;>iP%c5) z^rhOHt=uq>bK&3Aa%V4#iS=#EZ7-6|h#!61Te0aczxZm&wksrlM909%ACp7PAwUUgG}Zw=oWZ!69VXJZPy;0G?^6Z*B*3t19$7r0!?5!b+}cX10xDoJtD zW&>{?noQWdlZT(3t@&QeQ{sWQzhi85@Qyo3Ad+_!Jm077)4a%JNMCr#pa#42mCpKA zHNQUCWBumG`1w1~;n1p3&_P{Lu0LN>Mw1aL`kIOig^P|0hGiwDX%Z)TG$PTSjFH=r zyEyF`KglK>{ld5jjx&XP5O2+2VUOqf-5XFO z?#V(+cRIe11blLera}1!u{6k_f6S8i?c(dM#8rJZS#Ng=JnkxRL>7GRf0bBvQFnce zI><=cohjqvRs9~YBYRV`jJ*DsFY>GI8QfG6Qf&KKd$3#Mu6F<%Xl~7UK=u1bg>MWR z+HK={s^|jGS?Ct!T!$AP^Zs{e7?Z3{bT;L@l_DYLCYI&RRJky=>sQT25%kC6N%^=3 zbio%ej@)ONBc#;6>acS76Be}NBrl{9PS%>bn!rBH*zR9W-_u#ft$#?zP+O^0z@Qa4 z&7Mc0GA(}*JWu<);H*pj{l1`+qvC)iaY!EV8kawo(4-d65o555g{Z|BKj774E1&~1 z2%~}vRJ9_um%=jb8F#UopHf0zA;9NkZ#nbHl=78kCq?mTxy`JV^@{G0fhtqJOB6L? z7>k+H=W-B0%r2`~1U^yTj4S+FP3&g(iyMW3!)S{T&0wMZ8GZg@h-Bcr4+X zy)Z!;HxlA*$v_A1s;p}KjG>{$F_9i!kJV0h$a$cV0*1W|A?B#SKRL;cOBM{#_xxFkI?K(x(#Xdn81B2M6umA$#9Kf*o-Mm5;m8& zF)vkr5=u=$f-W`|UqR+`C&j_Uk)JvU4th56__zyg7HFdiMd$AO;iZP`RFM7g-poWv zY~$}|KP?-%Q>o?rAVdg&$X0<9T=8)2UHhhWd)8L6GN12ok*9v+A&%fR{Uya;BRojFd0LB7{8UX z@ya;U5jN)%F`)c1^;=;}p#b+@8z0NE?D&~&PM&=jm4VhGNudHC zJJFJD(l(edp|20Vjj)l(7L%AbcGDX2=RNfGat zNl^q@LJJ(Be#o;+{RjyVByPB}?HxJ^KH)_Q3bXB@y7S~bCd-w$DYux0BC1tu3wf`6 zV&N{N!X`bnhYB(%bKj2wF>#&*SeJ;_umNnTqw2mVqlIWJUCXXGK#n@7Ky1iWV|&h@+iJ0DzDDky~I6+zg6S-Iq|5N8Q!P zMJ^+08jQLsYH{U@Dl(#7tn1~Qo(;CA*QpE7p%BV%=nkvP96)RO8$oEUGkC#h|KZ?o zc^6W=bA!bF;nvn+iWUux!I1ORVA(VnKp&u>H}FGT#87)CF8|_9c&=YDquONmLEpGQ zEGLc-`v{!@7^ zb9aA;sPjsk{i)Z<^f=ULR7-&BgH(cW0WF_f^d3}v-AeD-o55)ee{Ta1Y8Z1>5TxY; z>-C=gqi}EP<;6pxxp`D@ttbG2nD~ooqu)|Pwn6AVkvxjAp?4QiG^fULo<(FVf3PlR zRs4p_lpRo^g`oEV#g||eUY1cw7IzrFNFyBw=y)F2DjV4|*1cSq_RKR5xNmWHzhnE6 zrbMMLhKY|9su|iFWobzVN-WGU16FxZ$GUBzjyQ$nHr={oi!&mwWpZ~Vn&zq~S>!at z&z*t_C!yv1OWz($!i1Z4Pu-aeBg}F@ofO4kQQ^k7ggu5C5X=&M#OU?YN<=kp0Pid( z{x$rOvHyiAWbT$6+lhq-1M(*?5^n`Ya3?!lBn_#8SFp+hEA6dN$B}aK7ifBaF9z=D zZ$igLF&INZ4p&#S6S@piTB%pT`R1L%EFKRo8gB17o;Y2^DMbacGp_FrK@T)-Fwl3w zOoo!OjvMPlG03(XC%U z+*D^`9xj{x6Ct=z!q5uEGd?kx0qK&gHx9L=tyQF>pC(?+hd#NA>IeFm1LH-5n^| zgqo^rfHDzQqi+>8i-FhWF$GGGlY*RangT z;N_G^Bzxi_i%H8ExVrXg*XU%QL+Kr_O0CQQj6^?g1vQmf3e?-iVjgVXkOo$qajr_m zXzTo=kSvi*6-6~~T<9=j!2$cNJ!)5V+`MquvsYSCOL3rsn%9b;A{k6r*A$-N#1@kv zo&-w8{Rbj!f;hu^6IJgPO`}SuSi#QaxcF$k^UN)NQU*F#h$o9gTfn|#UAs7W{s>BJ z+lcH3PXUGMEgPz45jiV_tgwWHmE<2gm=NkB`w%jzL=s7Xsj>#{s(w2I8~#J35p9t} zi?Ah2I13nTX@PJ1++}1$_n&&5G|*YLv!`@N+vASn&SXG8(x0Dsei0IPaUz?igV4N1 zqIGYMWKqjG&z*Tci<0v;Vmq)@gMIaP>#2Xhiz&y(m|qLd=;e=-jy1+&KfO)?JwHn@4KRZD#D_d$NOoDmVnA2n-Q84`xi!Xi-kj5RNoA%F>)AngW!E;c= z^EqI#eku&b3LQ@Txq}FTZ-MM>bc1&Hh%M{Q=gaAiP6HZVcE?=n7XKAmJZ9$G+oH#B2C%@nOABOSp-3F1 zlX;A50yz@5hvdD6=0{vvW*!GK*$78wikrIQv}{1|TtZ$;!f{=b1}Wn0jQk1|g2J7q zHW@bQ4MKVz*3PWZz2HL-hBsNZV%0O;oIO!IoPk5aVCWo>sPVPugyC>*b)H`#jj+gN zavc&(h`}2g#OEO;>x+~!n8i;r7-&b=*48iYc9!y`tpA?IE`) z4sbW@?y*Xo%k`a&(DBQQuyQg6cy=kJA6_%em|VqgjJXGSr0L>n!o8R_KD@H%*G}mw ziqS+sKk2RV6rRmdvJjk})+5abu1O0uK*Y2&ND6HGhW!A6>zB#|J*kEAIsIKV8PPDm zbisn%*tRYDbWds3_xtE9Ok|-oh0M`EJzC~fQ`@;B&wiS2=X^5jPO{12xw9+DA)|aT zhiDuyEBR{5o(b_AAHR931AHegt$xfHX%QA~z|8j*3(&d*N}V!g2XzV5xL=soX_b@Yps_84Na4tta{*I)eLHA1H~CJAVZ84-FvU8}n6Yq! zCpe~Z*t%2G=TGm9mf`1S_^=_OLw2;GGnoXlXnjis{Mg3d?zgmSHq%C{BglxTgdo#x zsEY9`Zk5>*exm@>-wL5ag@_u5Sx7(*3B@th^VB`K&* zD&FC?KYG;DSLE17UIT{>t{VmAXl4_mUf<1bJ(8WCF#SlwcN0o5zJWD@-yCL;EmNMD z?*3BBCs_MyE@SiGuLxs9pqP=Td}SzIKKy(+ z!NFjY1F><P+!F0p(p8}Zi!@#fK^WxkEKzcr?!AA#VOk9y^4om@UVBN|7uLmGqrNzTwRkTmcYWNka6St}=fN z$qQmkE(5XXYHGKhzh|wQgp8*h?u6nO^6bRvklU=Q!LY1)m@g2q3&A2g|2&GA=s89& zY>QXba=#SOI=BOOzqfCgyG-#KEbQB(?>--M(-%-;N)^aHx6Z|ODEDVHaHoE;Q5$C_ zPNyg-VUhijLh+u0smdDzDg`ouI69Wx1`^*#HWx*$+7XaT5uNCGvQ_q_4ucwI)#!>u z-OrOcSU`gnhow-2)HQ^bX*_*5fOW?&cphRvCE*15};ZK^dK?ta9z#>{4>jEpVo`bfqA zmaX(NcuZWa$7ywWky5kqZ~_*r_fI28B~~rkTM|#`#Xh{ceCm35xPJ}XFMmkkj6Cd= zh)t)O+EDgF^#F){{(+PF+J^6-vz`7<97^o+bJvJJ(7w|Kn>4t1i6s347MUydDEiYM7+ph^{cR;|t_FH*xypB@TE^-arZo46UtGL9&QTrUWo{s)VJB-)ZAtc#Z1mA$ zntK!8BNWukNj^Gt6r#qrwfx~;%Kq@Vf{k`sq&^70{`7vOf$dgeJ4%Zw!B0;OUjAqA>P7=SNq5E*af7Bq!f^)hDn5Bm4Uql%mqoUp}H z%|(rZzBt4B!lt5pKL&3LCplu6&%WV%rqb$l@S;^;1uSVKJTW?4`M%8fe*NW@qlnne zY8*&BRAj6M1S-u6E?2v@4#^Ytjlbj{Tf=@lX>W{dd(U^yGud<;^UCV>3r`8Ol6{1Z3Wx`O*flUrI0jDi_CoY^q6Tx;%hh#&hLGr~B(r41GHE z1I=Pk!EE%wL%c@rSj;z+BO+)Zk2Z4?Xi*+`QDt1pT2}~8G*{%$hBrp6KOYbE_koSk z;5^Mf2=AeaY_}usw?H`=?kBfAhotMH8vdSY71ekXcu+xfE4EW;datr)%}Xag=oWjC z<>K`QrP=MdcZB0%f`O@)q(pUx4x0v6YQW-hwx6Y$*&`1r4lt$pN2{4Y9+ndYgDb{P zfp~6Q(AO0omG852>hj$3Er%2?T8?>5=ZO_Y;=0^q#jby%a?Fza<7gf&(s6!YD1l0~ z{t4HuA7qU!bHkm@q&@p?`&g6#osRiwzhE={-9zUlK>4cqo__F4_YN2+V$Np8B1Ql}KSm9G^8oGi&MGMp2GA(rW2h}-X9 zGJV)|l_~&y8-80(azX%TWa7avow>iSEAM-On%wQB_e!t2M0BZq!M-_7uA_fUAmY~W_- zM#91#S^}i|O=GQdFAoVUx#@!93QuO)=9BOCm|5khObDHS!-5q^CJ$cvEE?LMox7x6 zP12b>9N#XV9L(VWla^aer5{UzMmPHW4j%f#o(kt&WF>1*rIe?({~q|_P@#8mz=ZVZ zMDwVk31Z)VG=e+bsuSVEPj9!(maUOoSO~FIepD3_fnKL;3PlP^clXSzJI5yjf@hA3 zvIsfHi!L)Y$7}|;j<3zKqJvZw-NTwM-LR_9!>@+dsTaaV?Z*%mayL0sMYEX46)ONhJgPd= zH;OyzY`FwhX-AeL#hNrMr)_`8R7YI^%<5Y@8=e!KO0G4AJl?AdszLRgsK) z>B2q$!&^T!E@5l5?42hnnGeahM4H&;59A@`HYxl7r@KwfN=^#K+HJ3FQ~Pe(&%1VG zBaZpS0mu}WYBJXY8FIGWGz)xIDFG_XZB! zz$7$HNbzKFpk*AmJd6$0rsDM&B_f_r~&R zdZFbNv~7!(znL=iJ^m|owQS2$(jGP^4Ge@<<^9JC507U8No&D{ZEi+L4+_5ZXid5b z6DFn~X!B#?3m;fta6*s_b+GaQ?79M6^%iT0eB9^Cef>a-H}d1cUPd^wGs+jr;+<1a z;9uG?N18tfs_z&56%y^#Hd4Ya`l?hqN3TggLtd|eSK>3qM;6T($y75U&j};AyhCK4 zbDK_QeNpH+G4g7#nE_{iC3tMdCwfAhi!U`4^{GL?3^Lmmmb6sjNS_yBa;2F;JMnZj zDv@O@%4M2yh34+Uow!JcVn(vX;zp15)I|S2s{z(KS1?6LJYQp14Ce6u|lni`(UlV$EMGtbfOL^A+AKQiwW4Z*v)vR-hr3qLCkGv-_zUP= zYA^-W$RYmn%7UOssr&O+g1>K8_EdIVAcy>rZt!|AoL)t#yP2u-pds^Q7Sle^fG@|_ z6_5g8Vhv2_IPp_I0L;__Bnf_SZ7nihcW2so>q>y?=MgI8T_sE38L~@tKX^6qF z?nnr7Hgie2YJX==$~1)cPN^z?FZ=B#GHCT0$9P%tOnDd_87}DZHpiOUw9|Fkf{4bD z5Zpkh=A(j5MMnzLK_;Gu>Eh&_?jk(oqarusumy7$DCJ3C8p1^Ho(u=O2j`}hfmxu8 z%Ks^LK;}BUaAaz>3(PWv8bY^;P`ggLdOMy@^^j`Ml@g zqZOxeEkHw13$z;xOh*G7V_8+L(AJ*XeOW=wl0ZB>iG$#okuihHRXL$!oFtqhwPTOu zK?9Y??IX7PQt7(7&5UG|G66LksmE?ANp9hnva@{WYl^XGUKC^@nnNWzCcONpUV(h_ z>9qjhbJbK#rvSw0RFSjXO~C;NHs<2RI-(NFX5->lMOGpx)(O_KZ- z_&%HwZsC!81WQoJ*t$x}2<*Zxk&|T}<9zYUV0ahfuSUu`ZiJ#FH`e?Vxh>`v=FGC7 z$y>i*-v#YI0&DGS*q|uAV-7uvIY$p^=j=>jpd;3W6m^QGZY1{!G2?*qF%w3|ZVLD5 zr~nbM^Qt<8@#OPqpIhF|Z#F;gCG)C!3g;LKzuZ=$7?KqtC=xp7Ho!nv{OD+=qz`gu z8?hIy0=W(l@SzqqFx`E~9UO_8>N0`7pbJNHyq22KtJkhe@XwPi6DmXYI_npE)i2qY|;I0h4*Klu%9Cg!ZiCp80cRUektIT1^&r)Uq9Li54jG&-w z@Keq@sB_VZL5V^x%unmW&yD5`de;(6Xk#7@rQl8h;jX=nSX>l(;&q*T>wXWs#>~(% zjD%L(?C|o<)kpVem!6=cZdo0}9is|1n$O3?EtK(OOl+Q65<+2FUCUwv1wH>yo_-Hx zpRwhr^j8-H)XH>TEmrN5N|gbwsip_>npzj7L~u7@#-3Y6-FfylgdDJ9nt9b+WfJTT znn{9(a4zfpl@{a8UB7Zb-baZ-jQRR=_d)kq zO7f_J@QM+PoelM`Zz*FW!CBWK&ksHvhI{GUb+~s|NIErYo{a|N)lUuFndmGn=qq1e zTKsF}7Ltq;N}m+5zD$FkMeng6J9RFhsb+;QorKSgMUIXT&Msg$7Dc@i{8W=Q5NA0cH$BxK&#AT=4c8iU7(H#(3+OxW z@A?!^+|E-*2aM25!;B-HfsU-_45y7_%_N9qxD8*xB0D$u=N|}&^~6Pf1aT1b#zkX_ zwv9fn#txXHPBf=2V$5`6)xTM$lx5JqP&k@s2(Ou?xTa8^!3L}mD~=vNY8yZ%Y90E0 zFB?yhM)Lou9kCjZG*HGsXylAp+#nhnE<9yO!Yki6T^-Dqe^BZqYPSUy)DTz{oG%8j zK(L6c-4Hb4E*2K`dC=43C_IW<)qe7leEiLg6EkT^9KAInc#34+`Te7Ut;obe9$8le zMN~ziYv9|l*o5to2OHYj;6`-L4G8j<*fRCF;lL3$*SomP)0_HzYI*}%V_EFNBR5LA ztd{C1r&R9OMa9A*Zp)Ni3X4yxijC%Ur^7j7=nmAB{PJ`mfw)P?>op+$1F+9B$G>dJ zJgw293G0_~2jZp2$@^n_ad#^??xyRm&E?%?2qbzC5hzDam<|#4ybXY@Vikx8LHIr2 zDKxSj$?{i7&snGc3zkpS?BmbJ^}7gBjNA@Uh+G|7h;g zV!Cm~1*XUoVEiN9sB2v9LtZ%KOw;)xXSc?lF?K2QB?MIM1?Ypq+Bk?$k2T#>5?m~o z(PHY&1=yp$8A7N}H;|kwinLgyK$?BAf&WfotFuqMTW6>1|h1COE5PTT0tUKjGYE- zt+iJN)29QIg%WYha9PO|l!(wU$sEMK-m&d>4s$gOQ_^F>en@7}SPgY`E zCQNPXm8Tck>Flwhy#O4#zaYhhjnI=+Q~vUS`ZRo#0tzEeLhYAPaha0VgXEG?>y_@%<6DH%T&S=zKGq-5FCQc>phke)g#ToTe6*8a+e#H zUT``&I0lq7s`7OP3I}eT?m0bIbf|jFT#@O{HNE}WGL)%MUj20}^knp@+1rd3`E+F9 zxNf>C`ZTux$5$f24f8L92R&?v?~cQS17<%aVEwo*&S&$z_WsiboruFi=x~DVhEwS# zz>~}Um?(HnGP*nYX}tyNA0jNVAgI9fp*XL9dq4!>iTDd`-B&6BsaA%~;SFDY!am(# zO%D^2Hk1|Qo_5<0?rMjMVuF&x2*xur=yb#x4kfjAbiLy_e$^~-$40&iH0|5%JL#h2 z0smw%^_Ri3!K<8#;n7*^85$Fp<%ydN-RYrRKHaAGw0%;-3iyTa+4uD(?$O1*|KUwx00jN$eOD-u za6m4YH7Mq&^cS+xYjH>})NMC>h%Oz2ZjXd!0=;5bcj+|!?@d~F0JN|2^o*^+t!C-; zOiU2aG0#0_l1Pq7#>D}V;+Ylg8Mg^mBfZ`Fvbv}rq7Ftrxzc<(bZk=oW2N!`a$l*# z|0@&tMg;#4&z66|f1;cHua*BlKt}`mF*%bief8)b_`v8JshUM2AEeZUO#;ytN3y;| z`Nxu5zmu)gfx8!wZ8R!|8?zypEIDFeuSD;HRe6W<7&3xKveAc=|ZJK!;o#b3PF*S>Y!dGh}D5Z zM^`}9;F@XS-f!L^7ustAXaN_J8vxxmF39rSdW7%LeNW0^Xxrq-b4^nB%_H(&+G``9 zE?44nt~}UA$h%D@Dre!{$m@e-G0NY5=Kpw&l9xdy05KP7{=@~;49^MYhjlsL?gMQ} z*+<#4RIWm~E0#XUF5l2+C-X7+nG>>E$&EpXQ!=~rF_~ZbT<7U9VV3(VfG}re?uHt*0xjl( z?aYx(##?TVLu8FzvP}>WtuNpb>|q@F{iW z!Eo}idg77z4($u@tsi=gdMS5d`GhBMG7GGXCz1xF#Csti&Wt61q-p00!BZ>%8Pf^h#>w%kklrBD8mH#TvFll zq51*PKA>-&Y6x9Mckt0^d9mY1|>mhJ{O~tZ%3rMC+Yz{&q5M9JP?0|X$|LIzYbX*J{c4^O+>&n>kMI%}{P;<&*i_nP zaZA9Z>be7r3)#bm>S4aKo@xm%O=r^ZujRvH)`fL!p;#Xbdh1PybFXD*u|)@Mqm@X7 zB__jb_HCJwZ#<_E@k6I-$;4bX7m9AACPyz(@UY~EoBRFVyn0nAb}}he>WA6kpLlxx z3QA!2QA5dRag(J`0N80; z&=Hl<0DKX4LnAI1)aHi5uU1mkrrokc!+yBz-+@PPrsc%%H_x-1Q_s8KX$iq_a%UQI zhFAcOovpi|?C3S_JVGIWxyfF-3~vC&%cx(j$Y4nH<)s{Pd;q|`fmHbRK!_521E+U8Q;Q?uZ2m{vL5zK4F)`>Pxfob~k@C16{ z)h*Ahgb5F7=04vy0!l={A6*aO{kJ?rv?pd5BLS!jaQ3&UZ_Rd}7BxV>9d*#`e`oIk zrzRGW#^;`JV>KM8k8RUYX&^)C>YDqUSr>`%%L!K*R-h@*v0;^-yB!v-Qp3k!XP2wU z*R2dle;~|EA~;%rr+0N3?aCb?8m%XjQ3fgju2#vY(;Kz=jj+^IPgS^%&i$B2)z~9J zkeoS?a6u>6+WriAp!_XvkKPT|qpx~!bxNMqub*aLW9r;YzC%%OZx#Lxj*8*eY=v1o z8gQ`}7qIz)))T3xsELXeL@V(ZK0tZ8&GD@S8+pU77zYkwmdZk7jF)Ds=*C~51&Z&ODdBEPm{+uva( zVVaqs{wa?Zs0?J1n&f+joxu(A5y2`8TvpOGED4Cs2aiE`8~2x1a(ZfQ}0{DU|CeAXzcgiFO2X{LqUeQ@s~X+&EKg&lFSZTdtrK!6>E zUAih64J4j+V4@m^ilc7u?<}$E9-^3TEg2-2bYo20~j!DUD&#H|Cwu0|+SiT7!eyL<=J55zwJcwKbIX zqDO5Kkv^n;^8Wd1si8y^vmNc{ZM;1@vZ}8wIf(LkV}BEBO^J#Gon-^;4dk~%4zq|i zQK^&BrIgmlR43^{vuDY+IFu1MeQ(zc3TXAk#+Xn01#w;$Gx+l;mhw6890%&K?=Wca z`y>WZ5}Hnx^z@;A|CmSFyCCc_H(vx{G_D>Ru{Jtqf6d|mDKKVOac7@|yl)k}KB;T4 zUW$`IfhC|)&^WOrO>{+q2y+%%jEbe(soS|zE8tC?pO#?1oG~|XMl;I#W&yXJeq+wd zc`V)HBWNBrmr^R=FMu%2NMf6w@4ZJ!?g@Vn&VFKT*Dh$Su?pA=ZA-VaHvG-u-WjwN zzT_A5lBceE75xa%xey02XJSaUU!nP_4)nt*TA%i{=j+D_?u^J8l*|;RZj+z>&WGrS zOFK15NM?__GyRP_E5+wI8bnx-v#Ch@*!)@Ncr99Ku!%3NH}|}&RbI&KyQL({JMd>q^2*KVi9+ zt_bR(2NlMO=b`oEVk=%=~k#I z#*0~bnP(h0xTL1w_PbC;$Nl-641>rp7X#fX+D-}_tgJ6gctto#_(+UCn@mP)^j^{> z3&q2f{R5t^B%wgU5|zvajSG66Z|Y&oWM4C=>D9NHwkObzgYeNWzFo1)NhD19!1vHi zZL7`i1lM3eh=e%2Y%^OgME#$Wg@p*9Ei|~`J7>gSK&H;?CZqw})m!UtPKoj3mrW|o z3&Y57v!=NB{l%?}Di|PvJyex!E1l&IF+XHz8Op(C^P~0icG-rQ3APSkbORT@_};5X z>;Q8~02lhzc@L23U++uqGHPlNIpv+)3yUEhE&G9RZt5L&do7&A&n> zW5=Bzru=0%XQsTWwpxvTtBecHPTw-o0`5Pdgy)Hu#a#^ud>SYl`Y4(8!!=To;siU` zyyGnr1xYPGh@B5l;4GT-Fl|!xE=KxSNv=)i@JKQIyvghw+H6INr|Q)^v}UVb2s=Np zHDONv;lM)M`!`m9ymc{#3J(gdI_K>VP- zRfXokW)Tle9gX=+$^kPYO!i>9tZ9l$p>$sCsLMjyh~n4?vlBoPM)~7bn=+osPT%23 zUEHPt!k?i9yb4!$^N15*sb_z-!36y^s3w1GHgZg5%KrtrJ3!CU;LI=f%ilIQ$TY%2 zu7m=6jKP$^r!uAD63l*q;iRu254U?s>gQGe8Xs4jO_EkK5B)r~l3X+@BP%vLz#y$P zFckLvSBjMd9?5@%oxhubQ6(5x6euu`?f-?&1CgGqn?i$^R)c{+y6q7%L2nY6Gh^iG z7M3xGgknn4O>%M5fe%Dn<9enQ95Z>X1g=<~KS?T7%LK=m0h_ZMZoRb@mh}um?b;|e zr<@6fjk4M{e>6**3lv3Z_sVi#PwyN}&W@QCkVXeZ^%clSo0jP(q3Pkl^WdtwGB=uA zt&e0)`uR9G_QRu%X;@+S(2KI7$=}5HCWAtYpy+R0b__nfv0=T~9BWu1T*29ff&)3U z6tvM%8)oh!Xd+~X^HAK@*SI(&xl?4Utg3RIbtk7T`0Pw%I3S5L;4IVx0aX9@J6lwC z_WlT;l-|l`f3nU6lKw=A-M_e6x8mPX6CkI)L{*Twlo&6Nt&X7nK09*3TJyp1Gm54v zM-hshOfoH(tz6eU><>nVxtkfeL8+CgBV($s+~W^Dy#_o#gz36<;g$s3-_< zqO3|+$naz{;5byX!Sd>o#^n;%$3w}Vu8}TrvfO|05mHxmk*uSx@z68R2)Ve_QaX%A z4JSgZ4xb@J1Urg9^040=`q}%R)wfMyaCVgqie#j4HC+W@r}Qh&aNU#dv59(<0*On+ zkrxVX7ZDPT_J4^T#k zz^g`+Kz!~RA{93@D%dYF3udkINY!(&6`XfE5Nx}iL3tbc0h?8eINUhmEn)vli!X%l zn{a{+uZ(DmXrA%}_IP4%()z+}9_;Gs{D>gr!Kfsctb{5&@e4VO{~RQd*-g0C0rwM< z!e~|e`m9X!Tm9C2Uxu2f*jE#02%>6p5|`A*Z24&oJoZo@o|r+xA;P}_-lvYme%qF8 zBs`4zAs)S-s#a*_wm;>S!>gGplbY$Bii@=M1Uq)f=er_MK&`W0lo39+*F1#pHE5e6 zXZ6kkwE&9>>f#~m${8_N37;Z0SjS_RSz*;{zy#CP?tz3kRkqBetEawOO>ioxpZ~g8 z^pYr*q-nMm3*tAB^8V_8n(!G95ZXl2&NxFAlr}GBTNxr+hJRyCh=JrwPJK(t2EI#H z5E-_6ST69?oL;nSse3Q=Q_~)C%*2pQXDh?=Kyh?D2=CA_qaH9YiaI0+rpOPXj*toeuU{1}_k*EG z8{Wx<2ads<6okmFc)dudBJ$JyU9fkiQXoO<`@RP;`*mK?>WHur!U@IA`L)#alB{`6 z<9YR0e+qI06#2A}sWjF*1?+^T6QU{Y8ylA^s{?^*vu}2naOgMSMf3!5a-5MS^&tN< z{*5`-7EDlnfC!0yoHppnnAIm(iZZDz7an|aCxT(Qto4fe#KVGRO9fT$u*3uMH;_b* z5qrKjJtbppsS|5VK&kUQ#Z6&4`}*bM?`aTJp@VoJeHpSXG@5E0n9hRwY9L2^oH5Ln zodoZpzU=qNf=^V$mX@s@c!^BHf4WOE3l{H~lK|0XS`H?hO|0YSrp(@`CpFk6F%gaA zAV#>L_5-A3{0Tt$Z8`@K--Kj7eD|wRK#Fwggn9a4gMs{zTu=bZF!lqwuox zmy#l+8ac->#m%iaML1x>yN5cveN@uof01aTOU^iik{drqqw>o}50bKr)BPK;A7?b3 z5W}nW25!Kp^CMCPA)`#KgpF22Rwx!SshLapP3%mG3|^y+AM%ow8ng1D-lS{7gbS<1 zC#! zxS+~xZ2`$dulKMtgO;=iK%AD)^PAeYXn$9+HkWB-=bdY)4Z>9RQe9~XHW(ny0+U~4 zIi&M%v^W;_TpszoU4kCY+T;q&IAkS2qs8q>KAD+hJvPSY6iSA(LN}(o9;K`qC$VDy zAx0$cGli?FY1J@5@7M|IC*xC;00Qlvkj<+O=Tw>3aSDU}8Wr6SOVSG|NoVwR=$}$xBz)0n(@<&0w%3i_ zcPp@{LW-2d#V|ms6|rlGnwO30?Bb%hGX_&UYnH1FU{iN*A1-oqUuF>$4uLex39_?c zYVZ?m*1QZlKdl-(!soglytwqw)G|>YAtN2HQi8u=PMTL$WadKzH&i{5BF88lZF8|- zJyL)-W#^@JA?K0I86TS#G&v1Ibf?cx)=PDD+L*zxMs1f+Fx7R?#U&JPTq0fZ2l(#> z!vD&K`ghn?vLkR?4-i_DDFeh%7k7-6x(82C#6;6Z3X}a%NidQ_^nK`Y@#T07(*eXJ zsd=%uaX^u2u5OV?_9lg8jmx7;Q%x68l3L)(>X9lDs*s2?+EslDx>}}o_GEe&Ik%UN z^t;D8A)F(M+djTpFeUDy+8V9D-x!`gjx&eA>E2$DaJf;dSg?_1(K3|mg=+~CE zsFXudR)zLVRtgBrO-%R!S*llCp^i9LEsIuucDbFx*5NwxOZc;$Q=2OS6QuZRP|Vnc zi3t6~l?6y~kff>b(7VN&P(F(=j1-Zw<4^e^OOy6?iv&a9Drh4bAoBHSoDqu6|B7nC zPf@)Z%t!ngk??s_D5aDoG?ayT@Y7ak&h~c?oON0$aTQ2?YZ~ySKiZdni!YR9GX6}X z^5D#QCNpQASLBe52N08eNBcDQ$w_w!Gm=Xq1u+*pFZlibKo}?TXj+7VUK;sW%+tPp z5z%AjR7MysYWfoW*-KP<*T&V|vxMf1H<=ii&QHZW@>vBkT4Jl}0Th+V`y3Gm+SRP? zwQ4G9<$HweJm{d){j-o~viFRd`i{QRI2XLTQ2Yf3aAeZ+eT`}5o9*{u|18@%qNLiG zAYFuDWl=*)3g6IbW!nTNWrsWf#2|>{)Al)dpW7QTtx7}to_vZLQ!juMxr%3>v<56I z+I02;t?^}5uIXW8HtW9Vvt6iI$&hxB1E^ys5(qq0Vea?(k_Gp~{X#W7bw_rTE7AZx zIvJ)WOms_gpRTsp)*}lc?7tk%Qj=C$EvcX zu~#Ue?`zZtkJ^RdqoV66!9xhf(TFxedE>PP1!9KcwJ_V0@#xvI#zY&5#$=DCB(>~b z$fQZqz}}GpLz<5W*&~a2q;iYC97M6rM3tc>4H+^3B+IAv%*F5&#bia(Zd`-^T9DO$ zQFGCI3-rd{R2o#3YOFRgU6XVR3eVF8LvS+4cB^X;T0g_HZk?)uw=kfzek z5e*TM{+Do+*ef6|Dq}JB+U$N(vu^yAD02&NxEU0;vHjb&h!>*WwUvIYODI>8D{`xC zxY6v`AqaFt8&lS_I~Y^mbgBPDUT%6h4N)|S$ZOC~oYsJmpGDkpY$1q3!CU5#-AJe$ zfE*Mc(j-a(qKxunjQS~09mn}#kIU-B$IIImbJYX{+OmU^66rrFJRmx)wURE`1jXkx zqbbrKhbTZ%2R#T=4p)1+9&w%_U|p(>A3^M5)*)@Ba*!aGqdzLJ6uimLYz=XG0+ej0 zDOnYGye?GU*w;87w~jez0R^4{hcIF1#k+Tyya+b>|6z^QIo36mJEbT-5R+pm|Xat z;Tb||2-NLs5>8!827a>WJo6wxeXO32c4Li_FuA}k?ONRA<@o)_`N zN({7@fV|E^Gk{m`v-*1!6rihBTXj&uXn8yIG#TAzpJcf2GVvADSQHRzvN|vtJM2?P zqcoe0w>J(E8%sp8`4PvBh>VfQ|K2xp8(l z>We+>0lp&&t-*y8>=Xe~Ou=G98XaCubqZqY7zFO*)I@@6YmkG*9vCP)!%)Skh+j&^ ziwwi;n)2TW)m6`F@+w0m{$8BOcgPe$ahjA)aoXFaug&#D);$obzLzhH=hTk8#+e6gxvLuKE4|vwS%l(77^U%S-UUNI#8($%IgS^e07apTA0ETe-xfOQ}jNP@<8C_`OE$4LW)NR z(*MK-LEN56wLwuBJN7;NK?oA<$)6LOzlJ>u6D_m@e=?R;Esw%H5a4m(xCR{zy_u%JpsS)r)77z-T{))E^BU24Sg0eyAMi$vVml2Nv3Vd)h zvE@lWK)A7`1t6fKT=8qGpb;b}iN$D~5AKsuRvEJz0*chY(wUK{RGMoB@%N#VFb_y% zDYm6>Fgue{##ok9=u`;55R!ZVM*Ex}Wl$F*$D0tM+jvD>Yv_i?^mZmA6hw#_$PMB} z-EwEW%)oX|Q!C9B*|f3+Kllr;tEaGt|Fi+>Q+Rgf?nnPYOZ3`Hujf*eHcg+gnlb1s z?%+`4S!5NaJqneFdc|b<4mf{%uwq)+(_VyUSf-t|o*Mh}(1nzyOI>bzad*+eQD7rl zrN`vq>b$Jx4tGyhcG)|@H9HNpE$Fc(S?AOO?y(8?DH+<8)R?CkiGnmepj}n?>Vu#? z2rNTAp9s8g-)V*}*-ErdS!a;`lt?=nGu=FP$?x6U(zIg6dI%vGwU9bAt(cS|L6&Jj zIyu7Uc7(?ORjq1>C!}YQYeXq9jXo50xgDZbI1qT=AT#FQG9`OPV_AAnQ>nuZl+M15 zk-MbfQ_-+ujPl0=Q}I+EU#IrY8=kb)3oRn}&45&Fl6cKoo)OeUJt~+8uEf_4gA%I| z&HGcA!$aom`dby8l@B*ZTKf*y-=rRn_-*xO48wiWIyPO2o2HP!QBfi%s3G!b7#81n ze={B<_cUpJwk+KP5j}s=V8XJ5 zJy3xJH^EpMTIwys6jMpv7YzCNid{W3)`377a)jEDxU!y;uEJS=6se{H$w!Et-sE`# zlzZ94UZN{5$nWbAN=*_O?l0w!si8px_wf)Nfz9gaJ?^=YY1kH~4_l2c-MN?|u!uPN zUf1~;JK0>`HM9+hC^S)Gq(F^u3To$r<9E?_-_*w~{jIR`TTw8xK4C2`O~$wzRXP;2 zFy*HYS3F!m+XX{HLx68TyEJ>w^1tvFUv(ZHeY*OeRy9OU@cszt^lvJ*>(}nebsSUC z&qeOF#`!+X35QLSZI`0EfU_`-B4Wk2yHr|T4q)uqZ1}A{dT{tr-UN}$$S`aiwuytH zlsMMlT--0-kqk}|H%k|(6q6Dvs-~%u{F$?N_hcZI!RBy-V0Ac=)ZBfCv;>hgwuAAj zCgyuENy!!WJJr~cRbAiwmn6%EXe2XRaqh63Bt)h!n9kd&t>Y%xzZWC{;{1I!TqOV! zb`SpEhF9YUE+@|Q%1B=1a5gR?S0*-D>UEso;i*;}r{nv@);$GwZL@CDBevc8kuNe{ zslMG-GXG%RB|(X5lkQK`l*|IAo9%m%p-Ue?w&XzW7l7CBk}_eiJKlv!)qOv-itV zeSQ=iiKZ|1n;+*^%;LeI&2!HJs*Fw_M&uMgqVX!i(RpM>ZTAbBab_GorHJA0?P-;= zN!Vc>cMzkXvBq1=&czm<=UC{O+3C#2{|H(BCj-+9+$Zma31_Cx`Ab_N!XV<*0=s&Y zZy3aC-gXraCjS)9{(HMg_s~rMODh|H09Dp@s;a9JWlytKUw5dvSjJqw@aab65e336LXMOklm6{C~Zo7Y0TI=6@gl zf4_sU;*eC*6VTPW|NiX%6O#;iJM{nSm;Zm>zRe2oxqO$oH-yF{8#n=uX(HX2H3)tu zQv10B0UH}TndO6Uf|6QZX>X(TBFVe~x;a%vMnA%wqr2TWesbqYeiz+n2w7j> zg`HGaBjvtCH4tt>!623_S!C`&%D%xvdI*T!5F|H!sSRuJYjrqV>z{xf_x#K_9MkRl zPVDZ5YUb1uKUCQSC#Adl3N0Ez=quYNy@)Rb+Li(dg8UDjPvX&m&tf!h9@hSv-_hmT zvif5D#?JTZ@8^Yxh}Yf$f|^FrGFm4Lo%zvG|M=P!WE?E=-E_8Rd_n4N_KQaNvmk^- zY$(D!GnC~Il^8J|cU|cdu)Y%)&$0q-|LdKApFpdR5LQiufjnls9amLo@kfgAwRV(G zEJjL=PI?jQ*3XDq8Cv+76_W_&)VA9fG0cD-#cX75Zf<9jH!__DlS-$E`5X@SL7sK( zLA0XcBGbVsRlF?4f#Dl62wt{{SG-4FK3HnQHEM5#fN&?iZhQQagP$=nW(mY%Q6;SU zU4i`eWfgdFfp9PZ`9hV0O?!va@|=vD4>T=oIp<*)T=j$`PO1DI=NKqTd$<6urQ1le z^sseZIgBs3S1uU|`t>f>v}%Rig1*j3o-EIF-yeS-CblP(<5UVdJKf=5SXTtu3rJVQ zS|}Dm3;-GeyXdCcCL=j)zS-N~txWGzrDpk>y!FQ`&Fq@>oK6E|W^Qh>uT7zlr`7H6WBPb(TeP|nCT+6uoYN)+fA)^{ z&!GUD(`Z>f8TWJOnA;xQ2Y7%-sGT^N1km$;l8=JMnaC zS+lFgSiCPao$9Qh<|8)Lbltr3Iis4?T)@RMp2VMa#Ejpb>)RpW;dTi~(ka0}zIy3Z zV(D;IPkj(=n25>B0N@zlg_u^?Ik5W$X6c9|b7ewa55|Nkz5z+?HBntJKkk z$TrNE#Gdx3nN93d;ITJ__#^M=nGOtxi0$rm#4I+E?wv*knyT#5EcdRmFd+%YF~xlY z1FIFVTVH%;hnnA7tVJ00Q(wQg!KuJ0NhOgW7#Zb3PPJEBAR7VbDABOn=|19OlDmeZ zhEcR}yvfp7rhdAUAl8e}+~Mz!1QtC=O~#3-Tfh25O3#jXy4nTLVoWYyMfFpU6ZVs~ z#NkI8Wm0Zx)D@wX-XEJ>ayk%)aWC8_c>Z>`Byt`-@#Q zAO8()rOOi=@&40*l}|4zR7gkJ>M#?!N1ljs$WF0{8rs&jDo79cSvO}yay25TEU=jQ z`xp!+sNKeTFI35kb7Jf#&>|z4b!X|1Cctcc{Z*yi!B$2|Hj?EL-^hE%%Oe6oBmq5{ z*{BW%^;EX;j9)H{3=H1uqQ`tTM(q zCUpNPYJ16I+!<~e$AzH%hViQMRqDNqBTwyWxOl}^aj_+l5-=FUmxA+qAdO_y^Mw`# zWR9l(@PXLinh)nI>SW#^cqKOuze{(aBgB5}u6KMIX;IQmM#-Pn;e>cq5P`)dqyme; zOQg<|{++B#%K5e_FE36dT%xdLog(sJL_SvqvGmrm-zPZuGc}Wqb?SD1LOjHayBg8W zu%mG@iLAqpdw4T`w58Jg(9YeA^~0CH@twg*PM_{D3ag2-LEc>4It%^ zZ8pBXK&x2HVM5>ZPfSL~6Ef6h1md+0R4-_y;%w0pttXsSy3iUj3#Uq@J{|z6@L^25 z%vjIcDn_S~2ye4x46avgOwh*N=o~4O>CrY^u9WSMa1vP9{vP>m?Z+a8JC~lYwo;T5 zYMOqLkB@XCE1mM8@_l9?f>+?FF&|o$y>L!0}{$j1y!gkSS2tVFr zo^S*MjpxBva|Kf+L)N(f>rcUTL+?+=iLq6F3~2y$|0K#p$dk#Q1GO7asNlbMs&p0b zr8%=jO)Y5flB5FeraOBW+d~Bhtf87myimVt%q=1lGS2+8nn{}C3oX9sfKC3~ohxYs z?7xtp6plbVwNgR>l^jxy1iWxOzrg}C%t)Vp83l{f=HW%zTjc~N&aB-^yo znUof4=Gp*Tm|W#!KcUprlBo{`cy*1?Rv8-Phr!e%4t+KMn`GT*$4lq)e`4QLgeZgeyJ%~om z=c2!ad$coKwaS&5*7}YG;sxD(gOBQR z)egB4LHd#W;!8J1rP>5hk6*N5pn$q}^jgn5_nm1kCJ0K)+UNrJ38R^m(}YBD$hw=Y z(rrF*szDLS6rH;+-;}2=Hl3lrfGj_nc_b+pm_%&3zc&?Ye3SL7z`~Zq0Lnc~nk;F3 z23DliTlnhi<7uX=m?##L7R}H?WIH! z)qAIvR;cpC+{pcXo3DpPemka9PB}F7TL!RzhDL@96Jh?UY1n8c&h>f z&p}H0g7zvS1N}3A!{61=e#k^F6OGb)`}=DLegT7)pc<0;7FmmDD^ZjSkRxkE2Hb#}N z99R{9bU&a~#;`eWpq=25YWj|UGGjfc%16`I^I-;rT>4qg1lRS-?)n+C3_dQIrxJz3!xz5xQBEsmco1AM=q&M#8-^`Y z*zXSH%Hvh*p!g<;I!5689?gc`FFZy#Kbf6AG~K)gT9&y9DMO2`uKU2i{7TotG`(}` zose9s%np;fp8=4ol9(?;?l6s}%^yg@84evZ!Zdnv4lX>ZQ$*7e70qCO+2;W=K05n+ z*RN7=o{&hSp(@VcrQ8ci;0HfI`=moIhuo3Bd#6@86n-2ix4Sm?6-VjAgE$?Neh;*U z)1vtCnbg<#k&4fS*-L0|=!BQ=u_S&~#gH5kbNjyaP?L^tNjS+c`LX`mtsnH}fg2v+ z&JjJL&T>zUE^*3Mze_Ek&RSQ;Z=(>L4fl{jqH(UD?kpJ%noYR=JN(5xE2#jVL}x`PhyXasR=iT358 z3h5wAQ>>Dz))1;(LrKjnweMPKJrUuOY&aaH1X);X9;OV6Njn1p-7>>#H5_ z1y?GLT|FW}>!|4L$%|J(79p|tVRGztS)=Usm1MZVChUoItKz-y#ZK`Ld?Q7@BrbEG z8pK6g$@n`Uc$eIWb9Z zV%#s&tF=OvMF>bCsRWODndm{!vc?sOn4WTRfrgCH;oLS(D}`=;7ZIwM?>Wg{{I8ws zn(NVTev!0~ii$PSwJJ+GZVxu|Nvi^E8qv~z6~zmmjWNJ&cfh1b%Ei<#}NP2o_HUT%-Y z#mP0329SKFX_#hze(mch$+iu_~v{VtCw_Ke6FA&L2P<}3NFyvH-XgNv!0acpPA z;N+=~UKIV?84oL4WBC3PR4b_#+?&((M=kY}*O(yE?S9m++!BIV;vu@?G0|!397`6l zNZoEYwy|f3_dF#2zF|eplux7~OW%)Z!~`)&(p)d&lLNXEmLE}$z@{IR!R!utEqI7z zy0RH49W7@eG_)mhHMp>Xg6rL}B@7A7_?lIk*v^C^zGA@p8B|^2WY};8i3!#G99$@K z$i}@NH*MxFCC!NyccM!Dr1B1cW;JH;EJ-i z2H%R92K+`$b&?1)`ut+&yf0V2fbGY<*pzS({RnZgR75~I?kp(IqP4)xbY!~W`m1#Qiu_YpQq3Q1> zXgyZUG~gf-Y@)GHe-axSq8C92lG(5oDs>BJiCXMKm&!<4$iyb~$vy~0BluB%Ar+kZ z6*5RjccLqZF80AXua`d8fn|dip(7z4v?OWtP}z} z8RkNKSEHiUX95De2hdV6H%WsoTup3?ePjiDo=AYa4U%(X=4ODdVHQlCsFmxet{{b9 z_{_R$?oYlcv>iUDpqzy|pn?BksPvAf-c7?&TQ}s%ribxuxkwuOLed&2JuVndFHhB@ z)ii1?x6h1PcE~r@$Kex0{4B{yBBkXvHO_EMh^cB&RfU#eImavlI7rf`l=S6Amp-_7 zAx5wVT`xbvC}jrIPqKIqT0|M%eZEEq5y|O>m4`)wNaYc#J|IJpJyBM}V;W?!H0uvaAif;(Sd0d7_Ons&o+X0{U6wAUHA zlnPlKdcz7`iqfM&Z^Hvk@lfW$z*m1aT7%l81?}v}rzYvpZWUd&gL6rAIa{zyb-v|T znt^2hM=mqkSFM>rdL}9kBP=NMU4sPCi)X$b(cmCbh3p3*UOv8hx|GRz4*hJN)h+Q5Yw#&jgZ&G^%d+iSwUNWKS^DS=` zzvM7L4Xl?LEG2`|h;jOD1)c%RNj5)^XsSiK*%f=MO^u%g6vG$R8Io~{k$tB6NBTP0 zbxQQFg?XE?V}`!U5kv&bu*k;5^?DtP`nS})E6cz)wQc~Q8kFk>kBxDN@#%7N49R0@ zb7JJpbMoPR-JdW7`Ep*bvU3}DI@8IAUirMZpZvjN{K`0uNQ*lfTNOCaE+c~#+4E*# zS!Y|#{j-lMzxrO^sM)`{VlQkm?U1W_Q!vDeb21ziM)&@BA?w1)woY8Q7fAY7)VbiQ zb9Nqq50LI--y6%W7o#z92i%HRtPztIfvL|TNy6+u4{ur?^yiCD2~n8;N-|<~Twy~d zixg6$;etZ*L$%@CJi28?_jsloY+XLS7n`BB`}8qJK-vdNL|Ufix*1#Mhdt6bB&XR>)yC< zJ8Q%A36Tp5j7=gvU!;jboi*BX_Y1%(jsATF<7SSERBql8NmD=s`nR9LQfx{aW+`wCsz~CFd>UP= z(kcyF@-7oM9FYY((!Ack4-ih@>&LO13)eX0Y$Jx@TxcI&`j=v!Y+}$8*!=ep2Su1W^bR7ho>wEvK6#6AzU=P(e2e z#1JWf9rS1l?93ovjNfS_m(*$+} zEXI8(fo~@mL)T8=j~;GrZZZlA;k7;Ya2ObzgBWej@x#t3*XJsjl&QhVlt}1|9J>mb zFJpC|NCH>j59g|3H#X2&Z@;^2>Rf64EI9JZmCRG9Y56dwv&bAa*jqQrg`Ed~OBDW# zVLle}IB^wB(AyUS3TnlD_{NzIgX%|fc5OYh+a3(PvtTNtGBXSr6xop)CB z0nc%ySo=ax{p=`sVk5s`B+TxifjUn+$OOnl$^3aPuIt2Qkt4=yJ z7sv)p3ZF5MCk}I>UL%EH?J?u4YktVBxnFH+Y6vbVu~=yJ#B8!%(*yY&Y1Pi_=Og-E zpC(FtMvx%yt0O{ybG0RJ_&9^!Bw96JK+Jr<9TJ5m)-=ke8X|%V1r2F$nF4YBr#`X} zeYL=VBE$Ubh2HpdzKI&9htL!%&cJ6u?iqMco*XHBHz^M}&3=-6TXg&qG}W@|UD$n} z>dH&Ok^>bC#eG`Xnis8yddRIR41DWfE>aS^t&$T=`;4V#i&uJjVZejo>NUCh&!` zChxz z_a@esbI~)aVYZ2r{!f@Jcf4+CfLQJ}Oxn2X$6!*gSQq{pz{hNUFqDB0nrP$GBJL`f z8p5(udaP&A=!N$`?KlRDNv3)XtbfH%75&E1MCJ8y`i5xGw7EF~LWAngH*il4k03_t z;I^rYFUWie9De_B)yiJ`Eif~&WRQ#KC4&9ohpC2;pGys0RFR6Gn0PBLem2|A2PCMF zB%n?X@lSI!4EgHW7^;{MKE2`RC9wJD#w|U-7qlsm@};*@%l26=NsPq2T188-t9i+< z<*DE<8$h>u@&(7Sy)|2tI*sxGNzc}YQu-A6{v2cLSlRBTA{8R721p#3$d4>QVq&6b2J(Oi zL8U@jTB18UMAq7<{?Lt8x||GJDBFq?y6s@Am{q~$(ws3l4>m1=JbIH@BoKKmkPDn9 z%Pyt&UZx>rg}xiNtX?pO%@030*-%0P27?G+O^j3NE%`V4_V~q5o1bz`kKUe_5L?xg zIB&)5Q`eucVMT%%@&lz};To2;x7k3IF1VVN&7iPHYXyhO)BaLkzL6-^sjsA&!mwwh z=(38UgnZt}OCAmkda9YfDnFJHSVEB(ef3s~iBPP)@e$^83GGhbS zT)1t|yeK<8u=&dlB8&6Xw?0I9_i0~pwkIAqpzb;0Uc~V>#!tF*i7;aV8Fs2tM%(v@ z@Y%@g>6aH0TC>@&1kPEz$x<@u&J)}n$;n9<7D^GU$b|s4KB;=yQv2=gUr-t;CN;_a zjOPKEy)z^FJVd9{oT4 zG3)j;-K@5Q6&u=5ALToO28X!yOHR#Dnb$EHQoCM~SakiMrI3xO(aR87U)O`tzH^dd z70yhMGvc=CPur8k%{SmPG{?(VoY@)4u3zCLH>4n2nb$wG!~*jOUP-8pzunB-))U9# zQXK!Px|PSQ_bLO0YGe4o%kj9bnv>Pbu&ng-WMcbCp5Ky@=NMz>rF-u(mk!6#3#S-v zVcDg=1zb-H;>@KE^*3oCXe&#LW<}rxrSXt=?dqJTcMjw_Xdlj|Agphp1pA% zZ$xDQeqLyql0(H(Iq}Ywo1ezFz(uM*^r{2aQioJpH%hWd~PP zZ+Rut+eT-6e1hqsd?h)iJw0iO12A$vTjC`$ZMw;K$$P2qOX84i8Plm(TZ zQY2y{>dU9g1w8y??wgqrfKP1rfE(~V41*-GXeXq#lg2NCo9axKFV!yqC5pwCF4h88 z;W!0z!hs;&wABs$ceF>>Q#7<~I(aO7{i=sH4DdK=5(nTCTidpT{*O>_4$VBmUB_G+ z4Q!qYx%i_9X^nTq2a2M|PpOj%^=xlmeyzxw)YCX$l}8mxddJ^JvE?VB1j&@}V79?h zG^fJ)hT``#Vxx>;^&&QHw9Z=^uNOQw}b#*gh8XE82Th>VNE!bfwebsFVxyNLGl zxe{x9Ios1SWwa-iU=M1so~_i@pAmJsz-^6OQD1Zp!x}~bM4QlZ3;a#gQ~M(jPxhv} zPPPvc6^}6PS{WA6lzLPNWtk|KWHFtwpAzjc&zfl{k|x)_Ii&uil_4u&L6(`ulwC-r z2D`3%I1PjF!pE-D^KLp`U7iY2O>yGVHS%Ys2ToeXm)ToB1W_foRD3-zT|H0oPHH6@ z|0$;p$+DhJ*Q$0yMq<)O++%nC!rr1iUy??Y$eji%;eEuIpCFt3EH3zil42%VzcOym zV1GAH?qRVkC#ugmzeh1VNULduvLwH z+bt1k=Z=Vo&0?lXJehT03xzE{F!y}9YZDbw+j>y>o>NX6r6p(f>R?3J@$C|4k^I2h zW3gN5tbtC~M(eW;>zFEax#o9B(HOT)`3-l+T!Ucn0diKlNkW09ioyK-P+#2PDWv=> zs$=S>B#~s_OOyb6Z1BB3UGJ#EBGap!`C5WR@R8|uEb=`UGY2s76SnzIYD{~EQ*<~0 zTMbS_RWZ>(c}8v*D=0W^N`4z%4&vLw1V?RO2x~*xuKi6q!dTu8-C2$@D|Tnse1oDj z%2#h3!?y5{qjaKx%|RCCjqysw=*+bft6tPi-hixo5>4jCio=S3A$&|abgHtzVeR8F zlN9MKZsV&MIX*Qc*Mp5^dJ2+uMwBK-%7`=(upDGKl5d|(Qd$x@aY(83mr<=VJcD{e z#L0O;wUzcpaqYTQ?4AiVn*RJ&$kCtywvaC&9L|spbB!x8p#-FK{Or;OZ|_R(zEFLw zP}kgPuO3!{tFpa3l&D1U0k%M3`9)-3N87dOs3f}S*|~#g(5s}`X#YpU3j4V#eN>Ty zc^PDVc~-J>d}}(dyTLqG4SoI6vHIjR5a_m(?PENSwWhPPJJd#08H;v0E6kn2Y^TBUP3JESgcl1uusmOL8MUoHZ?OjSd_EuNWhnvAC>7g`d2 zUuF#+;FMfdkNZdoT46zx`Y=2Li_f5ibdynX__9KO4a7pCP%vBhAp=n>-vzN?(r)G3&oSh_m0_nKhN%vsnWxyOR^QUCXj?@bM&{@_h7X4R!tP;CFVPZ^5T0I7f~uB zV|=vKyYli>P9As4I7)`=Fm%~xt29szR=AIm>Ns28Rv34P)i!^IRvB3`rC^00uXfUd zn3I##C;iN+vESKDg5e!dz3{Myv@3P8n9P&SH-0buf!QG(v3;Udv(9K~;`~{j5eG8raRA=^qfUPLAy*2N4p}Xj=+@ z0$b89#^C0EXpmsjihsDy9U%&v?%@F5{GF$l1V|E~{@L;GoS_<{EO6p1ke2tWX;Fmv zgs=U%?@VxU84Y=GV5e-?X&j{}<>Z^bneVa91XJ)KwKLSz)CFC4P9?Ch$wNvdw6I4U zU(#1$vV&-qzP;i(erC}_Pmx}ULW2vgU^_D-q%zltA{(&#ab>9kDWzW~n8fZTXMvk3 z^VXjcDP2*=EtAXpg$=$iGbyfw`YPq{@7f>!;$8d|v=`t#n$ffhp3s=iJQXRY~|c9m}RI~p5IuK6VdVHN)q68O?> z$NlL&B?SWt$_l-F^3-Oi_`nlEr0@50ZF?r@am2f{NPhxy!^POm6rX`8o3S~TT$iRe za1NO5h%FM;pX;sAOrej4X&giNqSiXypxzxFxA;LeWjyN^$3X0S zE0YHBK0VM*-y!Gs?5&|XwKg4s80#0kcpUl6enrXq;Om+5ue5ulXb4@&1u zxyogYYsmc^(ryQeTxPgRL>nzSjOz3k z9xuKP=S9BH5L*jz>B;|ughV9Gd9w(=@H~%J$%~tnzb|7P$tm&kd{7Va=KTty{JhkZ zSY|^-)>R}`!Yh>pn;8`b`;RISud6=g-(sZ~ z(&}s#q6Mtj2e8aBO%M65Swn8J7R;L)IxH}1qIF)v$Dw1o9&F>^c8q|>#u~RP?)>fH zKHttm!C{N(XvdU_@jePwZe-b`x-iof$0bgFP9rMO0HR1SH}dXN*UL3p~p^t&>@AWarH+ z9JpX*_5jM(@Z2__WJd8d<1za6?$})dKxc0HaE3S5vu+yFUuLD4^(r6gX{IAKeUaPX z`(w@V*RWG!8o9-|7!I3!$P=F=^MY#j*U^%RbZK3kE{p*=k0ZR>T|ZekDgnlq((w;3 zWl=5emdi}+izy+pFQ_@G`VJ1g9xdH6SK;LhGFN3P&%Nw4i-v6|32$lPDyeBU*qEAA z9fGDTx#vjz0|F9R=Fxv3>N4TlaS*PBXF*v*BBVnCM1eLK)j^eUF%l6*_D-&3+f@E% zAyF*Yoyp*|=~zQiF^h!i;jwt}##ZW23(CJf!_(pspjl+bHUIPHr=d>^&0GpY z>9^aaOaOuK_U?wuB&frCZIgE4I9go#7NJ{iOMW^7XrlbcB{ezabpDE(m&F2xBBA-3 zFHzvkcKYL?yulR=71Yw-@V0UpupLlf^Rg?*jd%#;a=xD>^?%tK=9hPkK^MgcxhbX3 zH`uhTKn>F>`LPVps>RV-eH6)=^~xiQUb~Y1D0L>KWWueJ&cpR|70g^p?U?0l1Z)Yx zxJnX!_EQ}G37)CcdO}1Qd&hjVl%op=x^r9-!6v4y~$%9CqE@cJBiXp+u%!1^U45{U(+~n$=}QwE+y3_-!fn@YCx<}zKUEu9LKbalu1!8 z5!a7nz)qoF@}%c~lNOS`B8#^_i1CHhQnw5#gfK{*djigL_Xv}d!AHC3M3 zKEVws6xiEiSjMlIZo-tej*bv;N^J2-FCr~Q6S4tEjXO|^vphsGJ?y2jy?t;gQBZG! z%@506G(%{%vm6Ah%MBV3%MlqjfkQ-p#AVd_OreKPE~E~ne5bMx*r78m@ZK;Q{HV|+oSxr3ADu~@W_9?8

kus06V6=g@ZX%}Wh9|ql#C2eQz7!mAWL*^7ltP>e=5|cQlIH;186RB2JOM>3Q+Kq)(Mukd@3-GX? zsc=kf4_;=I`qTc6hqfvu{tS`B7bnys6I;8d|${mj^cf9NXYv7+DmeSySQP;LJKwV z540&sI8Lb#X|)hNa0&wr?fxs({Df4dp#X-ni__1@NH0UgkxZZNh+@*~yk6l&&upCQ znjCLZGSevMUk#1Bgzr#NphiV8q{z)TU6!z=I=e~xmsk+_v9bPMqwp|}uB0JgVClJ|WNm4`(o`riV{%&#BYOc6gO5fQ|`nZl>vqgtsUWUTPFI)L2HO?Ec$`N zEj)@!I_TA2+B-$gpZD@>)fh$GYqjHPTc%@pu{J!HD!#C~5ebBQ73Nb^FC#g2XDbV= zc{aAWALfWW_u2-myFAO>Abw=)0 z-7uYdHaskQvvE(Jn(8V*u#fjK|JCI5+J{$4C11PO4A2r~k}q4hc6)dXQVS(<;EA$= zmO&p_bXiATBl$9lHts7y=>AJ5O3odl?&WRHW9uzTO_?dFJms#Msn|z*>3W;FRMIKOh2(eX&?h8L@JJTNYkg|y(y6lLIj0#m#WK*0BT$%#`l~E`A$&!tk3TrfEKS`tX;p@yF1-@5m2TtuP)HgO zl+T=z#s;SVxIpqF5m?>K?6M#t)QHNO%xi%1~#Q{il{&sOUaH%%EZMPncOJtEq3 z59K#P6I~8?rkA4;ugQ9Q9ueVRx*g8L9_JeYX|z411MG}1vh*K9O(MinbG)Zss4AD< zG$qt8ysL&LmdCMkAz@zCp7O+IvL@_6(bNgp|Aa%qAqY1Cz%t!{om6h7Ed_QgMVywbi#XA)5xaQV^ zJ8M4ru=xCFNM6)zvsT?x;OR9kVkB;6P}u!-nbF*=IC|v}0+|eKi|d&z+3%lN6-}sV z-Dj)no2sN}AUz9lRvBrfCGu;M3-3*F>|ZKU_-qX`>1!JuD-sewcZCB=YyDRKjc+LR zjI7y=6-Npqw_($aghX6SQl5L`v8=LwVcK8ujO$@l1SgT$I%oO~8lsm5J;}BX4SiIL zb?5FIne%b!Gfeu~Vy`lqdQ=46H#3ZxwV&Tc2ghG%i)}?|I8ooL9g8V5?>0Cg_(Zo; zi4Tp<1X@QY&#@Brh!?BI*oxt}V;3n(3n9_Vw}?TS^~|}$e=4IWTd;sbu>@t2zGGCU zev{lKX`ro#7gI)#xE@)X0G~uRyN;F?o?@v)Mu~ zaC<~Ei#=bbBeTl?(BXXSe&M-0!^1jYHHJoakU&x6VlMW`bc*v_jMp#O-I79VTzNV+WXq+iV zlO+?`DDHdJDzESYk~i6fXr*FvOHtFiuxC05Me)d%11Jvm{1dvjmyK1e`Gw68Na>lv zp->PZzhs6aDYq*{)k%ry1lEMko05Pqd>C$`Vi&^}XS|Vc(`)yS@Ci-f}ZwQF1loqQeF_}dkueG?Enh_57Y+NGVf-)Z2O2YubLU{Gy5sQG}9Vy+RmRt8mv(hE$^>+3e zeZE^Sx0=8678NeOP@$&GQL|SX)VB0=Jw^Z*=P6A5CNXcg6N8B%lks*|g z+-!^7LB+`Vine(&y2TDYzm%z%g$bB2!ZY-o>`F-C;UVoF(&Q@3=lq1^F`YjddnY#J776N-dBuKx zb8g7au+11-ZHa6bs+#*GZZ^H1(Xm*?cRr;jP8ytYu&-dtJTrt^kRiuM<05Y!@un^} zF|yZ8-ScZj633eMK^-DCqtfIob%BRcwPc{7UnUx=YB_ZeZ_oKd(($D5Qqg#L z@gri7Tn#V`*n0$SA32f*<=IESET;5-fYbbNc;f{=I5#Vu{JLJDUO zYL=bksEV;U?tgZJTmeB3%Zzdx?fm%^#fl@FYw$V%E9xE>5gRQv5-Bx@L$Zt}-l9bq z8>xU@h`yz$^!wIOyy?WZfKHpY{?CPcMFAWj%f^x)qSC~>Mz&Sh*sXr|DJt4QOkO)BPu8Ax z$q#wXnl?`v_vn1%wz8Bz&x>3wT2&ye6kRFR(n+x15uIhxBWpL z7aMETlj|ay zVjy1tXg8I9$BRbHFAHJGa7lIwUj58#wFE1$p>)`bsUnbeWj-;)a);( z@KM$HktA*77qhV`x#PEEcr}$^9($@A6Tj~`y6&$DP7sD3vU>$6emyA_I0md#XH<>h z9jDr^PD7~F-U0jj zAPwp}dhM2?F*q#j7rfy_-ya%3OK2f&lZg>7L(1bn7N*^MDD(%GguH5AN{5i>iGhZM z@F<0inN*FodYYHE;i-foJUyXWOp;IS`|)}cy`fNXp^k=RhpXR99;fL>->xU&ANxms z2h!cs3=`tI(vP(%C9fqF`BL!n!w*ogS;<9M{7A$C)Tpc*FVtiiHXUWE^&_zig`4F4 zF+EOt4qHR!pW9VG!J~LW63td2dGL>{w^lxiXHV+)`c1(p!!Q%1+ji<=VaJM*=poT# zT*>6R=*bmg6`3eOEg{5b;I|1-wHyW#KRxe4_LVP5fk*(Zppu#wNdZB>i3%M5fgln~ z_gVUTTV11_scDPChkg}Jt-RLVjmKrNT%|UxUdD(9;uyILzmx7RdXH;ga1g%KMbmX% zw%cG#_+k6)pXzM-vV^=h&|7Uq*q*ov#y+ zyX^0Z^IqV(@rKkia}##R_*fb?u^w~%muX5nKavhVL2%u%-GxH%6^o>B_!gVnNh_#d zL72$nsTV0>6}d&}{aWjJ==~-JVNfOoKq^&M^+A3QP-u~Jozsq);f!`{gEKr{ZTzHm zqkX`9AB_53<+aOxOZIr;bV`O%@Ig{llJVn)vWDw*X&@_B%8VXzejtDp`-e`$sx`=Z zK(7^+xK3|)9MuAjif_ud#qU#YxqbZViWuMLfiYpG>u*Tb0GAcYOIH0Z9yvlAK{)4z zMKX75x4!2aZI`p6~*^o8B?u#YwOH8yVvcC16PHG0a$xgz|1 ziFqfEs#1uPorbkRpBu@M;pJ!Ed?q*MBeK7<*SzBYqr>t)_xsm>-aZb@T3%~g6;NNk zv{H~7>A@@5FzOcDu~tSx;$jlCUi4tyW->6^mwEd!FhTa@-{oBYwL;bnA%Kd_tf4)R zbtDC9k{Pwhc*=**)G$CvGgC+&(qy`9>h*2+$b~5r-#c80+Lgpv#0=Y|4h^}3^uC`m;&FT82EZwQlw*j!f$CLSx)8 z3dB~PzQ@>r`zu+th|;X>)}9fr9=ZV>!Sxe@wn_!89iqpckauz^z@e%6;t@iXs@FT4 zd>Dv?s{DTp3NTXLOTpdx3XlZ>FG!0txy00HWl#TCI5w*^wlQ1Jw~+}3sPCR zsPI`F;yysXq~QS2E-k!a7!DUtJxBqQq7YlTg*I%{xYcT{&XqwvfS|-zKb+T(1L?yN z=amDWgNnf3yGdH)?SD5jkx1>DAj#EsIx2OkA9sG;u$7oI`!mHvj=yIF+!B+T>RdwY z%{?L>QGM`($(0}6k^z*Y*GV%s*NFX3D~3+yA}XGQD6CSkmn0RDnFnNIT49XSQ%6vkJM{4m84WDZAk zH!8Te2lNr-sk)C%f3rmFqPvL|APnBf`;%da#-ZH>OdQkwWS&>teiL29gnh3t9WZOb4%GM^$L(uL1&q%u{v~7Xt2nFXcBEo zc8Oh{{e;)w41^}t$s8E0mM7-8b7R}Q-)1-_K*{7dvXZykAJY4K<0^CoXrDA8lqPTvA6^o54FlK~Rt9e7ppi#}Y&8DTq&xZp>!Oi?XBf94}mggUse6?<~ zfyN)b-q!>SpI}|QL6Hzwm0`wTbUY06H|C|wW(n49{y_P8NESbA8tCu*GDMa5f#Ao^ zZN-E&A`ur$Wp5zuk;p_pZ$acHF}M47OYQSNevQWwcOW<-VzU(4)YiU;L|!f?M1Qxb zK&uxeA{t3;^pyl?@O}$3@KY!c$XFQm{sa2FKvmnhF&3DcEGK=XyF^Rd^~Z^7n3z57 zH5+-5!hIS{ONQgWTaEo&P+z41t)jq*qc>Z93X_$-fY+X=J1~3Q<_E|GwaUL@QY*lj zIS|tN<&d@Xf{J`aJ#K&OfdB+emO-}Wa}CWug=|H{dPdA)Fm2qGDM`0|*drEA`-c_p zze4N3;%zl{P*YrBf{EUufP`)gg<8He0egMJ2+hL6W5x==4~Hgl!kG@S`B`#>{NX4S zzx`kmrG=g%3$6PTkykx-g__;$!-q@g=wTj{d($m`I}SOB-jM2?8JJ1Bc9Z|z>Wupn zz)GFi0u!mNgUP+`gBj?UBBlNOac@^p5g~h7(FX_K=lrx3wjNI}qviq8bQRs3?QgUT zEhhnxLOD~CrPzA?js5R$y#DqDAdc@A6oc%o7AY41I+5k{{X#8>8E@TYg)2O|{NrD( z8`koxHpH^Htj>Sl(x^wB`E`-H%0SpC5I|eO-tcnomyk4^1pziCKpXwPmU>VVUXVvz zpyPQJh(+{KX?Y@qNxH8iFvQ`oW9RU1Ce?@-w8{^cf7v~-exM2a-#bzDciq z50?YiALUC^Gh0|MB}MjpNfaR^1+R=_7GvVSiHVC8C*QEZ)#ztJ;Q#BP0pm^5Kbexp z-rMt{e-}mmw`EcN2Qp}ccjjyK|KH*NeH{4q-tO{nz`MAhR$`S1D}|&KN>*~NdYyxZ zlM%a@xgCmw=!=BL-weY4?}`1#r}oG{#}tH>M%XRDFiyo1xI-~r-;IA`!|3W&7OVM} zz|>freo0W@2ma&eu|_vm5UETB_U)k+U*A3pm$3pN21XTiM3{|_L-$-D*fQq*SaToW zxB|mH=l7j>E--c$jfe5b++Yu>K&&49H^0LNjMueJ>BDIiv*@22iCE9VwPQnl(&F2b zPEY0jBKo#I4rEOcd|M)sYVJqRq$!oFlH23smEHX_Xsc3i`SXR;CT4G8LE#(SXKa$4f429s0Z5`~>zyD{ zYmLJ8?Z3-E|MTVa`;#eRrt;-h_~lHgmV03WG%{*9Gll1abz3{wj7JHRyG~;Mr!Yb) ziWyRTr`oVCA3%U!5mK7CA@rA*sU7e`g*^>h6$WmikBUS`u>iM;?)T=T=0AohXdWTR z<3r$Z$makc9~3h)_>&J(+spPOx%Koe!ipaoz;ZcnJOCI`5K-lT`c{b3 zx}PsZA|k!bPDnn?eVNQ%8Co|gCDd6G5I1lIL6^HQO0FwI_99d}exGfRqc&z-!m#B; z0X8Uc_ShWYr`LqOm=3{+VriFOM(j#=p6Uw!UFfCh zFxyLNgr9WlNz5z9rK*#vcXBQQ7z)Jy%-5sXzP2Gj<5X%Nn~G$=b(l; ztAA}*L`C9j*eq2ahQ-$Sexlcx7uUo!!*qZ~#}MpwXOBzLehW&{Xbfd@*$h^z-G^y* za!2h46i19-?*7N9{)tl3>!~fp*zDmM67lMyC|j~T^AB_}9A z7VQnQNj1+PeXZQ2$1FTjyo%bWi#X)sQ`GCtH5H3tw-eC@k|TpVg1!5}$bX6v{_BXl zY6vpohZURLg+hlhZWH*bk#WnGQAX#EUtln3l`5C;jtAP6aHBi}^uVEGcoHb|Bl}6! z0^HZiUy`StqG1vzjW~7kaZTL1&$drp5c7hIZPSDrFkmek7%Vl05*_lM$sda2qqQ#_ zUJL5aJq-}cGuxqH<%JVJUJ0y`x$Z%zq>z78ie6ltj(p~zao3kszT5bX%f1IThI<1> z<-0oN%{NU?&uf$g!ImJa+B36v_0o+pV_}GT@9*NC;N?@wSw_ha5qmoHp9pzpu!Y*? zgQDB$f2}nmU2TZUUsZ+XsT~thIeZO&&g%-KVpsKkZu8spr;!ZrZMFF=Npv~SA*|jv z?y|ITvly9g;NQHY+r92BwOv(Ll=?hK%MDW1@#SoIer$05RbEWnoyllodytIzm7V-$ zGTQEu45M4pboSe6=)z>R!1mC&nt+!e+>s0|gu0VFfe$#)V&+) z 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/zitadel.git\` - 3. Change directory to your Zitadel forks root. - 4. Pull your changes into the Zitadel fork by running \`make login_pull LOGIN_REMOTE_URL=https://github.com//typescript.git LOGIN_REMOTE_BRANCH=\`. - 5. Push your changes and [open a pull request to zitadel/zitadel](https://github.com/zitadel/zitadel/compare) - `.trim(); - await github.rest.issues.createComment({ - ...context.repo, - issue_number: context.issue.number, - body: message - }); - await github.rest.pulls.update({ - ...context.repo, - pull_number: context.issue.number, - state: "closed" - }); diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml deleted file mode 100644 index ff12b8fe0..000000000 --- a/.github/workflows/issues.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Add new issues to product management project - -on: - issues: - types: - - opened - -jobs: - add-to-project: - name: Add issue and community pr to project - runs-on: ubuntu-latest - if: github.repository_id == '622995060' - steps: - - name: add issue - uses: actions/add-to-project@v1.0.2 - if: ${{ github.event_name == 'issues' }} - with: - # You can target a repository in a different organization - # to the issue - project-url: https://github.com/orgs/zitadel/projects/2 - github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} - - uses: tspascoal/get-user-teams-membership@v3 - id: checkUserMember - if: github.actor != 'dependabot[bot]' - with: - username: ${{ github.actor }} - GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} - - name: add pr - uses: actions/add-to-project@v1.0.2 - if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}} - with: - # You can target a repository in a different organization - # to the issue - project-url: https://github.com/orgs/zitadel/projects/2 - github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} - - uses: actions-ecosystem/action-add-labels@v1.1.3 - if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'staff')}} - with: - github_token: ${{ secrets.ADD_TO_PROJECT_PAT }} - labels: | - os-contribution diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2508627d1..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Release - -on: - push: - branches: - - main - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - release: - runs-on: ubuntu-latest - if: github.repository_id != '622995060' - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Install dependencies - run: pnpm install - - - name: Create Release Pull Request - uses: changesets/action@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 7b4721dbe..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Quality -on: - pull_request: - workflow_dispatch: - inputs: - ignore-run-cache: - description: 'Whether to ignore the run cache' - required: false - default: true - ref-tag: - description: 'overwrite the DOCKER_METADATA_OUTPUT_VERSION environment variable used by the make file' - required: false - default: '' -jobs: - quality: - name: Ensure Quality - if: github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && github.repository_id != '622995060') - runs-on: ubuntu-22.04 - timeout-minutes: 30 - permissions: - contents: read # We only need read access to the repository contents - actions: write # We need write access to the actions cache - env: - CACHE_DIR: /tmp/login-run-caches - # Only run this job on workflow_dispatch or pushes to forks - steps: - - uses: actions/checkout@v4 - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: | - ghcr.io/zitadel/login - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - - name: Set up Buildx - uses: docker/setup-buildx-action@v3 - # Only with correctly restored build cache layers, the run caches work as expected. - # To restore docker build layer caches, extend the docker-bake.hcl to use the cache-from and cache-to options. - # https://docs.docker.com/build/ci/github-actions/cache/ - # Alternatively, you can use a self-hosted runner or a third-party builder that restores build layer caches out-of-the-box, like https://depot.dev/ - - name: Restore Run Caches - uses: actions/cache/restore@v4 - id: run-caches-restore - with: - path: ${{ env.CACHE_DIR }} - key: ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}-${{github.run_attempt}} - restore-keys: | - ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}- - ${{ runner.os }}-login-run-caches-${{github.ref_name}}- - ${{ runner.os }}-login-run-caches- - - run: make login_quality - env: - IGNORE_RUN_CACHE: ${{ github.event.inputs.ignore-run-cache == 'true' }} - DOCKER_METADATA_OUTPUT_VERSION: ${{ github.event.inputs.ref-tag || env.DOCKER_METADATA_OUTPUT_VERSION || steps.meta.outputs.version }} - - name: Save Run Caches - uses: actions/cache/save@v4 - with: - path: ${{ env.CACHE_DIR }} - key: ${{ steps.run-caches-restore.outputs.cache-primary-key }} - if: always() diff --git a/.gitignore b/.gitignore index e433d14fb..97c0f2a74 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ cypress .DS_Store node_modules -.turbo *.log .next dist diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index ac3f12965..000000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,128 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -- Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -- The use of sexualized language or imagery, and sexual attention or - advances of any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email - address, without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -legal@zitadel.com. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 50ca3172d..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,218 +0,0 @@ -# Contributing - -:attention: In this CONTRIBUTING.md you read about contributing to this very repository. -If you want to develop your own login UI, please refer [to the README.md](./README.md). - -## Introduction - -Thank you for your interest about how to contribute! - -:attention: If you notice a possible **security vulnerability**, please don't hesitate to disclose any concern by contacting [security@zitadel.com](mailto:security@zitadel.com). -You don't have to be perfectly sure about the nature of the vulnerability. -We will give them a high priority and figure them out. - -We also appreciate all your other ideas, thoughts and feedback and will take care of them as soon as possible. -We love to discuss in an open space using [GitHub issues](https://github.com/zitadel/typescript/issues), -[GitHub discussions in the core repo](https://github.com/zitadel/zitadel/discussions) -or in our [chat on Discord](https://zitadel.com/chat). -For private discussions, -you have [more contact options on our Website](https://zitadel.com/contact). - -## Pull Requests - -The repository zitadel/typescript is a read-only mirror of the git subtree at zitadel/zitadel/login. -To submit changes, please open a Pull Request [in the zitadel/zitadel repository](https://github.com/zitadel/zitadel/compare). - -If you already made changes based on the zitadel/typescript repository, these changes are not lost. -Submitting them to the main repository is easy: - -1. [Fork zitadel/zitadel](https://github.com/zitadel/zitadel/fork) -1. Clone your Zitadel fork git clone https://github.com//zitadel.git -1. Change directory to your Zitadel forks root. -1. Pull your changes into the Zitadel fork by running make login_pull LOGIN_REMOTE_URL=https://github.com//typescript.git LOGIN_REMOTE_BRANCH=. -1. Push your changes and [open a pull request to zitadel/zitadel](https://github.com/zitadel/zitadel/compare) - -Please consider the following guidelines when creating a pull request. - -- The latest changes are always in `main`, so please make your pull request against that branch. -- pull requests should be raised for any change -- Pull requests need approval of a Zitadel core engineer @zitadel/engineers before merging -- We use ESLint/Prettier for linting/formatting, so please run `pnpm lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [this Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to fix lint and formatting issues in development) -- If you add new functionality, please provide the corresponding documentation as well and make it part of the pull request - -### Setting up local environment - -```sh -# Install dependencies. Developing requires Node.js v20 -pnpm install - -# Generate gRPC stubs -pnpm generate - -# Start a local development server for the login and manually configure apps/login/.env.local -pnpm dev -``` - -The application is now available at `http://localhost:3000` - -Configure apps/login/.env.local to target the Zitadel instance of your choice. -The login app live-reloads on changes, so you can start developing right away. - -### Developing Against A Local Latest Zitadel Release - -The following command uses Docker to run a local Zitadel instance and the login application in live-reloading dev mode. -Additionally, it runs a Traefik reverse proxy that exposes the login with a self-signed certificate at https://127.0.0.1.sslip.io -127.0.0.1.sslip.io is a special domain that resolves to your localhost, so it's safe to allow your browser to proceed with loading the page. - -```sh -# Install dependencies. Developing requires Node.js v20 -pnpm install - -# Generate gRPC stubs -pnpm generate - -# Start a local development server and have apps/login/.env.test.local configured for you to target the local Zitadel instance. -pnpm dev:local -``` - -Log in at https://127.0.0.1.sslip.io/ui/v2/login/loginname and use the following credentials: -**Loginname**: *zitadel-admin@zitadel.127.0.0.1.sslip.io* -**Password**: _Password1!_. - -The login app live-reloads on changes, so you can start developing right away. - -### Developing Against A Locally Compiled Zitadel - -To develop against a locally compiled version of Zitadel, you need to build the Zitadel docker image first. -Clone the [Zitadel repository](https://github.com/zitadel/zitadel.git) and run the following command from its root: - -```sh -# This compiles a Zitadel binary if it does not exist at ./zitadel already and copies it into a Docker image. -# If you want to recompile the binary, run `make compile` first -make login_dev -``` - -Open another terminal session at zitadel/zitadel/login and run the following commands to start the dev server. - -```bash -# Install dependencies. Developing requires Node.js v20 -pnpm install - -# Start a local development server and have apps/login/.env.test.local configured for you to target the local Zitadel instance. -NODE_ENV=test pnpm dev -``` - -Log in at https://127.0.0.1.sslip.io/ui/v2/login/loginname and use the following credentials: -**Loginname**: *zitadel-admin@zitadel.127.0.0.1.sslip.io* -**Password**: _Password1!_. - -The login app live-reloads on changes, so you can start developing right away. - -### Quality Assurance - -Use `make` commands to test the quality of your code against a production build without installing any dependencies besides Docker. -Using `make` commands, you can reproduce and debug the CI pipelines locally. - -```sh -# Reproduce the whole CI pipeline in docker -make login_quality -# Show other options with make -make help -``` - -Use `pnpm` commands to run the tests in dev mode with live reloading and debugging capabilities. - -#### Linting and formatting - -Check the formatting and linting of the code in docker - -```sh -make login_lint -``` - -Check the linting of the code using pnpm - -```sh -pnpm lint -pnpm format -``` - -Fix the linting of your code - -```sh -pnpm lint:fix -pnpm format:fix -``` - -#### Running Unit Tests - -Run the tests in docker - -```sh -make login_test_unit -``` - -Run unit tests with live-reloading - -```sh -pnpm test:unit -``` - -#### Running Integration Tests - -Run the test in docker - -```sh -make login_test_integration -``` - -Alternatively, run a live-reloading development server with an interactive Cypress test suite. -First, set up your local test environment. - -```sh -# Install dependencies. Developing requires Node.js v20 -pnpm install - -# Generate gRPC stubs -pnpm generate - -# Start a local development server and use apps/login/.env.test to use the locally mocked Zitadel API. -pnpm test:integration:setup -``` - -Now, in another terminal session, open the interactive Cypress integration test suite. - -```sh -pnpm test:integration open -``` - -Show more options with Cypress - -```sh -pnpm test:integration help -``` - -#### Running Acceptance Tests - -To run the tests in docker against the latest release of Zitadel, use the following command: - -:warning: The acceptance tests are not reliable at the moment :construction: - -```sh -make login_test_acceptance -``` - -Alternatively, run can use a live-reloading development server with an interactive Playwright test suite. -Set up your local environment by running the commands either for [developing against a local latest Zitadel release](latest) or for [developing against a locally compiled Zitadel](compiled). - -Now, in another terminal session, open the interactive Playwright acceptance test suite. - -```sh -pnpm test:acceptance open -``` - -Show more options with Playwright - -```sh -pnpm test:acceptance help -``` diff --git a/Dockerfile b/Dockerfile index 3207ada8e..b95e3ab47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,37 +1,18 @@ -FROM node:20-alpine AS base - -FROM base AS build -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@10.13.1 --activate && \ - apk update && \ - rm -rf /var/cache/apk/* +FROM node:22-alpine WORKDIR /app -COPY pnpm-lock.yaml ./ -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile -COPY package.json ./ -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile -COPY . . -RUN pnpm build:login:standalone - -FROM scratch AS build-out -COPY --from=build /app/.next/standalone / -COPY --from=build /app/.next/static /.next/static -COPY public public - -FROM base AS login-standalone -WORKDIR /runtime RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs # If /.env-file/.env is mounted into the container, its variables are made available to the server before it starts up. RUN mkdir -p /.env-file && touch /.env-file/.env && chown -R nextjs:nodejs /.env-file -COPY --chown=nextjs:nodejs ./scripts/ ./ -COPY --chown=nextjs:nodejs --from=build-out / ./ + +COPY --chown=nextjs:nodejs .next/standalone ./ + USER nextjs ENV HOSTNAME="0.0.0.0" \ - NEXT_PUBLIC_BASE_PATH="/ui/v2/login" \ - PORT=3000 + PORT="3000" \ + NODE_ENV="production" + # TODO: Check healthy, not ready HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD ["/bin/sh", "-c", "node /runtime/healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"] -ENTRYPOINT ["/runtime/entrypoint.sh"] + CMD ["/bin/sh", "-c", "node /app/healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"] +ENTRYPOINT ["/app/entrypoint.sh", "node", "apps/login/server.js" ] diff --git a/Dockerfile.dockerignore b/Dockerfile.dockerignore deleted file mode 100644 index d65fd6994..000000000 --- a/Dockerfile.dockerignore +++ /dev/null @@ -1,22 +0,0 @@ -* - -!constants -!scripts -!src -!public -!locales -!next.config.mjs -!next-env-vars.d.ts -!next-env.d.ts -!tailwind.config.mjs -!postcss.config.cjs -!tsconfig.json -!package.json -!pnpm-lock.yaml - -**/*.md -**/*.png -**/node_modules -**/.turbo -**/*.test.ts -**/*.test.tsx \ No newline at end of file diff --git a/cypress.config.ts b/cypress.config.ts index f5bae5fe9..ca01336fb 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,5 +1,4 @@ import { defineConfig } from "cypress"; - export default defineConfig({ reporter: "list", video: true, @@ -7,11 +6,12 @@ export default defineConfig({ runMode: 2 }, e2e: { - baseUrl: process.env.LOGIN_BASE_URL || "http://localhost:3001/ui/v2/login", + baseUrl: `http://localhost:3001${process.env.NEXT_PUBLIC_BASE_PATH || ""}`, specPattern: "integration/integration/**/*.cy.{js,jsx,ts,tsx}", supportFile: "integration/support/e2e.{js,jsx,ts,tsx}", - setupNodeEvents(on, config) { - // implement node event listeners here - }, + pageLoadTimeout: 120_0000, + env: { + API_MOCK_STUBS_URL: process.env.API_MOCK_STUBS_URL || "http://localhost:22220/v1/stubs" + } }, }); diff --git a/docker-bake-release.hcl b/docker-bake-release.hcl deleted file mode 100644 index 51e1c194f..000000000 --- a/docker-bake-release.hcl +++ /dev/null @@ -1,3 +0,0 @@ -target "release" { - platforms = ["linux/amd64", "linux/arm64"] -} diff --git a/docker-bake.hcl b/docker-bake.hcl deleted file mode 100644 index e09d1176e..000000000 --- a/docker-bake.hcl +++ /dev/null @@ -1,25 +0,0 @@ -variable "LOGIN_TAG" { - default = "zitadel-login:local" -} - -group "default" { - targets = ["login-standalone"] -} - -# The release target is overwritten in docker-bake-release.hcl -# It makes sure the image is built for multiple platforms. -# By default the platforms property is empty, so images are only built for the current bake runtime platform. -target "release" {} - -target "docker-metadata-action" { - # In the pipeline, this target is overwritten by the docker metadata action. - tags = ["${LOGIN_TAG}"] -} - -# We run integration and acceptance tests against the next standalone server for docker. -target "login-standalone" { - inherits = [ - "docker-metadata-action", - "release", - ] -} diff --git a/integration/core-mock/Dockerfile b/integration/api-mock/Dockerfile similarity index 86% rename from integration/core-mock/Dockerfile rename to integration/api-mock/Dockerfile index ce2465480..84e7d1ca8 100644 --- a/integration/core-mock/Dockerfile +++ b/integration/api-mock/Dockerfile @@ -3,9 +3,7 @@ RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path vali buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto && \ buf export https://github.com/googleapis/googleapis.git --path google/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto -FROM bufbuild/buf:1.54.0 AS zitadel-protos -RUN buf export https://github.com/zitadel/zitadel.git --path ./proto/zitadel --output /zitadel - + FROM golang:1.20.5-alpine3.18 AS mock-zitadel RUN go install github.com/eliobischof/grpc-mock/cmd/grpc-mock@01b09f60db1b501178af59bed03b2c22661df48c diff --git a/integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json b/integration/api-mock/initial-stubs/zitadel.settings.v2.SettingsService.json similarity index 100% rename from integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json rename to integration/api-mock/initial-stubs/zitadel.settings.v2.SettingsService.json diff --git a/integration/core-mock/mocked-services.cfg b/integration/api-mock/mocked-services.cfg similarity index 100% rename from integration/core-mock/mocked-services.cfg rename to integration/api-mock/mocked-services.cfg diff --git a/integration/api-mock/project.json b/integration/api-mock/project.json new file mode 100644 index 000000000..0a5d92ff3 --- /dev/null +++ b/integration/api-mock/project.json @@ -0,0 +1,19 @@ +{ + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "name": "@zitadel/login-api-mock", + "targets": { + "build": { + "description": "Builds the API mock server", + "command": "nx run @zitadel/devcontainer:compose build login-api-mock" + }, + "serve": { + "description": "Starts the API mock server", + "continuous": true, + "command": "nx run @zitadel/devcontainer:compose up --force-recreate --renew-anon-volumes login-api-mock" + }, + "down": { + "description": "Stops the API mock server", + "command": "nx run @zitadel/devcontainer:compose down login-api-mock" + } + } +} \ No newline at end of file diff --git a/integration/support/e2e.ts b/integration/support/e2e.ts index 40b93bea8..75641d023 100644 --- a/integration/support/e2e.ts +++ b/integration/support/e2e.ts @@ -18,7 +18,7 @@ Cypress.on('uncaught:exception', (err, runnable) => { return true; }); -const url = Cypress.env("CORE_MOCK_STUBS_URL") || "http://localhost:22220/v1/stubs"; +const url = Cypress.env("API_MOCK_STUBS_URL"); function removeStub(service: string, method: string) { return cy.request({ diff --git a/next-env-vars.d.ts b/next-env-vars.d.ts index e7f8289ad..1d8467a67 100644 --- a/next-env-vars.d.ts +++ b/next-env-vars.d.ts @@ -16,6 +16,9 @@ declare namespace NodeJS { /** * The service user token + * If ZITADEL_SERVICE_USER_TOKEN is set, its value is used. + * If ZITADEL_SERVICE_USER_TOKEN is not set but ZITADEL_SERVICE_USER_TOKEN_FILE is set, the application blocks until the file is created. + * As soon as the file exists, its content is read and ZITADEL_SERVICE_USER_TOKEN is set. */ ZITADEL_SERVICE_USER_TOKEN: string; diff --git a/next.config.mjs b/next.config.mjs index 7d12d7886..c0aab041c 100755 --- a/next.config.mjs +++ b/next.config.mjs @@ -3,8 +3,6 @@ import { DEFAULT_CSP } from "./constants/csp.js"; const withNextIntl = createNextIntlPlugin(); -/** @type {import('next').NextConfig} */ - const secureHeaders = [ { key: "Strict-Transport-Security", @@ -33,6 +31,7 @@ const secureHeaders = [ { key: "X-Frame-Options", value: "deny" }, ]; +/** @type {import('next').NextConfig} */ const nextConfig = { basePath: process.env.NEXT_PUBLIC_BASE_PATH, output: process.env.NEXT_OUTPUT_MODE || undefined, diff --git a/package.json b/package.json index 39fcba18d..0c7c7e8dc 100644 --- a/package.json +++ b/package.json @@ -4,21 +4,16 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev", - "build": "next build", - "build:login:standalone": "NEXT_PUBLIC_BASE_PATH=/ui/v2/login NEXT_OUTPUT_MODE=standalone next build", - "start": "next start", - "lint": "pnpm run '/^lint:check:.*$/'", - "lint:check:next": "next lint", - "lint:check:prettier": "prettier --check .", - "lint:fix": "prettier --write .", - "test:unit": "vitest --run", + "build": "NEXT_OUTPUT_MODE=standalone next build && cp -r public scripts/* .next/standalone/ && cp -r .next/static .next/standalone/apps/login/.next/ && rm .next/standalone/apps/login/.env", + "build-vercel": "next build", + "dev": "HOSTNAME=127.0.0.1 ./scripts/entrypoint.sh next dev", + "prod": "cd ./.next/standalone && HOSTNAME=127.0.0.1 ./entrypoint.sh node apps/login/server.js", + "lint-check-next": "next lint", + "lint-check-prettier": "prettier --check .", + "lint-fix": "prettier --write .", + "test-unit": "vitest --run", "lint-staged": "lint-staged", - "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", - "test:integration:login": "wait-on --simultaneous 1 http://localhost:3001/ui/v2/login/verify?userId=221394658884845598&code=abc && cypress run", - "test:acceptance": "dotenv -e ../login/.env.test.local playwright", - "test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test turbo run test:acceptance:setup:dev", - "test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev" + "clean": "rm -rf node_modules .next cypress" }, "pnpm": { "overrides": { @@ -36,8 +31,6 @@ "@heroicons/react": "2.1.3", "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/forms": "0.5.7", - "@zitadel/client": "latest", - "@zitadel/proto": "latest", "clsx": "1.2.1", "copy-to-clipboard": "^3.3.3", "deepmerge": "^4.3.1", @@ -74,11 +67,13 @@ "@typescript-eslint/parser": "^7.0.0", "@vercel/git-hooks": "1.0.0", "@vitejs/plugin-react": "^4.4.1", + "@zitadel/client": "workspace:*", + "@zitadel/proto": "workspace:*", "autoprefixer": "10.4.21", "concurrently": "^9.1.2", - "cypress": "^14.5.2", + "cypress": "^14.5.4", "dotenv-cli": "^8.0.0", - "env-cmd": "^10.0.0", + "env-cmd": "^10.1.0", "eslint": "^8.57.0", "eslint-config-next": "15.4.0-canary.86", "eslint-config-prettier": "^9.1.0", @@ -98,7 +93,6 @@ "ts-proto": "^2.7.0", "typescript": "^5.8.3", "vite-tsconfig-paths": "^5.1.4", - "vitest": "^2.0.0", - "wait-on": "^7.2.0" + "vitest": "^2.0.0" } -} \ No newline at end of file +} diff --git a/project.json b/project.json new file mode 100644 index 000000000..223fbd52b --- /dev/null +++ b/project.json @@ -0,0 +1,156 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "targets": { + "prod": { + "description": "Runs the Next.js Login application in production mode from the standalone build", + "continuous": true, + "dependsOn": [ + "build" + ], + "defaultConfiguration": "default", + "configurations": { + "default": {}, + "test-login-integration": {} + } + }, + "dev": { + "description": "Runs the Next.js Login application in development mode with hot-reloading", + "continuous": true, + "dependsOn": [ + "^build" + ] + }, + "build": { + "description": "Builds the Next.js Login application in standalone mode for production", + "cache": true, + "dependsOn": [ + "^build" + ], + "inputs": [ + "default", + "{workspaceRoot}/pnpm-lock.yaml", + "!{projectRoot}/.env.*", + "!{projectRoot}/.local.env", + "!{projectRoot}/integration/**/*", + "!{projectRoot}/acceptance/**/*", + "!{projectRoot}/cypress.config.ts" + ], + "outputs": [ + "{projectRoot}/.next/standalone" + ] + }, + "build-vercel": { + "description": "Builds the Next.js Login application for Vercel deployment", + "cache": true, + "dependsOn": [ + "^build" + ], + "inputs": [ + "default", + "{workspaceRoot}/pnpm-lock.yaml", + "!{projectRoot}/.env.*", + "!{projectRoot}/.local.env", + "!{projectRoot}/integration/**/*", + "!{projectRoot}/acceptance/**/*", + "!{projectRoot}/cypress.config.ts" + ], + "outputs": [ + "{projectRoot}/.next", + "!{projectRoot}/.next/cache", + "!{projectRoot}/.next/standalone" + ] + }, + "lint": { + "description": "Runs all linters", + "dependsOn": [ + "lint-check-*" + ] + }, + "lint-check-prettier": { + "description": "Checks code formatting with Prettier", + "cache": true, + "inputs": [ + "default" + ] + }, + "lint-check-next": { + "description": "Runs Next.js specific lint checks", + "cache": true, + "inputs": [ + "default" + ] + }, + "test": { + "description": "Runs all tests (unit and integration)", + "dependsOn": [ + "test-unit", + "test-integration" + ] + }, + "test-unit": { + "description": "Runs unit tests using Vitest", + "dependsOn": [ + "^build" + ] + }, + "test-integration-run-login": { + "description": "Runs the Login application under test. It has its own target, separate from test-integration, because it's a continuous task.", + "dependsOn": [ + "build" + ], + "continuous": true, + "command": "nx run @zitadel/login:prod:test-login-integration --excludeTaskDependencies" + }, + "test-integration": { + "description": "Runs integration tests using Cypress against a running Login and a mocked API", + "dependsOn": [ + "test-integration-run-login", + "@zitadel/login-api-mock:build", + "@zitadel/login-api-mock:serve" + ], + "executor": "nx:run-commands", + "options": { + "cwd": "{projectRoot}", + "parallel": false, + "commands": [ + "pnpm cypress install", + "pnpm wait-on --verbose --interval 2000 --simultaneous 1 --timeout 30m \"tcp:${API_MOCK_STUBS_HOST}:22220\" \"http-get://localhost:3001/ui/v2/login/verify?userId=221394658884845598&code=abc\"", + "DISPLAY='' pnpm cypress run --headless", + "nx run @zitadel/login:test-integration-stop" + ] + }, + "inputs": [ + "default", + "{workspaceRoot}/pnpm-lock.yaml", + "!{projectRoot}/acceptance/**/*", + { "env": "LOGIN_BASE_URL" } + ] + }, + "test-integration-stop": { + "description": "Stops the gRPC mock container used for integration tests.", + "command": "nx run @zitadel/login-api-mock:down" + }, + "pack": { + "description": "Packages the standalone Login application build into an archive", + "dependsOn": [ + "build" + ], + "executor": "nx:run-commands", + "options": { + "parallel": false, + "env": { + "STANDALONE_DIR": "{projectRoot}/.next/standalone", + "PACK_DIR": "{workspaceRoot}/.artifacts/pack" + }, + "commands": [ + "mkdir -p ${PACK_DIR}", + "tar -czvf ${PACK_DIR}/zitadel-login-standalone.tar.gz -C ${STANDALONE_DIR} ." + ] + }, + "cache": true, + "outputs": [ + "{workspaceRoot}/.artifacts/pack/zitadel-login-*.tar.gz" + ] + } + } +} \ No newline at end of file diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 5bafc0012..f16d5ed50 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,11 +1,18 @@ #!/bin/sh -set -o allexport -. /.env-file/.env -set +o allexport -if [ -n "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ] && [ -f "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ]; then - echo "ZITADEL_SERVICE_USER_TOKEN_FILE=${ZITADEL_SERVICE_USER_TOKEN_FILE} is set and file exists, setting ZITADEL_SERVICE_USER_TOKEN to the files content" - export ZITADEL_SERVICE_USER_TOKEN=$(cat "${ZITADEL_SERVICE_USER_TOKEN_FILE}") +if [ -f /.env-file/.env ]; then + set -o allexport + . /.env-file/.env + set +o allexport fi -exec node /runtime/apps/login/server.js +if [ -n "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ]; then + echo "ZITADEL_SERVICE_USER_TOKEN_FILE=${ZITADEL_SERVICE_USER_TOKEN_FILE} is set. Awaiting file and reading token." + while [ ! -f "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ]; do + sleep 2 + done + echo "token file found, reading token" + export ZITADEL_SERVICE_USER_TOKEN=$(cat "${ZITADEL_SERVICE_USER_TOKEN_FILE}") +fi + +exec $@ diff --git a/tsconfig.json b/tsconfig.json index 4cec65b3d..dbb65389d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], diff --git a/turbo.json b/turbo.json deleted file mode 100644 index dce47a5a3..000000000 --- a/turbo.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "extends": [ - "//" - ], - "tasks": { - "build": { - "outputs": [ - "dist/**", - ".next/**", - "!.next/cache/**" - ], - "dependsOn": [ - "@zitadel/client#build" - ] - }, - "build:login:standalone": { - "outputs": [ - "dist/**", - ".next/**", - "!.next/cache/**" - ], - "dependsOn": [ - "@zitadel/client#build" - ] - }, - "dev": { - "persistent": true, - "cache": false, - "dependsOn": [ - "@zitadel/client#build" - ] - }, - "test": { - "dependsOn": [ - "@zitadel/client#build" - ] - }, - "test:unit": { - "dependsOn": [ - "@zitadel/client#build" - ] - }, - "test:integration:login": { - "inputs": [ - ".next/**", - "!.next/cache/**", - "integration/integration/**", - "integration/support/**", - "cypress.config.ts" - ], - "outputs": [ - "cypress/videos/**", - "cypress/screenshots/**" - ] - } - } -} \ No newline at end of file diff --git a/vercel.json b/vercel.json new file mode 100644 index 000000000..884322f98 --- /dev/null +++ b/vercel.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "pnpm nx run @zitadel/login:build-vercel" +} \ No newline at end of file