Skip to content

Commit

Permalink
Merge pull request #179 from bcgov/magic-links
Browse files Browse the repository at this point in the history
Implement Magic Link redirection support and InviteButton component
  • Loading branch information
kyle1morel authored Mar 15, 2024
2 parents 6507aa7 + 59c6e04 commit 5194efb
Show file tree
Hide file tree
Showing 14 changed files with 623 additions and 10 deletions.
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}`;
});
//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;
}
</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;
};
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

0 comments on commit 5194efb

Please sign in to comment.