Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Single use links Feature #1660

Merged
merged 20 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions static/css/sass/cdr_ui_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,14 @@ table.dataTable {
border-radius: 5px;
}

.is-narrow.item-actions {
text-align: right;

.actionlink {
margin-bottom: 0;
}
}

.item-actions {
.actionlink {
a.action {
Expand Down Expand Up @@ -1038,6 +1046,14 @@ table.dataTable {
}
}

.is-narrow.item-actions {
text-align: left;

.actionlink {
margin-bottom: 3px;
}
}

.record-metadata {
justify-content: center;
display: grid;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,31 @@
<div v-if="hasPermission(recordData, 'editDescription')" class="actionlink">
<a class="edit button action" :href="editDescriptionUrl(recordData.briefObject.id)"><i class="fa fa-edit"></i> {{ $t('full_record.edit') }}</a>
</div>
<template v-if="recordData.resourceType === 'File' && hasDownloadAccess(recordData)">
<div class="header-button" v-html="downloadButtonHtml(recordData.briefObject)"></div>
<div class="actionlink" v-if="hasPermission(recordData, 'viewOriginal')">
<a class="button view action" :href="recordData.dataFileUrl">
<i class="fa fa-search" aria-hidden="true"></i> View</a>
</div>
<template v-if="recordData.resourceType === 'File'">
<template v-if="hasDownloadAccess(recordData)">
<div class="header-button" v-html="downloadButtonHtml(recordData.briefObject)"></div>
<div class="actionlink" v-if="hasPermission(recordData, 'viewOriginal')">
<a class="button view action" :href="recordData.dataFileUrl">
<i class="fa fa-search" aria-hidden="true"></i> View</a>
</div>
</template>
<template v-if="hasPermission(recordData, 'viewHidden')">
<single-use-link :uuid="recordData.briefObject.id"></single-use-link>
</template>
</template>
</div>
</template>

<script>
import singleUseLink from '@/components/full_record/singleUseLink.vue';
import fileDownloadUtils from '../../mixins/fileDownloadUtils';
import fullRecordUtils from '../../mixins/fullRecordUtils';

export default {
name: 'restrictedContent',

components: {singleUseLink},

mixins: [fileDownloadUtils, fullRecordUtils],

props: {
Expand Down
162 changes: 162 additions & 0 deletions static/js/vue-cdr-access/src/components/full_record/singleUseLink.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<template>
<div class="header-button-single-download">
<div class="actionlink single-download">
<div class="single-use-msg-text" :class="{'display-msg': this.message !== ''}">
{{ this.message }}
</div>
<a class="button action" id="single-use-link" href="#" @click.prevent="createLink()">{{ $t('full_record.download_single_use') }}</a>
<ul>
<li v-for="single_use_link in single_use_links">
<div class="download-link-wrapper">
<div>{{ $t('full_record.created_link', { link: single_use_link.accessCode, expire_time: single_use_link.expires }) }}</div>
<a @click.prevent="copyUrl(single_use_link.link)" href="#" class="download button action">Copy Link</a>
</div>
</li>
</ul>
</div>
</div>
</template>

<script>
import axios from 'axios';
import {formatDistanceToNow} from "date-fns";
import {toDate} from "date-fns";

export default {
name: 'singleUseLink',

props: {
uuid: String
},

watch: {
'$route.path': {
handler() {
this.single_use_links = []
}
}
},

data() {
return {
single_use_links: [],
message: ''
}
},

methods: {
createLink() {
axios({
method: 'post',
url: `/services/api/single_use_link/create/${this.uuid}`
}).then((response) => {
let basePath = window.location.hostname;
let accessCode = response.data.key;
this.single_use_links.push({"link": this.generateUrl(basePath, accessCode),
"accessCode": accessCode.substring(0, 8),
"expires": this.formatTimestamp(response.data.expires)
});
}).catch((error) => {
console.log(error);
this.message = this.$t('full_record.created_link_failed', { uuid: this.uuid});
this.fadeOutMsg();
});
},

async copyUrl(text) {
try {
await navigator.clipboard.writeText(text);
this.message = this.$t('full_record.copied_link', { text: text});
} catch(err) {
this.message = this.$t('full_record.copied_link_failed', { text: text});
}
this.fadeOutMsg();
},

fadeOutMsg() {
setTimeout(() => this.message = '', 3000);
},

formatTimestamp(timestamp) {
return formatDistanceToNow(toDate(parseInt(timestamp)));
},

generateUrl(basePath, accessCode) {
return "https://" + basePath + "/services/api/single_use_link/" + accessCode;
}
}
}
</script>

<style scoped lang="scss">
.header-button-single-download {
text-align: right;

.single-download {
display: block;

a {
float: right;
max-width: 210px;
}

ul {
margin-top: 10px;

li {
.download-link-wrapper {
align-items: center;
display: inline-flex;
margin: 5px auto;

div {
background-color: white;
padding: 15px;
}
}
}
}
}

.single-use-msg-text {
display: none;
word-break: break-word;
padding: 5px;
}

.display-msg {
background: white;
border: 1px solid;
border-radius: 5px;
display: block;
height: auto;
padding: 5px;
position: fixed;
right: 10px;
text-align: center;
top: 10px;
width: auto;
z-index: 599;
}
}

@media (max-width: 768px) {
.header-button-single-download {
text-align: left;

.single-download {
display: block;

a {
float: none;
}

ul {
li {
margin-left: 0;
}
}
}
}
}
</style>
5 changes: 5 additions & 0 deletions static/js/vue-cdr-access/src/translations.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,17 @@ export default {
available_date: "Available after {available_date}",
collection_id: "Archival Collection ID",
contains: "Contains",
copied_link: "Download URL, {text}, copied to clipboard",
copied_link_failed: "Unable to copy download URL, {text}, to clipboard",
created_link: "Created link {link} expires in {expire_time}",
created_link_failed: "Unable to create single use link for {uuid}",
creator: "Creator",
date_added: "Date Added",
date_created: "Date Created",
detailed_metadata: "Detailed Metadata",
download: "Download",
download_file: "Download file",
download_single_use: "Generate Single-Use Link",
download_title: "Download {title}",
download_unavailable: "Download Unavailable",
edit: "Edit",
Expand Down
21 changes: 21 additions & 0 deletions static/js/vue-cdr-access/tests/unit/restrictedContent.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {createTestingPinia} from '@pinia/testing';
import { useAccessStore } from '@/stores/access';
import restrictedContent from '@/components/full_record/restrictedContent.vue';
import displayWrapper from '@/components/displayWrapper.vue';
import singleUseLink from '@/components/full_record/singleUseLink.vue';
import {createI18n} from 'vue-i18n';
import translations from '@/translations';
import cloneDeep from 'lodash.clonedeep';
Expand Down Expand Up @@ -264,6 +265,26 @@ describe('restrictedContent.vue', () => {
expect(wrapper.find('.download').exists()).toBe(false);
});

it('displays a single use link button for files with the proper permissions', async () => {
const updated_data = cloneDeep(record);
updated_data.briefObject.permissions = ['viewAccessCopies', 'viewHidden', 'viewOriginal'];
await wrapper.setProps({
recordData: updated_data
});
expect(wrapper.findComponent(singleUseLink).exists()).toBe(true);
});

it('does not display a single use link button for files without the proper permissions', async () => {
const updated_data = cloneDeep(record);
updated_data.dataFileUrl = 'content/4db695c0-5fd5-4abf-9248-2e115d43f57d';
updated_data.resourceType = 'File';
updated_data.briefObject.permissions = ['viewAccessCopies'];
await wrapper.setProps({
recordData: updated_data
});
expect(wrapper.findComponent(singleUseLink).exists()).toBe(false);
});

it('does not show view options if content is public', async () => {
const updated_data = cloneDeep(record);
updated_data.briefObject.groupRoleMap = {
Expand Down
107 changes: 107 additions & 0 deletions static/js/vue-cdr-access/tests/unit/singleUseLink.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { shallowMount } from '@vue/test-utils'
import singleUseLink from '@/components/full_record/singleUseLink.vue';
import displayWrapper from '@/components/displayWrapper.vue';
import {createI18n} from 'vue-i18n';
import { createRouter, createWebHistory } from 'vue-router';
import translations from '@/translations';
import moxios from 'moxios';

const uuid = '9f7f3746-0237-4261-96a2-4b4765d4ae03';
const oneDay = 86400000;
const response_date = { key: `12345`, expires: Date.now() + oneDay, id: uuid };
let wrapper, router;

describe('singleUseLink.vue', () => {
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: translations
});

beforeEach(() => {
router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes: [
{
path: '/record/:uuid',
name: 'displayRecords',
component: displayWrapper
}
]
});
wrapper = shallowMount(singleUseLink, {
global: {
plugins: [i18n, router]
},
props: {
uuid: '9f7f3746-0237-4261-96a2-4b4765d4ae03'
}
});

moxios.install();
});

afterEach(function () {
moxios.uninstall();
});

it("creates single use links", (done) => {
expect(wrapper.find('.download-link-wrapper').exists()).toBe(false);

moxios.stubRequest(`/services/api/single_use_link/create/${uuid}`, {
status: 200,
response: JSON.stringify(response_date)
});

moxios.wait(async () => {
await wrapper.find('#single-use-link').trigger('click');
expect(wrapper.find('.download-link-wrapper').exists()).toBe(true);
expect(wrapper.find('.download-link-wrapper div').text())
.toEqual(`Created link ${response_date.key} expires in 1 day`);
expect(wrapper.find('.download-link-wrapper a').exists()).toBe(true); // Copy button
done();
});
});

it("does not create single use links on response errors", (done) => {
expect(wrapper.find('.download-link-wrapper').exists()).toBe(false);

moxios.stubRequest(`/services/api/single_use_link/create/${uuid}`, {
status: 404,
response: JSON.stringify('No record here')
});

moxios.wait(async () => {
await wrapper.find('#single-use-link').trigger('click');
expect(wrapper.find('.download-link-wrapper').exists()).toBe(false);
done();
});
});

it("copies single use links", async () => {
Object.assign(window.navigator, {
clipboard: {
writeText: jest.fn().mockImplementation(() => Promise.resolve()),
},
});

await wrapper.setData({ single_use_links: [response_date] });
await wrapper.find('.download-link-wrapper a').trigger('click');
expect(window.navigator.clipboard.writeText)
.toHaveBeenCalledWith(response_date.link);
});

it("clears single use links if the route changes", async () => {
await wrapper.setData({
single_use_links: [{
link: 'https://localhost/services/api/single_use_link/2a8b7520c2634be78168caf3ab67b52c202bf89c7d8',
accessCode: '2a8b7520',
expires: '1 day'
}]
});
expect(wrapper.vm.single_use_links.length).toEqual(1);

await router.push('/record/1234');
expect(wrapper.vm.single_use_links.length).toEqual(0);
});
});
Loading
Loading