index.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. import {Component} from 'react';
  2. import {browserHistory, RouteComponentProps} from 'react-router';
  3. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  4. import {
  5. changeProjectSlug,
  6. removeProject,
  7. transferProject,
  8. } from 'sentry/actionCreators/projects';
  9. import {hasEveryAccess} from 'sentry/components/acl/access';
  10. import {Button} from 'sentry/components/button';
  11. import Confirm from 'sentry/components/confirm';
  12. import FieldGroup from 'sentry/components/forms/fieldGroup';
  13. import TextField from 'sentry/components/forms/fields/textField';
  14. import Form, {FormProps} from 'sentry/components/forms/form';
  15. import JsonForm from 'sentry/components/forms/jsonForm';
  16. import {FieldValue} from 'sentry/components/forms/model';
  17. import Hook from 'sentry/components/hook';
  18. import ExternalLink from 'sentry/components/links/externalLink';
  19. import {removePageFiltersStorage} from 'sentry/components/organizations/pageFilters/persistence';
  20. import Panel from 'sentry/components/panels/panel';
  21. import PanelAlert from 'sentry/components/panels/panelAlert';
  22. import PanelHeader from 'sentry/components/panels/panelHeader';
  23. import {fields} from 'sentry/data/forms/projectGeneralSettings';
  24. import {t, tct} from 'sentry/locale';
  25. import ProjectsStore from 'sentry/stores/projectsStore';
  26. import {Organization, Project} from 'sentry/types';
  27. import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
  28. import recreateRoute from 'sentry/utils/recreateRoute';
  29. import RequestError from 'sentry/utils/requestError/requestError';
  30. import routeTitleGen from 'sentry/utils/routeTitle';
  31. import withOrganization from 'sentry/utils/withOrganization';
  32. import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
  33. import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
  34. import TextBlock from 'sentry/views/settings/components/text/textBlock';
  35. import PermissionAlert from 'sentry/views/settings/project/permissionAlert';
  36. type Props = DeprecatedAsyncView['props'] &
  37. RouteComponentProps<{projectId: string}, {}> & {
  38. onChangeSlug: (slug: string) => void;
  39. organization: Organization;
  40. };
  41. type State = DeprecatedAsyncView['state'] & {
  42. data: Project;
  43. };
  44. class ProjectGeneralSettings extends DeprecatedAsyncView<Props, State> {
  45. private _form: Record<string, FieldValue> = {};
  46. getTitle() {
  47. const {projectId} = this.props.params;
  48. return routeTitleGen(t('Project Settings'), projectId, false);
  49. }
  50. getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
  51. const {organization} = this.props;
  52. const {projectId} = this.props.params;
  53. return [['data', `/projects/${organization.slug}/${projectId}/`]];
  54. }
  55. handleTransferFieldChange = (id: string, value: FieldValue) => {
  56. this._form[id] = value;
  57. };
  58. handleRemoveProject = () => {
  59. const {organization} = this.props;
  60. const project = this.state.data;
  61. removePageFiltersStorage(organization.slug);
  62. if (!project) {
  63. return;
  64. }
  65. removeProject({
  66. api: this.api,
  67. orgSlug: organization.slug,
  68. projectSlug: project.slug,
  69. origin: 'settings',
  70. })
  71. .then(
  72. () => {
  73. addSuccessMessage(
  74. tct('[project] was successfully removed', {project: project.slug})
  75. );
  76. },
  77. err => {
  78. addErrorMessage(tct('Error removing [project]', {project: project.slug}));
  79. throw err;
  80. }
  81. )
  82. .then(
  83. () => {
  84. // Need to hard reload because lots of components do not listen to Projects Store
  85. window.location.assign('/');
  86. },
  87. (err: RequestError) => handleXhrErrorResponse('Unable to remove project', err)
  88. );
  89. };
  90. handleTransferProject = async () => {
  91. const {organization} = this.props;
  92. const project = this.state.data;
  93. if (!project) {
  94. return;
  95. }
  96. if (typeof this._form.email !== 'string' || this._form.email.length < 1) {
  97. return;
  98. }
  99. try {
  100. await transferProject(this.api, organization.slug, project, this._form.email);
  101. // Need to hard reload because lots of components do not listen to Projects Store
  102. window.location.assign('/');
  103. } catch (err) {
  104. if (err.status >= 500) {
  105. handleXhrErrorResponse('Unable to transfer project', err);
  106. }
  107. }
  108. };
  109. isProjectAdmin = () =>
  110. hasEveryAccess(['project:admin'], {
  111. organization: this.props.organization,
  112. project: this.state.data,
  113. });
  114. renderRemoveProject() {
  115. const project = this.state.data;
  116. const isProjectAdmin = this.isProjectAdmin();
  117. const {isInternal} = project;
  118. return (
  119. <FieldGroup
  120. label={t('Remove Project')}
  121. help={tct(
  122. 'Remove the [project] project and all related data. [linebreak] Careful, this action cannot be undone.',
  123. {
  124. project: <strong>{project.slug}</strong>,
  125. linebreak: <br />,
  126. }
  127. )}
  128. >
  129. {!isProjectAdmin &&
  130. t('You do not have the required permission to remove this project.')}
  131. {isInternal &&
  132. t(
  133. 'This project cannot be removed. It is used internally by the Sentry server.'
  134. )}
  135. {isProjectAdmin && !isInternal && (
  136. <Confirm
  137. onConfirm={this.handleRemoveProject}
  138. priority="danger"
  139. confirmText={t('Remove Project')}
  140. message={
  141. <div>
  142. <TextBlock>
  143. <strong>
  144. {t('Removing this project is permanent and cannot be undone!')}
  145. </strong>
  146. </TextBlock>
  147. <TextBlock>
  148. {t('This will also remove all associated event data.')}
  149. </TextBlock>
  150. </div>
  151. }
  152. >
  153. <div>
  154. <Button priority="danger">{t('Remove Project')}</Button>
  155. </div>
  156. </Confirm>
  157. )}
  158. </FieldGroup>
  159. );
  160. }
  161. renderTransferProject() {
  162. const project = this.state.data;
  163. const {isInternal} = project;
  164. const isOrgOwner = hasEveryAccess(['org:admin'], {
  165. organization: this.props.organization,
  166. });
  167. return (
  168. <FieldGroup
  169. label={t('Transfer Project')}
  170. help={tct(
  171. 'Transfer the [project] project and all related data. [linebreak] Careful, this action cannot be undone.',
  172. {
  173. project: <strong>{project.slug}</strong>,
  174. linebreak: <br />,
  175. }
  176. )}
  177. >
  178. {!isOrgOwner &&
  179. t('You do not have the required permission to transfer this project.')}
  180. {isInternal &&
  181. t(
  182. 'This project cannot be transferred. It is used internally by the Sentry server.'
  183. )}
  184. {isOrgOwner && !isInternal && (
  185. <Confirm
  186. onConfirm={this.handleTransferProject}
  187. priority="danger"
  188. confirmText={t('Transfer project')}
  189. renderMessage={({confirm}) => (
  190. <div>
  191. <TextBlock>
  192. <strong>
  193. {t('Transferring this project is permanent and cannot be undone!')}
  194. </strong>
  195. </TextBlock>
  196. <TextBlock>
  197. {t(
  198. 'Please enter the email of an organization owner to whom you would like to transfer this project.'
  199. )}
  200. </TextBlock>
  201. <Panel>
  202. <Form
  203. hideFooter
  204. onFieldChange={this.handleTransferFieldChange}
  205. onSubmit={(_data, _onSuccess, _onError, e) => {
  206. e.stopPropagation();
  207. confirm();
  208. }}
  209. >
  210. <TextField
  211. name="email"
  212. label={t('Organization Owner')}
  213. placeholder="admin@example.com"
  214. required
  215. help={t(
  216. 'A request will be emailed to this address, asking the organization owner to accept the project transfer.'
  217. )}
  218. />
  219. </Form>
  220. </Panel>
  221. </div>
  222. )}
  223. >
  224. <div>
  225. <Button priority="danger">{t('Transfer Project')}</Button>
  226. </div>
  227. </Confirm>
  228. )}
  229. </FieldGroup>
  230. );
  231. }
  232. renderBody() {
  233. const {organization} = this.props;
  234. const project = this.state.data;
  235. const {projectId} = this.props.params;
  236. const endpoint = `/projects/${organization.slug}/${projectId}/`;
  237. const access = new Set(organization.access.concat(project.access));
  238. const jsonFormProps = {
  239. additionalFieldProps: {
  240. organization,
  241. },
  242. features: new Set(organization.features),
  243. access,
  244. disabled: !hasEveryAccess(['project:write'], {organization, project}),
  245. };
  246. const team = project.teams.length ? project.teams?.[0] : undefined;
  247. /*
  248. HACK: The <Form /> component applies its props to its children meaning the hooked component
  249. would need to conform to the form settings applied in a separate repository. This is
  250. not feasible to maintain and may introduce compatability errors if something changes
  251. in either repository. For that reason, the Form component is split in two, since the
  252. fields do not depend on one another, allowing for the Hook to manage it's own state.
  253. */
  254. const formProps: FormProps = {
  255. saveOnBlur: true,
  256. allowUndo: true,
  257. initialData: {
  258. ...project,
  259. team,
  260. },
  261. apiMethod: 'PUT' as const,
  262. apiEndpoint: endpoint,
  263. onSubmitSuccess: resp => {
  264. this.setState({data: resp});
  265. if (projectId !== resp.slug) {
  266. changeProjectSlug(projectId, resp.slug);
  267. // Container will redirect after stores get updated with new slug
  268. this.props.onChangeSlug(resp.slug);
  269. }
  270. // This will update our project context
  271. ProjectsStore.onUpdateSuccess(resp);
  272. },
  273. };
  274. const hasRecapServerFeature = project.features.includes('recap-server');
  275. return (
  276. <div>
  277. <SettingsPageHeader title={t('Project Settings')} />
  278. <PermissionAlert project={project} />
  279. <Form {...formProps}>
  280. <JsonForm
  281. {...jsonFormProps}
  282. title={t('Project Details')}
  283. // TODO(recap): Move this to a separate page or debug files one, not general settings
  284. fields={[
  285. fields.name,
  286. fields.platform,
  287. {
  288. ...fields.recapServerUrl,
  289. visible: hasRecapServerFeature,
  290. },
  291. {
  292. ...fields.recapServerToken,
  293. visible: hasRecapServerFeature,
  294. },
  295. ]}
  296. />
  297. <JsonForm
  298. {...jsonFormProps}
  299. title={t('Email')}
  300. fields={[fields.subjectPrefix]}
  301. />
  302. </Form>
  303. <Hook
  304. name="spend-visibility:spike-protection-project-settings"
  305. project={project}
  306. />
  307. <Form {...formProps}>
  308. <JsonForm
  309. {...jsonFormProps}
  310. title={t('Event Settings')}
  311. fields={[fields.resolveAge]}
  312. />
  313. <JsonForm
  314. {...jsonFormProps}
  315. title={t('Client Security')}
  316. fields={[
  317. fields.allowedDomains,
  318. fields.scrapeJavaScript,
  319. fields.securityToken,
  320. fields.securityTokenHeader,
  321. fields.verifySSL,
  322. ]}
  323. renderHeader={() => (
  324. <PanelAlert type="info">
  325. <TextBlock noMargin>
  326. {tct(
  327. 'Configure origin URLs which Sentry should accept events from. This is used for communication with clients like [link].',
  328. {
  329. link: (
  330. <ExternalLink href="https://github.com/getsentry/sentry-javascript">
  331. sentry-javascript
  332. </ExternalLink>
  333. ),
  334. }
  335. )}{' '}
  336. {tct(
  337. 'This will restrict requests based on the [Origin] and [Referer] headers.',
  338. {
  339. Origin: <code>Origin</code>,
  340. Referer: <code>Referer</code>,
  341. }
  342. )}
  343. </TextBlock>
  344. </PanelAlert>
  345. )}
  346. />
  347. </Form>
  348. <Panel>
  349. <PanelHeader>{t('Project Administration')}</PanelHeader>
  350. {this.renderRemoveProject()}
  351. {this.renderTransferProject()}
  352. </Panel>
  353. </div>
  354. );
  355. }
  356. }
  357. type ContainerProps = {
  358. organization: Organization;
  359. } & RouteComponentProps<{projectId: string}, {}>;
  360. class ProjectGeneralSettingsContainer extends Component<ContainerProps> {
  361. componentWillUnmount() {
  362. this.unsubscribe();
  363. }
  364. changedSlug: string | undefined = undefined;
  365. unsubscribe = ProjectsStore.listen(() => this.onProjectsUpdate(), undefined);
  366. onProjectsUpdate() {
  367. if (!this.changedSlug) {
  368. return;
  369. }
  370. const project = ProjectsStore.getBySlug(this.changedSlug);
  371. if (!project) {
  372. return;
  373. }
  374. browserHistory.replace(
  375. recreateRoute('', {
  376. ...this.props,
  377. params: {
  378. ...this.props.params,
  379. projectId: this.changedSlug,
  380. },
  381. })
  382. );
  383. }
  384. render() {
  385. return (
  386. <ProjectGeneralSettings
  387. onChangeSlug={(newSlug: string) => (this.changedSlug = newSlug)}
  388. {...this.props}
  389. />
  390. );
  391. }
  392. }
  393. export default withOrganization(ProjectGeneralSettingsContainer);