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 31, 2023
1 parent d7d937b commit c4925ba
Show file tree
Hide file tree
Showing 7 changed files with 303 additions and 5 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
5 changes: 4 additions & 1 deletion packages/web-pkg/src/composables/capability/useCapability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export const useCapabilityCoreSupportUrlSigning = createCapabilityComposable(
'core.support-url-signing',
false
)

export const useCapabilityGraphPersonalDataExport = createCapabilityComposable(
'graph.personal-data-export',
false
)
export const useCapabilityFilesSharingQuickLinkDefaultRole = createCapabilityComposable(
'files_sharing.quick_link.default_role',
'viewer'
Expand Down
139 changes: 139 additions & 0 deletions packages/web-runtime/src/components/Account/GdprExport.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<template>
<span v-if="loading">
<oc-spinner />
</span>
<span v-else-if="exportInProgress" class="oc-flex oc-flex-middle" data-testid="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-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-export-btn"
@click="downloadExport"
>
<oc-icon name="download" fill-type="line" size="small" />
<span v-text="$gettext('Download export')" />
</oc-button>
<span class="oc-ml-s" v-text="`(${exportDate})`" />
</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 } from 'web-pkg'
import { isPersonalSpaceResource } from 'web-client/src/helpers'
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 { webdav, graphAuthenticated } = 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((s) => isPersonalSpaceResource(s))
})
const loadExportTask = useTask(function* () {
try {
const resource = yield webdav.getFileInfo(unref(personalSpace), {
path: `/${GDPR_EXPORT_FILE_NAME}`
})
if (resource.processing) {
exportInProgress.value = true
if (!unref(checkInterval)) {
checkInterval.value = setInterval(() => {
loadExportTask.perform()
}, POLLING_INTERVAL)
}
return
}
exportFile.value = resource
exportInProgress.value = false
if (unref(checkInterval)) {
clearInterval(unref(checkInterval))
}
} catch (e) {
if (e.statusCode !== 404) {
// resource seems to exist, but something else went wrong
console.error(e)
}
} finally {
loading.value = false
}
}).restartable()
const requestExport = async () => {
try {
await graphAuthenticated.users.exportPersonalData(store.getters.user.uuid, {
storageLocation: `/${GDPR_EXPORT_FILE_NAME}`
})
await loadExportTask.perform()
return store.dispatch('showMessage', {
title: $gettext('GDPR export has been requested')
})
} catch (e) {
return store.dispatch('showMessage', {
title: $gettext('GDPR export could not be requested. Please contact an administrator.'),
status: 'danger'
})
}
}
const downloadExport = () => {
return downloadFile(unref(exportFile))
}
const exportDate = computed(() => {
return formatDateFromJSDate(new Date(unref(exportFile).mdate), currentLanguage)
})
onMounted(() => {
loadExportTask.perform()
})
onUnmounted(() => {
if (unref(checkInterval)) {
clearInterval(unref(checkInterval))
}
})
return {
loading,
loadExportTask,
exportFile,
exportInProgress,
requestExport,
downloadExport,
exportDate
}
}
})
</script>
24 changes: 22 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="showGdprExport" 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 @@ -104,6 +110,7 @@ import EditPasswordModal from '../components/EditPasswordModal.vue'
import { computed, defineComponent, onMounted, unref } from 'vue'
import {
useAccessToken,
useCapabilityGraphPersonalDataExport,
useCapabilitySpacesEnabled,
useClientService,
useStore
Expand All @@ -113,26 +120,38 @@ 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'
import { isPersonalSpaceResource } from 'web-client/src/helpers'
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()
const isPersonalDataExportEnabled = useCapabilityGraphPersonalDataExport()
const user = computed(() => {
return store.getters.user
})
const showGdprExport = computed(() => {
return (
unref(isPersonalDataExportEnabled) &&
store.getters['runtime/spaces/spaces'].some((s) => isPersonalSpaceResource(s))
)
})
const loadAccountBundleTask = useTask(function* () {
try {
const {
Expand Down Expand Up @@ -235,6 +254,7 @@ export default defineComponent({
updateSelectedLanguage,
accountEditLink,
isChangePasswordEnabled,
showGdprExport,
isLanguageSupported,
groupNames,
user,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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',
requestExportBtn: '[data-testid="request-export-btn"]',
downloadExportBtn: '[data-testid="download-export-btn"]',
exportInProgress: '[data-testid="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()
})
it('shows a "in progress"-hint', async () => {
const { wrapper } = getWrapper(mock<Resource>({ processing: true }))
await wrapper.vm.loadExportTask.last
expect(wrapper.find(selectors.exportInProgress).exists()).toBeTruthy()
})
describe('request button', () => {
it('shows if no export is being processed', async () => {
const { wrapper } = getWrapper(mock<Resource>({ processing: false }))
await wrapper.vm.loadExportTask.last
expect(wrapper.find(selectors.requestExportBtn).exists()).toBeTruthy()
})
it('does not show when an export is being processed', async () => {
const { wrapper } = getWrapper(mock<Resource>({ processing: true }))
await wrapper.vm.loadExportTask.last
expect(wrapper.find(selectors.requestExportBtn).exists()).toBeFalsy()
})
it('triggers the export when being clicked', async () => {
const { wrapper, mocks } = getWrapper(mock<Resource>({ processing: false }))
await wrapper.vm.loadExportTask.last
await wrapper.find(selectors.requestExportBtn).trigger('click')
expect(mocks.$clientService.graphAuthenticated.users.exportPersonalData).toHaveBeenCalled()
})
})
describe('download button', () => {
it('shows if a gdpr export exists', async () => {
const { wrapper } = getWrapper(mock<Resource>({ processing: false }))
await wrapper.vm.loadExportTask.last
expect(wrapper.find(selectors.downloadExportBtn).exists()).toBeTruthy()
})
it('does not show if no export exists', async () => {
const { wrapper } = getWrapper()
await wrapper.vm.loadExportTask.last
expect(wrapper.find(selectors.downloadExportBtn).exists()).toBeFalsy()
})
it('triggers the download when being clicked', async () => {
const { wrapper } = getWrapper(mock<Resource>({ processing: false }))
await wrapper.vm.loadExportTask.last
await wrapper.find(selectors.downloadExportBtn).trigger('click')
expect(downloadFile).toHaveBeenCalled()
})
})
})

function getWrapper(resource = undefined) {
const clientService = mockDeep<ClientService>()
if (resource) {
clientService.webdav.getFileInfo.mockResolvedValue(resource)
} else {
clientService.webdav.getFileInfo.mockRejectedValue({ statusCode: 404 })
}
const mocks = defaultComponentMocks()
mocks.$clientService = clientService
const store = createStore(defaultStoreMockOptions)
return {
mocks,
wrapper: shallowMount(GdprExport, {
global: {
mocks,
plugins: [...defaultPlugins(), store]
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ exports[`account page account information displays basic user information 1`] =
<oc-button-stub appearance="raw" href="https://account-manager/logout" target="_blank" type="a"></oc-button-stub>
</dd>
</div>
<!--v-if-->
</dl>
`;

Expand Down
41 changes: 39 additions & 2 deletions packages/web-runtime/tests/unit/pages/account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
shallowMount,
defaultStoreMockOptions
} from 'web-test-helpers'
import { mock } from 'jest-mock-extended'
import { SpaceResource } from 'web-client/src/helpers'

const $route = {
meta: {
Expand All @@ -20,7 +22,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 +91,33 @@ describe('account page', () => {
})
})

describe('gdpr export section', () => {
it('does show if announced via capabilities and user has a personal space', () => {
const spaces = [mock<SpaceResource>({ driveType: 'personal' })]
const { wrapper } = getWrapper({
spaces,
capabilities: { graph: { 'personal-data-export': true } }
})
expect(wrapper.find(selectors.gdprExport).exists()).toBeTruthy()
})
it('does not show if not announced via capabilities', () => {
const spaces = [mock<SpaceResource>({ driveType: 'personal' })]
const { wrapper } = getWrapper({
spaces,
capabilities: { graph: { 'personal-data-export': false } }
})
expect(wrapper.find(selectors.gdprExport).exists()).toBeFalsy()
})
it('does not show if user has no personal space', () => {
const spaces = [mock<SpaceResource>({ driveType: 'project' })]
const { wrapper } = getWrapper({
spaces,
capabilities: { graph: { 'personal-data-export': true } }
})
expect(wrapper.find(selectors.gdprExport).exists()).toBeFalsy()
})
})

describe('method "editPassword"', () => {
it('should show message on success', async () => {
const { wrapper, mocks } = getWrapper()
Expand Down Expand Up @@ -137,8 +167,15 @@ describe('account page', () => {
})
})

function getWrapper({ data = {}, user = {}, capabilities = {}, accountEditLink = undefined } = {}) {
function getWrapper({
data = {},
user = {},
capabilities = {},
accountEditLink = undefined,
spaces = []
} = {}) {
const storeOptions = { ...defaultStoreMockOptions }
storeOptions.modules.runtime.modules.spaces.getters.spaces.mockReturnValue(spaces)
storeOptions.getters.user.mockReturnValue({ groups: [], ...user })
storeOptions.getters.capabilities.mockReturnValue(capabilities)
storeOptions.getters.configuration.mockReturnValue({
Expand Down

0 comments on commit c4925ba

Please sign in to comment.