Skip to content

Commit

Permalink
Implement GDPR export for users
Browse files Browse the repository at this point in the history
  • Loading branch information
JammingBen committed Mar 29, 2023
1 parent 3c01a96 commit 29ad2f3
Show file tree
Hide file tree
Showing 5 changed files with 260 additions and 3 deletions.
6 changes: 6 additions & 0 deletions changelog/unreleased/enhancement-gdpr-export
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Enhancement: GDPR export

Users can now request a GDPR export on their account page. Note that this is only supported when running oCIS as backend.

https://github.com/owncloud/web/issues/8738
https://github.com/owncloud/web/pull/8741
133 changes: 133 additions & 0 deletions packages/web-runtime/src/components/Account/GdprExport.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<template>
<span v-if="loading">
<oc-spinner />
</span>
<span
v-else-if="exportInProgress"
class="oc-flex oc-flex-middle"
data-testid="gdpr-export-in-process"
>
<oc-icon name="time" fill-type="line" size="small" class="oc-mr-s" />
<span v-text="$gettext('Export is being processed. This can take up to 24 hours.')" />
</span>
<div v-else>
<oc-button
appearance="raw"
variation="primary"
data-testid="request-gdpr-export-btn"
@click="requestExport"
>
<span v-text="$gettext('Request new export')" />
</oc-button>
<div v-if="exportFile" class="oc-flex oc-flex-middle">
<oc-button
appearance="raw"
variation="primary"
data-testid="download-gdpr-export-btn"
@click="downloadExport"
>
<oc-icon name="download" fill-type="line" size="small" />
<span v-text="$gettext('Download export')" />
</oc-button>
<span v-oc-tooltip="absoluteExportDate" class="oc-ml-s" v-text="`(${relativeExportDate})`" />
</div>
</div>
</template>

<script lang="ts">
import { computed, defineComponent, onMounted, onUnmounted, ref, unref } from 'vue'
import { useTask } from 'vue-concurrency'
import { useGettext } from 'vue3-gettext'
import { Resource } from 'web-client'
import { useClientService, useStore } from 'web-pkg/src/composables'
import { useDownloadFile } from 'web-pkg/src/composables/download'
import { formatDateFromJSDate, formatRelativeDateFromJSDate } from 'web-pkg'
const GDPR_EXPORT_FILE_NAME = '.personal_data_export.json'
const POLLING_INTERVAL = 30000
export default defineComponent({
name: 'GdprExport',
setup() {
const store = useStore()
const { $gettext, current: currentLanguage } = useGettext()
const clientService = useClientService()
const { downloadFile } = useDownloadFile()
const loading = ref(true)
const checkInterval = ref()
const exportFile = ref<Resource>()
const exportInProgress = ref(false)
const personalSpace = computed(() => {
return store.getters['runtime/spaces/spaces'].find((space) => space.driveType === 'personal')
})
const loadExportTask = useTask(function* () {
try {
exportFile.value = yield clientService.webdav.getFileInfo(unref(personalSpace), {
path: `/${GDPR_EXPORT_FILE_NAME}`
})
if (unref(checkInterval)) {
clearInterval(unref(checkInterval))
}
} catch (e) {
if (e.statusCode === 425) {
// export has been triggered and is currently being processed
exportInProgress.value = true
}
} finally {
loading.value = false
}
}).restartable()
const requestExport = () => {
// TODO: Implement graph
exportInProgress.value = true
checkInterval.value = setInterval(() => {
loadExportTask.perform()
}, POLLING_INTERVAL)
return store.dispatch('showMessage', {
title: $gettext('GDPR export has been requested')
})
}
const downloadExport = () => {
return downloadFile(unref(exportFile))
}
const absoluteExportDate = computed(() => {
return formatDateFromJSDate(new Date(unref(exportFile).mdate), currentLanguage)
})
const relativeExportDate = computed(() => {
return formatRelativeDateFromJSDate(new Date(unref(exportFile).mdate), currentLanguage)
})
onMounted(() => {
loadExportTask.perform()
if (unref(exportInProgress)) {
checkInterval.value = setInterval(() => {
loadExportTask.perform()
}, POLLING_INTERVAL)
}
})
onUnmounted(() => {
if (unref(checkInterval)) {
clearInterval(unref(checkInterval))
}
})
return {
loading,
loadExportTask,
exportFile,
exportInProgress,
requestExport,
downloadExport,
absoluteExportDate,
relativeExportDate
}
}
})
</script>
16 changes: 14 additions & 2 deletions packages/web-runtime/src/pages/account.vue
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@
</oc-button>
</dd>
</div>
<div v-if="isGdprExportEnabled" class="account-page-gdpr-export oc-mb oc-width-1-2@s">
<dt class="oc-text-normal oc-text-muted" v-text="$gettext('GDPR export')" />
<dd data-testid="gdpr-export">
<gdpr-export />
</dd>
</div>
</dl>
</main>
</template>
Expand All @@ -113,22 +119,27 @@ import axios from 'axios'
import { v4 as uuidV4 } from 'uuid'
import { useGettext } from 'vue3-gettext'
import { setCurrentLanguage } from 'web-runtime/src/helpers/language'
import { configurationManager } from 'web-pkg/src/configuration'
import GdprExport from 'web-runtime/src/components/Account/GdprExport.vue'
import { useConfigurationManager } from 'web-pkg/src/composables/configuration'
export default defineComponent({
name: 'Personal',
components: {
EditPasswordModal
EditPasswordModal,
GdprExport
},
setup() {
const store = useStore()
const accessToken = useAccessToken({ store })
const language = useGettext()
const clientService = useClientService()
const configurationManager = useConfigurationManager()
// FIXME: Use graph capability when we have it
const isLanguageSupported = useCapabilitySpacesEnabled()
const isChangePasswordEnabled = useCapabilitySpacesEnabled()
// TODO: use correct capability
const isGdprExportEnabled = true
const user = computed(() => {
return store.getters.user
})
Expand Down Expand Up @@ -235,6 +246,7 @@ export default defineComponent({
updateSelectedLanguage,
accountEditLink,
isChangePasswordEnabled,
isGdprExportEnabled,
isLanguageSupported,
groupNames,
user,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import GdprExport from 'web-runtime/src/components/Account/GdprExport.vue'
import {
createStore,
defaultComponentMocks,
defaultPlugins,
shallowMount,
defaultStoreMockOptions
} from 'web-test-helpers'
import { mock, mockDeep } from 'jest-mock-extended'
import { ClientService } from 'web-pkg'
import { Resource } from 'web-client/src'

const selectors = {
ocSpinnerStub: 'oc-spinner-stub',
requestGdprExportBtn: '[data-testid="request-gdpr-export-btn"]',
downloadGdprExportBtn: '[data-testid="download-gdpr-export-btn"]',
gdprExportInProgress: '[data-testid="gdpr-export-in-process"]'
}

const downloadFile = jest.fn()
jest.mock('web-pkg/src/composables/download', () => ({
useDownloadFile: jest.fn(() => ({ downloadFile }))
}))

describe('GdprExport component', () => {
it('shows the loading spinner initially', () => {
const { wrapper } = getWrapper()
expect(wrapper.find(selectors.ocSpinnerStub).exists()).toBeTruthy()
})
describe('request button', () => {
it('shows if no gdpr export exists', async () => {
const clientService = mockDeep<ClientService>()
clientService.webdav.getFileInfo.mockRejectedValue({ statusCode: 404 })
const { wrapper } = getWrapper(clientService)
await wrapper.vm.loadExportTask.last
expect(wrapper.find(selectors.requestGdprExportBtn).exists()).toBeTruthy()
})
it('does not show when an export is being processed', async () => {
const clientService = mockDeep<ClientService>()
clientService.webdav.getFileInfo.mockRejectedValue({ statusCode: 425 })
const { wrapper } = getWrapper(clientService)
await wrapper.vm.loadExportTask.last
expect(wrapper.find(selectors.requestGdprExportBtn).exists()).toBeFalsy()
})
it.todo('triggers the export when being clicked')
})
describe('download button', () => {
it('shows if a gdpr export exists', async () => {
const { wrapper } = getWrapper()
await wrapper.vm.loadExportTask.last
expect(wrapper.find(selectors.downloadGdprExportBtn).exists()).toBeTruthy()
})
it('does not show if no export exists', async () => {
const clientService = mockDeep<ClientService>()
clientService.webdav.getFileInfo.mockRejectedValue({})
const { wrapper } = getWrapper(clientService)
await wrapper.vm.loadExportTask.last
expect(wrapper.find(selectors.downloadGdprExportBtn).exists()).toBeFalsy()
})
it('triggers the download when being clicked', async () => {
const { wrapper } = getWrapper()
await wrapper.vm.loadExportTask.last
await wrapper.find(selectors.downloadGdprExportBtn).trigger('click')
expect(downloadFile).toHaveBeenCalled()
})
})
it('shows a "in progress"-hint', async () => {
const clientService = mockDeep<ClientService>()
clientService.webdav.getFileInfo.mockRejectedValue({ statusCode: 425 })
const { wrapper } = getWrapper(clientService)
await wrapper.vm.loadExportTask.last
expect(wrapper.find(selectors.gdprExportInProgress).exists()).toBeTruthy()
})
})

function getWrapper(clientService = undefined) {
if (!clientService) {
clientService = mockDeep<ClientService>()
clientService.webdav.getFileInfo.mockResolvedValue(mock<Resource>())
}
const mocks = defaultComponentMocks()
mocks.$clientService = clientService
const store = createStore(defaultStoreMockOptions)
return {
mocks,
wrapper: shallowMount(GdprExport, {
global: {
mocks,
plugins: [...defaultPlugins(), store]
}
})
}
}
15 changes: 14 additions & 1 deletion packages/web-runtime/tests/unit/pages/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const selectors = {
editUrlButton: '[data-testid="account-page-edit-url-btn"]',
accountPageInfo: '.account-page-info',
groupNames: '[data-testid="group-names"]',
groupNamesEmpty: '[data-testid="group-names-empty"]'
groupNamesEmpty: '[data-testid="group-names-empty"]',
gdprExport: '[data-testid="gdpr-export"]'
}

jest.mock('web-pkg/src/configuration', () => ({
Expand Down Expand Up @@ -88,6 +89,18 @@ describe('account page', () => {
})
})

// TODO: un-skip if capabilities are implemented
describe.skip('gdpr export section', () => {
it('does not show if not announced via capabilities', () => {
const { wrapper } = getWrapper()
expect(wrapper.find(selectors.gdprExport).exists()).toBeFalsy()
})
it('does show if announced via capabilities', () => {
const { wrapper } = getWrapper({ capabilities: {} })
expect(wrapper.find(selectors.gdprExport).exists()).toBeTruthy()
})
})

describe('method "editPassword"', () => {
it('should show message on success', async () => {
const { wrapper, mocks } = getWrapper()
Expand Down

0 comments on commit 29ad2f3

Please sign in to comment.