From 9b948a6910a9f7f640ef894f7dbf9bfa4e876330 Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Mon, 21 Sep 2026 17:35:32 -0400 Subject: [PATCH] fix(auth): Support single-org login in Auth V2 Single-org installations need the login page to select their configured organization while still offering password authentication when SSO is optional or absent. Keep the organization fixed in the login UI and route to its canonical URL. --- static/app/types/auth.tsx | 1 + .../components/requiredOrganizationSso.tsx | 40 ++--- .../hooks/useSingleOrganizationLogin.tsx | 35 +++++ .../app/views/authV2/authLogin/index.spec.tsx | 143 +++++++++++++++++- static/app/views/authV2/authLogin/index.tsx | 18 ++- 5 files changed, 214 insertions(+), 23 deletions(-) create mode 100644 static/app/views/authV2/authLogin/hooks/useSingleOrganizationLogin.tsx diff --git a/static/app/types/auth.tsx b/static/app/types/auth.tsx index b545d7456ba2..535c4b2dd427 100644 --- a/static/app/types/auth.tsx +++ b/static/app/types/auth.tsx @@ -149,6 +149,7 @@ export type AuthConfig = { githubLoginLink?: string; googleLoginLink?: string; loginBannerMarkdown?: string; + singleOrganizationSlug?: string; vstsLoginLink?: string; warning?: string; }; diff --git a/static/app/views/authV2/authLogin/components/requiredOrganizationSso.tsx b/static/app/views/authV2/authLogin/components/requiredOrganizationSso.tsx index 63be1e212ba1..e037094ffd0c 100644 --- a/static/app/views/authV2/authLogin/components/requiredOrganizationSso.tsx +++ b/static/app/views/authV2/authLogin/components/requiredOrganizationSso.tsx @@ -10,7 +10,7 @@ import {OrganizationAuth} from './organizationAuth'; interface RequiredOrganizationSsoProps { authOrganization: AuthOrganization; - onClear: () => void; + onClear?: () => void; } export function RequiredOrganizationSso({ @@ -26,24 +26,26 @@ export function RequiredOrganizationSso({ onClear={onClear} /> - - - - + {onClear && ( + + + + + )} ); } diff --git a/static/app/views/authV2/authLogin/hooks/useSingleOrganizationLogin.tsx b/static/app/views/authV2/authLogin/hooks/useSingleOrganizationLogin.tsx new file mode 100644 index 000000000000..f6fe02918075 --- /dev/null +++ b/static/app/views/authV2/authLogin/hooks/useSingleOrganizationLogin.tsx @@ -0,0 +1,35 @@ +import {useEffect} from 'react'; + +import {ConfigStore} from 'sentry/stores/configStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; +import {useNavigate} from 'sentry/utils/useNavigate'; + +interface UseSingleOrganizationLoginOptions { + organizationSlug: string | undefined; + singleOrganizationSlug: string | undefined; +} + +export function useSingleOrganizationLogin({ + organizationSlug, + singleOrganizationSlug, +}: UseSingleOrganizationLoginOptions) { + const navigate = useNavigate(); + const isSingleOrganization = useLegacyStore(ConfigStore).singleOrganization; + + useEffect(() => { + if (!isSingleOrganization || !singleOrganizationSlug) { + return; + } + + if (organizationSlug === singleOrganizationSlug) { + return; + } + + navigate( + {pathname: `/auth/login/${encodeURIComponent(singleOrganizationSlug)}/`}, + {replace: true} + ); + }, [isSingleOrganization, navigate, organizationSlug, singleOrganizationSlug]); + + return isSingleOrganization; +} diff --git a/static/app/views/authV2/authLogin/index.spec.tsx b/static/app/views/authV2/authLogin/index.spec.tsx index 7803bef07cd7..5e6ead102810 100644 --- a/static/app/views/authV2/authLogin/index.spec.tsx +++ b/static/app/views/authV2/authLogin/index.spec.tsx @@ -7,6 +7,7 @@ import {setWindowLocation} from 'sentry-test/utils'; import {BrandPageLayout} from 'sentry/components/brandPageLayout'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; +import {ConfigStore} from 'sentry/stores/configStore'; import type {AuthConfig} from 'sentry/types/auth'; import {trackAnalytics} from 'sentry/utils/analytics'; import {testableWindowLocation} from 'sentry/utils/testableWindowLocation'; @@ -20,6 +21,7 @@ jest.mock('sentry/utils/analytics'); describe('AuthLogin', () => { beforeEach(() => { jest.mocked(trackAnalytics).mockClear(); + ConfigStore.set('singleOrganization', false); }); beforeAll(() => { @@ -33,7 +35,7 @@ describe('AuthLogin', () => { Reflect.deleteProperty(document, 'elementFromPoint'); }); - function mockAuthConfig() { + function mockAuthConfig(singleOrganizationSlug?: string) { MockApiClient.addMockResponse({ url: '/auth/config/', body: { @@ -44,6 +46,7 @@ describe('AuthLogin', () => { pendingMfa: null, serverHostname: 'sentry.example.com', vstsLoginLink: '', + ...(singleOrganizationSlug ? {singleOrganizationSlug} : {}), } satisfies AuthConfig, }); } @@ -161,6 +164,144 @@ describe('AuthLogin', () => { expect(screen.getByText('Loading authentication')).toBeInTheDocument(); }); + it('replaces the generic login route and shows password auth for a single org', async () => { + ConfigStore.set('singleOrganization', true); + mockAuthConfig('sentry'); + MockApiClient.addMockResponse({ + url: '/auth/organizations/sentry/config/', + body: { + authenticated: false, + memberAuthenticated: false, + canRegister: false, + joinRequestUrl: null, + loginMethod: 'password', + ssoRequired: false, + organization: {avatarUrl: null, name: 'Sentry', slug: 'sentry'}, + provider: null, + warnings: [], + }, + }); + + const {router} = render(, { + initialRouterConfig: { + location: {pathname: '/auth/login/'}, + route: '/auth/login/:orgSlug?/', + }, + }); + + await waitFor(() => { + expect(screen.getByText('Sentry')).toBeVisible(); + expect(screen.getByRole('textbox', {name: 'Email'})).toBeVisible(); + expect(screen.getByLabelText('Password')).toBeVisible(); + }); + expect(router.location.pathname).toBe('/auth/login/sentry/'); + expect(testableWindowLocation.assign).not.toHaveBeenCalled(); + expect( + screen.queryByRole('button', {name: 'Clear organization login context'}) + ).not.toBeInTheDocument(); + }); + + it('shows both SSO and password auth when SSO is optional for a single org', async () => { + ConfigStore.set('singleOrganization', true); + mockAuthConfig('sentry'); + MockApiClient.addMockResponse({ + url: '/auth/organizations/sentry/config/', + body: { + authenticated: false, + memberAuthenticated: false, + canRegister: false, + joinRequestUrl: null, + loginMethod: 'sso', + ssoRequired: false, + organization: {avatarUrl: null, name: 'Sentry', slug: 'sentry'}, + provider: {key: 'saml2', name: 'SAML'}, + warnings: [], + }, + }); + + render(, { + initialRouterConfig: { + location: {pathname: '/auth/login/sentry/'}, + route: '/auth/login/:orgSlug?/', + }, + }); + + expect(await screen.findByRole('textbox', {name: 'Email'})).toBeVisible(); + expect(screen.getByLabelText('Password')).toBeVisible(); + expect(screen.getByRole('button', {name: 'SSO'})).toBeEnabled(); + expect( + screen.queryByRole('button', {name: 'Clear organization login context'}) + ).not.toBeInTheDocument(); + }); + + it('offers password auth to an authenticated user without single-org access', async () => { + ConfigStore.set('singleOrganization', true); + MockApiClient.addMockResponse({ + url: '/auth/config/', + body: {nextUri: '/organizations/sentry/issues/'}, + }); + MockApiClient.addMockResponse({ + url: '/auth/organizations/sentry/config/', + body: { + authenticated: true, + memberAuthenticated: false, + canRegister: false, + joinRequestUrl: null, + loginMethod: 'password', + ssoRequired: false, + organization: {avatarUrl: null, name: 'Sentry', slug: 'sentry'}, + provider: null, + warnings: [], + }, + }); + + render(, { + initialRouterConfig: { + location: {pathname: '/auth/login/sentry/'}, + route: '/auth/login/:orgSlug?/', + }, + }); + + expect(await screen.findByRole('textbox', {name: 'Email'})).toBeVisible(); + expect(screen.getByLabelText('Password')).toBeVisible(); + expect(testableWindowLocation.assign).not.toHaveBeenCalled(); + expect( + screen.queryByRole('button', {name: 'Clear organization login context'}) + ).not.toBeInTheDocument(); + }); + + it('requires SSO without offering another org in single-org mode', async () => { + ConfigStore.set('singleOrganization', true); + mockAuthConfig('sentry'); + MockApiClient.addMockResponse({ + url: '/auth/organizations/sentry/config/', + body: { + authenticated: false, + memberAuthenticated: false, + canRegister: false, + joinRequestUrl: null, + loginMethod: 'sso', + ssoRequired: true, + organization: {avatarUrl: null, name: 'Sentry', slug: 'sentry'}, + provider: {key: 'saml2', name: 'SAML'}, + warnings: [], + }, + }); + + render(, { + initialRouterConfig: { + location: {pathname: '/auth/login/sentry/'}, + route: '/auth/login/:orgSlug?/', + }, + }); + + expect(await screen.findByRole('button', {name: 'SSO'})).toBeEnabled(); + expect(screen.queryByRole('textbox', {name: 'Email'})).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', {name: 'Wrong organization'}) + ).not.toBeInTheDocument(); + }); + it('authenticates a demo organization before rendering the sign-in flow', async () => { const demoLogin = Promise.withResolvers(); mockAuthConfig(); diff --git a/static/app/views/authV2/authLogin/index.tsx b/static/app/views/authV2/authLogin/index.tsx index 50bc3adf076b..c4cb50940a37 100644 --- a/static/app/views/authV2/authLogin/index.tsx +++ b/static/app/views/authV2/authLogin/index.tsx @@ -31,6 +31,7 @@ import {useAuthConfig} from './hooks/useAuthConfig'; import {useAuthOrganization} from './hooks/useAuthOrganization'; import {useDemoLogin} from './hooks/useDemoLogin'; import type {EmailAuthResult} from './hooks/useEmailAuth'; +import {useSingleOrganizationLogin} from './hooks/useSingleOrganizationLogin'; import type {AuthenticatedResult, MfaMethod} from './types'; type AuthProviderLinkKey = keyof Pick< @@ -75,6 +76,11 @@ export default function AuthLogin() { const nextUri = authConfig && 'nextUri' in authConfig ? authConfig.nextUri : undefined; const loginConfig = authConfig && !('nextUri' in authConfig) ? authConfig : undefined; + const singleOrganizationSlug = loginConfig?.singleOrganizationSlug; + const isSingleOrganization = useSingleOrganizationLogin({ + organizationSlug: orgSlug, + singleOrganizationSlug, + }); // An authenticated user may still need to authenticate with an organization's SSO // provider before its APIs will grant access. Keep that organization in focus instead @@ -82,6 +88,8 @@ export default function AuthLogin() { const focusedOrgAuth = Boolean( orgSlug && nextUri && authOrganization && !authOrganization.memberAuthenticated ); + const showEmailAuth = + !focusedOrgAuth || (isSingleOrganization && !authOrganization?.ssoRequired); const isAuthOrganizationNotFound = isNotFoundError(authOrganizationError); const hasAuthOrganizationError = Boolean( authOrganizationError && !isAuthOrganizationNotFound @@ -299,7 +307,7 @@ export default function AuthLogin() { ) : organizationSsoOnly ? ( ) : ( @@ -325,13 +333,17 @@ export default function AuthLogin() { authOrganization={authOrganization} isInputVisible={isOrganizationSlugInputVisible} onCancel={() => setIsOrganizationSlugInputVisible(false)} - onClear={focusedOrgAuth ? undefined : handleClearOrganization} + onClear={ + focusedOrgAuth || isSingleOrganization + ? undefined + : handleClearOrganization + } onOpen={() => setIsOrganizationSlugInputVisible(true)} onSelect={handleSelectOrganization} /> - {!focusedOrgAuth && ( + {showEmailAuth && (