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

Implement Magic Link redirection support and InviteButton component #179

Merged
merged 10 commits into from
Mar 15, 2024
3 changes: 0 additions & 3 deletions frontend/src/assets/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -307,9 +307,6 @@ a:visited {
.p-tabview-header.p-disabled .p-tabview-nav-link {
color: gray;
}
.p-tabview-header:not(.p-disabled) .p-tabview-nav-link {
border-color: $bcbox-link-text;
}

/* side panel */

Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/bucket/BucketTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ref, watch } from 'vue';
import { BucketChildConfig, BucketPermission, BucketTableBucketName } from '@/components/bucket';
import { Spinner } from '@/components/layout';
import { SyncButton } from '@/components/common';
import { SyncButton, InviteButton } from '@/components/common';
import { Button, Column, Dialog, TreeTable, useConfirm } from '@/lib/primevue';
import { useAppStore, useAuthStore, useBucketStore, usePermissionStore } from '@/store';
import { DELIMITER, Permissions } from '@/utils/constants';
Expand Down Expand Up @@ -288,10 +288,14 @@ watch(getBuckets, () => {
header="Actions"
header-class="text-right"
body-class="action-buttons"
style="width: 280px"
style="width: 300px"
>
<template #body="{ node }">
<span v-if="!node.data.dummy">
<InviteButton
:bucket-id="node.data.bucketId"
label-text="Bucket"
/>
<BucketChildConfig
v-if="permissionStore.isBucketActionAllowed(node.data.bucketId, getUserId, Permissions.MANAGE)"
:parent-bucket="node.data"
Expand Down
223 changes: 223 additions & 0 deletions frontend/src/components/common/InviteButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { computed, ref, onMounted } from 'vue';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import ShareLinkContent from '@/components/object/share/ShareLinkContent.vue';
import { Button, Dialog, TabView, TabPanel, RadioButton, InputText, useToast, InputSwitch } from '@/lib/primevue';
import { inviteService } from '@/services';
import { useConfigStore, useObjectStore, useBucketStore } from '@/store';
import type { Ref } from 'vue';
import type { COMSObject, Bucket } from '@/types';
// Props
type Props = {
bucketId?: string;
objectId?: string;
labelText: string;
restricted?: boolean;
};
export type InviteFormData = {
email?: string;
bucketId?: string;
expiresAt?: number;
objectId?: string;
};
const props = withDefaults(defineProps<Props>(), {
bucketId: '',
objectId: '',
labelText: '',
isRestricted: false
});
// Store
const objectStore = useObjectStore();
const bucketStore = useBucketStore();
const { getConfig } = storeToRefs(useConfigStore());
const toast = useToast();
// State
const obj: Ref<COMSObject | undefined> = ref(undefined);
const bucket: Ref<Bucket | undefined> = ref(undefined);
const isRestricted: Ref<boolean> = ref(props.restricted);
const showInviteLink: Ref<boolean> = ref(false);
// Share link
const inviteLink: Ref<string> = ref('');
const timeFrames: Record<string, number> = {
'1 Hour': 3600,
'8 Hour': 28800,
'1 Day (Default)': 86400,
'1 Week': 604800
};
const formData: Ref<InviteFormData> = ref({ expiresAt: 86400 });
// Dialog
const displayInviteDialog = ref(false);
// Share link
const bcBoxLink = computed(() => {
const path = props.objectId ? `detail/objects?objectId=${props.objectId}` : `list/objects?bucketId=${props.bucketId}`;
return `${window.location.origin}/${path}`;
});
const comsUrl = computed(() => {
return `${getConfig.value.coms?.apiPath}/object/${props.objectId}`;
});
jujaga marked this conversation as resolved.
Show resolved Hide resolved
//Action
async function sendInvite() {
try {
let expiresAt;
if (formData.value.expiresAt) {
expiresAt = Math.floor(Date.now() / 1000) + formData.value.expiresAt;
}
const permissionToken = await inviteService.createInvite(
props.bucketId,
formData.value.email,
expiresAt,
props.objectId
);
inviteLink.value = `${window.location.origin}/invite/${permissionToken.data}`;
toast.success('', 'Invite link is created.');
showInviteLink.value = true;
} catch (error: any) {
toast.error('', 'Error getting token');
}
}
onMounted(() => {
if (props.objectId) {
obj.value = objectStore.getObject(props.objectId);
} else if (props.bucketId) {
bucket.value = bucketStore.getBucket(props.bucketId);
}
});
</script>

<template>
<Dialog
v-model:visible="displayInviteDialog"
header="Share"
:modal="true"
:style="{ minWidth: '700px' }"
class="bcbox-info-dialog"
>
<template #header>
<font-awesome-icon
icon="fa-solid fa-share-alt"
fixed-width
/>
<span class="p-dialog-title">Share</span>
</template>

<h3 class="bcbox-info-dialog-subhead">
{{ obj?.name || bucket?.bucketName }}
</h3>

<ul class="mb-4">
<li>If a user already has permissions, you can link them with a share link</li>
<li>Invite someone using an invite link</li>
<li>To share publicly or with a direct link, you must set the file to public - this only works for objects</li>
</ul>
<TabView>
<TabPanel
v-if="obj?.public"
header="Direct public file link"
>
<ShareLinkContent
:share-link="comsUrl"
label="Direct Link"
/>
</TabPanel>
<!-- Disable for public until unauthed File Details page works -->
<TabPanel
header="Share link"
:disabled="obj?.public"
>
<ShareLinkContent
:share-link="bcBoxLink"
label="Share Link"
/>
</TabPanel>
<TabPanel header="Invite link">
<h3 class="mt-1 mb-2">{{ props.labelText }} Invite</h3>
<p>Make invite available for:</p>
<div class="flex flex-wrap gap-3">
<div
v-for="(value, name) in timeFrames"
:key="value"
class="flex align-items-center"
>
<RadioButton
v-model="formData.expiresAt"
:input-id="value.toString()"
:name="name"
:value="value"
/>
<label
:for="value.toString()"
class="ml-2"
>
{{ name }}
</label>
</div>
</div>
<p class="mt-4 mb-2">Restrict to user's email</p>
<div class="p-inputgroup mb-4">
<InputSwitch
v-model="isRestricted"
class="mt-1"
/>
<!-- Add email validation -->
<InputText
v-show="isRestricted"
v-model="formData.email"
name="inviteEmail"
placeholder="Enter email"
required
type="email"
class="ml-5 mr-8"
/>
</div>
<div class="p-inputgroup mb-4">
<Button
class="p-button p-button-primary"
@click="sendInvite"
>
<font-awesome-icon
icon="fa fa-envelope"
class="mr-2"
/>
Generate invite link
</Button>
</div>

<ShareLinkContent
v-if="showInviteLink"
:share-link="inviteLink"
label="Invite Link"
/>
</TabPanel>
</TabView>
</Dialog>
<Button
v-tooltip.bottom="`${props.labelText} Share`"
class="p-button-lg p-button-text primary"
aria-label="`${props.labelText} Share`"
@click="displayInviteDialog = true"
>
<font-awesome-icon icon="fa-solid fa-share-alt" />
</Button>
</template>

<style scoped lang="scss">
h2 {
font-weight: bold;
}
ul {
padding-left: 22px;
}
jujaga marked this conversation as resolved.
Show resolved Hide resolved
</style>
1 change: 1 addition & 0 deletions frontend/src/components/common/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as SyncButton } from './SyncButton.vue';
export { default as InviteButton } from './InviteButton.vue';
7 changes: 5 additions & 2 deletions frontend/src/components/object/ObjectFileDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
ObjectUploadBasic,
ObjectVersion
} from '@/components/object';
import { ShareObjectButton } from '@/components/object/share';
import { InviteButton } from '@/components/common';
import { Button, Dialog, Divider } from '@/lib/primevue';
import {
useAuthStore,
Expand Down Expand Up @@ -120,7 +120,10 @@ onMounted(async () => {
</div>

<div class="action-buttons">
<ShareObjectButton :id="object.id" />
<InviteButton
:object-id="props.objectId"
label-text="Object"
/>
<DownloadObjectButton
v-if="
object.public || permissionStore.isObjectActionAllowed(object.id, getUserId, Permissions.READ, bucketId)
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/object/ObjectTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import {
ObjectPermission,
ObjectPublicToggle
} from '@/components/object';
import { SyncButton } from '@/components/common';
import { ShareObjectButton } from '@/components/object/share';
import { SyncButton, InviteButton } from '@/components/common';
import { Button, Column, DataTable, Dialog, InputText, useToast } from '@/lib/primevue';
import { useAuthStore, useObjectStore, usePermissionStore } from '@/store';
import { Permissions } from '@/utils/constants';
Expand Down Expand Up @@ -316,7 +315,10 @@ const selectedFilters = (payload: any) => {
body-class="action-buttons"
>
<template #body="{ data }">
<ShareObjectButton :id="data.id" />
<InviteButton
:object-id="data.id"
label-text="Object"
/>
<DownloadObjectButton
v-if="
data.public ||
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ const routes: Array<RouteRecordRaw> = [
}
]
},
{
path: '/invite/:token',
name: RouteNames.INVITE,
component: () => import('@/views/invite/InviteView.vue'),
meta: { requiresAuth: true, breadcrumb: 'Invite', title: 'Invite' },
props: createProps
},
{
path: '/oidc',
component: () => import('@/views/GenericView.vue'),
Expand Down
1 change: 1 addition & 0 deletions frontend/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { default as AuthService } from './authService';
export { default as bucketService } from './bucketService';
export { default as ConfigService } from './configService';
export { default as inviteService } from './inviteService';
export { default as objectService } from './objectService';
export { default as permissionService } from './permissionService';
export { default as userService } from './userService';
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/services/inviteService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { comsAxios } from './interceptors';

const PATH = 'permission/invite';

export default {
/**
* @function createInvite
* Post an Invite, exclusive or on bucketId and objectId
* @param {string} bucketId
* @param {string} objectId
* @param {string} email to be used for access
* @param {string} expiration timestamp for token
* @returns {string} uuid token of the invite
*/
createInvite(bucketId?: string, email?: string, expiresAt?: number, objectId?: string) {
return comsAxios().post(`${PATH}`, {
bucketId: bucketId || undefined,
email: email || undefined,
expiresAt: expiresAt || undefined,
objectId: objectId || undefined
});
},

/**
* @function getInvite
* Use an invite token
* @param {string} token uuid
* @returns {Promise} An axios response
*/
getInvite(token: string): Promise<any> {
return comsAxios().get(`${PATH}/${token}`);
}
};
6 changes: 6 additions & 0 deletions frontend/src/types/InviteCreateOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type InviteCreateOptions = {
bucketId?: string;
email: string;
expiresAt?: string;
objectId?: string;
};
jujaga marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type { BucketTreeNode, BucketTreeNodeData } from './BucketTreeNode';
export type { COMSObject } from './COMSObject';
export type { COMSObjectPermission } from './COMSObjectPermission';
export type { IdentityProvider } from './IdentityProvider';
export type { InviteCreateOptions } from './InviteCreateOptions';
export type { Metadata } from './Metadata';
export type { MetadataPair } from './MetadataPair';
export type { Permission } from './Permission';
Expand Down
1 change: 1 addition & 0 deletions frontend/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const RouteNames = Object.freeze({
DEVELOPER: 'developer',
FORBIDDEN: 'forbidden',
HOME: 'home',
INVITE: 'invite',
LIST_BUCKETS: 'listBuckets',
LIST_OBJECTS: 'listObjects',
LOGIN: 'login',
Expand Down
Loading
Loading