Repository: getsentry/sentry
Pull request: feat(flags): add settings page for webhook secrets
# index: `/settings/feature-flags/`

## Empty state
<img width="1151" alt="SCR-20241203-lyoz" src="https://github.com/user-attachments/assets/ec37b639-7c40-49dd-9c52-46029919ea5b">

## Table populated
<img width="1191" alt="SCR-20241203-oczd" src="https://github.com/user-attachments/assets/a54096be-5eda-4004-878a-32b86d3f6696">

## Error boundary
<img width="844" alt="SCR-20241203-mans" src="https://github.com/user-attachments/assets/37c3bb29-5630-4649-9750-899f26bf074a">

## Remove 

### Confirm modal
<img width="1202" alt="SCR-20241203-nwnb" src="https://github.com/user-attachments/assets/bd3a3105-f223-4c5f-9e66-58099a4a82be">

### Removal 

https://github.com/user-attachments/assets/ac0d7631-45cd-48ec-ba6d-51e99b64213e



# Add new provider page: `/settings/feature-flags/new-provider/`
<img width="1211" alt="SCR-20241203-mlan" src="https://github.com/user-attachments/assets/2052cca8-046a-481e-b4d3-77308d673232">

### New provider:


https://github.com/user-attachments/assets/0b44f83b-59cf-479c-b4d4-df2eec47a4a9

### Update existing provider:

https://github.com/user-attachments/assets/ba3327b9-dd73-44d7-8880-83a5ec81bce8

### Failed post:


https://github.com/user-attachments/assets/8bd1c07b-9c3e-4175-95fd-9a0aabe7c33d







## Onboarding sidebar post

https://github.com/user-attachments/assets/fddd2172-fd6d-4b14-a716-434527726ee8

Closes https://github.com/getsentry/sentry/issues/81545

Problem statement:
[Feature Flags] Settings page for secrets
Create org settings page, somewhat similar to auth tokens page. Only 1 secret per provider

- [x] add a "create new secret" button, could be a modal or form (form in a new URL could be easily linked to in the docs)
  - [x] pick your provider & input signing secret 
  - [x] if the provider already has a secret, can update the secret
- [x] list
  - [x] don’t show full signing secret (could show first few chars)
  - [x] show the name of the provider
  - [x] show created time
  - [x] show created by (need another hook to fetch user name)
  - [x] ability to delete `/organizations/<organization_id_or_slug>/flags/signing-secrets/<signing_secret_id>/`
- [x] need to update signing secret post endpoint in sidebar
- [x] empty state
- [x] TESTS

Hints from issue discussion:

Commit message: :sparkles: add feature flag

Patch to review:
diff --git a/static/app/components/events/featureFlags/onboardingIntegrationSection.tsx b/static/app/components/events/featureFlags/onboardingIntegrationSection.tsx
index 8c1abab9345b84..c3291c304309fc 100644
--- a/static/app/components/events/featureFlags/onboardingIntegrationSection.tsx
+++ b/static/app/components/events/featureFlags/onboardingIntegrationSection.tsx
@@ -1,13 +1,14 @@
 import {Fragment, useState} from 'react';
 import styled from '@emotion/styled';
 
+import {addErrorMessage} from 'sentry/actionCreators/indicator';
 import Alert from 'sentry/components/alert';
 import {Button} from 'sentry/components/button';
 import {PROVIDER_OPTION_TO_URLS} from 'sentry/components/events/featureFlags/utils';
 import Input from 'sentry/components/input';
 import ExternalLink from 'sentry/components/links/externalLink';
 import TextCopyInput from 'sentry/components/textCopyInput';
-import {IconCheckmark} from 'sentry/icons';
+import {IconCheckmark, IconWarning} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import useApi from 'sentry/utils/useApi';
@@ -22,17 +23,24 @@ function usePostSecret({
 }) {
   const api = useApi();
 
-  const postSecret = async (secret: string) =>
-    await api.requestPromise(
-      `/organizations/${orgSlug}/flags/hooks/provider/${provider.toLowerCase()}/signing-secret/`,
-      {
+  const postSecret = async (
+    secret: string,
+    setSecretSaved: React.Dispatch<React.SetStateAction<boolean>>
+  ) => {
+    try {
+      await api.requestPromise(`/organizations/${orgSlug}/flags/signing-secrets/`, {
         method: 'POST',
         data: {
           secret: secret,
+          provider: provider.toLowerCase(),
         },
-      }
-    );
-
+      });
+      setSecretSaved(true);
+    } catch {
+      setSecretSaved(false);
+      addErrorMessage('Error posting signing secret.');
+    }
+  };
   return {postSecret};
 }
 
@@ -44,7 +52,7 @@ export default function OnboardingIntegrationSection({
   provider: string;
 }) {
   const organization = useOrganization();
-  const [tokenSaved, setTokenSaved] = useState(false);
+  const [secretSaved, setSecretSaved] = useState(false);
   const {postSecret} = usePostSecret({provider, orgSlug: organization?.slug});
   const [secret, setSecret] = useState('');
   const [storedProvider, setStoredProvider] = useState(provider);
@@ -54,7 +62,7 @@ export default function OnboardingIntegrationSection({
     setStoredProvider(provider);
     setStoredIntegration(integration);
     setSecret('');
-    setTokenSaved(false);
+    setSecretSaved(false);
   }
 
   return (
@@ -86,6 +94,9 @@ export default function OnboardingIntegrationSection({
           <InputTitle>{t('Secret')}</InputTitle>
           <InputArea>
             <Input
+              maxLength={32}
+              minLength={32}
+              required
               value={secret}
               type="text"
               placeholder={t('Secret')}
@@ -94,17 +105,20 @@ export default function OnboardingIntegrationSection({
             <Button
               priority="default"
               onClick={() => {
-                postSecret(secret);
-                setTokenSaved(true);
+                postSecret(secret, setSecretSaved);
               }}
               disabled={secret === ''}
             >
               {t('Save Secret')}
             </Button>
           </InputArea>
-          {tokenSaved ? (
+          {secretSaved ? (
             <StyledAlert showIcon type="success" icon={<IconCheckmark />}>
-              {t('Secret token verified.')}
+              {t('Secret verified.')}
+            </StyledAlert>
+          ) : secret ? (
+            <StyledAlert showIcon type="warning" icon={<IconWarning />}>
+              {t('Make sure the secret is 32 characters long.')}
             </StyledAlert>
           ) : null}
         </SubSection>
diff --git a/static/app/routes.tsx b/static/app/routes.tsx
index 7b2603ed4d1ee0..1a4fb363043170 100644
--- a/static/app/routes.tsx
+++ b/static/app/routes.tsx
@@ -967,6 +967,21 @@ function buildRoutes() {
         name={t('Dynamic Sampling')}
         component={make(() => import('sentry/views/settings/dynamicSampling'))}
       />
+      <Route path="feature-flags/" name={t('Feature Flags')}>
+        <IndexRoute
+          component={make(() => import('sentry/views/settings/featureFlags'))}
+        />
+        <Route
+          path="new-provider/"
+          name={t('Add New Provider')}
+          component={make(
+            () =>
+              import(
+                'sentry/views/settings/featureFlags/organizationFeatureFlagsNewSecret'
+              )
+          )}
+        />
+      </Route>
     </Route>
   );
 
diff --git a/static/app/views/settings/featureFlags/index.spec.tsx b/static/app/views/settings/featureFlags/index.spec.tsx
new file mode 100644
index 00000000000000..dff5f05f85036e
--- /dev/null
+++ b/static/app/views/settings/featureFlags/index.spec.tsx
@@ -0,0 +1,190 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {SecretFixture} from 'sentry-fixture/secret';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {
+  render,
+  renderGlobalModal,
+  screen,
+  userEvent,
+  waitForElementToBeRemoved,
+} from 'sentry-test/reactTestingLibrary';
+
+import * as indicators from 'sentry/actionCreators/indicator';
+import OrganizationsStore from 'sentry/stores/organizationsStore';
+import {
+  OrganizationFeatureFlagsIndex,
+  type Secret,
+} from 'sentry/views/settings/featureFlags';
+
+describe('OrganizationFeatureFlagsIndex', function () {
+  const ENDPOINT = '/organizations/org-slug/flags/signing-secrets/';
+  const {organization, router} = initializeOrg();
+
+  const defaultProps = {
+    organization,
+    router,
+    location: router.location,
+    params: {orgId: organization.slug},
+    routes: router.routes,
+    route: {},
+    routeParams: router.params,
+  };
+
+  beforeEach(function () {
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/users/1234/',
+      body: {},
+    });
+    OrganizationsStore.addOrReplace(organization);
+  });
+
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('shows secrets', async function () {
+    const secrets: Secret[] = [
+      SecretFixture(),
+      SecretFixture({id: 2, provider: 'openfeature', secret: '456def****'}),
+    ];
+
+    const mock = MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'GET',
+      body: {data: secrets},
+    });
+
+    render(<OrganizationFeatureFlagsIndex {...defaultProps} />);
+
+    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+    // Then list
+    expect(screen.getByText('launchdarkly')).toBeInTheDocument();
+    expect(screen.getByText('openfeature')).toBeInTheDocument();
+
+    expect(screen.queryByTestId('loading-error')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+
+    expect(mock).toHaveBeenCalledTimes(1);
+    expect(mock).toHaveBeenCalledWith(ENDPOINT, expect.objectContaining({method: 'GET'}));
+  });
+
+  it('handle error when loading secrets', async function () {
+    const mock = MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'GET',
+      statusCode: 400,
+    });
+
+    render(<OrganizationFeatureFlagsIndex {...defaultProps} />);
+
+    expect(await screen.findByTestId('loading-error')).toHaveTextContent(
+      'Failed to load secrets and providers for the organization.'
+    );
+    expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+
+    expect(mock).toHaveBeenCalledTimes(1);
+  });
+
+  it('shows empty state', async function () {
+    const secrets: Secret[] = [];
+
+    MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'GET',
+      body: {data: secrets},
+    });
+
+    render(<OrganizationFeatureFlagsIndex {...defaultProps} />);
+
+    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+    expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+    expect(screen.queryByTestId('loading-error')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
+  });
+
+  describe('removing', function () {
+    it('allows to remove secrets', async function () {
+      jest.spyOn(indicators, 'addSuccessMessage');
+
+      const secrets: Secret[] = [
+        SecretFixture(),
+        SecretFixture({id: 2, provider: 'openfeature', secret: '456def****'}),
+      ];
+
+      MockApiClient.addMockResponse({
+        url: ENDPOINT,
+        method: 'GET',
+        body: {data: secrets},
+      });
+
+      const deleteMock = MockApiClient.addMockResponse({
+        url: `${ENDPOINT}1/`,
+        method: 'DELETE',
+      });
+
+      render(<OrganizationFeatureFlagsIndex {...defaultProps} />);
+      renderGlobalModal();
+
+      expect(await screen.findByText('openfeature')).toBeInTheDocument();
+      expect(screen.getByText('launchdarkly')).toBeInTheDocument();
+
+      expect(
+        screen.getByLabelText('Remove secret for launchdarkly provider')
+      ).toBeEnabled();
+
+      await userEvent.click(
+        screen.getByLabelText('Remove secret for launchdarkly provider')
+      );
+      // Confirm modal
+      await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
+
+      expect(screen.getByText('openfeature')).toBeInTheDocument();
+      expect(screen.queryByText('launchdarkly')).not.toBeInTheDocument();
+
+      expect(indicators.addSuccessMessage).toHaveBeenCalledWith(
+        'Removed the provider and signing secret for the organization.'
+      );
+
+      expect(deleteMock).toHaveBeenCalledTimes(1);
+    });
+
+    it('does not allow to remove without permission', async function () {
+      const org = OrganizationFixture({
+        access: ['org:read'],
+      });
+
+      const secrets: Secret[] = [
+        SecretFixture(),
+        SecretFixture({
+          id: 2,
+          provider: 'openfeature',
+          secret: '456def**************************',
+        }),
+      ];
+
+      const props = {
+        ...defaultProps,
+        organization: org,
+      };
+
+      MockApiClient.addMockResponse({
+        url: ENDPOINT,
+        method: 'GET',
+        body: {data: secrets},
+      });
+
+      render(<OrganizationFeatureFlagsIndex {...props} />, {organization: org});
+
+      expect(await screen.findByText('launchdarkly')).toBeInTheDocument();
+
+      expect(
+        screen.getByLabelText('Remove secret for launchdarkly provider')
+      ).toBeDisabled();
+    });
+  });
+});
diff --git a/static/app/views/settings/featureFlags/index.tsx b/static/app/views/settings/featureFlags/index.tsx
new file mode 100644
index 00000000000000..45fb2853a09d7e
--- /dev/null
+++ b/static/app/views/settings/featureFlags/index.tsx
@@ -0,0 +1,201 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import Access from 'sentry/components/acl/access';
+import {LinkButton} from 'sentry/components/button';
+import ExternalLink from 'sentry/components/links/externalLink';
+import LoadingError from 'sentry/components/loadingError';
+import {PanelTable} from 'sentry/components/panels/panelTable';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t, tct} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
+import {
+  setApiQueryData,
+  useApiQuery,
+  useMutation,
+  useQueryClient,
+} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
+import withOrganization from 'sentry/utils/withOrganization';
+import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
+import TextBlock from 'sentry/views/settings/components/text/textBlock';
+import {OrganizationFeatureFlagsProviderRow} from 'sentry/views/settings/featureFlags/organizationFeatureFlagsProviderRow';
+
+export type Secret = {
+  createdAt: string;
+  createdBy: number;
+  id: number;
+  provider: string;
+  secret: string;
+};
+
+type FetchSecretResponse = {data: Secret[]};
+
+type FetchSecretParameters = {
+  orgSlug: string;
+};
+
+type RemoveSecretQueryVariables = {
+  id: number;
+};
+
+export const makeFetchSecretQueryKey = ({orgSlug}: FetchSecretParameters) =>
+  [`/organizations/${orgSlug}/flags/signing-secrets/`] as const;
+
+function SecretList({
+  secretList,
+  isRemoving,
+  removeSecret,
+}: {
+  isRemoving: boolean;
+  secretList: Secret[];
+  removeSecret?: (data: {id: number}) => void;
+}) {
+  return (
+    <Fragment>
+      {secretList.map(secret => {
+        return (
+          <OrganizationFeatureFlagsProviderRow
+            key={secret.id}
+            secret={secret}
+            isRemoving={isRemoving}
+            removeSecret={removeSecret ? () => removeSecret({id: secret.id}) : undefined}
+          />
+        );
+      })}
+    </Fragment>
+  );
+}
+
+export function OrganizationFeatureFlagsIndex({
+  organization,
+}: {
+  organization: Organization;
+}) {
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  const {
+    isPending,
+    isError,
+    data: secretList,
+    refetch: refetchSecretList,
+  } = useApiQuery<FetchSecretResponse>(
+    makeFetchSecretQueryKey({orgSlug: organization.slug}),
+    {
+      staleTime: Infinity,
+    }
+  );
+
+  const {mutate: handleRemoveSecret, isPending: isRemoving} = useMutation<
+    {},
+    RequestError,
+    RemoveSecretQueryVariables
+  >({
+    mutationFn: ({id}) =>
+      api.requestPromise(
+        `/organizations/${organization.slug}/flags/signing-secrets/${id}/`,
+        {
+          method: 'DELETE',
+        }
+      ),
+
+    onSuccess: (_data, {id}) => {
+      addSuccessMessage(
+        t('Removed the provider and signing secret for the organization.')
+      );
+
+      setApiQueryData(
+        queryClient,
+        makeFetchSecretQueryKey({orgSlug: organization.slug}),
+        (oldData: FetchSecretResponse) => {
+          return {data: oldData.data.filter(oldSecret => oldSecret.id !== id)};
+        }
+      );
+    },
+    onError: error => {
+      const message = t('Failed to remove the provider or signing secret.');
+      handleXhrErrorResponse(message, error);
+      addErrorMessage(message);
+    },
+  });
+
+  const addNewProvider = (
+    <LinkButton
+      priority="primary"
+      size="sm"
+      to={`/settings/${organization.slug}/feature-flags/new-provider/`}
+      data-test-id="create-new-provider"
+    >
+      {t('Add New Provider')}
+    </LinkButton>
+  );
+
+  return (
+    <Access access={['org:write']}>
+      {({hasAccess}) => (
+        <Fragment>
+          <SentryDocumentTitle title={t('Feature Flags')} />
+          <SettingsPageHeader title={t('Feature Flags')} action={addNewProvider} />
+
+          <TextBlock>
+            {t(
+              'Integrating Sentry with your feature flag provider enables Sentry to correlate feature flag changes with new error events and mark certain changes as suspicious. This page lists the webhooks you have set up with external providers. Note that each provider can only have one associated signing secret.'
+            )}
+          </TextBlock>
+          <TextBlock>
+            {tct(
+              'Learn more about how to interact with feature flag insights within the Sentry UI by reading the [link:documentation].',
+              {
+                link: (
+                  <ExternalLink href="https://docs.sentry.io/product/issues/issue-details/#feature-flags" />
+                ),
+              }
+            )}
+          </TextBlock>
+
+          <ResponsivePanelTable
+            isLoading={isPending || isError}
+            isEmpty={!isPending && !secretList?.data?.length}
+            loader={
+              isError ? (
+                <LoadingError
+                  message={t(
+                    'Failed to load secrets and providers for the organization.'
+                  )}
+                  onRetry={refetchSecretList}
+                />
+              ) : undefined
+            }
+            emptyMessage={t("You haven't linked any providers yet.")}
+            headers={[t('Provider'), t('Created'), t('Created by'), '']}
+          >
+            {!isError && !isPending && !!secretList?.data?.length && (
+              <SecretList
+                secretList={secretList.data}
+                isRemoving={isRemoving}
+                removeSecret={hasAccess ? handleRemoveSecret : undefined}
+              />
+            )}
+          </ResponsivePanelTable>
+        </Fragment>
+      )}
+    </Access>
+  );
+}
+
+export default withOrganization(OrganizationFeatureFlagsIndex);
+
+const ResponsivePanelTable = styled(PanelTable)`
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: 1fr 1fr;
+
+    > *:nth-child(4n + 2),
+    *:nth-child(4n + 3) {
+      display: none;
+    }
+  }
+`;
diff --git a/static/app/views/settings/featureFlags/newSecretHandler.spec.tsx b/static/app/views/settings/featureFlags/newSecretHandler.spec.tsx
new file mode 100644
index 00000000000000..8abd1303ea6976
--- /dev/null
+++ b/static/app/views/settings/featureFlags/newSecretHandler.spec.tsx
@@ -0,0 +1,12 @@
+import {SecretFixture} from 'sentry-fixture/secret';
+
+import {render} from 'sentry-test/reactTestingLibrary';
+
+import NewSecretHandler from 'sentry/views/settings/featureFlags/newSecretHandler';
+
+describe('NewSecretHandler', () => {
+  it('renders', () => {
+    const callback = ({}) => {};
+    render(<NewSecretHandler secret={SecretFixture().secret} handleGoBack={callback} />);
+  });
+});
diff --git a/static/app/views/settings/featureFlags/newSecretHandler.tsx b/static/app/views/settings/featureFlags/newSecretHandler.tsx
new file mode 100644
index 00000000000000..27925a5e92a07a
--- /dev/null
+++ b/static/app/views/settings/featureFlags/newSecretHandler.tsx
@@ -0,0 +1,68 @@
+import type {MouseEventHandler} from 'react';
+import styled from '@emotion/styled';
+
+import Alert from 'sentry/components/alert';
+import {Button} from 'sentry/components/button';
+import FieldGroup from 'sentry/components/forms/fieldGroup';
+import PanelItem from 'sentry/components/panels/panelItem';
+import TextCopyInput from 'sentry/components/textCopyInput';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+
+function NewSecretHandler({
+  secret,
+  handleGoBack,
+}: {
+  handleGoBack: MouseEventHandler;
+  secret: string;
+}) {
+  return (
+    <div>
+      <Alert type="warning" showIcon system>
+        {t('The secret has been posted.')}
+      </Alert>
+
+      <PanelItem>
+        <InputWrapper>
+          <FieldGroupNoPadding
+            label={t('Secret')}
+            help={t(
+              'The secret should not be shared and will not be retrievable once you leave this page.'
+            )}
+            inline
+            flexibleControlStateSize
+          >
+            <TextCopyInput aria-label={t('Secret')}>{secret}</TextCopyInput>
+          </FieldGroupNoPadding>
+        </InputWrapper>
+      </PanelItem>
+
+      <PanelItem>
+        <ButtonWrapper>
+          <Button onClick={handleGoBack} priority="primary">
+            {t('Done')}
+          </Button>
+        </ButtonWrapper>
+      </PanelItem>
+    </div>
+  );
+}
+
+const InputWrapper = styled('div')`
+  flex: 1;
+`;
+
+const FieldGroupNoPadding = styled(FieldGroup)`
+  padding: 0;
+`;
+
+const ButtonWrapper = styled('div')`
+  margin-left: auto;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  font-size: ${p => p.theme.fontSizeSmall};
+  gap: ${space(1)};
+`;
+
+export default NewSecretHandler;
diff --git a/static/app/views/settings/featureFlags/organizationFeatureFlagsNewSecret.spec.tsx b/static/app/views/settings/featureFlags/organizationFeatureFlagsNewSecret.spec.tsx
new file mode 100644
index 00000000000000..6e3242010cd856
--- /dev/null
+++ b/static/app/views/settings/featureFlags/organizationFeatureFlagsNewSecret.spec.tsx
@@ -0,0 +1,99 @@
+import {SecretFixture} from 'sentry-fixture/secret';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import * as indicators from 'sentry/actionCreators/indicator';
+import OrganizationsStore from 'sentry/stores/organizationsStore';
+import OrganizationFeatureFlagsNewSecet from 'sentry/views/settings/featureFlags/organizationFeatureFlagsNewSecret';
+
+describe('OrganizationFeatureFlagsNewSecret', function () {
+  const ENDPOINT = '/organizations/org-slug/flags/signing-secrets/';
+  const {organization, router} = initializeOrg();
+
+  const defaultProps = {
+    organization,
+    router,
+    location: router.location,
+    params: {orgId: organization.slug},
+    routes: router.routes,
+    route: {},
+    routeParams: router.params,
+  };
+
+  beforeEach(function () {
+    OrganizationsStore.addOrReplace(organization);
+  });
+
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('can create secret', async function () {
+    render(<OrganizationFeatureFlagsNewSecet {...defaultProps} />);
+
+    const mock = MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'POST',
+    });
+
+    await userEvent.type(screen.getByLabelText('Secret'), SecretFixture().secret);
+    const providerDropdown = screen.getByRole('textbox', {
+      name: 'Provider',
+    });
+    await userEvent.click(providerDropdown);
+    await userEvent.click(screen.getByRole('menuitemradio', {name: 'LaunchDarkly'}));
+    await userEvent.click(screen.getByRole('button', {name: 'Add Provider'}));
+
+    expect(screen.getByRole('textbox', {name: 'Secret'})).toHaveValue(
+      SecretFixture().secret
+    );
+
+    expect(mock).toHaveBeenCalledWith(
+      ENDPOINT,
+      expect.objectContaining({
+        data: {
+          provider: 'launchdarkly',
+          secret: SecretFixture().secret,
+        },
+      })
+    );
+  });
+
+  it('handles API errors when creating secret', async function () {
+    jest.spyOn(indicators, 'addErrorMessage');
+
+    render(<OrganizationFeatureFlagsNewSecet {...defaultProps} />);
+
+    const mock = MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'POST',
+      body: {
+        detail: 'Test API error occurred.',
+      },
+      statusCode: 403,
+    });
+
+    await userEvent.type(screen.getByLabelText('Secret'), SecretFixture().secret);
+    const providerDropdown = screen.getByRole('textbox', {
+      name: 'Provider',
+    });
+    await userEvent.click(providerDropdown);
+    await userEvent.click(screen.getByRole('menuitemradio', {name: 'LaunchDarkly'}));
+    await userEvent.click(screen.getByRole('button', {name: 'Add Provider'}));
+
+    expect(indicators.addErrorMessage).toHaveBeenCalledWith(
+      'Failed to add provider or secret.'
+    );
+
+    expect(mock).toHaveBeenCalledWith(
+      ENDPOINT,
+      expect.objectContaining({
+        data: {
+          provider: 'launchdarkly',
+          secret: SecretFixture().secret,
+        },
+      })
+    );
+  });
+});
diff --git a/static/app/views/settings/featureFlags/organizationFeatureFlagsNewSecret.tsx b/static/app/views/settings/featureFlags/organizationFeatureFlagsNewSecret.tsx
new file mode 100644
index 00000000000000..ea943a7f34be2f
--- /dev/null
+++ b/static/app/views/settings/featureFlags/organizationFeatureFlagsNewSecret.tsx
@@ -0,0 +1,178 @@
+import {useCallback, useState} from 'react';
+
+import {
+  addErrorMessage,
+  addLoadingMessage,
+  addSuccessMessage,
+} from 'sentry/actionCreators/indicator';
+import SelectField from 'sentry/components/forms/fields/selectField';
+import TextField from 'sentry/components/forms/fields/textField';
+import Form from 'sentry/components/forms/form';
+import ExternalLink from 'sentry/components/links/externalLink';
+import Panel from 'sentry/components/panels/panel';
+import PanelBody from 'sentry/components/panels/panelBody';
+import PanelHeader from 'sentry/components/panels/panelHeader';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t, tct} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import {browserHistory} from 'sentry/utils/browserHistory';
+import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
+import {useMutation, useQueryClient} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import normalizeUrl from 'sentry/utils/url/normalizeUrl';
+import useApi from 'sentry/utils/useApi';
+import withOrganization from 'sentry/utils/withOrganization';
+import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
+import TextBlock from 'sentry/views/settings/components/text/textBlock';
+import {makeFetchSecretQueryKey} from 'sentry/views/settings/featureFlags';
+import NewSecretHandler from 'sentry/views/settings/featureFlags/newSecretHandler';
+
+type CreateSecretQueryVariables = {
+  provider: string;
+  secret: string;
+};
+
+type CreateSecretResponse = string;
+
+function NewProviderForm({
+  organization,
+  onCreatedSecret,
+}: {
+  onCreatedSecret: (secret: string) => void;
+  organization: Organization;
+}) {
+  const initialData = {
+    provider: '',
+    secret: '',
+  };
+
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  const handleGoBack = useCallback(() => {
+    browserHistory.push(normalizeUrl(`/settings/${organization.slug}/feature-flags/`));
+  }, [organization.slug]);
+
+  const {mutate: submitSecret} = useMutation<
+    CreateSecretResponse,
+    RequestError,
+    CreateSecretQueryVariables
+  >({
+    mutationFn: ({provider, secret}) => {
+      addLoadingMessage();
+      return api.requestPromise(
+        `/organizations/${organization.slug}/flags/signing-secrets/`,
+        {
+          method: 'POST',
+          data: {
+            provider,
+            secret,
+          },
+        }
+      );
+    },
+
+    onSuccess: (_response, {secret}) => {
+      addSuccessMessage(t('Added provider and secret.'));
+      onCreatedSecret(secret);
+      queryClient.invalidateQueries({
+        queryKey: makeFetchSecretQueryKey({orgSlug: organization.slug}),
+      });
+    },
+    onError: error => {
+      const message = t('Failed to add provider or secret.');
+      handleXhrErrorResponse(message, error);
+      addErrorMessage(message);
+    },
+  });
+
+  return (
+    <Form
+      apiMethod="POST"
+      initialData={initialData}
+      apiEndpoint={`/organizations/${organization.slug}/flags/signing-secret`}
+      onSubmit={({provider, secret}) => {
+        submitSecret({
+          provider,
+          secret,
+        });
+      }}
+      onCancel={handleGoBack}
+      submitLabel={t('Add Provider')}
+      requireChanges
+    >
+      <SelectField
+        required
+        label={t('Provider')}
+        name="provider"
+        options={[{value: 'launchdarkly', label: 'LaunchDarkly'}]}
+        placeholder={t('Select one')}
+        help={t(
+          'If you have already linked this provider, pasting a new secret will override the existing secret.'
+        )}
+      />
+      <TextField
+        name="secret"
+        label={t('Secret')}
+        maxLength={32}
+        minLength={32}
+        required
+        help={t(
+          'Paste the signing secret given by your provider when creating the webhook.'
+        )}
+      />
+    </Form>
+  );
+}
+
+export function OrganizationFeatureFlagsNewSecet({
+  organization,
+}: {
+  organization: Organization;
+}) {
+  const [newSecret, setNewSecret] = useState<string | null>(null);
+
+  const handleGoBack = useCallback(() => {
+    browserHistory.push(normalizeUrl(`/settings/${organization.slug}/feature-flags/`));
+  }, [organization.slug]);
+
+  return (
+    <div>
+      <SentryDocumentTitle title={t('Add New Provider')} />
+      <SettingsPageHeader title={t('Add New Provider')} />
+
+      <TextBlock>
+        {t(
+          'Integrating Sentry with your feature flag provider enables Sentry to correlate feature flag changes with new error events and mark certain changes as suspicious. This page lists the webhooks you have set up with external providers. Note that each provider can only have one associated signing secret.'
+        )}
+      </TextBlock>
+      <TextBlock>
+        {tct(
+          'Learn more about how to interact with feature flag insights within the Sentry UI by reading the [link:documentation].',
+          {
+            link: (
+              <ExternalLink href="https://docs.sentry.io/product/issues/issue-details/#feature-flags" />
+            ),
+          }
+        )}
+      </TextBlock>
+
+      <Panel>
+        <PanelHeader>{t('Add New Provider')}</PanelHeader>
+
+        <PanelBody>
+          {newSecret ? (
+            <NewSecretHandler handleGoBack={handleGoBack} secret={newSecret} />
+          ) : (
+            <NewProviderForm
+              organization={organization}
+              onCreatedSecret={(secret: string) => setNewSecret(secret)}
+            />
+          )}
+        </PanelBody>
+      </Panel>
+    </div>
+  );
+}
+
+export default withOrganization(OrganizationFeatureFlagsNewSecet);
diff --git a/static/app/views/settings/featureFlags/organizationFeatureFlagsProviderRow.spec.tsx b/static/app/views/settings/featureFlags/organizationFeatureFlagsProviderRow.spec.tsx
new file mode 100644
index 00000000000000..1ce8e63c7daeee
--- /dev/null
+++ b/static/app/views/settings/featureFlags/organizationFeatureFlagsProviderRow.spec.tsx
@@ -0,0 +1,98 @@
+import {SecretFixture} from 'sentry-fixture/secret';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {
+  render,
+  renderGlobalModal,
+  screen,
+  userEvent,
+} from 'sentry-test/reactTestingLibrary';
+
+import OrganizationsStore from 'sentry/stores/organizationsStore';
+import type {Secret} from 'sentry/views/settings/featureFlags';
+import {OrganizationFeatureFlagsProviderRow} from 'sentry/views/settings/featureFlags/organizationFeatureFlagsProviderRow';
+
+describe('OrganizationFeatureFlagsProviderRow', function () {
+  const {organization, router} = initializeOrg();
+
+  const removeSecret = jest.fn();
+
+  const secret: Secret = SecretFixture();
+
+  const defaultProps = {
+    organization,
+    isRemoving: false,
+    secret,
+    removeSecret,
+    router,
+    location: router.location,
+    params: {orgId: organization.slug},
+    routes: router.routes,
+    route: {},
+    routeParams: router.params,
+  };
+
+  beforeEach(function () {
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/users/1234/',
+      body: {},
+    });
+    OrganizationsStore.addOrReplace(organization);
+  });
+
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('shows secret and provider name', function () {
+    render(<OrganizationFeatureFlagsProviderRow {...defaultProps} />);
+
+    expect(screen.getByLabelText('Secret preview')).toHaveTextContent('123abc*****');
+    expect(screen.getByText('launchdarkly')).toBeInTheDocument();
+  });
+
+  describe('removing', function () {
+    it('does not allow to remove without access', function () {
+      const props = {
+        ...defaultProps,
+        removeSecret: undefined,
+      };
+
+      render(<OrganizationFeatureFlagsProviderRow {...props} />);
+
+      expect(
+        screen.getByRole('button', {name: 'Remove secret for launchdarkly provider'})
+      ).toBeDisabled();
+    });
+
+    it('allows to remove', async function () {
+      render(<OrganizationFeatureFlagsProviderRow {...defaultProps} />);
+      renderGlobalModal();
+
+      expect(
+        screen.getByRole('button', {name: 'Remove secret for launchdarkly provider'})
+      ).toBeEnabled();
+
+      await userEvent.click(
+        screen.getByRole('button', {name: 'Remove secret for launchdarkly provider'})
+      );
+      // Confirm modal
+      await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
+
+      expect(removeSecret).toHaveBeenCalledWith(1); // the id of the secret
+    });
+
+    it('does not allow to remove while removing in progress', function () {
+      const props = {
+        ...defaultProps,
+        isRemoving: true,
+      };
+
+      render(<OrganizationFeatureFlagsProviderRow {...props} />);
+
+      expect(
+        screen.getByRole('button', {name: 'Remove secret for launchdarkly provider'})
+      ).toBeDisabled();
+    });
+  });
+});
diff --git a/static/app/views/settings/featureFlags/organizationFeatureFlagsProviderRow.tsx b/static/app/views/settings/featureFlags/organizationFeatureFlagsProviderRow.tsx
new file mode 100644
index 00000000000000..4a1a249212d53d
--- /dev/null
+++ b/static/app/views/settings/featureFlags/organizationFeatureFlagsProviderRow.tsx
@@ -0,0 +1,91 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {Button} from 'sentry/components/button';
+import Confirm from 'sentry/components/confirm';
+import {Flex} from 'sentry/components/container/flex';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import TimeSince from 'sentry/components/timeSince';
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconSubtract} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Secret} from 'sentry/views/settings/featureFlags';
+import useUserFromId from 'sentry/views/settings/featureFlags/useUserFromId';
+
+export function OrganizationFeatureFlagsProviderRow({
+  isRemoving,
+  secret,
+  removeSecret,
+}: {
+  isRemoving: boolean;
+  secret: Secret;
+  removeSecret?: (id: number) => void;
+}) {
+  const {isPending: isUserPending, data: user} = useUserFromId({id: secret.createdBy});
+
+  return (
+    <Fragment>
+      <div>
+        <div>{secret.provider}</div>
+        <SecretPreview aria-label={t('Secret preview')}>{secret.secret}</SecretPreview>
+      </div>
+
+      <DateTime>
+        <Fragment>
+          <TimeSince date={secret.createdAt} />
+        </Fragment>
+      </DateTime>
+
+      <Flex align="center">{isUserPending ? <LoadingIndicator mini /> : user?.name}</Flex>
+
+      <Actions>
+        <Tooltip
+          title={t(
+            'You must be an organization owner, manager or admin to remove a secret.'
+          )}
+          disabled={!!removeSecret}
+        >
+          <Confirm
+            disabled={!removeSecret || isRemoving}
+            onConfirm={removeSecret ? () => removeSecret(secret.id) : undefined}
+            message={t(
+              'Are you sure you want to remove the secret for %s provider? It will not be usable anymore, and this cannot be undone.',
+              secret.provider
+            )}
+          >
+            <Button
+              size="sm"
+              disabled={isRemoving || !removeSecret}
+              aria-label={t('Remove secret for %s provider', secret.provider)}
+              icon={
+                isRemoving ? (
+                  <LoadingIndicator mini />
+                ) : (
+                  <IconSubtract isCircled size="xs" />
+                )
+              }
+            >
+              {t('Remove')}
+            </Button>
+          </Confirm>
+        </Tooltip>
+      </Actions>
+    </Fragment>
+  );
+}
+
+const Actions = styled('div')`
+  display: flex;
+  justify-content: flex-end;
+`;
+
+const DateTime = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(0.5)};
+`;
+
+const SecretPreview = styled('div')`
+  color: ${p => p.theme.gray300};
+`;
diff --git a/static/app/views/settings/featureFlags/useUserFromId.tsx b/static/app/views/settings/featureFlags/useUserFromId.tsx
new file mode 100644
index 00000000000000..d497ee10858548
--- /dev/null
+++ b/static/app/views/settings/featureFlags/useUserFromId.tsx
@@ -0,0 +1,19 @@
+import type {User} from '@sentry/types';
+
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+
+export default function UseUserFromId({id}: {id: number}) {
+  const organization = useOrganization();
+
+  const {isPending, isError, data} = useApiQuery<User>(
+    [`/organizations/${organization.slug}/users/${id}/`],
+    {staleTime: 0}
+  );
+
+  if (isError) {
+    return {isPending: false, data: {name: id}};
+  }
+
+  return {isPending, data};
+}
diff --git a/static/app/views/settings/organization/navigationConfiguration.tsx b/static/app/views/settings/organization/navigationConfiguration.tsx
index 9b7e09cd70f475..e404844402f106 100644
--- a/static/app/views/settings/organization/navigationConfiguration.tsx
+++ b/static/app/views/settings/organization/navigationConfiguration.tsx
@@ -118,6 +118,14 @@ const organizationNavigation: NavigationSection[] = [
         show: ({organization}) =>
           !!organization && hasDynamicSamplingCustomFeature(organization),
       },
+      {
+        path: `${organizationSettingsPathPrefix}/feature-flags/`,
+        title: t('Feature Flags'),
+        description: t('Set up your provider webhooks'),
+        badge: () => <FeatureBadge type="beta" />,
+        show: ({organization}) =>
+          !!organization && organization.features.includes('feature-flag-ui'),
+      },
     ],
   },
   {
diff --git a/tests/js/fixtures/secret.ts b/tests/js/fixtures/secret.ts
new file mode 100644
index 00000000000000..b834b432c26bf7
--- /dev/null
+++ b/tests/js/fixtures/secret.ts
@@ -0,0 +1,15 @@
+import { Secret } from "sentry/views/settings/featureFlags";
+
+
+export function SecretFixture(
+  params: Partial<Secret> = {}
+): Secret {
+  return {
+    id: 1,
+    provider: 'launchdarkly',
+    secret: '123abc**************************',
+    createdAt: "2024-12-12T00:00:00+00:00",
+    createdBy: 1234,
+    ...params,
+  };
+}


Code review comments:
diff_hunk: @@ -0,0 +1,201 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import Access from 'sentry/components/acl/access';
+import {LinkButton} from 'sentry/components/button';
+import ExternalLink from 'sentry/components/links/externalLink';
+import LoadingError from 'sentry/components/loadingError';
+import {PanelTable} from 'sentry/components/panels/panelTable';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t, tct} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
+import {
+  setApiQueryData,
+  useApiQuery,
+  useMutation,
+  useQueryClient,
+} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
+import withOrganization from 'sentry/utils/withOrganization';
+import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
+import TextBlock from 'sentry/views/settings/components/text/textBlock';
+import {OrganizationFeatureFlagsProviderRow} from 'sentry/views/settings/featureFlags/organizationFeatureFlagsProviderRow';
+
+export type Secret = {
+  createdAt: string;
+  createdBy: number;
+  id: number;
+  provider: string;
+  secret: string;
+};
+
+type FetchSecretResponse = {data: Secret[]};
+
+type FetchSecretParameters = {
+  orgSlug: string;
+};
+
+type RemoveSecretQueryVariables = {
+  id: number;
+};
+
+export const makeFetchSecretQueryKey = ({orgSlug}: FetchSecretParameters) =>
+  [`/organizations/${orgSlug}/flags/signing-secrets/`] as const;
+
+function SecretList({
+  secretList,
+  isRemoving,
+  removeSecret,
+}: {
+  isRemoving: boolean;
+  secretList: Secret[];
+  removeSecret?: (data: {id: number}) => void;
+}) {
+  return (
+    <Fragment>
+      {secretList.map(secret => {
+        return (
+          <OrganizationFeatureFlagsProviderRow
+            key={secret.id}
+            secret={secret}
+            isRemoving={isRemoving}
+            removeSecret={removeSecret ? () => removeSecret({id: secret.id}) : undefined}
+          />
+        );
+      })}
+    </Fragment>
+  );
+}
+
+export function OrganizationFeatureFlagsIndex({
+  organization,
+}: {
+  organization: Organization;
+}) {
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  const {
+    isPending,
+    isError,
+    data: secretList,
+    refetch: refetchSecretList,
+  } = useApiQuery<FetchSecretResponse>(
+    makeFetchSecretQueryKey({orgSlug: organization.slug}),
+    {
+      staleTime: Infinity,
+    }
+  );
+
+  const {mutate: handleRemoveSecret, isPending: isRemoving} = useMutation<
+    {},
+    RequestError,
+    RemoveSecretQueryVariables
+  >({
+    mutationFn: ({id}) =>
+      api.requestPromise(
+        `/organizations/${organization.slug}/flags/signing-secrets/${id}/`,
+        {
+          method: 'DELETE',
+        }
+      ),
+
+    onSuccess: (_data, {id}) => {
+      addSuccessMessage(
+        t('Removed the provider and signing secret for the organization.')
+      );
+
+      setApiQueryData(
+        queryClient,
+        makeFetchSecretQueryKey({orgSlug: organization.slug}),
+        (oldData: FetchSecretResponse) => {
+          return {data: oldData.data.filter(oldSecret => oldSecret.id !== id)};
+        }
+      );
+    },
+    onError: error => {
+      const message = t('Failed to remove the provider or signing secret.');
+      handleXhrErrorResponse(message, error);
+      addErrorMessage(message);
+    },
+  });
+
+  const addNewProvider = (
+    <LinkButton
+      priority="primary"
+      size="sm"
+      to={`/settings/${organization.slug}/feature-flags/new-provider/`}
+      data-test-id="create-new-provider"
+    >
+      {t('Add New Provider')}
+    </LinkButton>
+  );
+
+  return (
+    <Access access={['org:write']}>
+      {({hasAccess}) => (
+        <Fragment>
+          <SentryDocumentTitle title={t('Feature Flags')} />
+          <SettingsPageHeader title={t('Feature Flags')} action={addNewProvider} />
+
+          <TextBlock>
+            {t(
+              'Integrating Sentry with your feature flag provider enables Sentry to correlate feature flag changes with new error events and mark certain changes as suspicious. This page lists the webhooks you have set up with external providers. Note that each provider can only have one associated signing secret.'
+            )}
+          </TextBlock>
+          <TextBlock>
+            {tct(
+              'Learn more about how to interact with feature flag insights within the Sentry UI by reading the [link:documentation].',
+              {
+                link: (
+                  <ExternalLink href="https://docs.sentry.io/product/issues/issue-details/#feature-flags" />
+                ),
+              }
+            )}
+          </TextBlock>
+
+          <ResponsivePanelTable
+            isLoading={isPending || isError}
+            isEmpty={!isPending && !secretList?.data?.length}
+            loader={
+              isError ? (
+                <LoadingError
+                  message={t(
+                    'Failed to load secrets and providers for the organization.'
+                  )}
+                  onRetry={refetchSecretList}
+                />
+              ) : undefined
+            }
+            emptyMessage={t("You haven't linked any providers yet.")}
+            headers={[t('Provider'), t('Created'), t('Created by'), '']}
+          >
+            {!isError && !isPending && !!secretList?.data?.length && (
+              <SecretList
+                secretList={secretList.data}
+                isRemoving={isRemoving}
+                removeSecret={hasAccess ? handleRemoveSecret : undefined}
+              />
+            )}
+          </ResponsivePanelTable>
+        </Fragment>
+      )}
+    </Access>
+  );
+}
+
+export default withOrganization(OrganizationFeatureFlagsIndex);
+
+const ResponsivePanelTable = styled(PanelTable)`
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: 1fr 1fr;
+
+    > *:nth-child(4n + 2),
+    *:nth-child(4n + 3) {
path: static/app/views/settings/featureFlags/index.tsx
text: @user1:
```suggestion
    > *:nth-child(4n + 2),
    > *:nth-child(4n + 3) {
```

diff_hunk: @@ -0,0 +1,201 @@
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import Access from 'sentry/components/acl/access';
+import {LinkButton} from 'sentry/components/button';
+import ExternalLink from 'sentry/components/links/externalLink';
+import LoadingError from 'sentry/components/loadingError';
+import {PanelTable} from 'sentry/components/panels/panelTable';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t, tct} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
+import {
+  setApiQueryData,
+  useApiQuery,
+  useMutation,
+  useQueryClient,
+} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
+import withOrganization from 'sentry/utils/withOrganization';
+import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
+import TextBlock from 'sentry/views/settings/components/text/textBlock';
+import {OrganizationFeatureFlagsProviderRow} from 'sentry/views/settings/featureFlags/organizationFeatureFlagsProviderRow';
+
+export type Secret = {
+  createdAt: string;
+  createdBy: number;
+  id: number;
+  provider: string;
+  secret: string;
+};
+
+type FetchSecretResponse = {data: Secret[]};
+
+type FetchSecretParameters = {
+  orgSlug: string;
+};
+
+type RemoveSecretQueryVariables = {
+  id: number;
+};
+
+export const makeFetchSecretQueryKey = ({orgSlug}: FetchSecretParameters) =>
+  [`/organizations/${orgSlug}/flags/signing-secrets/`] as const;
+
+function SecretList({
+  secretList,
+  isRemoving,
+  removeSecret,
+}: {
+  isRemoving: boolean;
+  secretList: Secret[];
+  removeSecret?: (data: {id: number}) => void;
+}) {
+  return (
+    <Fragment>
+      {secretList.map(secret => {
+        return (
+          <OrganizationFeatureFlagsProviderRow
+            key={secret.id}
+            secret={secret}
+            isRemoving={isRemoving}
+            removeSecret={removeSecret ? () => removeSecret({id: secret.id}) : undefined}
+          />
+        );
+      })}
+    </Fragment>
+  );
+}
+
+export function OrganizationFeatureFlagsIndex({
+  organization,
+}: {
+  organization: Organization;
+}) {
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  const {
+    isPending,
+    isError,
+    data: secretList,
+    refetch: refetchSecretList,
+  } = useApiQuery<FetchSecretResponse>(
+    makeFetchSecretQueryKey({orgSlug: organization.slug}),
+    {
+      staleTime: Infinity,
+    }
+  );
+
+  const {mutate: handleRemoveSecret, isPending: isRemoving} = useMutation<
+    {},
+    RequestError,
+    RemoveSecretQueryVariables
+  >({
+    mutationFn: ({id}) =>
+      api.requestPromise(
+        `/organizations/${organization.slug}/flags/signing-secrets/${id}/`,
+        {
+          method: 'DELETE',
+        }
+      ),
+
+    onSuccess: (_data, {id}) => {
+      addSuccessMessage(
+        t('Removed the provider and signing secret for the organization.')
+      );
+
+      setApiQueryData(
+        queryClient,
+        makeFetchSecretQueryKey({orgSlug: organization.slug}),
+        (oldData: FetchSecretResponse) => {
+          return {data: oldData.data.filter(oldSecret => oldSecret.id !== id)};
+        }
+      );
+    },
+    onError: error => {
+      const message = t('Failed to remove the provider or signing secret.');
+      handleXhrErrorResponse(message, error);
+      addErrorMessage(message);
+    },
+  });
+
+  const addNewProvider = (
+    <LinkButton
+      priority="primary"
+      size="sm"
+      to={`/settings/${organization.slug}/feature-flags/new-provider/`}
+      data-test-id="create-new-provider"
+    >
+      {t('Add New Provider')}
+    </LinkButton>
+  );
+
+  return (
+    <Access access={['org:write']}>
+      {({hasAccess}) => (
+        <Fragment>
+          <SentryDocumentTitle title={t('Feature Flags')} />
+          <SettingsPageHeader title={t('Feature Flags')} action={addNewProvider} />
+
+          <TextBlock>
+            {t(
+              'Integrating Sentry with your feature flag provider enables Sentry to correlate feature flag changes with new error events and mark certain changes as suspicious. This page lists the webhooks you have set up with external providers. Note that each provider can only have one associated signing secret.'
+            )}
+          </TextBlock>
+          <TextBlock>
+            {tct(
+              'Learn more about how to interact with feature flag insights within the Sentry UI by reading the [link:documentation].',
+              {
+                link: (
+                  <ExternalLink href="https://docs.sentry.io/product/issues/issue-details/#feature-flags" />
+                ),
+              }
+            )}
+          </TextBlock>
+
+          <ResponsivePanelTable
+            isLoading={isPending || isError}
+            isEmpty={!isPending && !secretList?.data?.length}
+            loader={
+              isError ? (
+                <LoadingError
+                  message={t(
+                    'Failed to load secrets and providers for the organization.'
+                  )}
+                  onRetry={refetchSecretList}
+                />
+              ) : undefined
+            }
+            emptyMessage={t("You haven't linked any providers yet.")}
+            headers={[t('Provider'), t('Created'), t('Created by'), '']}
+          >
+            {!isError && !isPending && !!secretList?.data?.length && (
+              <SecretList
+                secretList={secretList.data}
+                isRemoving={isRemoving}
+                removeSecret={hasAccess ? handleRemoveSecret : undefined}
+              />
+            )}
+          </ResponsivePanelTable>
+        </Fragment>
+      )}
+    </Access>
+  );
+}
+
+export default withOrganization(OrganizationFeatureFlagsIndex);
path: static/app/views/settings/featureFlags/index.tsx
text: @user1:
`useOrganization()`!

diff_hunk: @@ -0,0 +1,178 @@
+import {useCallback, useState} from 'react';
+
+import {
+  addErrorMessage,
+  addLoadingMessage,
+  addSuccessMessage,
+} from 'sentry/actionCreators/indicator';
+import SelectField from 'sentry/components/forms/fields/selectField';
+import TextField from 'sentry/components/forms/fields/textField';
+import Form from 'sentry/components/forms/form';
+import ExternalLink from 'sentry/components/links/externalLink';
+import Panel from 'sentry/components/panels/panel';
+import PanelBody from 'sentry/components/panels/panelBody';
+import PanelHeader from 'sentry/components/panels/panelHeader';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t, tct} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import {browserHistory} from 'sentry/utils/browserHistory';
+import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
+import {useMutation, useQueryClient} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import normalizeUrl from 'sentry/utils/url/normalizeUrl';
+import useApi from 'sentry/utils/useApi';
+import withOrganization from 'sentry/utils/withOrganization';
+import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
+import TextBlock from 'sentry/views/settings/components/text/textBlock';
+import {makeFetchSecretQueryKey} from 'sentry/views/settings/featureFlags';
+import NewSecretHandler from 'sentry/views/settings/featureFlags/newSecretHandler';
+
+type CreateSecretQueryVariables = {
+  provider: string;
+  secret: string;
+};
+
+type CreateSecretResponse = string;
+
+function NewProviderForm({
+  organization,
+  onCreatedSecret,
+}: {
+  onCreatedSecret: (secret: string) => void;
+  organization: Organization;
+}) {
+  const initialData = {
+    provider: '',
+    secret: '',
+  };
+
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  const handleGoBack = useCallback(() => {
+    browserHistory.push(normalizeUrl(`/settings/${organization.slug}/feature-flags/`));
+  }, [organization.slug]);
+
+  const {mutate: submitSecret} = useMutation<
+    CreateSecretResponse,
+    RequestError,
+    CreateSecretQueryVariables
+  >({
+    mutationFn: ({provider, secret}) => {
+      addLoadingMessage();
+      return api.requestPromise(
+        `/organizations/${organization.slug}/flags/signing-secrets/`,
+        {
+          method: 'POST',
+          data: {
+            provider,
+            secret,
+          },
+        }
+      );
+    },
+
+    onSuccess: (_response, {secret}) => {
+      addSuccessMessage(t('Added provider and secret.'));
+      onCreatedSecret(secret);
+      queryClient.invalidateQueries({
+        queryKey: makeFetchSecretQueryKey({orgSlug: organization.slug}),
+      });
+    },
+    onError: error => {
+      const message = t('Failed to add provider or secret.');
+      handleXhrErrorResponse(message, error);
+      addErrorMessage(message);
+    },
+  });
+
+  return (
+    <Form
+      apiMethod="POST"
+      initialData={initialData}
+      apiEndpoint={`/organizations/${organization.slug}/flags/signing-secret`}
path: static/app/views/settings/featureFlags/organizationFeatureFlagsNewSecret.tsx
text: @user1:
Does this need trailing slash?

diff_hunk: @@ -0,0 +1,178 @@
+import {useCallback, useState} from 'react';
+
+import {
+  addErrorMessage,
+  addLoadingMessage,
+  addSuccessMessage,
+} from 'sentry/actionCreators/indicator';
+import SelectField from 'sentry/components/forms/fields/selectField';
+import TextField from 'sentry/components/forms/fields/textField';
+import Form from 'sentry/components/forms/form';
+import ExternalLink from 'sentry/components/links/externalLink';
+import Panel from 'sentry/components/panels/panel';
+import PanelBody from 'sentry/components/panels/panelBody';
+import PanelHeader from 'sentry/components/panels/panelHeader';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t, tct} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import {browserHistory} from 'sentry/utils/browserHistory';
+import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
+import {useMutation, useQueryClient} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import normalizeUrl from 'sentry/utils/url/normalizeUrl';
+import useApi from 'sentry/utils/useApi';
+import withOrganization from 'sentry/utils/withOrganization';
+import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
+import TextBlock from 'sentry/views/settings/components/text/textBlock';
+import {makeFetchSecretQueryKey} from 'sentry/views/settings/featureFlags';
+import NewSecretHandler from 'sentry/views/settings/featureFlags/newSecretHandler';
+
+type CreateSecretQueryVariables = {
+  provider: string;
+  secret: string;
+};
+
+type CreateSecretResponse = string;
+
+function NewProviderForm({
+  organization,
+  onCreatedSecret,
+}: {
+  onCreatedSecret: (secret: string) => void;
+  organization: Organization;
+}) {
+  const initialData = {
+    provider: '',
+    secret: '',
+  };
+
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  const handleGoBack = useCallback(() => {
+    browserHistory.push(normalizeUrl(`/settings/${organization.slug}/feature-flags/`));
+  }, [organization.slug]);
+
+  const {mutate: submitSecret} = useMutation<
+    CreateSecretResponse,
+    RequestError,
+    CreateSecretQueryVariables
+  >({
+    mutationFn: ({provider, secret}) => {
+      addLoadingMessage();
+      return api.requestPromise(
+        `/organizations/${organization.slug}/flags/signing-secrets/`,
+        {
+          method: 'POST',
+          data: {
+            provider,
+            secret,
+          },
+        }
+      );
+    },
+
+    onSuccess: (_response, {secret}) => {
+      addSuccessMessage(t('Added provider and secret.'));
+      onCreatedSecret(secret);
+      queryClient.invalidateQueries({
+        queryKey: makeFetchSecretQueryKey({orgSlug: organization.slug}),
+      });
+    },
+    onError: error => {
+      const message = t('Failed to add provider or secret.');
+      handleXhrErrorResponse(message, error);
+      addErrorMessage(message);
+    },
+  });
+
+  return (
+    <Form
+      apiMethod="POST"
+      initialData={initialData}
+      apiEndpoint={`/organizations/${organization.slug}/flags/signing-secret`}
+      onSubmit={({provider, secret}) => {
+        submitSecret({
+          provider,
+          secret,
+        });
+      }}
+      onCancel={handleGoBack}
+      submitLabel={t('Add Provider')}
+      requireChanges
+    >
+      <SelectField
+        required
+        label={t('Provider')}
+        name="provider"
+        options={[{value: 'launchdarkly', label: 'LaunchDarkly'}]}
+        placeholder={t('Select one')}
+        help={t(
+          'If you have already linked this provider, pasting a new secret will override the existing secret.'
+        )}
+      />
+      <TextField
+        name="secret"
+        label={t('Secret')}
+        maxLength={32}
+        minLength={32}
+        required
+        help={t(
+          'Paste the signing secret given by your provider when creating the webhook.'
+        )}
+      />
+    </Form>
+  );
+}
+
+export function OrganizationFeatureFlagsNewSecet({
+  organization,
+}: {
+  organization: Organization;
+}) {
+  const [newSecret, setNewSecret] = useState<string | null>(null);
+
+  const handleGoBack = useCallback(() => {
+    browserHistory.push(normalizeUrl(`/settings/${organization.slug}/feature-flags/`));
+  }, [organization.slug]);
+
+  return (
+    <div>
+      <SentryDocumentTitle title={t('Add New Provider')} />
+      <SettingsPageHeader title={t('Add New Provider')} />
+
+      <TextBlock>
+        {t(
+          'Integrating Sentry with your feature flag provider enables Sentry to correlate feature flag changes with new error events and mark certain changes as suspicious. This page lists the webhooks you have set up with external providers. Note that each provider can only have one associated signing secret.'
+        )}
+      </TextBlock>
+      <TextBlock>
+        {tct(
+          'Learn more about how to interact with feature flag insights within the Sentry UI by reading the [link:documentation].',
+          {
+            link: (
+              <ExternalLink href="https://docs.sentry.io/product/issues/issue-details/#feature-flags" />
+            ),
+          }
+        )}
+      </TextBlock>
+
+      <Panel>
+        <PanelHeader>{t('Add New Provider')}</PanelHeader>
+
+        <PanelBody>
+          {newSecret ? (
+            <NewSecretHandler handleGoBack={handleGoBack} secret={newSecret} />
path: static/app/views/settings/featureFlags/organizationFeatureFlagsNewSecret.tsx
text: @user1:
l: prop name for event handlers should start with `on*`