index.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import {Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  4. import {hasEveryAccess} from 'sentry/components/acl/access';
  5. import {LinkButton} from 'sentry/components/button';
  6. import ExternalLink from 'sentry/components/links/externalLink';
  7. import LoadingError from 'sentry/components/loadingError';
  8. import {PanelTable} from 'sentry/components/panels/panelTable';
  9. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  10. import {Tooltip} from 'sentry/components/tooltip';
  11. import {t, tct} from 'sentry/locale';
  12. import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
  13. import {
  14. setApiQueryData,
  15. useApiQuery,
  16. useMutation,
  17. useQueryClient,
  18. } from 'sentry/utils/queryClient';
  19. import type RequestError from 'sentry/utils/requestError/requestError';
  20. import useApi from 'sentry/utils/useApi';
  21. import useOrganization from 'sentry/utils/useOrganization';
  22. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  23. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  24. import {OrganizationFeatureFlagsProviderRow} from 'sentry/views/settings/featureFlags/organizationFeatureFlagsProviderRow';
  25. export type Secret = {
  26. createdAt: string;
  27. createdBy: number;
  28. id: number;
  29. provider: string;
  30. secret: string;
  31. };
  32. type FetchSecretResponse = {data: Secret[]};
  33. type FetchSecretParameters = {
  34. orgSlug: string;
  35. };
  36. type RemoveSecretQueryVariables = {
  37. id: number;
  38. };
  39. export const makeFetchSecretQueryKey = ({orgSlug}: FetchSecretParameters) =>
  40. [`/organizations/${orgSlug}/flags/signing-secrets/`] as const;
  41. function SecretList({
  42. secretList,
  43. isRemoving,
  44. removeSecret,
  45. }: {
  46. isRemoving: boolean;
  47. secretList: Secret[];
  48. removeSecret?: (data: {id: number}) => void;
  49. }) {
  50. return (
  51. <Fragment>
  52. {secretList.map(secret => {
  53. return (
  54. <OrganizationFeatureFlagsProviderRow
  55. key={secret.id}
  56. secret={secret}
  57. isRemoving={isRemoving}
  58. removeSecret={removeSecret ? () => removeSecret({id: secret.id}) : undefined}
  59. />
  60. );
  61. })}
  62. </Fragment>
  63. );
  64. }
  65. export function OrganizationFeatureFlagsIndex() {
  66. const organization = useOrganization();
  67. const api = useApi();
  68. const queryClient = useQueryClient();
  69. const {
  70. isPending,
  71. isError,
  72. data: secretList,
  73. refetch: refetchSecretList,
  74. } = useApiQuery<FetchSecretResponse>(
  75. makeFetchSecretQueryKey({orgSlug: organization.slug}),
  76. {
  77. staleTime: Infinity,
  78. }
  79. );
  80. const {mutate: handleRemoveSecret, isPending: isRemoving} = useMutation<
  81. {},
  82. RequestError,
  83. RemoveSecretQueryVariables
  84. >({
  85. mutationFn: ({id}) =>
  86. api.requestPromise(
  87. `/organizations/${organization.slug}/flags/signing-secrets/${id}/`,
  88. {
  89. method: 'DELETE',
  90. }
  91. ),
  92. onSuccess: (_data, {id}) => {
  93. addSuccessMessage(
  94. t('Removed the provider and signing secret for the organization.')
  95. );
  96. setApiQueryData(
  97. queryClient,
  98. makeFetchSecretQueryKey({orgSlug: organization.slug}),
  99. (oldData: FetchSecretResponse) => {
  100. return {data: oldData.data.filter(oldSecret => oldSecret.id !== id)};
  101. }
  102. );
  103. },
  104. onError: error => {
  105. const message = t('Failed to remove the provider or signing secret.');
  106. handleXhrErrorResponse(message, error);
  107. addErrorMessage(message);
  108. },
  109. });
  110. const addNewProvider = (hasAccess: any) => (
  111. <Tooltip
  112. title={t('You must be an organization member to add a provider.')}
  113. disabled={hasAccess}
  114. >
  115. <LinkButton
  116. priority="primary"
  117. size="sm"
  118. to={`/settings/${organization.slug}/feature-flags/new-provider/`}
  119. data-test-id="create-new-provider"
  120. disabled={!hasAccess}
  121. >
  122. {t('Add New Provider')}
  123. </LinkButton>
  124. </Tooltip>
  125. );
  126. const canRead = hasEveryAccess(['org:read'], {organization});
  127. const canWrite = hasEveryAccess(['org:write'], {organization});
  128. const canAdmin = hasEveryAccess(['org:admin'], {organization});
  129. const hasAccess = canRead || canWrite || canAdmin;
  130. const hasDeleteAccess = canWrite || canAdmin;
  131. return (
  132. <Fragment>
  133. <SentryDocumentTitle title={t('Feature Flags')} orgSlug={organization.slug} />
  134. <SettingsPageHeader title={t('Feature Flags')} action={addNewProvider(hasAccess)} />
  135. <TextBlock>
  136. {t(
  137. '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.'
  138. )}
  139. </TextBlock>
  140. <TextBlock>
  141. {tct(
  142. 'Learn more about how to interact with feature flag insights within the Sentry UI by reading the [link:documentation].',
  143. {
  144. link: (
  145. <ExternalLink href="https://docs.sentry.io/product/explore/feature-flags/#change-tracking" />
  146. ),
  147. }
  148. )}
  149. </TextBlock>
  150. <ResponsivePanelTable
  151. isLoading={isPending || isError}
  152. isEmpty={!isPending && !secretList?.data?.length}
  153. loader={
  154. isError ? (
  155. <LoadingError
  156. message={t('Failed to load secrets and providers for the organization.')}
  157. onRetry={refetchSecretList}
  158. />
  159. ) : undefined
  160. }
  161. emptyMessage={t("You haven't linked any providers yet.")}
  162. headers={[t('Provider'), t('Created'), t('Created by'), '']}
  163. >
  164. {!isError && !isPending && !!secretList?.data?.length && (
  165. <SecretList
  166. secretList={secretList.data}
  167. isRemoving={isRemoving}
  168. removeSecret={hasDeleteAccess ? handleRemoveSecret : undefined}
  169. />
  170. )}
  171. </ResponsivePanelTable>
  172. </Fragment>
  173. );
  174. }
  175. export default OrganizationFeatureFlagsIndex;
  176. const ResponsivePanelTable = styled(PanelTable)`
  177. @media (max-width: ${p => p.theme.breakpoints.small}) {
  178. grid-template-columns: 1fr 1fr;
  179. > *:nth-child(4n + 2),
  180. > *:nth-child(4n + 3) {
  181. display: none;
  182. }
  183. }
  184. `;