feat(frontend): login page cta (#13337)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
HeyItsChloe
2026-03-17 13:14:59 -07:00
committed by GitHub
parent 3b215c4ad1
commit af1fa8961a
8 changed files with 425 additions and 111 deletions

View File

@@ -49,9 +49,17 @@ vi.mock("#/utils/custom-toast-handlers", () => ({
displayErrorToast: vi.fn(), displayErrorToast: vi.fn(),
})); }));
// Mock feature flags - we'll control the return value in each test
const mockEnableProjUserJourney = vi.fn(() => true);
vi.mock("#/utils/feature-flags", () => ({
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
}));
describe("LoginContent", () => { describe("LoginContent", () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal("location", { href: "" }); vi.stubGlobal("location", { href: "" });
// Reset mock to return true by default
mockEnableProjUserJourney.mockReturnValue(true);
}); });
afterEach(() => { afterEach(() => {
@@ -274,6 +282,65 @@ describe("LoginContent", () => {
expect(screen.getByTestId("terms-and-privacy-notice")).toBeInTheDocument(); expect(screen.getByTestId("terms-and-privacy-notice")).toBeInTheDocument();
}); });
it("should display the enterprise LoginCTA component when appMode is saas and feature flag enabled", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
});
it("should not display the enterprise LoginCTA component when appMode is oss even with feature flag enabled", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="oss"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
});
it("should not display the enterprise LoginCTA component when appMode is null", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode={null}
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
});
it("should not display the enterprise LoginCTA component when feature flag is disabled", () => {
// Disable the feature flag
mockEnableProjUserJourney.mockReturnValue(false);
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
});
it("should display invitation pending message when hasInvitation is true", () => { it("should display invitation pending message when hasInvitation is true", () => {
render( render(
<MemoryRouter> <MemoryRouter>

View File

@@ -0,0 +1,63 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { LoginCTA } from "#/components/features/auth/login-cta";
// Mock useTracking hook
const mockTrackSaasSelfhostedInquiry = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
}),
}));
describe("LoginCTA", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should render enterprise CTA with title and description", () => {
render(<LoginCTA />);
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
expect(screen.getByText("CTA$ENTERPRISE")).toBeInTheDocument();
expect(screen.getByText("CTA$ENTERPRISE_DEPLOY")).toBeInTheDocument();
});
it("should render all enterprise feature list items", () => {
render(<LoginCTA />);
expect(screen.getByText("CTA$FEATURE_ON_PREMISES")).toBeInTheDocument();
expect(screen.getByText("CTA$FEATURE_DATA_CONTROL")).toBeInTheDocument();
expect(screen.getByText("CTA$FEATURE_COMPLIANCE")).toBeInTheDocument();
expect(screen.getByText("CTA$FEATURE_SUPPORT")).toBeInTheDocument();
});
it("should render Learn More as a link with correct href and target", () => {
render(<LoginCTA />);
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
expect(learnMoreLink).toHaveAttribute(
"href",
"https://openhands.dev/enterprise/",
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
});
it("should call trackSaasSelfhostedInquiry with location 'login_page' when Learn More is clicked", async () => {
const user = userEvent.setup();
render(<LoginCTA />);
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
await user.click(learnMoreLink);
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
location: "login_page",
});
});
});

View File

@@ -73,6 +73,11 @@ vi.mock("#/hooks/use-invitation", () => ({
useInvitation: () => useInvitationMock(), useInvitation: () => useInvitationMock(),
})); }));
// Mock feature flags - enable by default for tests
vi.mock("#/utils/feature-flags", () => ({
ENABLE_PROJ_USER_JOURNEY: () => true,
}));
const RouterStub = createRoutesStub([ const RouterStub = createRoutesStub([
{ {
Component: LoginPage, Component: LoginPage,

View File

@@ -13,6 +13,9 @@ import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-not
import { useRecaptcha } from "#/hooks/use-recaptcha"; import { useRecaptcha } from "#/hooks/use-recaptcha";
import { useConfig } from "#/hooks/query/use-config"; import { useConfig } from "#/hooks/query/use-config";
import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { cn } from "#/utils/utils";
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
import { LoginCTA } from "./login-cta";
export interface LoginContentProps { export interface LoginContentProps {
githubAuthUrl: string | null; githubAuthUrl: string | null;
@@ -177,125 +180,133 @@ export function LoginContent({
return ( return (
<div <div
className="flex flex-col items-center w-full gap-12.5" className={cn(
data-testid="login-content" "flex flex-col md:flex-row items-center md:items-stretch gap-6 h-full",
)}
> >
<div> <div
<OpenHandsLogoWhite width={106} height={72} /> className={cn("flex flex-col items-center w-full gap-12.5")}
</div> data-testid="login-content"
>
<div>
<OpenHandsLogoWhite width={106} height={72} />
</div>
<h1 className="text-[39px] leading-5 font-medium text-white text-center"> <h1 className="text-[39px] leading-5 font-medium text-white text-center">
{t(I18nKey.AUTH$LETS_GET_STARTED)} {t(I18nKey.AUTH$LETS_GET_STARTED)}
</h1> </h1>
{shouldShownHelperText && (
<div className="flex flex-col items-center gap-3">
{emailVerified && (
<p className="text-sm text-muted-foreground text-center">
{t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)}
</p>
)}
{hasDuplicatedEmail && (
<p className="text-sm text-danger text-center">
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)}
</p>
)}
{recaptchaBlocked && (
<p className="text-sm text-danger text-center max-w-125">
{t(I18nKey.AUTH$RECAPTCHA_BLOCKED)}
</p>
)}
{hasInvitation && (
<p className="text-sm text-muted-foreground text-center">
{t(I18nKey.AUTH$INVITATION_PENDING)}
</p>
)}
{showBitbucket && (
<p className="text-sm text-white text-center max-w-125">
{t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)}
</p>
)}
</div>
)}
{shouldShownHelperText && (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
{emailVerified && ( {noProvidersConfigured ? (
<p className="text-sm text-muted-foreground text-center"> <div className="text-center p-4 text-muted-foreground">
{t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)} {t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)}
</p> </div>
)} ) : (
{hasDuplicatedEmail && ( <>
<p className="text-sm text-danger text-center"> {showGithub && (
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)} <button
</p> type="button"
)} onClick={handleGitHubAuth}
{recaptchaBlocked && ( className={`${buttonBaseClasses} bg-[#9E28B0] text-white`}
<p className="text-sm text-danger text-center max-w-125"> >
{t(I18nKey.AUTH$RECAPTCHA_BLOCKED)} <GitHubLogo width={14} height={14} className="shrink-0" />
</p> <span className={buttonLabelClasses}>
)} {t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
{hasInvitation && ( </span>
<p className="text-sm text-muted-foreground text-center"> </button>
{t(I18nKey.AUTH$INVITATION_PENDING)} )}
</p>
)} {showGitlab && (
{showBitbucket && ( <button
<p className="text-sm text-white text-center max-w-125"> type="button"
{t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)} onClick={handleGitLabAuth}
</p> className={`${buttonBaseClasses} bg-[#FC6B0E] text-white`}
>
<GitLabLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
</span>
</button>
)}
{showBitbucket && (
<button
type="button"
onClick={handleBitbucketAuth}
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
>
<BitbucketLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
</span>
</button>
)}
{showBitbucketDataCenter && (
<button
type="button"
onClick={handleBitbucketDataCenterAuth}
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
>
<BitbucketLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(
I18nKey.BITBUCKET_DATA_CENTER$CONNECT_TO_BITBUCKET_DATA_CENTER,
)}
</span>
</button>
)}
{showEnterpriseSso && (
<button
type="button"
onClick={handleEnterpriseSsoAuth}
className={`${buttonBaseClasses} bg-[#374151] text-white`}
>
<FaUserShield size={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
</span>
</button>
)}
</>
)} )}
</div> </div>
)}
<div className="flex flex-col items-center gap-3"> <TermsAndPrivacyNotice className="max-w-[320px] text-[#A3A3A3]" />
{noProvidersConfigured ? (
<div className="text-center p-4 text-muted-foreground">
{t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)}
</div>
) : (
<>
{showGithub && (
<button
type="button"
onClick={handleGitHubAuth}
className={`${buttonBaseClasses} bg-[#9E28B0] text-white`}
>
<GitHubLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
</span>
</button>
)}
{showGitlab && (
<button
type="button"
onClick={handleGitLabAuth}
className={`${buttonBaseClasses} bg-[#FC6B0E] text-white`}
>
<GitLabLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
</span>
</button>
)}
{showBitbucket && (
<button
type="button"
onClick={handleBitbucketAuth}
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
>
<BitbucketLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
</span>
</button>
)}
{showBitbucketDataCenter && (
<button
type="button"
onClick={handleBitbucketDataCenterAuth}
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
>
<BitbucketLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(
I18nKey.BITBUCKET_DATA_CENTER$CONNECT_TO_BITBUCKET_DATA_CENTER,
)}
</span>
</button>
)}
{showEnterpriseSso && (
<button
type="button"
onClick={handleEnterpriseSsoAuth}
className={`${buttonBaseClasses} bg-[#374151] text-white`}
>
<FaUserShield size={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
</span>
</button>
)}
</>
)}
</div> </div>
<TermsAndPrivacyNotice className="max-w-[320px] text-[#A3A3A3]" /> {appMode === "saas" && ENABLE_PROJ_USER_JOURNEY() && <LoginCTA />}
</div> </div>
); );
} }

View File

@@ -0,0 +1,66 @@
import { useTranslation } from "react-i18next";
import { Card } from "#/ui/card";
import { CardTitle } from "#/ui/card-title";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import StackedIcon from "#/icons/stacked.svg?react";
import { useTracking } from "#/hooks/use-tracking";
export function LoginCTA() {
const { t } = useTranslation();
const { trackSaasSelfhostedInquiry } = useTracking();
const handleLearnMoreClick = () => {
trackSaasSelfhostedInquiry({ location: "login_page" });
};
return (
<Card
testId="login-cta"
theme="dark"
className={cn("w-full max-w-80 h-auto flex-col", "cta-card-gradient")}
>
<div className={cn("flex flex-col gap-[11px] p-6")}>
<div className={cn("size-10")}>
<StackedIcon width={40} height={40} />
</div>
<CardTitle>{t(I18nKey.CTA$ENTERPRISE)}</CardTitle>
<Typography.Text className="text-[#8C8C8C] font-inter font-normal text-sm leading-5">
{t(I18nKey.CTA$ENTERPRISE_DEPLOY)}
</Typography.Text>
<ul
className={cn(
"text-[#8C8C8C] font-inter font-normal text-sm leading-5 list-disc list-inside flex flex-col gap-1",
)}
>
<li>{t(I18nKey.CTA$FEATURE_ON_PREMISES)}</li>
<li>{t(I18nKey.CTA$FEATURE_DATA_CONTROL)}</li>
<li>{t(I18nKey.CTA$FEATURE_COMPLIANCE)}</li>
<li>{t(I18nKey.CTA$FEATURE_SUPPORT)}</li>
</ul>
<div className={cn("h-10 flex justify-start")}>
<a
href="https://openhands.dev/enterprise/"
target="_blank"
rel="noopener noreferrer"
onClick={handleLearnMoreClick}
className={cn(
"inline-flex items-center justify-center",
"h-10 px-4 rounded",
"bg-[#050505] border border-[#242424]",
"text-white hover:bg-[#0a0a0a]",
"font-semibold text-sm",
)}
>
{t(I18nKey.CTA$LEARN_MORE)}
</a>
</div>
</div>
</Card>
);
}

View File

@@ -1141,6 +1141,12 @@ export enum I18nKey {
ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON", ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON",
ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON", ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON",
ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON", ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON",
CTA$ENTERPRISE = "CTA$ENTERPRISE",
CTA$ENTERPRISE_DEPLOY = "CTA$ENTERPRISE_DEPLOY",
CTA$FEATURE_ON_PREMISES = "CTA$FEATURE_ON_PREMISES",
CTA$FEATURE_DATA_CONTROL = "CTA$FEATURE_DATA_CONTROL",
CTA$FEATURE_COMPLIANCE = "CTA$FEATURE_COMPLIANCE",
CTA$FEATURE_SUPPORT = "CTA$FEATURE_SUPPORT",
ENTERPRISE$SELF_HOSTED = "ENTERPRISE$SELF_HOSTED", ENTERPRISE$SELF_HOSTED = "ENTERPRISE$SELF_HOSTED",
ENTERPRISE$TITLE = "ENTERPRISE$TITLE", ENTERPRISE$TITLE = "ENTERPRISE$TITLE",
ENTERPRISE$DESCRIPTION = "ENTERPRISE$DESCRIPTION", ENTERPRISE$DESCRIPTION = "ENTERPRISE$DESCRIPTION",

View File

@@ -18255,6 +18255,102 @@
"tr": "Bitir", "tr": "Bitir",
"uk": "Завершити" "uk": "Завершити"
}, },
"CTA$ENTERPRISE": {
"en": "Enterprise",
"ja": "エンタープライズ",
"zh-CN": "企业版",
"zh-TW": "企業版",
"ko-KR": "엔터프라이즈",
"no": "Bedrift",
"ar": "المؤسسات",
"de": "Unternehmen",
"fr": "Entreprise",
"it": "Azienda",
"pt": "Empresarial",
"es": "Empresa",
"tr": "Kurumsal",
"uk": "Підприємство"
},
"CTA$ENTERPRISE_DEPLOY": {
"en": "Deploy OpenHands on your own infrastructure. Full control over data, compliance, and security.",
"ja": "独自のインフラストラクチャにOpenHandsをデプロイ。データ、コンプライアンス、セキュリティを完全にコントロール。",
"zh-CN": "在您自己的基础设施上部署 OpenHands。完全控制数据、合规性和安全性。",
"zh-TW": "在您自己的基礎設施上部署 OpenHands。完全控制資料、合規性和安全性。",
"ko-KR": "자체 인프라에 OpenHands를 배포하세요. 데이터, 규정 준수 및 보안을 완벽하게 제어합니다.",
"no": "Distribuer OpenHands på din egen infrastruktur. Full kontroll over data, samsvar og sikkerhet.",
"ar": "انشر OpenHands على بنيتك التحتية الخاصة. تحكم كامل في البيانات والامتثال والأمان.",
"de": "Stellen Sie OpenHands auf Ihrer eigenen Infrastruktur bereit. Volle Kontrolle über Daten, Compliance und Sicherheit.",
"fr": "Déployez OpenHands sur votre propre infrastructure. Contrôle total sur les données, la conformité et la sécurité.",
"it": "Distribuisci OpenHands sulla tua infrastruttura. Controllo completo su dati, conformità e sicurezza.",
"pt": "Implante o OpenHands em sua própria infraestrutura. Controle total sobre dados, conformidade e segurança.",
"es": "Implemente OpenHands en su propia infraestructura. Control total sobre datos, cumplimiento y seguridad.",
"tr": "OpenHands'i kendi altyapınızda dağıtın. Veri, uyumluluk ve güvenlik üzerinde tam kontrol.",
"uk": "Розгорніть OpenHands на власній інфраструктурі. Повний контроль над даними, відповідністю та безпекою."
},
"CTA$FEATURE_ON_PREMISES": {
"en": "On-premises or private cloud",
"ja": "オンプレミスまたはプライベートクラウド",
"zh-CN": "本地部署或私有云",
"zh-TW": "本地部署或私有雲",
"ko-KR": "온프레미스 또는 프라이빗 클라우드",
"no": "Lokalt eller privat sky",
"ar": "محلي أو سحابة خاصة",
"de": "On-Premises oder Private Cloud",
"fr": "Sur site ou cloud privé",
"it": "On-premise o cloud privato",
"pt": "Local ou nuvem privada",
"es": "Local o nube privada",
"tr": "Şirket içi veya özel bulut",
"uk": "Локально або приватна хмара"
},
"CTA$FEATURE_DATA_CONTROL": {
"en": "Full data control",
"ja": "完全なデータ管理",
"zh-CN": "完全数据控制",
"zh-TW": "完全資料控制",
"ko-KR": "완전한 데이터 제어",
"no": "Full datakontroll",
"ar": "تحكم كامل في البيانات",
"de": "Volle Datenkontrolle",
"fr": "Contrôle total des données",
"it": "Controllo completo dei dati",
"pt": "Controle total de dados",
"es": "Control total de datos",
"tr": "Tam veri kontrolü",
"uk": "Повний контроль даних"
},
"CTA$FEATURE_COMPLIANCE": {
"en": "Custom compliance requirements",
"ja": "カスタムコンプライアンス要件",
"zh-CN": "自定义合规要求",
"zh-TW": "自訂合規要求",
"ko-KR": "사용자 정의 규정 준수 요구 사항",
"no": "Tilpassede samsvarkrav",
"ar": "متطلبات الامتثال المخصصة",
"de": "Individuelle Compliance-Anforderungen",
"fr": "Exigences de conformité personnalisées",
"it": "Requisiti di conformità personalizzati",
"pt": "Requisitos de conformidade personalizados",
"es": "Requisitos de cumplimiento personalizados",
"tr": "Özel uyumluluk gereksinimleri",
"uk": "Індивідуальні вимоги відповідності"
},
"CTA$FEATURE_SUPPORT": {
"en": "Dedicated support options",
"ja": "専用サポートオプション",
"zh-CN": "专属支持选项",
"zh-TW": "專屬支援選項",
"ko-KR": "전용 지원 옵션",
"no": "Dedikerte støttealternativer",
"ar": "خيارات دعم مخصصة",
"de": "Dedizierte Supportoptionen",
"fr": "Options de support dédiées",
"it": "Opzioni di supporto dedicate",
"pt": "Opções de suporte dedicado",
"es": "Opciones de soporte dedicado",
"tr": "Özel destek seçenekleri",
"uk": "Виділені варіанти підтримки"
},
"ENTERPRISE$SELF_HOSTED": { "ENTERPRISE$SELF_HOSTED": {
"en": "Self-Hosted", "en": "Self-Hosted",
"ja": "セルフホスト", "ja": "セルフホスト",

View File

@@ -379,6 +379,6 @@
/* CTA card gradient and shadow */ /* CTA card gradient and shadow */
.cta-card-gradient { .cta-card-gradient {
background: radial-gradient(85.36% 123.38% at 50% 0%, rgba(255, 255, 255, 0.14) 0%, rgba(0, 0, 0, 0) 100%); background: radial-gradient(85.36% 123.38% at 50% 0%, rgba(255, 255, 255, 0.14) 0%, rgba(0, 0, 0, 0) 100%), #000000;
box-shadow: 0px 4px 6px -4px rgba(0, 0, 0, 0.1), 0px 10px 15px -3px rgba(0, 0, 0, 0.1); box-shadow: 0px 4px 6px -4px rgba(0, 0, 0, 0.1), 0px 10px 15px -3px rgba(0, 0, 0, 0.1);
} }