From f77e5e9fac8c31491a3247d5d1f14b36579099c1 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Wed, 8 Apr 2026 12:52:32 -0700 Subject: [PATCH 1/5] ref(cells): Implement org member invite redirect from old path to new We have to support the old invite urls for up to INVITE_DAYS_VALID (30 days) however putting this web redirect in place now allows us to clean up the rest of the frontend and api logic and other settings (such as `is_historical_monolith_region` handling) that was all connected to the legacy route. --- .../accept_organization_invite_redirect.py | 39 +++++++++++++++++++ src/sentry/web/urls.py | 5 ++- ...est_accept_organization_invite_redirect.py | 34 ++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/sentry/web/frontend/accept_organization_invite_redirect.py create mode 100644 tests/sentry/web/frontend/test_accept_organization_invite_redirect.py diff --git a/src/sentry/web/frontend/accept_organization_invite_redirect.py b/src/sentry/web/frontend/accept_organization_invite_redirect.py new file mode 100644 index 00000000000000..bd93ffd26b5d60 --- /dev/null +++ b/src/sentry/web/frontend/accept_organization_invite_redirect.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.urls import reverse + +from sentry.api.endpoints.accept_organization_invite import get_invite_state +from sentry.demo_mode.utils import is_demo_user +from sentry.utils.http import query_string +from sentry.web.frontend.react_page import GenericReactPageView + + +# TODO(cells): Temporary redirect to support previous invitations. Remove after May 8th +class AcceptOrganizationInviteRedirectView(GenericReactPageView): + auth_required = False + + def handle(self, request: HttpRequest, member_id: int, token: str, **kwargs) -> HttpResponse: + if request.user.is_authenticated and not is_demo_user(request.user): + user_id: int | None = request.user.id + else: + user_id = None + + invite_context = get_invite_state( + member_id=member_id, + organization_id_or_slug=None, + user_id=user_id, + request=request, + ) + if invite_context is None: + return self.handle_react(request, **kwargs) + + redirect_url = reverse( + "sentry-organization-accept-invite", + kwargs={ + "organization_slug": invite_context.organization.slug, + "member_id": member_id, + "token": token, + }, + ) + return HttpResponseRedirect(f"{redirect_url}{query_string(request)}") diff --git a/src/sentry/web/urls.py b/src/sentry/web/urls.py index 3790497227bd18..e118becbffd191 100644 --- a/src/sentry/web/urls.py +++ b/src/sentry/web/urls.py @@ -23,6 +23,9 @@ from sentry.users.web.user_avatar import UserAvatarPhotoView from sentry.web import api from sentry.web.frontend import csrf_failure, generic +from sentry.web.frontend.accept_organization_invite_redirect import ( + AcceptOrganizationInviteRedirectView, +) from sentry.web.frontend.auth_channel_login import AuthChannelLoginView from sentry.web.frontend.auth_close import AuthCloseView from sentry.web.frontend.auth_login import AuthLoginView @@ -554,7 +557,7 @@ ), re_path( r"^accept/(?P\d+)/(?P\w+)/$", - GenericReactPageView.as_view(auth_required=False), + AcceptOrganizationInviteRedirectView.as_view(), name="sentry-accept-invite", ), re_path( diff --git a/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py b/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py new file mode 100644 index 00000000000000..794eb0af8e3d60 --- /dev/null +++ b/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py @@ -0,0 +1,34 @@ +from django.urls import reverse + +from sentry.testutils.cases import TestCase +from sentry.testutils.silo import control_silo_test + + +@control_silo_test +class AcceptOrganizationInviteRedirectViewTest(TestCase): + def test_redirects_legacy_invite_to_org_scoped_route(self) -> None: + organization = self.create_organization() + member = self.create_member(organization=organization, email="newuser@example.com") + + response = self.client.get( + reverse("sentry-accept-invite", args=[member.id, member.token]) + "?referrer=email" + ) + + assert response.status_code == 302 + assert response["Location"] == ( + reverse( + "sentry-organization-accept-invite", + kwargs={ + "organization_slug": organization.slug, + "member_id": member.id, + "token": member.token, + }, + ) + + "?referrer=email" + ) + + def test_unresolved_legacy_invite_falls_back_to_react_page(self) -> None: + response = self.client.get(reverse("sentry-accept-invite", args=[123456, "invalidtoken"])) + + assert response.status_code == 200 + self.assertTemplateUsed(response, "sentry/base-react.html") From cea1e5ae1016b95122586175ff891b1208065023 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Wed, 8 Apr 2026 13:04:58 -0700 Subject: [PATCH 2/5] validate token first --- .../frontend/accept_organization_invite_redirect.py | 5 +++++ .../test_accept_organization_invite_redirect.py | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/sentry/web/frontend/accept_organization_invite_redirect.py b/src/sentry/web/frontend/accept_organization_invite_redirect.py index bd93ffd26b5d60..90e3d7440371a0 100644 --- a/src/sentry/web/frontend/accept_organization_invite_redirect.py +++ b/src/sentry/web/frontend/accept_organization_invite_redirect.py @@ -4,6 +4,7 @@ from django.urls import reverse from sentry.api.endpoints.accept_organization_invite import get_invite_state +from sentry.api.invite_helper import ApiInviteHelper from sentry.demo_mode.utils import is_demo_user from sentry.utils.http import query_string from sentry.web.frontend.react_page import GenericReactPageView @@ -28,6 +29,10 @@ def handle(self, request: HttpRequest, member_id: int, token: str, **kwargs) -> if invite_context is None: return self.handle_react(request, **kwargs) + helper = ApiInviteHelper(request=request, token=token, invite_context=invite_context) + if not helper.valid_token: + return self.handle_react(request, **kwargs) + redirect_url = reverse( "sentry-organization-accept-invite", kwargs={ diff --git a/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py b/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py index 794eb0af8e3d60..5ea2d01b437faa 100644 --- a/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py +++ b/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py @@ -27,6 +27,17 @@ def test_redirects_legacy_invite_to_org_scoped_route(self) -> None: + "?referrer=email" ) + def test_invalid_token_does_not_leak_org_slug(self) -> None: + organization = self.create_organization() + member = self.create_member(organization=organization, email="newuser@example.com") + + response = self.client.get( + reverse("sentry-accept-invite", args=[member.id, "invalidtoken"]) + ) + + assert response.status_code == 200 + self.assertTemplateUsed(response, "sentry/base-react.html") + def test_unresolved_legacy_invite_falls_back_to_react_page(self) -> None: response = self.client.get(reverse("sentry-accept-invite", args=[123456, "invalidtoken"])) From 7be8700e3808eb03b04497f9b07238d5c78d7374 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Wed, 8 Apr 2026 13:12:32 -0700 Subject: [PATCH 3/5] member_id type fix --- .../web/frontend/accept_organization_invite_redirect.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sentry/web/frontend/accept_organization_invite_redirect.py b/src/sentry/web/frontend/accept_organization_invite_redirect.py index 90e3d7440371a0..fd3e543f5002ba 100644 --- a/src/sentry/web/frontend/accept_organization_invite_redirect.py +++ b/src/sentry/web/frontend/accept_organization_invite_redirect.py @@ -14,14 +14,14 @@ class AcceptOrganizationInviteRedirectView(GenericReactPageView): auth_required = False - def handle(self, request: HttpRequest, member_id: int, token: str, **kwargs) -> HttpResponse: + def handle(self, request: HttpRequest, member_id: str, token: str, **kwargs) -> HttpResponse: if request.user.is_authenticated and not is_demo_user(request.user): user_id: int | None = request.user.id else: user_id = None invite_context = get_invite_state( - member_id=member_id, + member_id=int(member_id), organization_id_or_slug=None, user_id=user_id, request=request, From bcfbba6aa1bc78cab8a3956d8fc3b18cc7b9b34d Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Wed, 8 Apr 2026 13:21:24 -0700 Subject: [PATCH 4/5] match type signature of parent class --- .../web/frontend/accept_organization_invite_redirect.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/sentry/web/frontend/accept_organization_invite_redirect.py b/src/sentry/web/frontend/accept_organization_invite_redirect.py index fd3e543f5002ba..cd92b6c6f51c47 100644 --- a/src/sentry/web/frontend/accept_organization_invite_redirect.py +++ b/src/sentry/web/frontend/accept_organization_invite_redirect.py @@ -14,7 +14,9 @@ class AcceptOrganizationInviteRedirectView(GenericReactPageView): auth_required = False - def handle(self, request: HttpRequest, member_id: str, token: str, **kwargs) -> HttpResponse: + def handle(self, request: HttpRequest, **kwargs) -> HttpResponse: + member_id: str = kwargs["member_id"] + token: str = kwargs["token"] if request.user.is_authenticated and not is_demo_user(request.user): user_id: int | None = request.user.id else: From 9fc820af6c62e231d2d2c2b56200943d48259ae1 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Wed, 8 Apr 2026 14:40:23 -0700 Subject: [PATCH 5/5] update test --- .../web/frontend/test_accept_organization_invite_redirect.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py b/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py index 5ea2d01b437faa..16945ab37c8712 100644 --- a/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py +++ b/tests/sentry/web/frontend/test_accept_organization_invite_redirect.py @@ -8,7 +8,9 @@ class AcceptOrganizationInviteRedirectViewTest(TestCase): def test_redirects_legacy_invite_to_org_scoped_route(self) -> None: organization = self.create_organization() - member = self.create_member(organization=organization, email="newuser@example.com") + member = self.create_member( + organization=organization, email="newuser@example.com", token="abc" + ) response = self.client.get( reverse("sentry-accept-invite", args=[member.id, member.token]) + "?referrer=email"