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}
/>
-
- }
- size="zero"
- variant="transparent"
- onClick={onClear}
- >
- {t('Wrong organization')}
-
-
-
+ {onClear && (
+
+ }
+ size="zero"
+ variant="transparent"
+ onClick={onClear}
+ >
+ {t('Wrong organization')}
+
+
+
+ )}
);
}
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 && (