fix(auth): stabilize SSO option loading

This commit is contained in:
boojack
2026-07-14 20:06:54 +08:00
parent 9c3bd44a1b
commit 1e91cfba9d
5 changed files with 109 additions and 12 deletions
+2
View File
@@ -50,6 +50,8 @@ export const AuthLinkPrompt = ({ prompt, to, label }: { prompt: string; to: stri
</p>
);
export const AuthOptionsLoading = () => <div className="h-9 w-full animate-pulse rounded-md bg-muted/60" aria-hidden="true" />;
const AuthPageLayout = ({ chip, title, subtitle, hideExplore, children }: Props) => {
const t = useTranslate();
const { generalSetting, profile } = useInstance();
+6 -3
View File
@@ -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,
};
}
+8 -5
View File
@@ -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 (
<AuthPageLayout title={t("common.sign-in")} subtitle={subtitle}>
{passwordAuthAllowed || hasIdentityProviders ? (
<AuthPageLayout title={t("common.sign-in")} subtitle={showAuthOptions ? t("auth.welcome-back") : undefined}>
{identityProvidersLoading ? (
<AuthOptionsLoading />
) : showAuthOptions ? (
<>
{hasIdentityProviders && <IdentityProviderButtons identityProviderList={identityProviderList} redirectTarget={redirectTarget} />}
{hasIdentityProviders && passwordAuthAllowed && (
+10 -4
View File
@@ -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<HTMLFormElement>) => {
@@ -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 (
<AuthPageLayout title={t("auth.create-your-account")} subtitle={hasIdentityProviders ? t("auth.sso-signup-tip") : undefined}>
{hasIdentityProviders ? (
<AuthPageLayout title={t("auth.create-your-account")} subtitle={showSsoOptions ? t("auth.sso-signup-tip") : undefined}>
{identityProvidersLoading ? (
<AuthOptionsLoading />
) : showSsoOptions ? (
<IdentityProviderButtons identityProviderList={identityProviderList} redirectTarget={redirectTarget} />
) : (
<AuthEmptyState
+83
View File
@@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SignIn from "@/pages/SignIn";
const state = vi.hoisted(() => ({
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 }[] }) => (
<div data-testid="identity-providers">{identityProviderList.map((provider) => provider.title).join(", ")}</div>
),
}));
vi.mock("@/components/PasswordSignInForm", () => ({
default: () => <div data-testid="password-sign-in" />,
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
const renderPage = () =>
render(
<MemoryRouter>
<SignIn />
</MemoryRouter>,
);
describe("<SignIn>", () => {
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(
<MemoryRouter>
<SignIn />
</MemoryRouter>,
);
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();
});
});