diff --git a/.changeset/login-sso-button-server-gated.md b/.changeset/login-sso-button-server-gated.md new file mode 100644 index 000000000..9958e2ff4 --- /dev/null +++ b/.changeset/login-sso-button-server-gated.md @@ -0,0 +1,17 @@ +--- +'@object-ui/auth': patch +--- + +fix(auth): only render the "Sign in with SSO" button when the server reports it + +`LoginForm` rendered the SSO button unconditionally, so a deployment without +enterprise SSO wired (the default for self-hosted / `os dev` local runs) showed +a button whose `POST /sign-in/sso` route isn't mounted — clicking it surfaced +the misleading "No SSO provider is configured for this email domain." only at +click time. + +The button is now gated on `features.sso` from `GET /auth/config`, mirroring how +`SocialSignInButtons` already gates social providers. It defaults to hidden, so a +failed config fetch or an older server that doesn't report the flag simply omits +the button rather than offering a dead end. Requires the matching +`@objectstack/plugin-auth` change that surfaces `features.sso`. diff --git a/packages/auth/src/LoginForm.tsx b/packages/auth/src/LoginForm.tsx index f67d84f99..a0bee2441 100644 --- a/packages/auth/src/LoginForm.tsx +++ b/packages/auth/src/LoginForm.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useAuth } from './useAuth'; import { SocialSignInButtons } from './SocialSignInButtons'; import type { AuthLinkComponentProps } from './types'; @@ -115,11 +115,32 @@ export function LoginForm({ labels = {}, errorMessages, }: LoginFormProps) { - const { signIn, isLoading } = useAuth(); + const { signIn, isLoading, getAuthConfig } = useAuth(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [hasSocialProviders, setHasSocialProviders] = useState(false); + // Enterprise SSO is opt-in server-side (`@better-auth/sso`). Mirror the + // social-provider pattern below: ask the server whether SSO is wired and + // only render the "Sign in with SSO" button when it is. Defaults to hidden, + // so a server that doesn't report `features.sso` (or a failed config fetch) + // never shows a button whose `/sign-in/sso` route 404s at click time. + const [ssoEnabled, setSsoEnabled] = useState(false); + + useEffect(() => { + let cancelled = false; + Promise.resolve() + .then(() => getAuthConfig()) + .then((config) => { + if (!cancelled) setSsoEnabled(config?.features?.sso === true); + }) + .catch(() => { + // SSO is an enhancement, not required — leave the button hidden. + }); + return () => { + cancelled = true; + }; + }, [getAuthConfig]); const l = { emailLabel: labels.emailLabel ?? 'Email', @@ -246,14 +267,16 @@ export function LoginForm({ {isLoading ? l.submittingButton : l.submitButton} - + {ssoEnabled && ( + + )} diff --git a/packages/auth/src/__tests__/LoginForm.test.tsx b/packages/auth/src/__tests__/LoginForm.test.tsx new file mode 100644 index 000000000..04a8a1e21 --- /dev/null +++ b/packages/auth/src/__tests__/LoginForm.test.tsx @@ -0,0 +1,80 @@ +/** + * Tests for LoginForm — specifically the server-gated "Sign in with SSO" + * button. The button must only render when the server's `/auth/config` + * reports `features.sso`, mirroring how social providers are gated. Otherwise + * a self-hosted / local deployment (where `@better-auth/sso` isn't wired) + * shows a button whose `/sign-in/sso` route 404s, surfacing the misleading + * "No SSO provider is configured for this email domain." only at click time. + */ + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { AuthProvider } from '../AuthProvider'; +import { LoginForm } from '../LoginForm'; +import type { AuthClient, AuthPublicConfig } from '../types'; + +const SSO_BUTTON = { name: 'Sign in with SSO' } as const; + +function createMockClient( + config: AuthPublicConfig, + overrides: Partial = {}, +): AuthClient { + return { + signIn: vi.fn().mockResolvedValue({ + user: { id: '1', name: 'Test User', email: 'test@test.com' }, + session: { token: 'tok123' }, + }), + signUp: vi.fn().mockResolvedValue({ user: { id: '2' }, session: null, requiresVerification: false }), + signOut: vi.fn().mockResolvedValue(undefined), + getSession: vi.fn().mockResolvedValue(null), + forgotPassword: vi.fn().mockResolvedValue(undefined), + resetPassword: vi.fn().mockResolvedValue(undefined), + updateUser: vi.fn().mockResolvedValue({ id: '1', name: 'Updated', email: 'test@test.com' }), + getConfig: vi.fn().mockResolvedValue(config), + ...overrides, + } as unknown as AuthClient; +} + +function renderLogin(client: AuthClient) { + return render( + + + , + ); +} + +describe('LoginForm — server-gated SSO button', () => { + it('hides the SSO button when the server does not report features.sso', async () => { + renderLogin(createMockClient({ features: { sso: false } })); + + // Email/password form is always present… + await screen.findByLabelText('Email'); + // …but the SSO button must not render. + expect(screen.queryByRole('button', SSO_BUTTON)).toBeNull(); + }); + + it('hides the SSO button when features is absent entirely (older server)', async () => { + renderLogin(createMockClient({})); + + await screen.findByLabelText('Email'); + expect(screen.queryByRole('button', SSO_BUTTON)).toBeNull(); + }); + + it('shows the SSO button only when the server reports features.sso = true', async () => { + renderLogin(createMockClient({ features: { sso: true } })); + + await waitFor(() => { + expect(screen.getByRole('button', SSO_BUTTON)).toBeTruthy(); + }); + }); + + it('keeps the button hidden when the config fetch fails (SSO is an enhancement)', async () => { + renderLogin( + createMockClient({}, { getConfig: vi.fn().mockRejectedValue(new Error('boom')) }), + ); + + await screen.findByLabelText('Email'); + expect(screen.queryByRole('button', SSO_BUTTON)).toBeNull(); + }); +}); diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index fd6a3de19..a8b3c6d8e 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -119,6 +119,13 @@ export interface AuthPublicConfig { passkeys?: boolean; magicLink?: boolean; organization?: boolean; + /** + * Whether enterprise SSO (domain-routed `@better-auth/sso`) is wired on + * the server. When falsy the `/sign-in/sso` route isn't mounted, so the + * login UI hides the "Sign in with SSO" button instead of rendering one + * that only fails at click time with "No SSO provider is configured". + */ + sso?: boolean; /** * When `false`, the server's `beforeCreateOrganization` hook blocks * creation of new organizations. The org plugin endpoints (list, update,