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 && (