diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3dd3cca3415..6bd2abcf7cc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: pull_request jobs: quality: env: - ZITADEL_IMAGE: ghcr.io/zitadel/zitadel:v2.63.4 + ZITADEL_IMAGE: ghcr.io/zitadel/zitadel:v2.65.0 POSTGRES_IMAGE: postgres:17.0-alpine3.19 name: Ensure Quality diff --git a/acceptance/docker-compose.yaml b/acceptance/docker-compose.yaml index 59839e00179..e8448ddb002 100644 --- a/acceptance/docker-compose.yaml +++ b/acceptance/docker-compose.yaml @@ -13,7 +13,7 @@ services: condition: "service_healthy" db: - restart: 'always' + restart: "always" image: "${POSTGRES_IMAGE:-postgres:latest}" environment: - POSTGRES_USER=zitadel @@ -23,21 +23,16 @@ services: command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 healthcheck: test: ["CMD-SHELL", "pg_isready"] - interval: '10s' - timeout: '30s' + interval: "10s" + timeout: "30s" retries: 5 - start_period: '20s' + start_period: "20s" ports: - 5432:5432 wait_for_zitadel: image: curlimages/curl:8.00.1 - command: - [ - "/bin/sh", - "-c", - "i=0; while ! curl http://zitadel:8080/debug/ready && [ $$i -lt 30 ]; do sleep 1; i=$$((i+1)); done; [ $$i -eq 120 ] && exit 1 || exit 0", - ] + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false depends_on: - zitadel diff --git a/acceptance/setup.sh b/acceptance/setup.sh index 5359659efab..01b6bd826ec 100755 --- a/acceptance/setup.sh +++ b/acceptance/setup.sh @@ -29,6 +29,23 @@ echo "Writing environment file to ${WRITE_ENVIRONMENT_FILE} when done." echo "ZITADEL_API_URL=${ZITADEL_API_URL} ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID} -ZITADEL_SERVICE_USER_TOKEN=${PAT}" > ${WRITE_ENVIRONMENT_FILE} +ZITADEL_SERVICE_USER_TOKEN=${PAT} +DEBUG=true" > ${WRITE_ENVIRONMENT_FILE} + echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}" cat ${WRITE_ENVIRONMENT_FILE} + +DEFAULTORG_RESPONSE_RESULTS=0 +# waiting for default organization +until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ] +do + DEFAULTORG_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/v2/organizations/_search" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"queries\": [{\"defaultQuery\":{}}]}" ) + echo "Received default organization response: ${DEFAULTORG_RESPONSE}" + DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length') + echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}" +done diff --git a/acceptance/tests/username-password.spec.ts b/acceptance/tests/username-password.spec.ts index e9ab31d998c..1bf7f17422c 100644 --- a/acceptance/tests/username-password.spec.ts +++ b/acceptance/tests/username-password.spec.ts @@ -1,45 +1,45 @@ -import {test as base} from "@playwright/test"; -import {PasswordUser} from './user'; -import path from 'path'; -import dotenv from 'dotenv'; -import {loginScreenExpect, loginWithPassword, startLogin} from "./login"; -import {loginnameScreenExpect} from "./loginname-screen"; -import {passwordScreenExpect} from "./password-screen"; -import {loginname} from "./loginname"; -import {password} from "./password"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { loginnameScreenExpect } from "./loginname-screen"; +import { password } from "./password"; +import { passwordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; // Read from ".env" file. -dotenv.config({path: path.resolve(__dirname, '.env.local')}); +dotenv.config({ path: path.resolve(__dirname, ".env.local") }); const test = base.extend<{ user: PasswordUser }>({ - user: async ({page}, use) => { - const user = new PasswordUser({ - email: "password@example.com", - firstName: "first", - lastName: "last", - password: "Password1!", - organization: "", - }); - await user.ensure(page); - await use(user); - }, + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: "password@example.com", + firstName: "first", + lastName: "last", + password: "Password1!", + organization: "", + }); + await user.ensure(page); + await use(user); + }, }); -test("username and password login", async ({user, page}) => { - await loginWithPassword(page, user.getUsername(), user.getPassword()) - await loginScreenExpect(page, user.getFullName()); +test("username and password login", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await loginScreenExpect(page, user.getFullName()); }); -test("username and password login, unknown username", async ({page}) => { - const username = "unknown" - await startLogin(page); - await loginname(page, username) - await loginnameScreenExpect(page, username) +test("username and password login, unknown username", async ({ page }) => { + const username = "unknown"; + await startLogin(page); + await loginname(page, username); + await loginnameScreenExpect(page, username); }); -test("username and password login, wrong password", async ({user, page}) => { - await startLogin(page); - await loginname(page, user.getUsername()) - await password(page, "wrong") - await passwordScreenExpect(page, "wrong") +test("username and password login, wrong password", async ({ user, page }) => { + await startLogin(page); + await loginname(page, user.getUsername()); + await password(page, "wrong"); + await passwordScreenExpect(page, "wrong"); }); diff --git a/apps/login/cypress/integration/login.cy.ts b/apps/login/cypress/integration/login.cy.ts index bb83ca375a2..f293653af1d 100644 --- a/apps/login/cypress/integration/login.cy.ts +++ b/apps/login/cypress/integration/login.cy.ts @@ -2,6 +2,14 @@ import { stub } from "../support/mock"; describe("login", () => { beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); stub("zitadel.session.v2.SessionService", "CreateSession", { data: { details: { diff --git a/apps/login/src/app/(login)/loginname/page.tsx b/apps/login/src/app/(login)/loginname/page.tsx index 928bf1389dd..68928755d33 100644 --- a/apps/login/src/app/(login)/loginname/page.tsx +++ b/apps/login/src/app/(login)/loginname/page.tsx @@ -3,11 +3,12 @@ import { SignInWithIdp } from "@/components/sign-in-with-idp"; import { UsernameForm } from "@/components/username-form"; import { getBrandingSettings, - getLegalAndSupportSettings, + getDefaultOrg, getLoginSettings, settingsService, } from "@/lib/zitadel"; import { makeReqCtx } from "@zitadel/client/v2"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { getLocale, getTranslations } from "next-intl/server"; function getIdentityProviders(orgId?: string) { @@ -31,16 +32,29 @@ export default async function Page({ const organization = searchParams?.organization; const submit: boolean = searchParams?.submit === "true"; - const loginSettings = await getLoginSettings(organization); - const legal = await getLegalAndSupportSettings(); - - const identityProviders = await getIdentityProviders(organization); + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg(); + if (org) { + defaultOrganization = org.id; + } + } const host = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000"; - const branding = await getBrandingSettings(organization); + const loginSettings = await getLoginSettings( + organization ?? defaultOrganization, + ); + + const identityProviders = await getIdentityProviders( + organization ?? defaultOrganization, + ); + + const branding = await getBrandingSettings( + organization ?? defaultOrganization, + ); return ( @@ -51,16 +65,16 @@ export default async function Page({ - {legal && identityProviders && process.env.ZITADEL_API_URL && ( + {identityProviders && process.env.ZITADEL_API_URL && ( )} diff --git a/apps/login/src/app/(login)/password/page.tsx b/apps/login/src/app/(login)/password/page.tsx index 1f752850e63..9731e4030e8 100644 --- a/apps/login/src/app/(login)/password/page.tsx +++ b/apps/login/src/app/(login)/password/page.tsx @@ -3,7 +3,12 @@ import { DynamicTheme } from "@/components/dynamic-theme"; import { PasswordForm } from "@/components/password-form"; import { UserAvatar } from "@/components/user-avatar"; import { loadMostRecentSession } from "@/lib/session"; -import { getBrandingSettings, getLoginSettings } from "@/lib/zitadel"; +import { + getBrandingSettings, + getDefaultOrg, + getLoginSettings, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { getLocale, getTranslations } from "next-intl/server"; @@ -16,7 +21,16 @@ export default async function Page({ const t = await getTranslations({ locale, namespace: "password" }); const tError = await getTranslations({ locale, namespace: "error" }); - const { loginName, organization, authRequestId, alt } = searchParams; + let { loginName, organization, authRequestId, alt } = searchParams; + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg(); + + if (org) { + defaultOrganization = org.id; + } + } // also allow no session to be found (ignoreUnkownUsername) let sessionFactors; @@ -30,8 +44,12 @@ export default async function Page({ console.warn(error); } - const branding = await getBrandingSettings(organization); - const loginSettings = await getLoginSettings(organization); + const branding = await getBrandingSettings( + organization ?? defaultOrganization, + ); + const loginSettings = await getLoginSettings( + organization ?? defaultOrganization, + ); return ( @@ -62,7 +80,7 @@ export default async function Page({ { - console.warn(error); - return null; - }); - if (!org) { - console.warn("No default organization found"); - } else { + const org: Organization | null = await getDefaultOrg(); + if (org) { organization = org.id; } } diff --git a/apps/login/src/components/change-password-form.tsx b/apps/login/src/components/change-password-form.tsx index 153e9bc4fd3..c2e891ffb80 100644 --- a/apps/login/src/components/change-password-form.tsx +++ b/apps/login/src/components/change-password-form.tsx @@ -12,6 +12,7 @@ import { create } from "@zitadel/client"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { useTranslations } from "next-intl"; +import { redirect } from "next/navigation"; import { useState } from "react"; import { FieldValues, useForm } from "react-hook-form"; import { Alert } from "./alert"; @@ -103,6 +104,11 @@ export function ChangePasswordForm({ passwordResponse.error ) { setError(passwordResponse.error); + return; + } + + if (passwordResponse && passwordResponse.nextStep) { + return redirect(passwordResponse.nextStep); } return; diff --git a/apps/login/src/components/mobile-nav-toggle.tsx b/apps/login/src/components/mobile-nav-toggle.tsx deleted file mode 100644 index e288237ee15..00000000000 --- a/apps/login/src/components/mobile-nav-toggle.tsx +++ /dev/null @@ -1,71 +0,0 @@ -"use client"; - -import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid"; -import { clsx } from "clsx"; -import { - createContext, - Dispatch, - ReactNode, - SetStateAction, - useContext, - useState, -} from "react"; - -const MobileNavContext = createContext< - [boolean, Dispatch>] | undefined ->(undefined); - -export function MobileNavContextProvider({ - children, -}: { - children: ReactNode; -}) { - const [isOpen, setIsOpen] = useState(false); - return ( - - {children} - - ); -} - -export function useMobileNavToggle() { - const context = useContext(MobileNavContext); - if (context === undefined) { - throw new Error( - "useMobileNavToggle must be used within a MobileNavContextProvider", - ); - } - return context; -} - -export function MobileNavToggle({ children }: { children: ReactNode }) { - const [isOpen, setIsOpen] = useMobileNavToggle(); - - return ( - <> - - -
- {children} -
- - ); -} diff --git a/apps/login/src/components/password-form.tsx b/apps/login/src/components/password-form.tsx index b75854d6ddb..5885ecc6d44 100644 --- a/apps/login/src/components/password-form.tsx +++ b/apps/login/src/components/password-form.tsx @@ -71,9 +71,12 @@ export function PasswordForm({ if (response && "error" in response && response.error) { setError(response.error); + return; } - return response; + if (response && response.nextStep) { + return router.push(response.nextStep); + } } async function resetPasswordAndContinue() { diff --git a/apps/login/src/components/set-password-form.tsx b/apps/login/src/components/set-password-form.tsx index 69f5a1bd225..65405ed8f0d 100644 --- a/apps/login/src/components/set-password-form.tsx +++ b/apps/login/src/components/set-password-form.tsx @@ -11,6 +11,7 @@ import { create } from "@zitadel/client"; import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; import { useTranslations } from "next-intl"; +import { redirect } from "next/navigation"; import { useState } from "react"; import { FieldValues, useForm } from "react-hook-form"; import { Alert } from "./alert"; @@ -123,7 +124,14 @@ export function SetPasswordForm({ passwordResponse.error ) { setError(passwordResponse.error); + return; } + + if (passwordResponse && passwordResponse.nextStep) { + return redirect(passwordResponse.nextStep); + } + + return; } const { errors } = formState; diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 2cb512bb971..5731229a1c2 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -124,23 +124,11 @@ export async function sendPassword(command: UpdateSessionCommand) { } } - const submitted = { - sessionId: session.id, - factors: session.factors, - challenges: session.challenges, - authMethods, - userState: user.state, - }; - - if ( - !submitted || - !submitted.authMethods || - !submitted.factors?.user?.loginName - ) { + if (!authMethods || !session.factors?.user?.loginName) { return { error: "Could not verify password!" }; } - const availableSecondFactors = submitted?.authMethods?.filter( + const availableSecondFactors = authMethods?.filter( (m: AuthenticationMethodType) => m !== AuthenticationMethodType.PASSWORD && m !== AuthenticationMethodType.PASSKEY, @@ -148,15 +136,18 @@ export async function sendPassword(command: UpdateSessionCommand) { if (availableSecondFactors?.length == 1) { const params = new URLSearchParams({ - loginName: submitted.factors?.user.loginName, + loginName: session.factors?.user.loginName, }); if (command.authRequestId) { params.append("authRequestId", command.authRequestId); } - if (command.organization) { - params.append("organization", command.organization); + if (command.organization || session.factors?.user?.organizationId) { + params.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); } const factor = availableSecondFactors[0]; @@ -172,35 +163,41 @@ export async function sendPassword(command: UpdateSessionCommand) { } } else if (availableSecondFactors?.length >= 1) { const params = new URLSearchParams({ - loginName: submitted.factors.user.loginName, + loginName: session.factors.user.loginName, }); if (command.authRequestId) { params.append("authRequestId", command.authRequestId); } - if (command.organization) { - params.append("organization", command.organization); + if (command.organization || session.factors?.user?.organizationId) { + params.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); } return redirect(`/mfa?` + params); - } else if (submitted.userState === UserState.INITIAL) { + } else if (user.state === UserState.INITIAL) { const params = new URLSearchParams({ - loginName: submitted.factors.user.loginName, + loginName: session.factors.user.loginName, }); if (command.authRequestId) { params.append("authRequestId", command.authRequestId); } - if (command.organization) { - params.append("organization", command.organization); + if (command.organization || session.factors?.user?.organizationId) { + params.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); } return redirect(`/password/change?` + params); } else if (command.forceMfa && !availableSecondFactors.length) { const params = new URLSearchParams({ - loginName: submitted.factors.user.loginName, + loginName: session.factors.user.loginName, force: "true", // this defines if the mfa is forced in the settings checkAfter: "true", // this defines if the check is directly made after the setup }); @@ -209,8 +206,11 @@ export async function sendPassword(command: UpdateSessionCommand) { params.append("authRequestId", command.authRequestId); } - if (command.organization) { - params.append("organization", command.organization); + if (command.organization || session.factors?.user?.organizationId) { + params.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); } // TODO: provide a way to setup passkeys on mfa page? @@ -239,33 +239,39 @@ export async function sendPassword(command: UpdateSessionCommand) { // return router.push(`/passkey/set?` + params); // } - else if (command.authRequestId && submitted.sessionId) { + else if (command.authRequestId && session.id) { const params = new URLSearchParams({ - sessionId: submitted.sessionId, + sessionId: session.id, authRequest: command.authRequestId, }); - if (command.organization) { - params.append("organization", command.organization); + if (command.organization || session.factors?.user?.organizationId) { + params.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); } - return redirect(`/login?` + params); + return { nextStep: `/login?${params}` }; } // without OIDC flow const params = new URLSearchParams( command.authRequestId ? { - loginName: submitted.factors.user.loginName, + loginName: session.factors.user.loginName, authRequestId: command.authRequestId, } : { - loginName: submitted.factors.user.loginName, + loginName: session.factors.user.loginName, }, ); - if (command.organization) { - params.append("organization", command.organization); + if (command.organization || session.factors?.user?.organizationId) { + params.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); } return redirect(`/signedin?` + params);