Skip to content

Commit 8fc9b8e

Browse files
azaslavskyc298lee
authored andcommitted
feat(relocation): Add auto redirect after 2FA enroll (#68645)
1 parent 8c7b7bf commit 8fc9b8e

File tree

2 files changed

+118
-2
lines changed

2 files changed

+118
-2
lines changed

static/app/views/settings/account/accountSecurity/accountSecurityEnroll.spec.tsx

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
import {AuthenticatorsFixture} from 'sentry-fixture/authenticators';
2+
import {OrganizationFixture} from 'sentry-fixture/organization';
23
import {RouterContextFixture} from 'sentry-fixture/routerContextFixture';
34
import {RouterFixture} from 'sentry-fixture/routerFixture';
45

56
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
67

8+
import OrganizationsStore from 'sentry/stores/organizationsStore';
79
import AccountSecurityEnroll from 'sentry/views/settings/account/accountSecurity/accountSecurityEnroll';
810

911
const ENDPOINT = '/users/me/authenticators/';
12+
const usorg = OrganizationFixture({
13+
slug: 'us-org',
14+
links: {
15+
organizationUrl: 'https://us-org.example.test',
16+
regionUrl: 'https://us.example.test',
17+
},
18+
});
1019

1120
describe('AccountSecurityEnroll', function () {
21+
jest.spyOn(window.location, 'assign').mockImplementation(() => {});
22+
1223
describe('Totp', function () {
1324
const authenticator = AuthenticatorsFixture().Totp({
1425
isEnrolled: false,
@@ -32,14 +43,31 @@ describe('AccountSecurityEnroll', function () {
3243
},
3344
]);
3445

46+
let location;
3547
beforeEach(function () {
48+
location = window.location;
49+
window.location.href = 'https://example.test';
50+
window.__initialData = {
51+
...window.__initialData,
52+
links: {
53+
organizationUrl: undefined,
54+
regionUrl: undefined,
55+
sentryUrl: 'https://example.test',
56+
},
57+
};
58+
OrganizationsStore.load([usorg]);
59+
3660
MockApiClient.clearMockResponses();
3761
MockApiClient.addMockResponse({
3862
url: `${ENDPOINT}${authenticator.authId}/enroll/`,
3963
body: authenticator,
4064
});
4165
});
4266

67+
beforeEach(function () {
68+
window.location = location;
69+
});
70+
4371
it('does not have enrolled circle indicator', function () {
4472
render(<AccountSecurityEnroll />, {context: routerContext});
4573

@@ -54,11 +82,63 @@ describe('AccountSecurityEnroll', function () {
5482
expect(screen.getByLabelText('Enrollment QR Code')).toBeInTheDocument();
5583
});
5684

57-
it('can enroll', async function () {
85+
it('can enroll from org subdomain', async function () {
86+
window.location.href = 'https://us-org.example.test';
87+
window.__initialData = {
88+
...window.__initialData,
89+
links: {
90+
organizationUrl: 'https://us-org.example.test',
91+
regionUrl: 'https://us.example.test',
92+
sentryUrl: 'https://example.test',
93+
},
94+
};
95+
96+
const enrollMock = MockApiClient.addMockResponse({
97+
url: `${ENDPOINT}${authenticator.authId}/enroll/`,
98+
method: 'POST',
99+
});
100+
const fetchOrgsMock = MockApiClient.addMockResponse({
101+
url: `/organizations/`,
102+
body: [usorg],
103+
});
104+
105+
render(<AccountSecurityEnroll />, {context: routerContext});
106+
107+
await userEvent.type(screen.getByRole('textbox', {name: 'OTP Code'}), 'otp{enter}');
108+
109+
expect(enrollMock).toHaveBeenCalledWith(
110+
`${ENDPOINT}15/enroll/`,
111+
expect.objectContaining({
112+
method: 'POST',
113+
data: expect.objectContaining({
114+
secret: 'secret',
115+
otp: 'otp',
116+
}),
117+
})
118+
);
119+
expect(fetchOrgsMock).not.toHaveBeenCalled();
120+
expect(window.location.assign).not.toHaveBeenCalled();
121+
});
122+
123+
it('can enroll from main domain', async function () {
124+
OrganizationsStore.load([]);
125+
window.__initialData = {
126+
...window.__initialData,
127+
links: {
128+
organizationUrl: 'https://us-org.example.test',
129+
regionUrl: 'https://us.example.test',
130+
sentryUrl: 'https://example.test',
131+
},
132+
};
133+
58134
const enrollMock = MockApiClient.addMockResponse({
59135
url: `${ENDPOINT}${authenticator.authId}/enroll/`,
60136
method: 'POST',
61137
});
138+
const fetchOrgsMock = MockApiClient.addMockResponse({
139+
url: `/organizations/`,
140+
body: [usorg],
141+
});
62142

63143
render(<AccountSecurityEnroll />, {context: routerContext});
64144

@@ -74,6 +154,9 @@ describe('AccountSecurityEnroll', function () {
74154
}),
75155
})
76156
);
157+
expect(fetchOrgsMock).toHaveBeenCalledTimes(1);
158+
expect(window.location.assign).toHaveBeenCalledTimes(1);
159+
expect(window.location.assign).toHaveBeenCalledWith('http://us-org.example.test/');
77160
});
78161

79162
it('can redirect with already enrolled error', function () {

static/app/views/settings/account/accountSecurity/accountSecurityEnroll.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
addSuccessMessage,
1010
} from 'sentry/actionCreators/indicator';
1111
import {openRecoveryOptions} from 'sentry/actionCreators/modal';
12-
import {fetchOrganizationByMember} from 'sentry/actionCreators/organizations';
12+
import {
13+
fetchOrganizationByMember,
14+
fetchOrganizations,
15+
} from 'sentry/actionCreators/organizations';
1316
import {Alert} from 'sentry/components/alert';
1417
import {Button} from 'sentry/components/button';
1518
import ButtonBar from 'sentry/components/buttonBar';
@@ -24,8 +27,10 @@ import PanelItem from 'sentry/components/panels/panelItem';
2427
import TextCopyInput from 'sentry/components/textCopyInput';
2528
import U2fsign from 'sentry/components/u2f/u2fsign';
2629
import {t} from 'sentry/locale';
30+
import OrganizationsStore from 'sentry/stores/organizationsStore';
2731
import {space} from 'sentry/styles/space';
2832
import type {Authenticator} from 'sentry/types';
33+
import {generateOrgSlugUrl} from 'sentry/utils';
2934
import getPendingInvite from 'sentry/utils/getPendingInvite';
3035
// eslint-disable-next-line no-restricted-imports
3136
import withSentryRouter from 'sentry/utils/withSentryRouter';
@@ -330,6 +335,34 @@ class AccountSecurityEnroll extends DeprecatedAsyncView<Props, State> {
330335

331336
this.props.router.push('/settings/account/security/');
332337
openRecoveryOptions({authenticatorName: this.authenticatorName});
338+
339+
// The remainder of this function is included primarily to smooth out the relocation flow. The
340+
// newly claimed user will have landed on `https://sentry.io/settings/account/security` to
341+
// perform the 2FA registration. But now that they have in fact registered, we want to redirect
342+
// them to the subdomain of the organization they are already a member of (ex:
343+
// `https://my-2fa-org.sentry.io`), but did not have the ability to access due to their previous
344+
// lack of 2FA enrollment.
345+
let orgs = OrganizationsStore.getAll();
346+
if (orgs.length === 0) {
347+
// Try to load orgs post 2FA again.
348+
orgs = await fetchOrganizations(this.api, {member: '1'});
349+
OrganizationsStore.load(orgs);
350+
351+
// Still no orgs? Nowhere to redirect the user to, so just stay in place.
352+
if (orgs.length === 0) {
353+
return;
354+
}
355+
}
356+
357+
// If we are already in an org sub-domain, we don't need to do any redirection. If we are not
358+
// (this is usually only the case for a newly claimed relocated user), we redirect to the org
359+
// slug's subdomain now.
360+
const isAlreadyInOrgSubDomain = orgs.some(org => {
361+
return org.links.organizationUrl === new URL(window.location.href).origin;
362+
});
363+
if (!isAlreadyInOrgSubDomain) {
364+
window.location.assign(generateOrgSlugUrl(orgs[0].slug));
365+
}
333366
}
334367

335368
// Handler when we failed to add a 2fa device

0 commit comments

Comments
 (0)