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
1 change: 1 addition & 0 deletions static/app/types/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export type AuthConfig = {
githubLoginLink?: string;
googleLoginLink?: string;
loginBannerMarkdown?: string;
singleOrganizationSlug?: string;
vstsLoginLink?: string;
warning?: string;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {OrganizationAuth} from './organizationAuth';

interface RequiredOrganizationSsoProps {
authOrganization: AuthOrganization;
onClear: () => void;
onClear?: () => void;
}

export function RequiredOrganizationSso({
Expand All @@ -26,24 +26,26 @@ export function RequiredOrganizationSso({
onClear={onClear}
/>
</Container>
<Flex width="100%" align="center" justify="between">
<Button
icon={<IconArrow direction="left" />}
size="zero"
variant="transparent"
onClick={onClear}
>
{t('Wrong organization')}
</Button>
<InfoTip
position="bottom"
variant="muted"
size="xs"
title={t(
'This organization requires SSO authentication. You may still log in with an email and password to access other organizations and account settings.'
)}
/>
</Flex>
{onClear && (
<Flex width="100%" align="center" justify="between">
<Button
icon={<IconArrow direction="left" />}
size="zero"
variant="transparent"
onClick={onClear}
>
{t('Wrong organization')}
</Button>
<InfoTip
position="bottom"
variant="muted"
size="xs"
title={t(
'This organization requires SSO authentication. You may still log in with an email and password to access other organizations and account settings.'
)}
/>
</Flex>
)}
</Stack>
);
}
Original file line number Diff line number Diff line change
@@ -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;
}
143 changes: 142 additions & 1 deletion static/app/views/authV2/authLogin/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,6 +21,7 @@ jest.mock('sentry/utils/analytics');
describe('AuthLogin', () => {
beforeEach(() => {
jest.mocked(trackAnalytics).mockClear();
ConfigStore.set('singleOrganization', false);
});

beforeAll(() => {
Expand All @@ -33,7 +35,7 @@ describe('AuthLogin', () => {
Reflect.deleteProperty(document, 'elementFromPoint');
});

function mockAuthConfig() {
function mockAuthConfig(singleOrganizationSlug?: string) {
MockApiClient.addMockResponse({
url: '/auth/config/',
body: {
Expand All @@ -44,6 +46,7 @@ describe('AuthLogin', () => {
pendingMfa: null,
serverHostname: 'sentry.example.com',
vstsLoginLink: '',
...(singleOrganizationSlug ? {singleOrganizationSlug} : {}),
} satisfies AuthConfig,
});
}
Expand Down Expand Up @@ -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(<AuthLogin />, {
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(<AuthLogin />, {
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(<AuthLogin />, {
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(<AuthLogin />, {
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<AuthenticatedResult>();
mockAuthConfig();
Expand Down
18 changes: 15 additions & 3 deletions static/app/views/authV2/authLogin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -75,13 +76,20 @@ 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
// of following the generic authenticated-user redirect.
const focusedOrgAuth = Boolean(
orgSlug && nextUri && authOrganization && !authOrganization.memberAuthenticated
);
const showEmailAuth =
!focusedOrgAuth || (isSingleOrganization && !authOrganization?.ssoRequired);
const isAuthOrganizationNotFound = isNotFoundError(authOrganizationError);
const hasAuthOrganizationError = Boolean(
authOrganizationError && !isAuthOrganizationNotFound
Expand Down Expand Up @@ -299,7 +307,7 @@ export default function AuthLogin() {
) : organizationSsoOnly ? (
<RequiredOrganizationSso
authOrganization={organizationSsoOnly}
onClear={handleClearOrganization}
onClear={isSingleOrganization ? undefined : handleClearOrganization}
/>
) : (
<Fragment>
Expand All @@ -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}
/>
</Stack>

{!focusedOrgAuth && (
{showEmailAuth && (
<Fragment>
<AuthDivider />

Expand Down
Loading