Merge branch 'main' into acceptance-test-suite

This commit is contained in:
Max Peintner
2024-11-15 13:36:56 +01:00
13 changed files with 174 additions and 175 deletions

View File

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

View File

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

View File

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

View File

@@ -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");
});

View File

@@ -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: {

View File

@@ -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 (
<DynamicTheme branding={branding}>
@@ -51,16 +65,16 @@ export default async function Page({
<UsernameForm
loginName={loginName}
authRequestId={authRequestId}
organization={organization}
organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user
submit={submit}
allowRegister={!!loginSettings?.allowRegister}
>
{legal && identityProviders && process.env.ZITADEL_API_URL && (
{identityProviders && process.env.ZITADEL_API_URL && (
<SignInWithIdp
host={host}
identityProviders={identityProviders}
authRequestId={authRequestId}
organization={organization}
organization={organization ?? defaultOrganization} // use the organization from the searchParams here otherwise fallback to the default organization
></SignInWithIdp>
)}
</UsernameForm>

View File

@@ -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 (
<DynamicTheme branding={branding}>
@@ -62,7 +80,7 @@ export default async function Page({
<PasswordForm
loginName={loginName}
authRequestId={authRequestId}
organization={organization}
organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user
loginSettings={loginSettings}
promptPasswordless={
loginSettings?.passkeysType === PasskeysType.ALLOWED

View File

@@ -22,13 +22,8 @@ export default async function Page({
searchParams;
if (!organization) {
const org: Organization | null = await getDefaultOrg().catch((error) => {
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;
}
}

View File

@@ -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;

View File

@@ -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<SetStateAction<boolean>>] | undefined
>(undefined);
export function MobileNavContextProvider({
children,
}: {
children: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<MobileNavContext.Provider value={[isOpen, setIsOpen]}>
{children}
</MobileNavContext.Provider>
);
}
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 (
<>
<button
type="button"
className="group absolute right-0 top-0 flex h-14 items-center space-x-2 px-4 lg:hidden"
onClick={() => setIsOpen(!isOpen)}
>
<div className="font-medium text-text-light-secondary-500 dark:text-text-dark-secondary-500 group-hover:text-text-light-500 dark:group-hover:text-text-dark-500">
Menu
</div>
{isOpen ? (
<XMarkIcon className="block w-6" />
) : (
<Bars3Icon className="block w-6" />
)}
</button>
<div
className={clsx("overflow-y-auto lg:static lg:block", {
"fixed inset-x-0 bottom-0 top-14 bg-gray-900": isOpen,
hidden: !isOpen,
})}
>
{children}
</div>
</>
);
}

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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);