Merge commit 'bea341513d5bfe11b0b40f66dad9344d0be6b7b2' into integrat-login-clean-test

This commit is contained in:
Elio Bischof
2025-06-26 12:12:38 +02:00
31 changed files with 410 additions and 89 deletions

BIN
login/.github/custom-i18n.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -15,9 +15,6 @@ on:
jobs:
quality:
name: Ensure Quality
if: github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && github.repository_owner != 'zitadel') ||
(github.event_name == 'pull_request' && github.repository_owner != 'zitadel')
runs-on: ubuntu-22.04
timeout-minutes: 30
permissions:

View File

@@ -27,7 +27,7 @@ export LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG := login-test-acceptance-samlidp:${DOCK
export POSTGRES_TAG := postgres:17.0-alpine3.19
export GOLANG_TAG := golang:1.24-alpine
export ZITADEL_TAG ?= ghcr.io/zitadel/zitadel:latest
export CORE_MOCK_TAG := login-core-mock:${DOCKER_METADATA_OUTPUT_VERSION}
export LOGIN_CORE_MOCK_TAG := login-core-mock:${DOCKER_METADATA_OUTPUT_VERSION}
login_help:
@echo "Makefile for the login service"

View File

@@ -66,8 +66,8 @@ You can already use the current state, and extend it with your needs.
- [x] Generic OIDC
- [x] Generic OAuth
- [x] Generic JWT
- [ ] LDAP
- [ ] SAML SP
- [x] LDAP
- [x] SAML SP
- Multifactor Registration an Login
- [x] Passkeys
- [x] TOTP
@@ -143,6 +143,13 @@ You can already use the current state, and extend it with your needs.
You can find a more detailed documentation of the different pages [here](./apps/login/readme.md).
#### Custom translations
The new login uses the [SettingsApi](https://zitadel.com/docs/apis/resources/settings_service_v2/settings-service-get-hosted-login-translation) to load custom translations.
Translations can be overriden at both the instance and organization levels.
To find the keys more easily, you can inspect the HTML and search for a `data-i18n-key` attribute, or look at the defaults in `/apps/login/locales/[locale].ts`.
![Custom Translations](.github/custom-i18n.png)
## Tooling
- [TypeScript](https://www.typescriptlang.org/) for static type checking

View File

@@ -49,6 +49,7 @@
"idp": {
"title": "Mit SSO anmelden",
"description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden",
"orSignInWith": "oder melden Sie sich an mit",
"signInWithApple": "Mit Apple anmelden",
"signInWithGoogle": "Mit Google anmelden",
"signInWithAzureAD": "Mit AzureAD anmelden",
@@ -79,6 +80,13 @@
"description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen."
}
},
"ldap": {
"title": "LDAP Login",
"description": "Geben Sie Ihre LDAP-Anmeldedaten ein.",
"username": "Benutzername",
"password": "Passwort",
"submit": "Weiter"
},
"mfa": {
"verify": {
"title": "Bestätigen Sie Ihre Identität",

View File

@@ -49,6 +49,7 @@
"idp": {
"title": "Sign in with SSO",
"description": "Select one of the following providers to sign in",
"orSignInWith": "or sign in with",
"signInWithApple": "Sign in with Apple",
"signInWithGoogle": "Sign in with Google",
"signInWithAzureAD": "Sign in with AzureAD",
@@ -79,6 +80,13 @@
"description": "You need to complete your registration by providing your email address and name."
}
},
"ldap": {
"title": "LDAP Login",
"description": "Enter your LDAP credentials.",
"username": "Username",
"password": "Password",
"submit": "Continue"
},
"mfa": {
"verify": {
"title": "Verify your identity",

View File

@@ -49,6 +49,7 @@
"idp": {
"title": "Iniciar sesión con SSO",
"description": "Selecciona uno de los siguientes proveedores para iniciar sesión",
"orSignInWith": "o iniciar sesión con",
"signInWithApple": "Iniciar sesión con Apple",
"signInWithGoogle": "Iniciar sesión con Google",
"signInWithAzureAD": "Iniciar sesión con AzureAD",
@@ -79,6 +80,13 @@
"description": "Para completar el registro, debes establecer una contraseña."
}
},
"ldap": {
"title": "Iniciar sesión con LDAP",
"description": "Introduce tus credenciales LDAP.",
"username": "Nombre de usuario",
"password": "Contraseña",
"submit": "Continuar"
},
"mfa": {
"verify": {
"title": "Verifica tu identidad",

View File

@@ -49,6 +49,7 @@
"idp": {
"title": "Accedi con SSO",
"description": "Seleziona uno dei seguenti provider per accedere",
"orSignInWith": "o accedi con",
"signInWithApple": "Accedi con Apple",
"signInWithGoogle": "Accedi con Google",
"signInWithAzureAD": "Accedi con AzureAD",
@@ -79,6 +80,13 @@
"description": "Completa la registrazione del tuo account."
}
},
"ldap": {
"title": "Accedi con LDAP",
"description": "Inserisci le tue credenziali LDAP.",
"username": "Nome utente",
"password": "Password",
"submit": "Continua"
},
"mfa": {
"verify": {
"title": "Verifica la tua identità",

View File

@@ -49,6 +49,7 @@
"idp": {
"title": "Zaloguj się za pomocą SSO",
"description": "Wybierz jednego z poniższych dostawców, aby się zalogować",
"orSignInWith": "lub zaloguj się przez",
"signInWithApple": "Zaloguj się przez Apple",
"signInWithGoogle": "Zaloguj się przez Google",
"signInWithAzureAD": "Zaloguj się przez AzureAD",
@@ -79,6 +80,13 @@
"description": "Ukończ rejestrację swojego konta."
}
},
"ldap": {
"title": "Zaloguj się przez LDAP",
"description": "Wprowadź swoje dane logowania LDAP.",
"username": "Nazwa użytkownika",
"password": "Hasło",
"submit": "Kontynuuj"
},
"mfa": {
"verify": {
"title": "Zweryfikuj swoją tożsamość",

View File

@@ -49,6 +49,7 @@
"idp": {
"title": "Войти через SSO",
"description": "Выберите одного из провайдеров для входа",
"orSignInWith": "или войти через",
"signInWithApple": "Войти через Apple",
"signInWithGoogle": "Войти через Google",
"signInWithAzureAD": "Войти через AzureAD",
@@ -79,6 +80,13 @@
"description": "Завершите регистрацию вашего аккаунта."
}
},
"ldap": {
"title": "Войти через LDAP",
"description": "Введите ваши учетные данные LDAP.",
"username": "Имя пользователя",
"password": "Пароль",
"submit": "Продолжить"
},
"mfa": {
"verify": {
"title": "Подтвердите вашу личность",

View File

@@ -49,6 +49,7 @@
"idp": {
"title": "使用 SSO 登录",
"description": "选择以下提供商中的一个进行登录",
"orSignInWith": "或使用以下方式登录",
"signInWithApple": "用 Apple 登录",
"signInWithGoogle": "用 Google 登录",
"signInWithAzureAD": "用 AzureAD 登录",
@@ -79,6 +80,13 @@
"description": "完成您的账户注册。"
}
},
"ldap": {
"title": "使用 LDAP 登录",
"description": "请输入您的 LDAP 凭据。",
"username": "用户名",
"password": "密码",
"submit": "继续"
},
"mfa": {
"verify": {
"title": "验证您的身份",

View File

@@ -5,6 +5,7 @@
"scripts": {
"dev": "pnpm next dev --turbopack",
"test:unit": "pnpm vitest",
"test:unit:standalone": "pnpm test:unit",
"test:unit:watch": "pnpm test:unit --watch",
"lint": "pnpm exec next lint && pnpm exec prettier --check .",
"lint:fix": "pnpm exec prettier --write .",

View File

@@ -0,0 +1,56 @@
import { DynamicTheme } from "@/components/dynamic-theme";
import { LDAPUsernamePasswordForm } from "@/components/ldap-username-password-form";
import { Translated } from "@/components/translated";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { headers } from "next/headers";
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
params: Promise<{ provider: string }>;
}) {
const searchParams = await props.searchParams;
const { idpId, organization, link } = searchParams;
if (!idpId) {
throw new Error("No idpId provided in searchParams");
}
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg({
serviceUrl,
});
if (org) {
defaultOrganization = org.id;
}
}
const branding = await getBrandingSettings({
serviceUrl,
organization: organization ?? defaultOrganization,
});
// return login failed if no linking or creation is allowed and no user was found
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>
<Translated i18nKey="title" namespace="ldap" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="description" namespace="ldap" />
</p>
<LDAPUsernamePasswordForm
idpId={idpId}
link={link === "true"}
></LDAPUsernamePasswordForm>
</div>
</DynamicTheme>
);
}

View File

@@ -76,15 +76,17 @@ export default async function Page(props: {
suffix={suffix}
submit={submit}
allowRegister={!!loginSettings?.allowRegister}
>
{identityProviders && loginSettings?.allowExternalIdp && (
></UsernameForm>
{identityProviders && loginSettings?.allowExternalIdp && (
<div className="w-full pt-6 pb-4">
<SignInWithIdp
identityProviders={identityProviders}
requestId={requestId}
organization={organization}
></SignInWithIdp>
)}
</UsernameForm>
</div>
)}
</div>
</DynamicTheme>
);

View File

@@ -11,7 +11,6 @@ import {
getLoginSettings,
} from "@/lib/zitadel";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { headers } from "next/headers";
export default async function Page(props: {
@@ -95,10 +94,6 @@ export default async function Page(props: {
requestId={requestId}
organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user
loginSettings={loginSettings}
promptPasswordless={
loginSettings?.passkeysType == PasskeysType.ALLOWED
}
isAlternative={alt === "true"}
/>
)}
</div>

View File

@@ -1,43 +0,0 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
export default function SamlPost() {
const searchParams = useSearchParams();
const url = searchParams.get("url");
const relayState = searchParams.get("RelayState");
const samlResponse = searchParams.get("SAMLResponse");
useEffect(() => {
// Automatically submit the form after rendering
const form = document.getElementById("samlForm") as HTMLFormElement;
if (form) {
form.submit();
}
}, []);
if (!url || !relayState || !samlResponse) {
return (
<p className="text-center">Missing required parameters for SAML POST.</p>
);
}
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redirecting...</title>
</head>
<body>
<form id="samlForm" action={url} method="POST">
<input type="hidden" name="RelayState" value={relayState} />
<input type="hidden" name="SAMLResponse" value={samlResponse} />
</form>
<p>Redirecting...</p>
</body>
</html>
);
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const url = searchParams.get("url");
const relayState = searchParams.get("RelayState");
const samlResponse = searchParams.get("SAMLResponse");
if (!url || !relayState || !samlResponse) {
return new NextResponse("Missing required parameters", { status: 400 });
}
// Respond with an HTML form that auto-submits via POST
const html = `
<html>
<body onload="document.forms[0].submit()">
<form action="${url}" method="post">
<input type="hidden" name="RelayState" value="${relayState}" />
<input type="hidden" name="SAMLResponse" value="${samlResponse}" />
<noscript>
<button type="submit">Continue</button>
</noscript>
</form>
</body>
</html>
`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
});
}

View File

@@ -24,6 +24,7 @@ import {
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { DEFAULT_CSP } from "../../../constants/csp";
@@ -191,6 +192,19 @@ export async function GET(request: NextRequest) {
const origin = request.nextUrl.origin;
const identityProviderType = identityProviders[0].type;
if (identityProviderType === IdentityProviderType.LDAP) {
const ldapUrl = constructUrl(request, "/ldap");
if (authRequest.id) {
ldapUrl.searchParams.set("requestId", `oidc_${authRequest.id}`);
}
if (organization) {
ldapUrl.searchParams.set("organization", organization);
}
return NextResponse.redirect(ldapUrl);
}
let provider = idpTypeToSlug(identityProviderType);
const params = new URLSearchParams();

View File

@@ -0,0 +1,109 @@
"use client";
import { createNewSessionForLDAP } from "@/lib/server/idp";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Alert } from "./alert";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
type Inputs = {
loginName: string;
password: string;
};
type Props = {
idpId: string;
link: boolean;
};
export function LDAPUsernamePasswordForm({ idpId, link }: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
});
const t = useTranslations("ldap");
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
async function submitUsernamePassword(values: Inputs) {
setError("");
setLoading(true);
const response = await createNewSessionForLDAP({
idpId: idpId,
username: values.loginName,
password: values.password,
link: link,
})
.catch(() => {
setError("Could not start LDAP flow");
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response.redirect) {
return router.push(response.redirect);
}
}
return (
<form className="w-full space-y-4">
<TextInput
type="text"
autoComplete="username"
{...register("loginName", { required: "This field is required" })}
label={t("username")}
data-testid="username-text-input"
/>
<div className={`${error && "transform-gpu animate-shake"}`}>
<TextInput
type="password"
autoComplete="password"
{...register("password", { required: "This field is required" })}
label={t("password")}
data-testid="password-text-input"
/>
</div>
{error && (
<div className="py-4" data-testid="error">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<BackButton data-testid="back-button" />
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(submitUsernamePassword)}
data-testid="submit-button"
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
<Translated i18nKey="submit" namespace="ldap" />
</Button>
</div>
</form>
);
}

View File

@@ -208,26 +208,26 @@ export function LoginPasskey({
type="button"
variant={ButtonVariants.Secondary}
onClick={() => {
const params: any = { alt: "true" };
const params = new URLSearchParams();
if (loginName) {
params.loginName = loginName;
params.append("loginName", loginName);
}
if (sessionId) {
params.sessionId = sessionId;
params.append("sessionId", sessionId);
}
if (requestId) {
params.requestId = requestId;
params.append("requestId", requestId);
}
if (organization) {
params.organization = organization;
params.append("organization", organization);
}
return router.push(
"/password?" + new URLSearchParams(params), // alt is set because password is requested as alternative auth method, so passwordless prompt can be escaped
"/password?" + params, // alt is set because password is requested as alternative auth method, so passwordless prompt can be escaped
);
}}
data-testid="password-button"

View File

@@ -23,8 +23,6 @@ type Props = {
loginName: string;
organization?: string;
requestId?: string;
isAlternative?: boolean; // whether password was requested as alternative auth method
promptPasswordless?: boolean;
};
export function PasswordForm({
@@ -32,8 +30,6 @@ export function PasswordForm({
loginName,
organization,
requestId,
promptPasswordless,
isAlternative,
}: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",

View File

@@ -15,6 +15,7 @@ import { SignInWithGeneric } from "./idps/sign-in-with-generic";
import { SignInWithGithub } from "./idps/sign-in-with-github";
import { SignInWithGitlab } from "./idps/sign-in-with-gitlab";
import { SignInWithGoogle } from "./idps/sign-in-with-google";
import { Translated } from "./translated";
export interface SignInWithIDPProps {
children?: ReactNode;
@@ -53,6 +54,7 @@ export function SignInWithIdp({
[IdentityProviderType.GITLAB]: SignInWithGitlab,
[IdentityProviderType.GITLAB_SELF_HOSTED]: SignInWithGitlab,
[IdentityProviderType.SAML]: SignInWithGeneric,
[IdentityProviderType.LDAP]: SignInWithGeneric,
[IdentityProviderType.JWT]: SignInWithGeneric,
};
@@ -75,6 +77,9 @@ export function SignInWithIdp({
return (
<div className="flex flex-col w-full space-y-2 text-sm">
<p className="text-center ztdl-p">
<Translated i18nKey="orSignInWith" namespace="idp" />
</p>
{!!identityProviders.length && identityProviders?.map(renderIDPButton)}
{state?.error && (
<div className="py-4">

View File

@@ -136,9 +136,6 @@ export function UsernameForm({
<Alert>{error}</Alert>
</div>
)}
<div className="pt-6 pb-4">{children}</div>
<div className="mt-4 flex w-full flex-row items-center">
<BackButton data-testid="back-button" />
<span className="flex-grow"></span>

View File

@@ -24,6 +24,8 @@ export function idpTypeToSlug(idpType: IdentityProviderType) {
return "oauth";
case IdentityProviderType.OIDC:
return "oidc";
case IdentityProviderType.LDAP:
return "ldap";
case IdentityProviderType.JWT:
return "jwt";
default:

View File

@@ -4,6 +4,7 @@ import {
getLoginSettings,
getUserByID,
startIdentityProviderFlow,
startLDAPIdentityProviderFlow,
} from "@/lib/zitadel";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
@@ -18,6 +19,13 @@ export async function redirectToIdp(
prevState: RedirectToIdpState,
formData: FormData,
): Promise<RedirectToIdpState> {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const host = _headers.get("host");
if (!host) {
return { error: "Could not get host" };
}
const params = new URLSearchParams();
const linkOnly = formData.get("linkOnly") === "true";
@@ -30,44 +38,48 @@ export async function redirectToIdp(
if (requestId) params.set("requestId", requestId);
if (organization) params.set("organization", organization);
// redirect to LDAP page where username and password is requested
if (provider === "ldap") {
params.set("idpId", idpId);
redirect(`/idp/ldap?` + params.toString());
}
const response = await startIDPFlow({
serviceUrl,
host,
idpId,
successUrl: `/idp/${provider}/success?` + params.toString(),
failureUrl: `/idp/${provider}/failure?` + params.toString(),
});
if (response && "error" in response && response?.error) {
return { error: response.error };
if (!response) {
return { error: "Could not start IDP flow" };
}
if (response && "redirect" in response && response?.redirect) {
redirect(response.redirect);
}
return { error: "Unexpected response from IDP flow" };
}
export type StartIDPFlowCommand = {
serviceUrl: string;
host: string;
idpId: string;
successUrl: string;
failureUrl: string;
};
export async function startIDPFlow(command: StartIDPFlowCommand) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const host = _headers.get("host");
if (!host) {
return { error: "Could not get host" };
}
async function startIDPFlow(command: StartIDPFlowCommand) {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
return startIdentityProviderFlow({
serviceUrl,
serviceUrl: command.serviceUrl,
idpId: command.idpId,
urls: {
successUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}${command.successUrl}`,
failureUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}${command.failureUrl}`,
successUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.successUrl}`,
failureUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.failureUrl}`,
},
}).then((response) => {
if (
@@ -174,3 +186,58 @@ export async function createNewSessionFromIdpIntent(
return { redirect: url };
}
}
type createNewSessionForLDAPCommand = {
username: string;
password: string;
idpId: string;
link: boolean;
};
export async function createNewSessionForLDAP(
command: createNewSessionForLDAPCommand,
) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const host = _headers.get("host");
if (!host) {
return { error: "Could not get domain" };
}
if (!command.username || !command.password) {
return { error: "No username or password provided" };
}
const response = await startLDAPIdentityProviderFlow({
serviceUrl,
idpId: command.idpId,
username: command.username,
password: command.password,
});
if (
!response ||
response.nextStep.case !== "idpIntent" ||
!response.nextStep.value
) {
return { error: "Could not start LDAP identity provider flow" };
}
const { userId, idpIntentId, idpIntentToken } = response.nextStep.value;
const params = new URLSearchParams({
userId,
id: idpIntentId,
token: idpIntentToken,
});
if (command.link) {
params.set("link", "true");
}
return {
redirect: `/idp/ldap/success?` + params.toString(),
};
}

View File

@@ -962,7 +962,6 @@ export async function startIdentityProviderFlow({
urls,
}: {
serviceUrl: string;
idpId: string;
urls: RedirectURLsJson;
}) {
@@ -980,6 +979,34 @@ export async function startIdentityProviderFlow({
});
}
export async function startLDAPIdentityProviderFlow({
serviceUrl,
idpId,
username,
password,
}: {
serviceUrl: string;
idpId: string;
username: string;
password: string;
}) {
const userService: Client<typeof UserService> = await createServiceForHost(
UserService,
serviceUrl,
);
return userService.startIdentityProviderIntent({
idpId,
content: {
case: "ldap",
value: {
username,
password,
},
},
});
}
export async function getAuthRequest({
serviceUrl,
authRequestId,

View File

@@ -14,6 +14,7 @@
"test:unit": {
"dependsOn": ["@zitadel/client#build"]
},
"test:unit:standalone": {},
"test:watch": {
"dependsOn": ["@zitadel/client#build"]
}

View File

@@ -82,7 +82,7 @@ target "login-client" {
}
variable "LOGIN_CORE_MOCK_TAG" {
default = "core-mock:local"
default = "login-core-mock:local"
}
# the core-mock context must not be overwritten, so we don't prefix it with login-.

View File

@@ -1,7 +1,8 @@
FROM typescript-proto-client AS login-client
COPY packages/zitadel-tsconfig packages/zitadel-tsconfig
COPY packages/zitadel-client/package.json ./packages/zitadel-client/
RUN ls -la
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --workspace-root --filter ./packages/zitadel-client
COPY packages/zitadel-client packages/zitadel-client
COPY packages/zitadel-client ./packages/zitadel-client
RUN pnpm build:client:standalone

View File

@@ -3,4 +3,4 @@ COPY apps/login/package.json ./apps/login/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --workspace-root --filter ./apps/login
COPY apps/login ./apps/login
RUN cd apps/login && pnpm test:unit
RUN pnpm test:unit:standalone

View File

@@ -13,6 +13,7 @@
"start": "pnpm exec turbo run start",
"start:built": "pnpm exec turbo run start:built",
"test:unit": "pnpm exec turbo run test:unit -- --passWithNoTests",
"test:unit:standalone": "pnpm exec turbo run test:unit:standalone -- --passWithNoTests",
"test:integration": "cd apps/login-test-integration && pnpm test:integration",
"test:integration:setup": "NODE_ENV=test pnpm exec turbo run test:integration:setup",
"test:acceptance": "cd apps/login-test-acceptance && pnpm test:acceptance",