From 974c6f76292322a965c1ff791e9282d097e1380f Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Wed, 7 Aug 2024 16:33:10 -0700 Subject: [PATCH 01/17] build data secrecy fe --- .../organizationSecurityAndPrivacyGroups.tsx | 11 + static/app/types/organization.tsx | 1 + .../components/dataSecrecy/index.spec.tsx | 143 ++++++++++ .../settings/components/dataSecrecy/index.tsx | 255 ++++++++++++++++++ .../organizationSecurityAndPrivacy/index.tsx | 8 + 5 files changed, 418 insertions(+) create mode 100644 static/app/views/settings/components/dataSecrecy/index.spec.tsx create mode 100644 static/app/views/settings/components/dataSecrecy/index.tsx diff --git a/static/app/data/forms/organizationSecurityAndPrivacyGroups.tsx b/static/app/data/forms/organizationSecurityAndPrivacyGroups.tsx index d2854411567ad6..83dc61c9e438fb 100644 --- a/static/app/data/forms/organizationSecurityAndPrivacyGroups.tsx +++ b/static/app/data/forms/organizationSecurityAndPrivacyGroups.tsx @@ -111,6 +111,17 @@ const formGroups: JsonFormObject[] = [ }, visible: ({hasSsoEnabled}) => !hasSsoEnabled, }, + { + name: 'allowSuperuserAccess', + type: 'boolean', + label: t('Allow Superuser Access'), + help: t('Allow Sentry staff to access your data in order to diagnose issues.'), + confirm: { + false: t('Are you sure you want to disable superuser access?'), + true: t('Are you sure you want to enable superuser access?'), + }, + visible: ({showDataSecrecySettings}) => showDataSecrecySettings, + }, ], }, { diff --git a/static/app/types/organization.tsx b/static/app/types/organization.tsx index 3904a7eedb4457..7304e9072a1620 100644 --- a/static/app/types/organization.tsx +++ b/static/app/types/organization.tsx @@ -55,6 +55,7 @@ export interface Organization extends OrganizationSummary { allowMemberInvite: boolean; allowMemberProjectCreation: boolean; allowSharedIssues: boolean; + allowSuperuserAccess: boolean; attachmentsRole: string; /** @deprecated use orgRoleList instead. */ availableRoles: {id: string; name: string}[]; diff --git a/static/app/views/settings/components/dataSecrecy/index.spec.tsx b/static/app/views/settings/components/dataSecrecy/index.spec.tsx new file mode 100644 index 00000000000000..f4d0b3fd877ef9 --- /dev/null +++ b/static/app/views/settings/components/dataSecrecy/index.spec.tsx @@ -0,0 +1,143 @@ +import {initializeOrg} from 'sentry-test/initializeOrg'; +import { + render, + renderGlobalModal, + screen, + userEvent, + waitFor, +} from 'sentry-test/reactTestingLibrary'; + +import * as indicatorActions from 'sentry/actionCreators/indicator'; +import {DataSecrecy} from 'sentry/views/settings/components/dataSecrecy'; + +jest.mock('sentry/actionCreators/indicator'); + +describe('DataSecrecy', function () { + const {organization} = initializeOrg(); + + beforeEach(function () { + MockApiClient.clearMockResponses(); + jest.clearAllMocks(); + }); + + it('renders default state with no waiver', async function () { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-secrecy/`, + body: null, + }); + + render(, {organization: organization}); + + await waitFor(() => { + expect(screen.getByText('Data Secrecy Waiver')).toBeInTheDocument(); + expect( + screen.getByText('Data secrecy is not currently waived') + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Add Waiver'})).toBeInTheDocument(); + }); + }); + + it('renders current waiver state', async function () { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-secrecy/`, + body: { + access_start: '2023-08-01T00:00:00Z', + access_end: '2024-08-01T00:00:00Z', + }, + }); + + render(, {organization: organization}); + + await waitFor(() => { + expect(screen.getByText(/Data secrecy will be waived from/)).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Edit'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Remove Waiver'})).toBeInTheDocument(); + }); + }); + + it('opens edit form when Edit button is clicked', async function () { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-secrecy/`, + body: { + access_start: '2023-08-01T00:00:00Z', + access_end: '2024-08-01T00:00:00Z', + }, + }); + + render(, {organization: organization}); + + const editButton = await screen.findByRole('button', {name: 'Edit'}); + await userEvent.click(editButton); + + await waitFor(() => { + expect(screen.getByText(/waiver start time/i)).toBeInTheDocument(); + expect(screen.getByDisplayValue(/2023\-08\-01t00:00/i)).toBeInTheDocument(); + expect(screen.getByText(/waiver end time/i)).toBeInTheDocument(); + }); + }); + + it('submits form successfully', async function () { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-secrecy/`, + body: null, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-secrecy/`, + method: 'PUT', + statusCode: 200, + }); + + render(, {organization: organization}); + + const addWaiverButton = await screen.findByRole('button', {name: 'Add Waiver'}); + await userEvent.click(addWaiverButton); + + await userEvent.type(await screen.getByText(/waiver end time/i), '2025-08-01T00:00'); + + const saveButton = await screen.findByRole('button', {name: 'Save Changes'}); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(indicatorActions.addSuccessMessage).toHaveBeenCalledWith( + 'Successfully updated data secrecy waiver' + ); + }); + }); + + it('removes waiver successfully', async function () { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-secrecy/`, + body: { + access_start: '2023-08-01T00:00:00Z', + access_end: '2024-08-01T00:00:00Z', + }, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-secrecy/`, + method: 'DELETE', + statusCode: 204, + }); + + render(, {organization: organization}); + + await waitFor(() => { + expect(screen.getByText('Data Secrecy Waiver')).toBeInTheDocument(); + }); + + const removeWaiverButton = await screen.getByRole('button', {name: /remove waiver/i}); + await userEvent.click(removeWaiverButton); + renderGlobalModal(); + + const confirmButton = await screen.findByRole('button', {name: 'Confirm'}); + + await userEvent.click(confirmButton); + + await waitFor(() => { + expect(indicatorActions.addSuccessMessage).toHaveBeenCalledWith( + 'Successfully removed data secrecy waiver' + ); + }); + }); +}); diff --git a/static/app/views/settings/components/dataSecrecy/index.tsx b/static/app/views/settings/components/dataSecrecy/index.tsx new file mode 100644 index 00000000000000..007c16dd56359d --- /dev/null +++ b/static/app/views/settings/components/dataSecrecy/index.tsx @@ -0,0 +1,255 @@ +import React, {useEffect, useState} from 'react'; +import styled from '@emotion/styled'; + +import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; +import {Button} from 'sentry/components/button'; +import Confirm from 'sentry/components/confirm'; +import Form from 'sentry/components/forms/form'; +import JsonForm from 'sentry/components/forms/jsonForm'; +import LoadingError from 'sentry/components/loadingError'; +import LoadingIndicator from 'sentry/components/loadingIndicator'; +import Panel from 'sentry/components/panels/panel'; +import PanelAlert from 'sentry/components/panels/panelAlert'; +import PanelBody from 'sentry/components/panels/panelBody'; +import PanelHeader from 'sentry/components/panels/panelHeader'; +import Text from 'sentry/components/text'; +import {t, tct} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; + +type WaiverData = { + access_end: string | null; + access_start: string | null; +}; + +const formatDateTime = (dateString: string) => { + const date = new Date(dateString); + const options: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, + timeZoneName: 'short', + }; + return new Intl.DateTimeFormat('en-US', options).format(date); +}; + +const formatDateForInput = (dateString: string | null | undefined) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toISOString().slice(0, 16); +}; + +const getWaiverStatus = (waiverData: WaiverData | null) => { + if (!waiverData || !waiverData.access_start || !waiverData.access_end) { + return { + status: t('Data secrecy is not currently waived'), + isPast: false, + isFuture: false, + isCurrentlyWaived: false, + }; + } + + const now = new Date(); + const startDate = new Date(waiverData.access_start); + const endDate = new Date(waiverData.access_end); + + if (startDate <= now && endDate > now) { + return { + status: tct('Data secrecy is currently waived until [to]', { + to: {formatDateTime(waiverData.access_end)}, + }), + isPast: false, + isFuture: false, + isCurrentlyWaived: true, + }; + } + if (startDate > now) { + return { + status: tct('Data secrecy will be waived from [from] until [to]', { + from: {formatDateTime(waiverData.access_start)}, + to: {formatDateTime(waiverData.access_end)}, + }), + isPast: false, + isFuture: true, + isCurrentlyWaived: false, + }; + } + + return { + status: tct('Data secrecy was waived [from] until [to]', { + from: {formatDateTime(waiverData.access_start)}, + to: {formatDateTime(waiverData.access_end)}, + }), + isPast: true, + isFuture: false, + isCurrentlyWaived: false, + }; +}; + +export function DataSecrecy() { + const api = useApi(); + const organization = useOrganization(); + const [isEditing, setIsEditing] = useState(false); + const [waiverData, setWaiverData] = useState(null); + + const { + isLoading, + data, + refetch, + error: queryError, + } = useApiQuery([`/organizations/${organization.slug}/data-secrecy/`], { + staleTime: 3000, + retry: (failureCount, error) => failureCount < 3 && error.status !== 404, + }); + + useEffect(() => { + if (data) { + setWaiverData(data); + } + }, [data]); + + const initialData = { + access_start: + formatDateForInput(waiverData?.access_start) || + formatDateForInput(new Date().toISOString()), + access_end: formatDateForInput(waiverData?.access_end) || '', + }; + + if (isLoading) { + return ; + } + + if (queryError && queryError.status !== 404) { + return ; + } + + const handleSubmit = async formData => { + try { + const response = await api.requestPromise( + `/organizations/${organization.slug}/data-secrecy/`, + { + method: 'PUT', + data: { + access_start: new Date(formData.access_start || '').toISOString(), + access_end: new Date(formData.access_end || '').toISOString(), + }, + } + ); + + setWaiverData(response); + addSuccessMessage(t('Successfully updated data secrecy waiver')); + setIsEditing(false); + } catch (error) { + addErrorMessage(t('Unable to update data secrecy waiver')); + } + }; + + const handleDelete = async () => { + try { + await api.requestPromise(`/organizations/${organization.slug}/data-secrecy/`, { + method: 'DELETE', + }); + + setWaiverData(null); + addSuccessMessage(t('Successfully removed data secrecy waiver')); + } catch (error) { + addErrorMessage(t('Unable to remove data secrecy waiver')); + } + }; + + const waiverStatus = getWaiverStatus(waiverData); + const showWaiver = !waiverStatus.isPast; + const showRemoveButton = waiverStatus.isCurrentlyWaived || waiverStatus.isFuture; + + return ( + + + {t('Data Secrecy Waiver')} + {!isEditing && showWaiver && !organization.allowSuperuserAccess && ( + + + {showRemoveButton && ( + + + + )} + + + + )} + + {organization.allowSuperuserAccess ? ( + + {t( + 'Superuser access is currently enabled. To turn superuser access off, toggle the setting above.' + )} + + ) : ( + + {t( + 'Data secrecy waiver allows Sentry employees to access the organization temporarily to address issues.' + )} + + )} + {!organization.allowSuperuserAccess && ( + + {isEditing ? ( +
setIsEditing(false)} + > + + + ) : ( + {waiverStatus.status} + )} +
+ )} +
+ ); +} + +const PanelAction = styled('div')` + padding: ${space(1)} ${space(2)}; + position: relative; + display: grid; + gap: ${space(1)}; + grid-template-columns: auto auto; + justify-content: flex-end; +`; + +const StyledPanelBody = styled(PanelBody)` + padding: ${space(2)}; +`; diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx index 5060f24585ec54..1701d4b5070492 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx @@ -7,10 +7,12 @@ import JsonForm from 'sentry/components/forms/jsonForm'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import organizationSecurityAndPrivacyGroups from 'sentry/data/forms/organizationSecurityAndPrivacyGroups'; import {t} from 'sentry/locale'; +import ConfigStore from 'sentry/stores/configStore'; import type {AuthProvider} from 'sentry/types/auth'; import type {Organization} from 'sentry/types/organization'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; +import {DataSecrecy} from 'sentry/views/settings/components/dataSecrecy'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import {DataScrubbing} from '../components/dataScrubbing'; @@ -47,6 +49,10 @@ export default function OrganizationSecurityAndPrivacyContent() { updateOrganization(data); } + const {isSelfHosted} = ConfigStore.getState(); + const showDataSecrecySettings = + organization.features.includes('data-secrecy') && !isSelfHosted; + return ( @@ -66,6 +72,7 @@ export default function OrganizationSecurityAndPrivacyContent() { features={features} forms={organizationSecurityAndPrivacyGroups} disabled={!organization.access.includes('org:write')} + additionalFieldProps={{showDataSecrecySettings}} /> handleUpdateOrganization({...organization, ...data})} /> + {showDataSecrecySettings && } ); } From 54320a38a25716ee05819d310d3b0c9b578a90f3 Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Wed, 7 Aug 2024 16:45:26 -0700 Subject: [PATCH 02/17] fix test failure --- tests/js/fixtures/organization.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/js/fixtures/organization.ts b/tests/js/fixtures/organization.ts index 49b0c12a364e6c..f142e5e2cdc9b3 100644 --- a/tests/js/fixtures/organization.ts +++ b/tests/js/fixtures/organization.ts @@ -42,6 +42,7 @@ export function OrganizationFixture( params: Partial = {}): Organi allowJoinRequests: false, allowMemberInvite: true, allowMemberProjectCreation: false, + allowSuperuserAccess: false, allowSharedIssues: false, attachmentsRole: '', availableRoles: [], From a9343d1300e499224f31ce1613faf7494aab5e47 Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Wed, 7 Aug 2024 17:47:38 -0700 Subject: [PATCH 03/17] update settings order --- .../organizationSecurityAndPrivacy/index.tsx | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx index 1701d4b5070492..41578a8449bfd9 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx @@ -1,4 +1,5 @@ import {Fragment, useEffect, useState} from 'react'; +import {cloneDeep} from 'lodash'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {updateOrganization} from 'sentry/actionCreators/organizations'; @@ -51,7 +52,11 @@ export default function OrganizationSecurityAndPrivacyContent() { const {isSelfHosted} = ConfigStore.getState(); const showDataSecrecySettings = - organization.features.includes('data-secrecy') && !isSelfHosted; + !organization.features.includes('data-secrecy') && !isSelfHosted; + + const [securityFormConfig, dataScrubbingFormConfig] = cloneDeep( + organizationSecurityAndPrivacyGroups + ); return ( @@ -70,7 +75,26 @@ export default function OrganizationSecurityAndPrivacyContent() { > + + {showDataSecrecySettings && } +
addErrorMessage(t('Unable to save change'))} + saveOnBlur + allowUndo + > + From 1f7f0eb3a013540e159bf7bb6cd8cb74ee4d69f1 Mon Sep 17 00:00:00 2001 From: Danny Lee Date: Thu, 22 Aug 2024 11:43:42 -0700 Subject: [PATCH 04/17] fix(pr): Updated with better UIUX. Still a bit buggy --- .../organizationSecurityAndPrivacyGroups.tsx | 11 -- .../components/dataSecrecy/index2.tsx | 164 ++++++++++++++++++ .../organizationSecurityAndPrivacy/index.tsx | 10 +- 3 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 static/app/views/settings/components/dataSecrecy/index2.tsx diff --git a/static/app/data/forms/organizationSecurityAndPrivacyGroups.tsx b/static/app/data/forms/organizationSecurityAndPrivacyGroups.tsx index 83dc61c9e438fb..d2854411567ad6 100644 --- a/static/app/data/forms/organizationSecurityAndPrivacyGroups.tsx +++ b/static/app/data/forms/organizationSecurityAndPrivacyGroups.tsx @@ -111,17 +111,6 @@ const formGroups: JsonFormObject[] = [ }, visible: ({hasSsoEnabled}) => !hasSsoEnabled, }, - { - name: 'allowSuperuserAccess', - type: 'boolean', - label: t('Allow Superuser Access'), - help: t('Allow Sentry staff to access your data in order to diagnose issues.'), - confirm: { - false: t('Are you sure you want to disable superuser access?'), - true: t('Are you sure you want to enable superuser access?'), - }, - visible: ({showDataSecrecySettings}) => showDataSecrecySettings, - }, ], }, { diff --git a/static/app/views/settings/components/dataSecrecy/index2.tsx b/static/app/views/settings/components/dataSecrecy/index2.tsx new file mode 100644 index 00000000000000..2ce37dc4dd74e8 --- /dev/null +++ b/static/app/views/settings/components/dataSecrecy/index2.tsx @@ -0,0 +1,164 @@ +import {useEffect, useState} from 'react'; +import moment from 'moment-timezone'; + +import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; +import BooleanField, { + type BooleanFieldProps, +} from 'sentry/components/forms/fields/booleanField'; +import DateTimeField, { + type DateTimeFieldProps, +} from 'sentry/components/forms/fields/dateTimeField'; +import Panel from 'sentry/components/panels/panel'; +import PanelAlert from 'sentry/components/panels/panelAlert'; +import PanelBody from 'sentry/components/panels/panelBody'; +import PanelHeader from 'sentry/components/panels/panelHeader'; +import {t, tct} from 'sentry/locale'; +import {useApiQuery} from 'sentry/utils/queryClient'; +import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; + +type WaiverData = { + access_end: string | null; + access_start: string | null; +}; + +export default function DataSecrecy() { + const api = useApi(); + const organization = useOrganization(); + + const [allowAccess, setAllowAccess] = useState(organization.allowSuperuserAccess); + const [allowDate, setAllowDate] = useState(); + const [allowDateFormData, setAllowDateFormData] = useState(''); + + const {data} = useApiQuery( + [`/organizations/${organization.slug}/data-secrecy/`], + { + staleTime: 3000, + retry: (failureCount, error) => failureCount < 3 && error.status !== 404, + } + ); + + const hasValidTempAccess = + allowDate?.access_end && moment().toISOString() < allowDate.access_end; + + useEffect(() => { + if (data?.access_end) { + setAllowDate(data); + + // Slice it to yyyy-MM-ddThh:mmc + const dateObj = moment.utc(data.access_end); + const formattedDate = dateObj.local().format('YYYY-MM-DDTHH:MM'); + setAllowDateFormData(formattedDate); + } + }, [data]); + + const updateAllowedAccess = async (value: boolean) => { + try { + await api.requestPromise(`/organizations/${organization.slug}/`, { + method: 'PUT', + data: {allowSuperuserAccess: value}, + }); + setAllowAccess(value); + addSuccessMessage(t('Successfully updated access.')); + } catch (error) { + addErrorMessage(t('Unable to save changes.')); + } + }; + + const updateTempAccessDate = async () => { + if (!allowDateFormData) { + try { + await api.requestPromise(`/organizations/${organization.slug}/data-secrecy/`, { + method: 'DELETE', + }); + setAllowDate({access_start: '', access_end: ''}); + addSuccessMessage(t('Successfully removed temporary access window.')); + } catch (error) { + addErrorMessage(t('Unable to remove temporary access window.')); + } + + return; + } + + const nextData = { + access_start: moment().toISOString(), + access_end: moment(allowDateFormData).toISOString(), + }; + + try { + await await api.requestPromise( + `/organizations/${organization.slug}/data-secrecy/`, + { + method: 'PUT', + data: nextData, + } + ); + setAllowDate(nextData); + addSuccessMessage(t('Successfully updated temporary access window.')); + } catch (error) { + addErrorMessage(t('Unable to save changes.')); + } + }; + + const allowAccessProps: BooleanFieldProps = { + name: 'allowSuperuserAccess', + label: t('Allow access to Sentry employees'), + help: t( + 'Sentry employees will not have access to your organization unless granted permission' + ), + 'aria-label': t( + 'Sentry employees will not have access to your data unless granted permission' + ), + value: allowAccess, + onBlur: updateAllowedAccess, + }; + + const allowTempAccessProps: DateTimeFieldProps = { + name: 'allowTemporarySuperuserAccess', + label: t('Allow temporary access for Sentry employees'), + help: t( + 'Open a temporary time window for Sentry employees to access your organization' + ), + disabled: allowAccess, + value: allowAccess ? '' : allowDateFormData, + onBlur: updateTempAccessDate, + onChange: v => { + // Convert to UTC before setting into state + setAllowDateFormData(moment(v).toISOString()); + }, + }; + + return ( + + {t('Support Access 2')} + + {!allowAccess && ( + + {hasValidTempAccess + ? tct(`Sentry employees has access to your organization until [date]`, { + date: formatDateTime(allowDate?.access_end as string), + }) + : t('Sentry employees do not have access to your organization')} + + )} + + + + + + ); +} + +const formatDateTime = (dateString: string) => { + const date = new Date(dateString); + const options: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, + timeZoneName: 'short', + }; + return new Intl.DateTimeFormat('en-US', options).format(date); +}; diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx index 41578a8449bfd9..a7232024c634a7 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx @@ -14,6 +14,7 @@ import type {Organization} from 'sentry/types/organization'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import {DataSecrecy} from 'sentry/views/settings/components/dataSecrecy'; +import DataSecrecy2 from 'sentry/views/settings/components/dataSecrecy/index2'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import {DataScrubbing} from '../components/dataScrubbing'; @@ -62,6 +63,7 @@ export default function OrganizationSecurityAndPrivacyContent() { + - {showDataSecrecySettings && } + + {!showDataSecrecySettings && } + {!showDataSecrecySettings && } +
+ handleUpdateOrganization({...organization, ...data})} /> - {showDataSecrecySettings && }
); } From 5a417b1402e65c35699585eac348a32bf9b467ef Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Sun, 25 Aug 2024 17:44:08 -0700 Subject: [PATCH 05/17] fix timezone issue --- .../settings/components/dataSecrecy/index.tsx | 328 +++++++----------- .../components/dataSecrecy/index2.tsx | 164 --------- .../index.spec.tsx | 141 -------- .../organizationSecurityAndPrivacy/index.tsx | 6 +- 4 files changed, 123 insertions(+), 516 deletions(-) delete mode 100644 static/app/views/settings/components/dataSecrecy/index2.tsx delete mode 100644 static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx diff --git a/static/app/views/settings/components/dataSecrecy/index.tsx b/static/app/views/settings/components/dataSecrecy/index.tsx index 007c16dd56359d..8aa9b2abea4969 100644 --- a/static/app/views/settings/components/dataSecrecy/index.tsx +++ b/static/app/views/settings/components/dataSecrecy/index.tsx @@ -1,20 +1,18 @@ -import React, {useEffect, useState} from 'react'; -import styled from '@emotion/styled'; +import {useEffect, useState} from 'react'; +import moment from 'moment-timezone'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import {Button} from 'sentry/components/button'; -import Confirm from 'sentry/components/confirm'; -import Form from 'sentry/components/forms/form'; -import JsonForm from 'sentry/components/forms/jsonForm'; -import LoadingError from 'sentry/components/loadingError'; -import LoadingIndicator from 'sentry/components/loadingIndicator'; +import BooleanField, { + type BooleanFieldProps, +} from 'sentry/components/forms/fields/booleanField'; +import DateTimeField, { + type DateTimeFieldProps, +} from 'sentry/components/forms/fields/dateTimeField'; import Panel from 'sentry/components/panels/panel'; import PanelAlert from 'sentry/components/panels/panelAlert'; import PanelBody from 'sentry/components/panels/panelBody'; import PanelHeader from 'sentry/components/panels/panelHeader'; -import Text from 'sentry/components/text'; import {t, tct} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; import {useApiQuery} from 'sentry/utils/queryClient'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; @@ -24,232 +22,148 @@ type WaiverData = { access_start: string | null; }; -const formatDateTime = (dateString: string) => { - const date = new Date(dateString); - const options: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: 'numeric', - hour12: true, - timeZoneName: 'short', - }; - return new Intl.DateTimeFormat('en-US', options).format(date); -}; +export default function DataSecrecy() { + const api = useApi(); + const organization = useOrganization(); -const formatDateForInput = (dateString: string | null | undefined) => { - if (!dateString) return ''; - const date = new Date(dateString); - return date.toISOString().slice(0, 16); -}; + const [allowAccess, setAllowAccess] = useState(organization.allowSuperuserAccess); + const [allowDate, setAllowDate] = useState(); + const [allowDateFormData, setAllowDateFormData] = useState(''); -const getWaiverStatus = (waiverData: WaiverData | null) => { - if (!waiverData || !waiverData.access_start || !waiverData.access_end) { - return { - status: t('Data secrecy is not currently waived'), - isPast: false, - isFuture: false, - isCurrentlyWaived: false, - }; - } - - const now = new Date(); - const startDate = new Date(waiverData.access_start); - const endDate = new Date(waiverData.access_end); - - if (startDate <= now && endDate > now) { - return { - status: tct('Data secrecy is currently waived until [to]', { - to: {formatDateTime(waiverData.access_end)}, - }), - isPast: false, - isFuture: false, - isCurrentlyWaived: true, - }; - } - if (startDate > now) { - return { - status: tct('Data secrecy will be waived from [from] until [to]', { - from: {formatDateTime(waiverData.access_start)}, - to: {formatDateTime(waiverData.access_end)}, - }), - isPast: false, - isFuture: true, - isCurrentlyWaived: false, - }; - } - - return { - status: tct('Data secrecy was waived [from] until [to]', { - from: {formatDateTime(waiverData.access_start)}, - to: {formatDateTime(waiverData.access_end)}, - }), - isPast: true, - isFuture: false, - isCurrentlyWaived: false, - }; -}; + const {data, refetch} = useApiQuery( + [`/organizations/${organization.slug}/data-secrecy/`], + { + staleTime: 3000, + retry: (failureCount, error) => failureCount < 3 && error.status !== 404, + } + ); -export function DataSecrecy() { - const api = useApi(); - const organization = useOrganization(); - const [isEditing, setIsEditing] = useState(false); - const [waiverData, setWaiverData] = useState(null); - - const { - isLoading, - data, - refetch, - error: queryError, - } = useApiQuery([`/organizations/${organization.slug}/data-secrecy/`], { - staleTime: 3000, - retry: (failureCount, error) => failureCount < 3 && error.status !== 404, - }); + const hasValidTempAccess = + allowDate?.access_end && moment().toISOString() < allowDate.access_end; useEffect(() => { - if (data) { - setWaiverData(data); + if (data?.access_end) { + setAllowDate(data); + // slice it to yyyy-MM-ddThh:mm format (be aware of the timezone) + const localDate = moment(data.access_end).local(); + setAllowDateFormData(localDate.format('YYYY-MM-DDTHH:mm')); } }, [data]); - const initialData = { - access_start: - formatDateForInput(waiverData?.access_start) || - formatDateForInput(new Date().toISOString()), - access_end: formatDateForInput(waiverData?.access_end) || '', + const updateAllowedAccess = async (value: boolean) => { + try { + await api.requestPromise(`/organizations/${organization.slug}/`, { + method: 'PUT', + data: {allowSuperuserAccess: value}, + }); + setAllowAccess(value); + addSuccessMessage(t('Successfully updated access.')); + } catch (error) { + addErrorMessage(t('Unable to save changes.')); + } }; - if (isLoading) { - return ; - } + const updateTempAccessDate = async () => { + if (!allowDateFormData) { + try { + await api.requestPromise(`/organizations/${organization.slug}/data-secrecy/`, { + method: 'DELETE', + }); + setAllowDate({access_start: '', access_end: ''}); + addSuccessMessage(t('Successfully removed temporary access window.')); + } catch (error) { + addErrorMessage(t('Unable to remove temporary access window.')); + } + + return; + } - if (queryError && queryError.status !== 404) { - return ; - } + // maintain the standard format of storing the date in UTC + // even though the api should be able to handle the local time + const nextData = { + access_start: moment().utc().toISOString(), + access_end: moment.tz(allowDateFormData, moment.tz.guess()).utc().toISOString(), + }; - const handleSubmit = async formData => { try { - const response = await api.requestPromise( + await await api.requestPromise( `/organizations/${organization.slug}/data-secrecy/`, { method: 'PUT', - data: { - access_start: new Date(formData.access_start || '').toISOString(), - access_end: new Date(formData.access_end || '').toISOString(), - }, + data: nextData, } ); - - setWaiverData(response); - addSuccessMessage(t('Successfully updated data secrecy waiver')); - setIsEditing(false); + setAllowDate(nextData); + addSuccessMessage(t('Successfully updated temporary access window.')); } catch (error) { - addErrorMessage(t('Unable to update data secrecy waiver')); + addErrorMessage(t('Unable to save changes.')); + setAllowDateFormData(''); } + // refetch to get the latest waiver data + refetch(); }; - const handleDelete = async () => { - try { - await api.requestPromise(`/organizations/${organization.slug}/data-secrecy/`, { - method: 'DELETE', - }); - - setWaiverData(null); - addSuccessMessage(t('Successfully removed data secrecy waiver')); - } catch (error) { - addErrorMessage(t('Unable to remove data secrecy waiver')); - } + const allowAccessProps: BooleanFieldProps = { + name: 'allowSuperuserAccess', + label: t('Allow access to Sentry employees'), + help: t( + 'Sentry employees will not have access to your organization unless granted permission' + ), + 'aria-label': t( + 'Sentry employees will not have access to your data unless granted permission' + ), + value: allowAccess, + onBlur: updateAllowedAccess, }; - const waiverStatus = getWaiverStatus(waiverData); - const showWaiver = !waiverStatus.isPast; - const showRemoveButton = waiverStatus.isCurrentlyWaived || waiverStatus.isFuture; + const allowTempAccessProps: DateTimeFieldProps = { + name: 'allowTemporarySuperuserAccess', + label: t('Allow temporary access for Sentry employees'), + help: t( + 'Open a temporary time window for Sentry employees to access your organization' + ), + disabled: allowAccess, + value: allowAccess ? '' : allowDateFormData, + onBlur: updateTempAccessDate, + onChange: v => { + // the picker doesn't like having a datetime string with seconds+ and a timezone, + // so we remove it -- we will add it back when we save the date + const formattedDate = v ? moment(v).format('YYYY-MM-DDTHH:mm') : ''; + setAllowDateFormData(formattedDate); + }, + }; return ( - - {t('Data Secrecy Waiver')} - {!isEditing && showWaiver && !organization.allowSuperuserAccess && ( - - - {showRemoveButton && ( - - - - )} - - - + {t('Support Access')} + + {!allowAccess && ( + + {hasValidTempAccess + ? tct(`Sentry employees has access to your organization until [date]`, { + date: formatDateTime(allowDate?.access_end as string), + }) + : t('Sentry employees do not have access to your organization')} + )} - - {organization.allowSuperuserAccess ? ( - - {t( - 'Superuser access is currently enabled. To turn superuser access off, toggle the setting above.' - )} - - ) : ( - - {t( - 'Data secrecy waiver allows Sentry employees to access the organization temporarily to address issues.' - )} - - )} - {!organization.allowSuperuserAccess && ( - - {isEditing ? ( -
setIsEditing(false)} - > - - - ) : ( - {waiverStatus.status} - )} -
- )} + + + +
); } -const PanelAction = styled('div')` - padding: ${space(1)} ${space(2)}; - position: relative; - display: grid; - gap: ${space(1)}; - grid-template-columns: auto auto; - justify-content: flex-end; -`; - -const StyledPanelBody = styled(PanelBody)` - padding: ${space(2)}; -`; +const formatDateTime = (dateString: string) => { + const date = new Date(dateString); + const options: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, + timeZoneName: 'short', + }; + return new Intl.DateTimeFormat('en-US', options).format(date); +}; diff --git a/static/app/views/settings/components/dataSecrecy/index2.tsx b/static/app/views/settings/components/dataSecrecy/index2.tsx deleted file mode 100644 index 2ce37dc4dd74e8..00000000000000 --- a/static/app/views/settings/components/dataSecrecy/index2.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import {useEffect, useState} from 'react'; -import moment from 'moment-timezone'; - -import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; -import BooleanField, { - type BooleanFieldProps, -} from 'sentry/components/forms/fields/booleanField'; -import DateTimeField, { - type DateTimeFieldProps, -} from 'sentry/components/forms/fields/dateTimeField'; -import Panel from 'sentry/components/panels/panel'; -import PanelAlert from 'sentry/components/panels/panelAlert'; -import PanelBody from 'sentry/components/panels/panelBody'; -import PanelHeader from 'sentry/components/panels/panelHeader'; -import {t, tct} from 'sentry/locale'; -import {useApiQuery} from 'sentry/utils/queryClient'; -import useApi from 'sentry/utils/useApi'; -import useOrganization from 'sentry/utils/useOrganization'; - -type WaiverData = { - access_end: string | null; - access_start: string | null; -}; - -export default function DataSecrecy() { - const api = useApi(); - const organization = useOrganization(); - - const [allowAccess, setAllowAccess] = useState(organization.allowSuperuserAccess); - const [allowDate, setAllowDate] = useState(); - const [allowDateFormData, setAllowDateFormData] = useState(''); - - const {data} = useApiQuery( - [`/organizations/${organization.slug}/data-secrecy/`], - { - staleTime: 3000, - retry: (failureCount, error) => failureCount < 3 && error.status !== 404, - } - ); - - const hasValidTempAccess = - allowDate?.access_end && moment().toISOString() < allowDate.access_end; - - useEffect(() => { - if (data?.access_end) { - setAllowDate(data); - - // Slice it to yyyy-MM-ddThh:mmc - const dateObj = moment.utc(data.access_end); - const formattedDate = dateObj.local().format('YYYY-MM-DDTHH:MM'); - setAllowDateFormData(formattedDate); - } - }, [data]); - - const updateAllowedAccess = async (value: boolean) => { - try { - await api.requestPromise(`/organizations/${organization.slug}/`, { - method: 'PUT', - data: {allowSuperuserAccess: value}, - }); - setAllowAccess(value); - addSuccessMessage(t('Successfully updated access.')); - } catch (error) { - addErrorMessage(t('Unable to save changes.')); - } - }; - - const updateTempAccessDate = async () => { - if (!allowDateFormData) { - try { - await api.requestPromise(`/organizations/${organization.slug}/data-secrecy/`, { - method: 'DELETE', - }); - setAllowDate({access_start: '', access_end: ''}); - addSuccessMessage(t('Successfully removed temporary access window.')); - } catch (error) { - addErrorMessage(t('Unable to remove temporary access window.')); - } - - return; - } - - const nextData = { - access_start: moment().toISOString(), - access_end: moment(allowDateFormData).toISOString(), - }; - - try { - await await api.requestPromise( - `/organizations/${organization.slug}/data-secrecy/`, - { - method: 'PUT', - data: nextData, - } - ); - setAllowDate(nextData); - addSuccessMessage(t('Successfully updated temporary access window.')); - } catch (error) { - addErrorMessage(t('Unable to save changes.')); - } - }; - - const allowAccessProps: BooleanFieldProps = { - name: 'allowSuperuserAccess', - label: t('Allow access to Sentry employees'), - help: t( - 'Sentry employees will not have access to your organization unless granted permission' - ), - 'aria-label': t( - 'Sentry employees will not have access to your data unless granted permission' - ), - value: allowAccess, - onBlur: updateAllowedAccess, - }; - - const allowTempAccessProps: DateTimeFieldProps = { - name: 'allowTemporarySuperuserAccess', - label: t('Allow temporary access for Sentry employees'), - help: t( - 'Open a temporary time window for Sentry employees to access your organization' - ), - disabled: allowAccess, - value: allowAccess ? '' : allowDateFormData, - onBlur: updateTempAccessDate, - onChange: v => { - // Convert to UTC before setting into state - setAllowDateFormData(moment(v).toISOString()); - }, - }; - - return ( - - {t('Support Access 2')} - - {!allowAccess && ( - - {hasValidTempAccess - ? tct(`Sentry employees has access to your organization until [date]`, { - date: formatDateTime(allowDate?.access_end as string), - }) - : t('Sentry employees do not have access to your organization')} - - )} - - - - - - ); -} - -const formatDateTime = (dateString: string) => { - const date = new Date(dateString); - const options: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: 'numeric', - hour12: true, - timeZoneName: 'short', - }; - return new Intl.DateTimeFormat('en-US', options).format(date); -}; diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx deleted file mode 100644 index c84d63bdaf3a8a..00000000000000 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import {Fragment} from 'react'; - -import {initializeOrg} from 'sentry-test/initializeOrg'; -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; - -import GlobalModal from 'sentry/components/globalModal'; -import OrganizationSecurityAndPrivacy from 'sentry/views/settings/organizationSecurityAndPrivacy'; - -describe('OrganizationSecurityAndPrivacy', function () { - const {organization} = initializeOrg(); - - beforeEach(() => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/auth-provider/`, - method: 'GET', - body: {}, - }); - }); - - it('shows require2fa switch', async function () { - render(); - - expect( - await screen.findByRole('checkbox', { - name: 'Enable to require and enforce two-factor authentication for all members', - }) - ).toBeInTheDocument(); - }); - - it('returns to "off" if switch enable fails (e.g. API error)', async function () { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/`, - method: 'PUT', - statusCode: 500, - }); - - render( - - - - - ); - - await userEvent.click( - await screen.findByRole('checkbox', { - name: 'Enable to require and enforce two-factor authentication for all members', - }) - ); - - // Hide console.error for this test - jest.spyOn(console, 'error').mockImplementation(() => {}); - - // Confirm but has API failure - await userEvent.click(screen.getByRole('button', {name: 'Confirm'})); - - expect( - await screen.findByRole('checkbox', { - name: 'Enable to require and enforce two-factor authentication for all members', - }) - ).not.toBeChecked(); - }); - - it('renders join request switch', async function () { - render(); - - expect( - await screen.findByRole('checkbox', { - name: 'Enable to allow users to request to join your organization', - }) - ).toBeInTheDocument(); - }); - - it('enables require2fa but cancels confirm modal', async function () { - const mock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/`, - method: 'PUT', - }); - - render( - - - - - ); - - await userEvent.click( - await screen.findByRole('checkbox', { - name: 'Enable to require and enforce two-factor authentication for all members', - }) - ); - - // Cancel - await userEvent.click(screen.getByRole('button', {name: 'Cancel'})); - - expect( - screen.getByRole('checkbox', { - name: 'Enable to require and enforce two-factor authentication for all members', - }) - ).not.toBeChecked(); - - expect(mock).not.toHaveBeenCalled(); - }); - - it('enables require2fa with confirm modal', async function () { - const mock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/`, - method: 'PUT', - }); - - render( - - - - - ); - - await userEvent.click( - await screen.findByRole('checkbox', { - name: 'Enable to require and enforce two-factor authentication for all members', - }) - ); - - await userEvent.click(screen.getByRole('button', {name: 'Confirm'})); - - expect( - screen.getByRole('checkbox', { - name: 'Enable to require and enforce two-factor authentication for all members', - }) - ).toBeChecked(); - - expect(mock).toHaveBeenCalledWith( - '/organizations/org-slug/', - expect.objectContaining({ - method: 'PUT', - data: { - require2FA: true, - }, - }) - ); - }); -}); diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx index a7232024c634a7..9c2f808b4291d9 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx @@ -13,8 +13,7 @@ import type {AuthProvider} from 'sentry/types/auth'; import type {Organization} from 'sentry/types/organization'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; -import {DataSecrecy} from 'sentry/views/settings/components/dataSecrecy'; -import DataSecrecy2 from 'sentry/views/settings/components/dataSecrecy/index2'; +import DataSecrecy from 'sentry/views/settings/components/dataSecrecy/index'; import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader'; import {DataScrubbing} from '../components/dataScrubbing'; @@ -83,8 +82,7 @@ export default function OrganizationSecurityAndPrivacyContent() { /> - {!showDataSecrecySettings && } - {!showDataSecrecySettings && } + {showDataSecrecySettings && }
Date: Sun, 25 Aug 2024 18:21:15 -0700 Subject: [PATCH 06/17] fix tests --- .../components/dataSecrecy/index.spec.tsx | 116 ++------------ .../index.spec.tsx | 141 ++++++++++++++++++ 2 files changed, 156 insertions(+), 101 deletions(-) create mode 100644 static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx diff --git a/static/app/views/settings/components/dataSecrecy/index.spec.tsx b/static/app/views/settings/components/dataSecrecy/index.spec.tsx index f4d0b3fd877ef9..595bc5f48e150a 100644 --- a/static/app/views/settings/components/dataSecrecy/index.spec.tsx +++ b/static/app/views/settings/components/dataSecrecy/index.spec.tsx @@ -1,14 +1,7 @@ import {initializeOrg} from 'sentry-test/initializeOrg'; -import { - render, - renderGlobalModal, - screen, - userEvent, - waitFor, -} from 'sentry-test/reactTestingLibrary'; +import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; -import * as indicatorActions from 'sentry/actionCreators/indicator'; -import {DataSecrecy} from 'sentry/views/settings/components/dataSecrecy'; +import DataSecrecy from 'sentry/views/settings/components/dataSecrecy'; jest.mock('sentry/actionCreators/indicator'); @@ -29,115 +22,36 @@ describe('DataSecrecy', function () { render(, {organization: organization}); await waitFor(() => { - expect(screen.getByText('Data Secrecy Waiver')).toBeInTheDocument(); - expect( - screen.getByText('Data secrecy is not currently waived') - ).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Add Waiver'})).toBeInTheDocument(); - }); - }); - - it('renders current waiver state', async function () { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/data-secrecy/`, - body: { - access_start: '2023-08-01T00:00:00Z', - access_end: '2024-08-01T00:00:00Z', - }, - }); - - render(, {organization: organization}); - - await waitFor(() => { - expect(screen.getByText(/Data secrecy will be waived from/)).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Edit'})).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Remove Waiver'})).toBeInTheDocument(); - }); - }); - - it('opens edit form when Edit button is clicked', async function () { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/data-secrecy/`, - body: { - access_start: '2023-08-01T00:00:00Z', - access_end: '2024-08-01T00:00:00Z', - }, - }); - - render(, {organization: organization}); - - const editButton = await screen.findByRole('button', {name: 'Edit'}); - await userEvent.click(editButton); - - await waitFor(() => { - expect(screen.getByText(/waiver start time/i)).toBeInTheDocument(); - expect(screen.getByDisplayValue(/2023\-08\-01t00:00/i)).toBeInTheDocument(); - expect(screen.getByText(/waiver end time/i)).toBeInTheDocument(); + expect(screen.getByText('Support Access')).toBeInTheDocument(); }); - }); - - it('submits form successfully', async function () { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/data-secrecy/`, - body: null, - }); - - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/data-secrecy/`, - method: 'PUT', - statusCode: 200, - }); - - render(, {organization: organization}); - - const addWaiverButton = await screen.findByRole('button', {name: 'Add Waiver'}); - await userEvent.click(addWaiverButton); - - await userEvent.type(await screen.getByText(/waiver end time/i), '2025-08-01T00:00'); - const saveButton = await screen.findByRole('button', {name: 'Save Changes'}); - await userEvent.click(saveButton); + organization.allowSuperuserAccess = false; await waitFor(() => { - expect(indicatorActions.addSuccessMessage).toHaveBeenCalledWith( - 'Successfully updated data secrecy waiver' - ); + expect( + screen.getByText(/sentry employees do not have access to your organization/i) + ).toBeInTheDocument(); }); }); - it('removes waiver successfully', async function () { + it('renders current waiver state', async function () { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/data-secrecy/`, body: { - access_start: '2023-08-01T00:00:00Z', - access_end: '2024-08-01T00:00:00Z', + access_start: '2023-08-29T01:05:00+00:00', + access_end: '2024-08-29T01:05:00+00:00', }, }); - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/data-secrecy/`, - method: 'DELETE', - statusCode: 204, - }); - + organization.allowSuperuserAccess = false; render(, {organization: organization}); await waitFor(() => { - expect(screen.getByText('Data Secrecy Waiver')).toBeInTheDocument(); - }); - - const removeWaiverButton = await screen.getByRole('button', {name: /remove waiver/i}); - await userEvent.click(removeWaiverButton); - renderGlobalModal(); - - const confirmButton = await screen.findByRole('button', {name: 'Confirm'}); - - await userEvent.click(confirmButton); - - await waitFor(() => { - expect(indicatorActions.addSuccessMessage).toHaveBeenCalledWith( - 'Successfully removed data secrecy waiver' + const accessMessage = screen.getByText( + /Sentry employees has access to your organization until/i ); + expect(accessMessage).toBeInTheDocument(); + expect(screen.getByDisplayValue(/2024\-08\-28t18:05/i)).toBeInTheDocument(); }); }); }); diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx new file mode 100644 index 00000000000000..c84d63bdaf3a8a --- /dev/null +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx @@ -0,0 +1,141 @@ +import {Fragment} from 'react'; + +import {initializeOrg} from 'sentry-test/initializeOrg'; +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import GlobalModal from 'sentry/components/globalModal'; +import OrganizationSecurityAndPrivacy from 'sentry/views/settings/organizationSecurityAndPrivacy'; + +describe('OrganizationSecurityAndPrivacy', function () { + const {organization} = initializeOrg(); + + beforeEach(() => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/auth-provider/`, + method: 'GET', + body: {}, + }); + }); + + it('shows require2fa switch', async function () { + render(); + + expect( + await screen.findByRole('checkbox', { + name: 'Enable to require and enforce two-factor authentication for all members', + }) + ).toBeInTheDocument(); + }); + + it('returns to "off" if switch enable fails (e.g. API error)', async function () { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + method: 'PUT', + statusCode: 500, + }); + + render( + + + + + ); + + await userEvent.click( + await screen.findByRole('checkbox', { + name: 'Enable to require and enforce two-factor authentication for all members', + }) + ); + + // Hide console.error for this test + jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Confirm but has API failure + await userEvent.click(screen.getByRole('button', {name: 'Confirm'})); + + expect( + await screen.findByRole('checkbox', { + name: 'Enable to require and enforce two-factor authentication for all members', + }) + ).not.toBeChecked(); + }); + + it('renders join request switch', async function () { + render(); + + expect( + await screen.findByRole('checkbox', { + name: 'Enable to allow users to request to join your organization', + }) + ).toBeInTheDocument(); + }); + + it('enables require2fa but cancels confirm modal', async function () { + const mock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + method: 'PUT', + }); + + render( + + + + + ); + + await userEvent.click( + await screen.findByRole('checkbox', { + name: 'Enable to require and enforce two-factor authentication for all members', + }) + ); + + // Cancel + await userEvent.click(screen.getByRole('button', {name: 'Cancel'})); + + expect( + screen.getByRole('checkbox', { + name: 'Enable to require and enforce two-factor authentication for all members', + }) + ).not.toBeChecked(); + + expect(mock).not.toHaveBeenCalled(); + }); + + it('enables require2fa with confirm modal', async function () { + const mock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/`, + method: 'PUT', + }); + + render( + + + + + ); + + await userEvent.click( + await screen.findByRole('checkbox', { + name: 'Enable to require and enforce two-factor authentication for all members', + }) + ); + + await userEvent.click(screen.getByRole('button', {name: 'Confirm'})); + + expect( + screen.getByRole('checkbox', { + name: 'Enable to require and enforce two-factor authentication for all members', + }) + ).toBeChecked(); + + expect(mock).toHaveBeenCalledWith( + '/organizations/org-slug/', + expect.objectContaining({ + method: 'PUT', + data: { + require2FA: true, + }, + }) + ); + }); +}); From 34bb23bde066b790eaff4b969a04914feb62627a Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Sun, 25 Aug 2024 18:40:00 -0700 Subject: [PATCH 07/17] fix tests --- .../settings/organizationSecurityAndPrivacy/index.spec.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx index c84d63bdaf3a8a..dfb3ee0a6f8d38 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.spec.tsx @@ -15,6 +15,12 @@ describe('OrganizationSecurityAndPrivacy', function () { method: 'GET', body: {}, }); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-secrecy/`, + method: 'GET', + body: null, + }); }); it('shows require2fa switch', async function () { From 4f667b3b49e37d1f17b6c7c79d2919f961ab4717 Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Sun, 25 Aug 2024 21:09:10 -0700 Subject: [PATCH 08/17] fix test --- .../app/views/settings/components/dataSecrecy/index.spec.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/app/views/settings/components/dataSecrecy/index.spec.tsx b/static/app/views/settings/components/dataSecrecy/index.spec.tsx index 595bc5f48e150a..f105bf382bb3f7 100644 --- a/static/app/views/settings/components/dataSecrecy/index.spec.tsx +++ b/static/app/views/settings/components/dataSecrecy/index.spec.tsx @@ -6,7 +6,9 @@ import DataSecrecy from 'sentry/views/settings/components/dataSecrecy'; jest.mock('sentry/actionCreators/indicator'); describe('DataSecrecy', function () { - const {organization} = initializeOrg(); + const {organization} = initializeOrg({ + organization: {features: ['data-secrecy']}, + }); beforeEach(function () { MockApiClient.clearMockResponses(); @@ -51,7 +53,6 @@ describe('DataSecrecy', function () { /Sentry employees has access to your organization until/i ); expect(accessMessage).toBeInTheDocument(); - expect(screen.getByDisplayValue(/2024\-08\-28t18:05/i)).toBeInTheDocument(); }); }); }); From cf02a038cc5adfedcc2b51891427d4967f55aa4a Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Mon, 26 Aug 2024 09:28:12 -0700 Subject: [PATCH 09/17] more tests --- .../components/dataSecrecy/index.spec.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/static/app/views/settings/components/dataSecrecy/index.spec.tsx b/static/app/views/settings/components/dataSecrecy/index.spec.tsx index f105bf382bb3f7..8bfd3e7edde24f 100644 --- a/static/app/views/settings/components/dataSecrecy/index.spec.tsx +++ b/static/app/views/settings/components/dataSecrecy/index.spec.tsx @@ -36,6 +36,54 @@ describe('DataSecrecy', function () { }); }); + it('renders default state with waiver', async function () { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-secrecy/`, + body: null, + }); + + render(, {organization: organization}); + + await waitFor(() => { + expect(screen.getByText('Support Access')).toBeInTheDocument(); + }); + + organization.allowSuperuserAccess = true; + + await waitFor(() => { + expect( + screen.getByText(/sentry employees do not have access to your organization/i) + ).toBeInTheDocument(); + }); + }); + + it('renders no access state with waiver present', async function () { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/data-secrecy/`, + body: { + access_start: '2022-08-29T01:05:00+00:00', + access_end: '2023-08-29T01:05:00+00:00', + }, + }); + + render(, {organization: organization}); + + await waitFor(() => { + expect(screen.getByText('Support Access')).toBeInTheDocument(); + }); + + organization.allowSuperuserAccess = false; + + // we should see no access message + await waitFor(() => { + expect( + screen.getByText( + /sentry employees will not have access to your organization unless granted permission/i + ) + ).toBeInTheDocument(); + }); + }); + it('renders current waiver state', async function () { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/data-secrecy/`, From d7d64f0859335bcf47320ee36864b412996bc85c Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Mon, 26 Aug 2024 21:18:29 -0700 Subject: [PATCH 10/17] add disabled check --- static/app/views/settings/components/dataSecrecy/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/static/app/views/settings/components/dataSecrecy/index.tsx b/static/app/views/settings/components/dataSecrecy/index.tsx index 8aa9b2abea4969..6da3b91590e1e1 100644 --- a/static/app/views/settings/components/dataSecrecy/index.tsx +++ b/static/app/views/settings/components/dataSecrecy/index.tsx @@ -113,6 +113,7 @@ export default function DataSecrecy() { 'Sentry employees will not have access to your data unless granted permission' ), value: allowAccess, + disabled: organization.access.includes('org:write'), onBlur: updateAllowedAccess, }; @@ -122,7 +123,7 @@ export default function DataSecrecy() { help: t( 'Open a temporary time window for Sentry employees to access your organization' ), - disabled: allowAccess, + disabled: allowAccess && organization.access.includes('org:write'), value: allowAccess ? '' : allowDateFormData, onBlur: updateTempAccessDate, onChange: v => { From 326d564960f89ef26dd5a901a6546a89197b7503 Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Mon, 26 Aug 2024 21:26:11 -0700 Subject: [PATCH 11/17] add comment --- .../app/views/settings/organizationSecurityAndPrivacy/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx index 9c2f808b4291d9..ef25d5f78973c7 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx @@ -51,6 +51,7 @@ export default function OrganizationSecurityAndPrivacyContent() { } const {isSelfHosted} = ConfigStore.getState(); + // only need data secrecy in saas const showDataSecrecySettings = !organization.features.includes('data-secrecy') && !isSelfHosted; From 5bd93c0e45b879e0387df7ec73897d58791d7053 Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Mon, 26 Aug 2024 21:28:33 -0700 Subject: [PATCH 12/17] precommit hook complain fix --- .../app/views/settings/organizationSecurityAndPrivacy/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx index ef25d5f78973c7..3b55f9a59e5e44 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx @@ -1,5 +1,5 @@ import {Fragment, useEffect, useState} from 'react'; -import {cloneDeep} from 'lodash'; +import {cloneDeep} from 'lodash/cloneDeep'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {updateOrganization} from 'sentry/actionCreators/organizations'; From 462062481898051d72fef56ca3f4e8b43d2c08fa Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Mon, 26 Aug 2024 21:35:16 -0700 Subject: [PATCH 13/17] fixing linting --- .../app/views/settings/organizationSecurityAndPrivacy/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx index 3b55f9a59e5e44..a9c8d3268fc81f 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx @@ -1,5 +1,5 @@ import {Fragment, useEffect, useState} from 'react'; -import {cloneDeep} from 'lodash/cloneDeep'; +import cloneDeep from 'lodash/cloneDeep'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {updateOrganization} from 'sentry/actionCreators/organizations'; From aee7da696d972861968e1b2c27e62e646c0d6449 Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Wed, 28 Aug 2024 12:01:54 -0700 Subject: [PATCH 14/17] nits --- .../settings/components/dataSecrecy/index.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/static/app/views/settings/components/dataSecrecy/index.tsx b/static/app/views/settings/components/dataSecrecy/index.tsx index 6da3b91590e1e1..98bc605c103a69 100644 --- a/static/app/views/settings/components/dataSecrecy/index.tsx +++ b/static/app/views/settings/components/dataSecrecy/index.tsx @@ -18,8 +18,8 @@ import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; type WaiverData = { - access_end: string | null; - access_start: string | null; + accessEnd: string | null; + accessStart: string | null; }; export default function DataSecrecy() { @@ -39,13 +39,13 @@ export default function DataSecrecy() { ); const hasValidTempAccess = - allowDate?.access_end && moment().toISOString() < allowDate.access_end; + allowDate?.accessEnd && moment().toISOString() < allowDate.accessEnd; useEffect(() => { - if (data?.access_end) { + if (data?.accessEnd) { setAllowDate(data); // slice it to yyyy-MM-ddThh:mm format (be aware of the timezone) - const localDate = moment(data.access_end).local(); + const localDate = moment(data.accessEnd).local(); setAllowDateFormData(localDate.format('YYYY-MM-DDTHH:mm')); } }, [data]); @@ -69,7 +69,7 @@ export default function DataSecrecy() { await api.requestPromise(`/organizations/${organization.slug}/data-secrecy/`, { method: 'DELETE', }); - setAllowDate({access_start: '', access_end: ''}); + setAllowDate({accessStart: '', accessEnd: ''}); addSuccessMessage(t('Successfully removed temporary access window.')); } catch (error) { addErrorMessage(t('Unable to remove temporary access window.')); @@ -80,9 +80,9 @@ export default function DataSecrecy() { // maintain the standard format of storing the date in UTC // even though the api should be able to handle the local time - const nextData = { - access_start: moment().utc().toISOString(), - access_end: moment.tz(allowDateFormData, moment.tz.guess()).utc().toISOString(), + const nextData: WaiverData = { + accessStart: moment().utc().toISOString(), + accessEnd: moment.tz(allowDateFormData, moment.tz.guess()).utc().toISOString(), }; try { @@ -119,7 +119,7 @@ export default function DataSecrecy() { const allowTempAccessProps: DateTimeFieldProps = { name: 'allowTemporarySuperuserAccess', - label: t('Allow temporary access for Sentry employees'), + label: t('Allow temporary access to Sentry employees'), help: t( 'Open a temporary time window for Sentry employees to access your organization' ), @@ -142,7 +142,7 @@ export default function DataSecrecy() { {hasValidTempAccess ? tct(`Sentry employees has access to your organization until [date]`, { - date: formatDateTime(allowDate?.access_end as string), + date: formatDateTime(allowDate?.accessEnd as string), }) : t('Sentry employees do not have access to your organization')} From 6568bedfa69c982cea7d5e09d71ded2c3506d6d9 Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Wed, 28 Aug 2024 15:46:08 -0700 Subject: [PATCH 15/17] fix to use camelcase & tiny bug --- .../views/settings/components/dataSecrecy/index.spec.tsx | 8 ++++---- .../app/views/settings/components/dataSecrecy/index.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/static/app/views/settings/components/dataSecrecy/index.spec.tsx b/static/app/views/settings/components/dataSecrecy/index.spec.tsx index 8bfd3e7edde24f..d509fe8ba333e6 100644 --- a/static/app/views/settings/components/dataSecrecy/index.spec.tsx +++ b/static/app/views/settings/components/dataSecrecy/index.spec.tsx @@ -61,8 +61,8 @@ describe('DataSecrecy', function () { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/data-secrecy/`, body: { - access_start: '2022-08-29T01:05:00+00:00', - access_end: '2023-08-29T01:05:00+00:00', + accessStart: '2022-08-29T01:05:00+00:00', + accessEnd: '2023-08-29T01:05:00+00:00', }, }); @@ -88,8 +88,8 @@ describe('DataSecrecy', function () { MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/data-secrecy/`, body: { - access_start: '2023-08-29T01:05:00+00:00', - access_end: '2024-08-29T01:05:00+00:00', + accessStart: '2023-08-29T01:05:00+00:00', + accessEnd: '2024-08-29T01:05:00+00:00', }, }); diff --git a/static/app/views/settings/components/dataSecrecy/index.tsx b/static/app/views/settings/components/dataSecrecy/index.tsx index 98bc605c103a69..db3d76a8803ea8 100644 --- a/static/app/views/settings/components/dataSecrecy/index.tsx +++ b/static/app/views/settings/components/dataSecrecy/index.tsx @@ -113,7 +113,7 @@ export default function DataSecrecy() { 'Sentry employees will not have access to your data unless granted permission' ), value: allowAccess, - disabled: organization.access.includes('org:write'), + disabled: !organization.access.includes('org:write'), onBlur: updateAllowedAccess, }; @@ -123,7 +123,7 @@ export default function DataSecrecy() { help: t( 'Open a temporary time window for Sentry employees to access your organization' ), - disabled: allowAccess && organization.access.includes('org:write'), + disabled: allowAccess && !organization.access.includes('org:write'), value: allowAccess ? '' : allowDateFormData, onBlur: updateTempAccessDate, onChange: v => { From b8752bf8cfc69ddbca478bc079d477595473207b Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Tue, 3 Sep 2024 08:30:08 -0700 Subject: [PATCH 16/17] fix when to show ds --- .../app/views/settings/organizationSecurityAndPrivacy/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx index a9c8d3268fc81f..918434c984e153 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx @@ -53,7 +53,7 @@ export default function OrganizationSecurityAndPrivacyContent() { const {isSelfHosted} = ConfigStore.getState(); // only need data secrecy in saas const showDataSecrecySettings = - !organization.features.includes('data-secrecy') && !isSelfHosted; + organization.features.includes('data-secrecy') && !isSelfHosted; const [securityFormConfig, dataScrubbingFormConfig] = cloneDeep( organizationSecurityAndPrivacyGroups From 5441d069c8b37b965a447da47bfa2a8ef81557e4 Mon Sep 17 00:00:00 2001 From: Raj Joshi Date: Tue, 3 Sep 2024 08:56:45 -0700 Subject: [PATCH 17/17] remove old code --- .../organizationSecurityAndPrivacy/index.tsx | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx index 918434c984e153..0e588102704403 100644 --- a/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx +++ b/static/app/views/settings/organizationSecurityAndPrivacy/index.tsx @@ -1,5 +1,4 @@ import {Fragment, useEffect, useState} from 'react'; -import cloneDeep from 'lodash/cloneDeep'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {updateOrganization} from 'sentry/actionCreators/organizations'; @@ -55,10 +54,6 @@ export default function OrganizationSecurityAndPrivacyContent() { const showDataSecrecySettings = organization.features.includes('data-secrecy') && !isSelfHosted; - const [securityFormConfig, dataScrubbingFormConfig] = cloneDeep( - organizationSecurityAndPrivacyGroups - ); - return ( @@ -77,7 +72,7 @@ export default function OrganizationSecurityAndPrivacyContent() { > @@ -85,24 +80,6 @@ export default function OrganizationSecurityAndPrivacyContent() { {showDataSecrecySettings && } - addErrorMessage(t('Unable to save change'))} - saveOnBlur - allowUndo - > - - -