diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx
index 25a0f098990d..6922f4706a35 100644
--- a/static/app/router/routes.tsx
+++ b/static/app/router/routes.tsx
@@ -193,6 +193,10 @@ function buildRoutes(): RouteObject[] {
path: '/sentry-apps/:sentryAppSlug/external-install/',
component: make(() => import('sentry/views/sentryAppExternalInstallation')),
},
+ {
+ path: '/extensions/slack/link-team/:signedParams/',
+ component: make(() => import('sentry/views/slackLinkTeam')),
+ },
{
path: '/account/',
redirectTo: '/settings/account/details/',
diff --git a/static/app/utils/useParams.tsx b/static/app/utils/useParams.tsx
index 607448b591de..07ca37eca17b 100644
--- a/static/app/utils/useParams.tsx
+++ b/static/app/utils/useParams.tsx
@@ -63,6 +63,7 @@ type ParamKeys =
| 'searchId'
| 'sentryAppSlug'
| 'shareId'
+ | 'signedParams'
| 'snapshotId'
| 'spanSlug'
| 'step'
diff --git a/static/app/views/slackLinkTeam/index.spec.tsx b/static/app/views/slackLinkTeam/index.spec.tsx
new file mode 100644
index 000000000000..4b523af68af0
--- /dev/null
+++ b/static/app/views/slackLinkTeam/index.spec.tsx
@@ -0,0 +1,154 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {TeamFixture} from 'sentry-fixture/team';
+
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+import {selectEvent} from 'sentry-test/selectEvent';
+
+import SlackLinkTeam from 'sentry/views/slackLinkTeam';
+
+const SIGNED_PARAMS = 'signed-params';
+
+const initialRouterConfig = {
+ route: '/extensions/slack/link-team/:signedParams/',
+ location: {pathname: `/extensions/slack/link-team/${SIGNED_PARAMS}/`},
+};
+
+describe('SlackLinkTeam', () => {
+ const usOrg = OrganizationFixture({
+ slug: 'us-org',
+ name: 'US Org',
+ links: {
+ organizationUrl: 'https://us-org.sentry.io',
+ regionUrl: 'https://us.sentry.io',
+ },
+ });
+ const deOrg = OrganizationFixture({
+ id: '2',
+ slug: 'de-org',
+ name: 'DE Org',
+ links: {
+ organizationUrl: 'https://de-org.sentry.io',
+ regionUrl: 'https://de.sentry.io',
+ },
+ });
+ const team = TeamFixture({id: '10', slug: 'backend'});
+
+ function mockContext(organizations = [usOrg, deOrg]) {
+ MockApiClient.addMockResponse({
+ url: `/integrations/slack/link-team/${SIGNED_PARAMS}/`,
+ body: {channelName: 'alerts', channelUrl: 'slack://channel', organizations},
+ });
+ }
+
+ function mockTeams(organization = deOrg, teams = [team]) {
+ return MockApiClient.addMockResponse({
+ url: `/organizations/${organization.slug}/slack/link-team/${SIGNED_PARAMS}/`,
+ body: teams,
+ match: [(_url, options) => options.host === organization.links.regionUrl],
+ });
+ }
+
+ beforeEach(() => {
+ MockApiClient.clearMockResponses();
+ });
+
+ it('lists teams from the selected organization cell', async () => {
+ mockContext();
+ const teamsMock = mockTeams();
+
+ render(, {initialRouterConfig});
+
+ await selectEvent.select(await screen.findByLabelText('Organization'), 'DE Org');
+ await selectEvent.openMenu(screen.getByLabelText('Team'));
+
+ expect(teamsMock).toHaveBeenCalled();
+ expect(screen.getByText('#backend')).toBeInTheDocument();
+ });
+
+ it('lists teams from every page', async () => {
+ mockContext();
+ MockApiClient.addMockResponse({
+ url: `/organizations/${deOrg.slug}/slack/link-team/${SIGNED_PARAMS}/`,
+ body: [team],
+ match: [(_url, options) => options.host === deOrg.links.regionUrl],
+ headers: {
+ Link:
+ '; rel="previous"; results="false"; cursor="0:0:1", ' +
+ '; rel="next"; results="true"; cursor="0:100:0"',
+ },
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${deOrg.slug}/slack/link-team/${SIGNED_PARAMS}/`,
+ body: [TeamFixture({id: '11', slug: 'frontend'})],
+ match: [MockApiClient.matchQuery({cursor: '0:100:0'})],
+ });
+
+ render(, {initialRouterConfig});
+
+ await selectEvent.select(await screen.findByLabelText('Organization'), 'DE Org');
+ await selectEvent.openMenu(screen.getByLabelText('Team'));
+
+ expect(await screen.findByText('#frontend')).toBeInTheDocument();
+ expect(screen.getByText('#backend')).toBeInTheDocument();
+ });
+
+ it('shows why the teams could not be listed', async () => {
+ mockContext();
+ MockApiClient.addMockResponse({
+ url: `/organizations/${deOrg.slug}/slack/link-team/${SIGNED_PARAMS}/`,
+ statusCode: 404,
+ body: {detail: 'The Slack workspace is not installed in this organization.'},
+ });
+
+ render(, {initialRouterConfig});
+
+ await selectEvent.select(await screen.findByLabelText('Organization'), 'DE Org');
+
+ expect(
+ await screen.findByText(
+ 'The Slack workspace is not installed in this organization.'
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText('Team')).toBeDisabled();
+ });
+
+ it('selects the only organization', async () => {
+ mockContext([usOrg]);
+ mockTeams(usOrg, []);
+
+ render(, {initialRouterConfig});
+
+ expect(
+ await screen.findByText(
+ /You don't have any teams you can link in this organization/
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('explains when no organization has the workspace installed', async () => {
+ mockContext([]);
+
+ render(, {initialRouterConfig});
+
+ expect(
+ await screen.findByText(/None of your organizations have this Slack workspace/)
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/Choose a team/)).not.toBeInTheDocument();
+ });
+
+ it('shows an invalid link error', async () => {
+ MockApiClient.addMockResponse({
+ url: `/integrations/slack/link-team/${SIGNED_PARAMS}/`,
+ statusCode: 400,
+ body: {detail: 'This link is invalid or has expired. Run the command again.'},
+ });
+
+ render(, {initialRouterConfig});
+
+ expect(
+ await screen.findByText(
+ 'This link is invalid or has expired. Run the command again.'
+ )
+ ).toBeInTheDocument();
+ });
+});
diff --git a/static/app/views/slackLinkTeam/index.tsx b/static/app/views/slackLinkTeam/index.tsx
new file mode 100644
index 000000000000..138ab37abc52
--- /dev/null
+++ b/static/app/views/slackLinkTeam/index.tsx
@@ -0,0 +1,83 @@
+import {useQuery} from '@tanstack/react-query';
+
+import {Alert} from '@sentry/scraps/alert';
+import {Stack} from '@sentry/scraps/layout';
+import {Heading, Text} from '@sentry/scraps/text';
+
+import {LoadingIndicator} from 'sentry/components/loadingIndicator';
+import {NarrowLayout} from 'sentry/components/narrowLayout';
+import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle';
+import {t, tct} from 'sentry/locale';
+import {apiOptions} from 'sentry/utils/api/apiOptions';
+import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage';
+import {useParams} from 'sentry/utils/useParams';
+
+import {LinkTeamForm} from './linkTeamForm';
+import type {LinkTeamContext} from './types';
+
+function SlackLinkTeamBody({signedParams}: {signedParams: string}) {
+ const {
+ data: context,
+ isPending,
+ isError,
+ error,
+ } = useQuery({
+ ...apiOptions.as()('/integrations/slack/link-team/$signedParams/', {
+ path: {signedParams},
+ staleTime: Infinity,
+ }),
+ retry: false,
+ });
+
+ if (isPending) {
+ return ;
+ }
+
+ if (isError) {
+ return {getRequestErrorUserMessage(error)};
+ }
+
+ const heading = (
+
+ {tct('Link a team to [channel]', {channel: `#${context.channelName}`})}
+
+ );
+
+ if (context.organizations.length === 0) {
+ return (
+
+ {heading}
+
+ {t(
+ 'None of your organizations have this Slack workspace installed. Ask an organization owner or manager to install it first.'
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+ {heading}
+
+ {t(
+ 'Choose a team. Its issue alert notifications will be sent to this Slack channel.'
+ )}
+
+
+
+
+ );
+}
+
+export default function SlackLinkTeam() {
+ const {signedParams} = useParams<{signedParams: string}>();
+
+ return (
+
+
+
+
+ );
+}
diff --git a/static/app/views/slackLinkTeam/linkTeamForm.tsx b/static/app/views/slackLinkTeam/linkTeamForm.tsx
new file mode 100644
index 000000000000..23158fa6797c
--- /dev/null
+++ b/static/app/views/slackLinkTeam/linkTeamForm.tsx
@@ -0,0 +1,120 @@
+import {skipToken, useInfiniteQuery} from '@tanstack/react-query';
+import {z} from 'zod';
+
+import {Alert} from '@sentry/scraps/alert';
+import {defaultFormOptions, useScrapsForm, useStore} from '@sentry/scraps/form';
+import {Stack} from '@sentry/scraps/layout';
+
+import {t} from 'sentry/locale';
+import type {Team} from 'sentry/types/organization';
+import {useFetchAllPages} from 'sentry/utils/api/apiFetch';
+import {apiOptions} from 'sentry/utils/api/apiOptions';
+import {getRequestErrorUserMessage} from 'sentry/utils/requestError/getRequestErrorUserMessage';
+
+import type {LinkTeamOrganization} from './types';
+
+const schema = z.object({
+ organization: z
+ .string()
+ .nullable()
+ .refine(value => value !== null, t('Select an organization')),
+ team: z
+ .string()
+ .nullable()
+ .refine(value => value !== null, t('Select a team')),
+});
+
+interface LinkTeamFormProps {
+ organizations: LinkTeamOrganization[];
+ signedParams: string;
+}
+
+export function LinkTeamForm({organizations, signedParams}: LinkTeamFormProps) {
+ const defaultValues: z.input = {
+ organization: organizations.length === 1 ? (organizations[0]?.slug ?? null) : null,
+ team: null,
+ };
+
+ const form = useScrapsForm({
+ ...defaultFormOptions,
+ defaultValues,
+ validators: {onDynamic: schema},
+ });
+
+ const selectedSlug = useStore(form.store, state => state.values.organization);
+ const selectedOrganization = organizations.find(org => org.slug === selectedSlug);
+
+ const teamsQuery = useInfiniteQuery({
+ ...apiOptions.asInfinite()(
+ '/organizations/$organizationIdOrSlug/slack/link-team/$signedParams/',
+ {
+ path: selectedOrganization
+ ? {organizationIdOrSlug: selectedOrganization.slug, signedParams}
+ : skipToken,
+ host: selectedOrganization?.links.regionUrl,
+ staleTime: 0,
+ }
+ ),
+ select: ({pages}) => pages.flatMap(page => page.json),
+ retry: false,
+ });
+ useFetchAllPages({result: teamsQuery});
+ const {
+ data: teams,
+ isFetching: isFetchingTeams,
+ isError: isTeamsError,
+ error: teamsError,
+ } = teamsQuery;
+
+ const hasNoTeams = teams !== undefined && teams.length === 0;
+
+ return (
+
+
+ form.setFieldValue('team', null)}}
+ >
+ {field => (
+
+ ({
+ label: org.name,
+ value: org.slug,
+ }))}
+ />
+
+ )}
+
+
+ {field => (
+
+ ({
+ label: `#${team.slug}`,
+ value: team.id,
+ }))}
+ />
+
+ )}
+
+ {isTeamsError && (
+ {getRequestErrorUserMessage(teamsError)}
+ )}
+ {hasNoTeams && (
+
+ {t(
+ "You don't have any teams you can link in this organization. You need to be on a team to link it, and unless you're an organization owner, manager, or admin, you also need to be its team admin."
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/static/app/views/slackLinkTeam/types.tsx b/static/app/views/slackLinkTeam/types.tsx
new file mode 100644
index 000000000000..89ac7c3f2b4a
--- /dev/null
+++ b/static/app/views/slackLinkTeam/types.tsx
@@ -0,0 +1,12 @@
+import type {OrganizationSummary} from 'sentry/types/organization';
+
+export type LinkTeamOrganization = Pick<
+ OrganizationSummary,
+ 'id' | 'slug' | 'name' | 'links'
+>;
+
+export interface LinkTeamContext {
+ channelName: string;
+ channelUrl: string;
+ organizations: LinkTeamOrganization[];
+}