diff --git a/packages/auth/src/LoginForm.tsx b/packages/auth/src/LoginForm.tsx index a0bee2441..fff4fe649 100644 --- a/packages/auth/src/LoginForm.tsx +++ b/packages/auth/src/LoginForm.tsx @@ -36,6 +36,16 @@ export interface LoginFormLabels { orText?: string; /** Label for the SSO sign-in button (defaults to "Sign in with SSO") */ ssoButton?: string; + /** + * Break-glass link shown under the federated button when SSO-only + * ("enforced") mode hides the password form (defaults to "Use a password + * instead"). Reveals the email/password form for the env owner / local admin. + */ + usePasswordText?: string; + /** Link to collapse the revealed break-glass form back to SSO-only (defaults to "Back to single sign-on"). */ + backToSsoText?: string; + /** Description shown above the form in SSO-only mode (defaults to "Sign in with your organization's single sign-on"). */ + ssoOnlyDescription?: string; } export interface LoginFormProps { @@ -126,22 +136,41 @@ export function LoginForm({ // 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); + // SSO-only ("enforced") mode: the server locks the team login to the IdP. + // Hide the local password form + sign-up; show the federated button(s) plus + // an understated break-glass link. `emailPassword.enabled === false` is a + // belt-and-suspenders fallback for older servers that signal the lock that + // way. Defaults to false (a failed config fetch never hides the form). + const [ssoEnforced, setSsoEnforced] = useState(false); + // Break-glass: reveal the password form for the env owner / local admin even + // under enforced mode (e.g. during an IdP outage). + const [showPasswordFallback, setShowPasswordFallback] = useState(false); useEffect(() => { let cancelled = false; Promise.resolve() .then(() => getAuthConfig()) .then((config) => { - if (!cancelled) setSsoEnabled(config?.features?.sso === true); + if (cancelled) return; + setSsoEnabled(config?.features?.sso === true); + setSsoEnforced( + config?.features?.ssoEnforced === true || + config?.emailPassword?.enabled === false, + ); }) .catch(() => { - // SSO is an enhancement, not required — leave the button hidden. + // SSO is an enhancement, not required — leave the buttons/form hidden + // or shown at their safe defaults. }); return () => { cancelled = true; }; }, [getAuthConfig]); + // Under enforced mode the password form is collapsed until the user opts into + // the break-glass path; otherwise it's always shown. + const passwordFormVisible = !ssoEnforced || showPasswordFallback; + const l = { emailLabel: labels.emailLabel ?? 'Email', emailPlaceholder: labels.emailPlaceholder ?? 'name@example.com', @@ -154,6 +183,10 @@ export function LoginForm({ signUpText: labels.signUpText ?? 'Sign up', orText: labels.orText ?? 'or', ssoButton: labels.ssoButton ?? 'Sign in with SSO', + usePasswordText: labels.usePasswordText ?? 'Use a password instead', + backToSsoText: labels.backToSsoText ?? 'Back to single sign-on', + ssoOnlyDescription: + labels.ssoOnlyDescription ?? "Sign in with your organization's single sign-on", }; const handleSubmit = async (e: React.FormEvent) => { @@ -203,12 +236,13 @@ export function LoginForm({ )} title={title} - description={description} + description={ssoEnforced && !showPasswordFallback ? l.ssoOnlyDescription : description} />
setHasSocialProviders(hasProviders)} /> + {passwordFormVisible ? (
{hasSocialProviders && } @@ -277,10 +311,37 @@ export function LoginForm({ {l.ssoButton} )} + + {/* Break-glass form was opened under enforced mode — let the user + collapse back to the SSO-only view. */} + {ssoEnforced && showPasswordFallback && ( + + )} + ) : ( + /* SSO-only ("enforced"): the federated button(s) above are the path. + Surface any social-sign-in error and an understated break-glass + link to the password form for the env owner / local admin. */ +
+ {error && } + +
+ )}
- {registerUrl && ( + {registerUrl && !ssoEnforced && (

{l.noAccountText}{' '} diff --git a/packages/auth/src/__tests__/LoginForm.test.tsx b/packages/auth/src/__tests__/LoginForm.test.tsx index 04a8a1e21..a6efd0723 100644 --- a/packages/auth/src/__tests__/LoginForm.test.tsx +++ b/packages/auth/src/__tests__/LoginForm.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { AuthProvider } from '../AuthProvider'; import { LoginForm } from '../LoginForm'; import type { AuthClient, AuthPublicConfig } from '../types'; @@ -78,3 +78,44 @@ describe('LoginForm — server-gated SSO button', () => { expect(screen.queryByRole('button', SSO_BUTTON)).toBeNull(); }); }); + +describe('LoginForm — SSO-only (enforced) mode', () => { + const BREAK_GLASS = { name: 'Use a password instead' } as const; + + it('hides the password form + sign-up and shows a break-glass link when features.ssoEnforced', async () => { + renderLogin(createMockClient({ features: { sso: true, ssoEnforced: true } })); + + // The break-glass link appears (federated buttons are the path)… + await screen.findByRole('button', BREAK_GLASS); + // …the local password form is hidden… + expect(screen.queryByLabelText('Email')).toBeNull(); + expect(screen.queryByLabelText('Password')).toBeNull(); + // …and the sign-up link is hidden (no self-registration under enforced). + expect(screen.queryByText('Sign up')).toBeNull(); + }); + + it('treats emailPassword.enabled === false as enforced (belt-and-suspenders)', async () => { + renderLogin(createMockClient({ emailPassword: { enabled: false } })); + + await screen.findByRole('button', BREAK_GLASS); + expect(screen.queryByLabelText('Email')).toBeNull(); + }); + + it('reveals the password form when the break-glass link is clicked', async () => { + renderLogin(createMockClient({ features: { ssoEnforced: true } })); + + fireEvent.click(await screen.findByRole('button', BREAK_GLASS)); + + // Password form now visible, plus a way back to SSO-only. + await screen.findByLabelText('Email'); + expect(screen.getByLabelText('Password')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Back to single sign-on' })).toBeTruthy(); + }); + + it('shows the password form normally when not enforced', async () => { + renderLogin(createMockClient({ features: { ssoEnforced: false } })); + + await screen.findByLabelText('Email'); + expect(screen.queryByRole('button', BREAK_GLASS)).toBeNull(); + }); +}); diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index a8b3c6d8e..69333f70e 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -126,6 +126,17 @@ export interface AuthPublicConfig { * that only fails at click time with "No SSO provider is configured". */ sso?: boolean; + /** + * SSO-only ("enforced") login. When `true`, the team/console login is + * locked to the configured IdP (cloud-as-IdP or an external OIDC/SAML + * provider): the login UI hides the local email/password form and the + * sign-up link, showing the federated button only — plus an understated + * break-glass "use a password instead" link so the env owner / local admin + * can still reach the password form during an IdP outage. The server keeps + * `emailPassword.enabled` true (managed users simply hold no credential), + * so this is purely a UI affordance over the same endpoints. + */ + ssoEnforced?: boolean; /** * When `false`, the server's `beforeCreateOrganization` hook blocks * creation of new organizations. The org plugin endpoints (list, update,