diff --git a/web/src/components/AuthPageLayout.tsx b/web/src/components/AuthPageLayout.tsx
index 85cffb0d..bdfee90e 100644
--- a/web/src/components/AuthPageLayout.tsx
+++ b/web/src/components/AuthPageLayout.tsx
@@ -50,6 +50,8 @@ export const AuthLinkPrompt = ({ prompt, to, label }: { prompt: string; to: stri
);
+export const AuthOptionsLoading = () => ;
+
const AuthPageLayout = ({ chip, title, subtitle, hideExplore, children }: Props) => {
const t = useTranslate();
const { generalSetting, profile } = useInstance();
diff --git a/web/src/hooks/useIdentityProviderQueries.ts b/web/src/hooks/useIdentityProviderQueries.ts
index d422250e..7667cd3d 100644
--- a/web/src/hooks/useIdentityProviderQueries.ts
+++ b/web/src/hooks/useIdentityProviderQueries.ts
@@ -12,12 +12,15 @@ const EMPTY_LIST: IdentityProvider[] = [];
// Hook to fetch the configured identity providers. Pass `enabled: false` on
// pages/branches that never render provider buttons to skip the request.
-export function useIdentityProviderList(enabled = true): IdentityProvider[] {
- const { data } = useQuery({
+export function useIdentityProviderList(enabled = true) {
+ const { data, isLoading } = useQuery({
queryKey: identityProviderKeys.list(),
queryFn: async () => (await identityProviderServiceClient.listIdentityProviders({})).identityProviders,
staleTime: 60_000,
enabled,
});
- return data ?? EMPTY_LIST;
+ return {
+ identityProviderList: data ?? EMPTY_LIST,
+ isLoading,
+ };
}
diff --git a/web/src/pages/SignIn.tsx b/web/src/pages/SignIn.tsx
index 52b2ea1d..c0e377b8 100644
--- a/web/src/pages/SignIn.tsx
+++ b/web/src/pages/SignIn.tsx
@@ -1,6 +1,6 @@
import { ArrowRightIcon, LockIcon } from "lucide-react";
import { Link, useSearchParams } from "react-router-dom";
-import AuthPageLayout, { AuthEmptyState, AuthLinkPrompt } from "@/components/AuthPageLayout";
+import AuthPageLayout, { AuthEmptyState, AuthLinkPrompt, AuthOptionsLoading } from "@/components/AuthPageLayout";
import IdentityProviderButtons from "@/components/IdentityProviderButtons";
import PasswordSignInForm from "@/components/PasswordSignInForm";
import { Separator } from "@/components/ui/separator";
@@ -14,18 +14,21 @@ const SignIn = () => {
const t = useTranslate();
const { generalSetting: instanceGeneralSetting } = useInstance();
const [searchParams] = useSearchParams();
- const identityProviderList = useIdentityProviderList();
+ const { identityProviderList, isLoading: identityProvidersLoading } = useIdentityProviderList();
const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM));
const signUpPath = appendSearchParams(ROUTES.AUTH_SIGNUP, searchParams);
const passwordAuthAllowed = !instanceGeneralSetting.disallowPasswordAuth;
const hasIdentityProviders = identityProviderList.length > 0;
- const subtitle = passwordAuthAllowed || hasIdentityProviders ? t("auth.welcome-back") : undefined;
+ // Shared by the subtitle and the body branch so they can't disagree.
+ const showAuthOptions = identityProvidersLoading || passwordAuthAllowed || hasIdentityProviders;
return (
-
- {passwordAuthAllowed || hasIdentityProviders ? (
+
+ {identityProvidersLoading ? (
+
+ ) : showAuthOptions ? (
<>
{hasIdentityProviders && }
{hasIdentityProviders && passwordAuthAllowed && (
diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx
index e767406e..cd8204a3 100644
--- a/web/src/pages/SignUp.tsx
+++ b/web/src/pages/SignUp.tsx
@@ -5,7 +5,7 @@ import { useState } from "react";
import { toast } from "react-hot-toast";
import { useSearchParams } from "react-router-dom";
import { setAccessToken } from "@/auth-state";
-import AuthPageLayout, { AuthChip, AuthEmptyState, AuthLinkPrompt } from "@/components/AuthPageLayout";
+import AuthPageLayout, { AuthChip, AuthEmptyState, AuthLinkPrompt, AuthOptionsLoading } from "@/components/AuthPageLayout";
import CredentialFields from "@/components/CredentialFields";
import IdentityProviderButtons from "@/components/IdentityProviderButtons";
import { Button } from "@/components/ui/button";
@@ -37,7 +37,9 @@ const SignUp = () => {
const registrationOpen = !instanceGeneralSetting.disallowUserRegistration;
const needsSetup = profile.needsSetup;
// Provider buttons only render on the SSO-provisioned branch below; skip the request elsewhere.
- const identityProviderList = useIdentityProviderList(!needsSetup && registrationOpen && !passwordAuthAllowed);
+ const { identityProviderList, isLoading: identityProvidersLoading } = useIdentityProviderList(
+ !needsSetup && registrationOpen && !passwordAuthAllowed,
+ );
const hasIdentityProviders = identityProviderList.length > 0;
const handleFormSubmit = async (e: React.FormEvent) => {
@@ -141,9 +143,13 @@ const SignUp = () => {
// Password sign-up disallowed: accounts come from the identity provider.
if (!passwordAuthAllowed) {
+ // Shared by the subtitle and the body branch so they can't disagree.
+ const showSsoOptions = identityProvidersLoading || hasIdentityProviders;
return (
-
- {hasIdentityProviders ? (
+
+ {identityProvidersLoading ? (
+
+ ) : showSsoOptions ? (
) : (
({
+ generalSetting: {
+ disallowPasswordAuth: true,
+ },
+ identityProviders: {
+ identityProviderList: [] as { name: string; title: string }[],
+ isLoading: true,
+ },
+}));
+
+vi.mock("@/contexts/InstanceContext", () => ({
+ useInstance: () => ({
+ generalSetting: state.generalSetting,
+ profile: { instanceUrl: "" },
+ }),
+}));
+
+vi.mock("@/hooks/useIdentityProviderQueries", () => ({
+ useIdentityProviderList: () => state.identityProviders,
+}));
+
+vi.mock("@/components/AuthFooter", () => ({ default: () => null }));
+
+vi.mock("@/components/IdentityProviderButtons", () => ({
+ default: ({ identityProviderList }: { identityProviderList: { title: string }[] }) => (
+ {identityProviderList.map((provider) => provider.title).join(", ")}
+ ),
+}));
+
+vi.mock("@/components/PasswordSignInForm", () => ({
+ default: () => ,
+}));
+
+vi.mock("@/utils/i18n", () => ({
+ useTranslate: () => (key: string) => key,
+}));
+
+const renderPage = () =>
+ render(
+
+
+ ,
+ );
+
+describe("", () => {
+ beforeEach(() => {
+ state.generalSetting.disallowPasswordAuth = true;
+ state.identityProviders.identityProviderList = [];
+ state.identityProviders.isLoading = true;
+ });
+
+ it("waits for identity providers before choosing the sign-in method", () => {
+ const { container, rerender } = renderPage();
+
+ expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
+ expect(screen.queryByText("auth.signin-unavailable-title")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("password-sign-in")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("identity-providers")).not.toBeInTheDocument();
+
+ state.identityProviders.identityProviderList = [{ name: "identityProviders/acme", title: "Acme SSO" }];
+ state.identityProviders.isLoading = false;
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("identity-providers")).toHaveTextContent("Acme SSO");
+ expect(screen.queryByText("auth.signin-unavailable-title")).not.toBeInTheDocument();
+ });
+
+ it("shows the unavailable state only after an empty provider response", () => {
+ state.identityProviders.isLoading = false;
+ renderPage();
+
+ expect(screen.getByText("auth.signin-unavailable-title")).toBeInTheDocument();
+ });
+});