Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions packages/auth/src/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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',
Expand All @@ -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) => {
Expand Down Expand Up @@ -203,12 +236,13 @@ export function LoginForm({
<AuthFormHeader
icon={hideIcon ? undefined : (icon ?? <DefaultLockIcon />)}
title={title}
description={description}
description={ssoEnforced && !showPasswordFallback ? l.ssoOnlyDescription : description}
/>

<div className="space-y-5">
<SocialSignInButtons mode="sign-in" onProvidersResolved={(hasProviders) => setHasSocialProviders(hasProviders)} />

{passwordFormVisible ? (
<form onSubmit={handleSubmit} className="space-y-4">
{hasSocialProviders && <AuthDivider label={l.orText} />}

Expand Down Expand Up @@ -277,10 +311,37 @@ export function LoginForm({
{l.ssoButton}
</button>
)}

{/* Break-glass form was opened under enforced mode — let the user
collapse back to the SSO-only view. */}
{ssoEnforced && showPasswordFallback && (
<button
type="button"
onClick={() => setShowPasswordFallback(false)}
className="w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
>
{l.backToSsoText}
</button>
)}
</form>
) : (
/* 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. */
<div className="space-y-4">
{error && <AuthErrorBanner message={error} />}
<button
type="button"
onClick={() => setShowPasswordFallback(true)}
className="w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
>
{l.usePasswordText}
</button>
</div>
)}
</div>

{registerUrl && (
{registerUrl && !ssoEnforced && (
<p className="px-8 text-center text-sm text-muted-foreground">
{l.noAccountText}{' '}
<LinkComp href={registerUrl} className={AUTH_LINK_CLASS}>
Expand Down
43 changes: 42 additions & 1 deletion packages/auth/src/__tests__/LoginForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
});
11 changes: 11 additions & 0 deletions packages/auth/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading