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 ? (
+ ) : (
+ /* 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. */
+
{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,