diff --git a/app/.eslintrc.js b/app/.eslintrc.js index 9bd438c3..0137b18d 100644 --- a/app/.eslintrc.js +++ b/app/.eslintrc.js @@ -31,7 +31,7 @@ module.exports = { 'max-len': ['warn', { code: 120, comments: 120, ignoreUrls: true }], 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', - quotes: ['error', 'single'], + quotes: ['error', 'single', { avoidEscape: true }], semi: ['error', 'always'] }, overrides: [ diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index acfc189b..caa2364e 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -33,7 +33,7 @@ module.exports = { 'max-len': ['warn', { code: 120, comments: 120, ignoreUrls: true }], 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', - quotes: ['error', 'single'], + quotes: ['error', 'single', { avoidEscape: true }], semi: ['error', 'always'], 'vue/component-tags-order': [ 'error', diff --git a/frontend/src/assets/main.scss b/frontend/src/assets/main.scss index 901bc34f..fa5a2632 100644 --- a/frontend/src/assets/main.scss +++ b/frontend/src/assets/main.scss @@ -256,6 +256,21 @@ div:focus-visible { font-weight: 400; } +.p-tag-danger { + background-color: #fef1d8; + outline-style: solid; + outline-color: #f8bb47; + outline-width: thin; + + .p-tag-value { + color: #474543; + } + + .p-tag-icon { + color: #474543 + } +} + /* datatable */ .p-datatable, .p-treetable { @@ -312,6 +327,7 @@ div:focus-visible { } .action-buttons { text-align: right; + white-space: nowrap; // width: 150px; } diff --git a/frontend/src/components/bucket/BucketConfigForm.vue b/frontend/src/components/bucket/BucketConfigForm.vue index 90b97948..228c7a1f 100644 --- a/frontend/src/components/bucket/BucketConfigForm.vue +++ b/frontend/src/components/bucket/BucketConfigForm.vue @@ -132,33 +132,33 @@ const onCancel = () => { (), {}); const permissionStore = usePermissionStore(); const bucketStore = useBucketStore(); const { getMappedBucketToUserPermissions } = storeToRefs(permissionStore); +const { getUserId } = storeToRefs(useAuthStore()); + // State const showSearchUsers: Ref = ref(false); @@ -78,6 +81,25 @@ onBeforeMount(async () => { diff --git a/frontend/src/components/object/PublicObjectTable.vue b/frontend/src/components/object/PublicObjectTable.vue new file mode 100644 index 00000000..85f5f857 --- /dev/null +++ b/frontend/src/components/object/PublicObjectTable.vue @@ -0,0 +1,424 @@ + + + + diff --git a/frontend/src/components/object/index.ts b/frontend/src/components/object/index.ts index a7a9b166..89022897 100644 --- a/frontend/src/components/object/index.ts +++ b/frontend/src/components/object/index.ts @@ -19,4 +19,6 @@ export { default as ObjectUpload } from './ObjectUpload.vue'; export { default as ObjectUploadBasic } from './ObjectUploadBasic.vue'; export { default as ObjectUploadFile } from './ObjectUploadFile.vue'; export { default as ObjectVersion } from './ObjectVersion.vue'; +export { default as PublicObjectList } from './PublicObjectList.vue'; +export { default as PublicObjectTable } from './PublicObjectTable.vue'; export { default as RestoreObjectButton } from './RestoreObjectButton.vue'; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 0fdb8c7b..0b078495 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -57,8 +57,8 @@ const routes: Array = [ path: 'objects', name: RouteNames.LIST_OBJECTS, component: () => import('@/views/list/ListObjectsView.vue'), - meta: { requiresAuth: true, breadcrumb: '__listObjectsDynamic', title: 'My Objects' }, - props: createProps, + meta: { breadcrumb: '__listObjectsDynamic', title: 'My Objects' }, + props: createProps }, { path: 'deleted', @@ -66,6 +66,19 @@ const routes: Array = [ component: () => import('@/views/list/ListDeletedObjectsView.vue'), meta: { requiresAuth: true, breadcrumb: '__listDeletedObjectsDynamic', title: 'My Deleted Objects' }, props: createProps + }, + { + path: 'public', + component: () => import('@/views/GenericView.vue'), + children: [ + { + path: 'objects', + name: RouteNames.LIST_OBJECTS_PUBLIC, + component: () => import('@/views/list/ListPublicObjectsView.vue'), + meta: { requiresAuth: false, breadcrumb: '__listPublicObjectsDynamic', title: 'My Public Objects' }, + props: createProps + } + ] } ] }, diff --git a/frontend/src/services/bucketService.ts b/frontend/src/services/bucketService.ts index 0fa794ee..4fe10bfd 100644 --- a/frontend/src/services/bucketService.ts +++ b/frontend/src/services/bucketService.ts @@ -96,6 +96,16 @@ export default { }); }, + /** + * @function getBucket + * Get a bucket + * @param {string} bucketId The id for the bucket to get + * @returns {Promise} An axios response + */ + fetchBucket(bucketId: string) { + return comsAxios().get(`${BUCKET_PATH}/${bucketId}`); + }, + /** * @function updateBucket * Updates a bucket @@ -106,6 +116,22 @@ export default { return comsAxios().patch(`${BUCKET_PATH}/${bucketId}`, data); }, + /** + * @function togglePublic + * Toggles the public property for a bucket + * @param {string} bucketId The id for the bucket + * @param {boolean} isPublic Boolean on public status + * @returns {Promise} An axios response + */ + togglePublic(bucketId: string, isPublic: boolean) { + return comsAxios().patch(`${BUCKET_PATH}/${bucketId}/public`, null, { + params: { + public: isPublic + } + }); + }, + + /** * @function syncBucket * Synchronizes a bucket diff --git a/frontend/src/store/bucketStore.ts b/frontend/src/store/bucketStore.ts index 62a52826..b330f22e 100644 --- a/frontend/src/store/bucketStore.ts +++ b/frontend/src/store/bucketStore.ts @@ -3,8 +3,8 @@ import { computed, ref } from 'vue'; import { useToast } from '@/lib/primevue'; import { bucketService } from '@/services'; -import { useAppStore, usePermissionStore } from '@/store'; -import { partition } from '@/utils/utils'; +import { useAppStore, useAuthStore, usePermissionStore } from '@/store'; +import { getBucketPath, partition } from '@/utils/utils'; import type { Ref } from 'vue'; import type { Bucket, BucketSearchPermissionsOptions } from '@/types'; @@ -18,6 +18,7 @@ export const useBucketStore = defineStore('bucket', () => { // Store const appStore = useAppStore(); + const authStore = useAuthStore(); const permissionStore = usePermissionStore(); // State @@ -28,6 +29,9 @@ export const useBucketStore = defineStore('bucket', () => { // Getters const getters = { getBucket: computed(() => (id: string) => state.buckets.value.find((bucket) => bucket.bucketId === id)), + getBucketByFullPath: computed( + () => (fullPath: string) => state.buckets.value.find((bucket) => getBucketPath(bucket) === fullPath) + ), getBuckets: computed(() => state.buckets.value) }; @@ -62,6 +66,45 @@ export const useBucketStore = defineStore('bucket', () => { appStore.endIndeterminateLoading(); } } + + // /** + // * @function fetchPublicBucket + // * - Adds bucket to store if bucket is public + // * @param params search parameters + // * @returns an array containing single bucket + // */ + // async function fetchPublicBucket(bucketId: string) { + // try { + // appStore.beginIndeterminateLoading(); + // //search for bucket + // const bucket = (await bucketService.searchBuckets()).data; + // console.log(bucket); + // if(bucket?.length) { + // // update store + // const matches = (x: Bucket) => !bucketId || x.bucketId === bucketId; + // const [, difference] = partition(state.buckets.value, matches); + // state.buckets.value = difference.concat(bucket); + // return bucket; + // } else return []; + // } catch (error: any) { + // toast.error('Fetching public bucket', error); + // } finally { + // appStore.endIndeterminateLoading(); + // } + // } + + async function fetchBucket(bucketId: string) { + try { + appStore.beginIndeterminateLoading(); + return bucketService.fetchBucket(bucketId).then((response) => response.data); + // return {}; + } catch (error: any) { + toast.error('Getting bucket', error); + } finally { + appStore.endIndeterminateLoading(); + } + } + /** * function does the following in order: * - fetches bucket permissions @@ -114,6 +157,16 @@ export const useBucketStore = defineStore('bucket', () => { } } + async function togglePublic(bucketId: string, isPublic: boolean) { + try { + appStore.beginIndeterminateLoading(); + await bucketService.togglePublic(bucketId, isPublic); + await fetchBuckets({ userId: authStore.getUserId, objectPerms: true }); + } finally { + appStore.endIndeterminateLoading(); + } + } + async function syncBucket(bucketId: string, recursive: boolean) { try { appStore.beginIndeterminateLoading(); @@ -125,7 +178,6 @@ export const useBucketStore = defineStore('bucket', () => { } } - async function syncBucketStatus(bucketId: string) { try { appStore.beginIndeterminateLoading(); @@ -149,9 +201,12 @@ export const useBucketStore = defineStore('bucket', () => { createBucket, createBucketChild, deleteBucket, + fetchBucket, + // fetchPublicBucket, fetchBuckets, syncBucket, syncBucketStatus, + togglePublic, updateBucket }; }); diff --git a/frontend/src/store/objectStore.ts b/frontend/src/store/objectStore.ts index 6f427055..d1ff70fe 100644 --- a/frontend/src/store/objectStore.ts +++ b/frontend/src/store/objectStore.ts @@ -244,12 +244,15 @@ export const useObjectStore = defineStore('object', () => { try { appStore.beginIndeterminateLoading(); await objectService.togglePublic(objectId, isPublic); + // let parent process catch the error const obj = getters.getObject.value(objectId); if (obj) obj.public = isPublic; } catch (error: any) { toast.error('Changing public state', error.response?.data.detail ?? error, { life: 0 }); } finally { + // code will still excecute on error appStore.endIndeterminateLoading(); + // TODO: update store } } diff --git a/frontend/src/types/Bucket.ts b/frontend/src/types/Bucket.ts index 34355514..b6169472 100644 --- a/frontend/src/types/Bucket.ts +++ b/frontend/src/types/Bucket.ts @@ -9,6 +9,7 @@ export type Bucket = { endpoint: string; key: string; lastSyncRequestedDate?: string; + public: boolean, region: string; secretAccessKey: string; } & IAudit; diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index b2668021..afa3bf8a 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -47,6 +47,7 @@ export const RouteNames = Object.freeze({ LIST_BUCKETS: 'listBuckets', LIST_OBJECTS: 'listObjects', LIST_OBJECTS_DELETED: 'listObjectsDeleted', + LIST_OBJECTS_PUBLIC: 'listObjectsPublic', LOGIN: 'login', LOGOUT: 'logout' }); diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 27415b09..82761a60 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -153,3 +153,11 @@ export function onDialogHide() { focusedElement.value?.focus(); focusedElement.value = null; } +/** + * Trims a URL of any trailing slashes, as well as any leading/trailing whitespace + * @param {string} input the string to cleanup + * @returns the cleaned-up string, less any trailing slashes and leading/trailing whitespace + */ +export function trimUrl(input: string) { + return input.trim().replace(/\/+$/, ''); +} diff --git a/frontend/src/views/list/ListObjectsView.vue b/frontend/src/views/list/ListObjectsView.vue index 724375e4..353207c3 100644 --- a/frontend/src/views/list/ListObjectsView.vue +++ b/frontend/src/views/list/ListObjectsView.vue @@ -5,7 +5,7 @@ import { useRouter } from 'vue-router'; import { differenceInSeconds } from 'date-fns'; import { ObjectList } from '@/components/object'; -import { useToast } from '@/lib/primevue'; +import { Tag, useToast } from '@/lib/primevue'; import { useAuthStore, useBucketStore, usePermissionStore } from '@/store'; import { RouteNames, Permissions } from '@/utils/constants'; @@ -23,10 +23,9 @@ const props = withDefaults(defineProps(), { // Store const bucketStore = useBucketStore(); -const { getUserId } = storeToRefs(useAuthStore()); +const { getUserId, getIsAuthenticated } = storeToRefs(useAuthStore()); const permissionStore = usePermissionStore(); const { getBucketPermissions } = storeToRefs(permissionStore); - const toast = useToast(); // State @@ -41,13 +40,15 @@ const autoSync = async () => { const last = new Date(lastSyncRequestedDate.value as string); const now = new Date(); const since = differenceInSeconds(now, last); - if(since > 3600){ - await bucketStore.syncBucket(props.bucketId, false ); - toast.info('Sync in progress.', - 'It\'s been a while since we checked for changes on the file server. ' + - 'Please refresh this page to ensure the file listing is up-to-date.', { life: 0 }); - } - else if(syncQueueSize.value > 0){ + if (since > 3600) { + await bucketStore.syncBucket(props.bucketId, false); + toast.info( + 'Sync in progress.', + "It's been a while since we checked for changes on the file server. " + + 'Please refresh this page to ensure the file listing is up-to-date.', + { life: 0 } + ); + } else if (syncQueueSize.value > 0) { const word = syncQueueSize.value > 1 ? 'files' : 'file'; toast.info('Sync in progress.', `${syncQueueSize.value} ${word} remaining.`, { life: 0 }); } @@ -61,17 +62,25 @@ onBeforeMount(async () => { const router = useRouter(); // fetch buckets (which is already scoped by cur user's permissions) and populates bucket and permissions in store - const bucketResponse = await bucketStore.fetchBuckets({ - bucketId: props.bucketId, - userId: getUserId.value, - objectPerms: true - }); + let bucketResponse: any = []; + if (props?.bucketId) { + if (getIsAuthenticated.value) { + bucketResponse = await bucketStore.fetchBuckets({ + bucketId: props.bucketId, + userId: getUserId.value, + objectPerms: true + }); + } else { + bucketResponse = [await bucketStore.fetchBucket(props.bucketId)]; + } + } + if (bucketResponse?.length) { bucket.value = bucketResponse[0]; ready.value = true; // sync bucket if necessary const hasRead = getBucketPermissions.value.filter((p) => p.permCode === Permissions.READ); - if(hasRead.length > 0) await autoSync(); + if (hasRead.length > 0) await autoSync(); } else { router.replace({ name: RouteNames.FORBIDDEN }); } @@ -87,9 +96,26 @@ onBeforeMount(async () => { icon="fa-solid fa-folder" class="mr-2 mt-2" /> - {{ bucket.bucketName }} + + {{ bucket.bucketName }} + + - + diff --git a/frontend/tests/unit/components/bucket/BucketSidebar.spec.ts b/frontend/tests/unit/components/bucket/BucketSidebar.spec.ts index 854c6c46..ae1c1cc8 100644 --- a/frontend/tests/unit/components/bucket/BucketSidebar.spec.ts +++ b/frontend/tests/unit/components/bucket/BucketSidebar.spec.ts @@ -19,6 +19,7 @@ const testSidebarInfo = { secretAccessKey: 'REDACTED', region: 'null', active: true, + public: false, createdBy: '11111111-2222-3333-4444-555555555555', createdAt: '2023-09-28T21:25:38.927Z', updatedBy: '2023-09-28T21:25:38.927Z', diff --git a/frontend/tests/unit/store/bucketStore.spec.ts b/frontend/tests/unit/store/bucketStore.spec.ts index d5647375..d6dd479a 100644 --- a/frontend/tests/unit/store/bucketStore.spec.ts +++ b/frontend/tests/unit/store/bucketStore.spec.ts @@ -17,6 +17,7 @@ const bucket: Bucket = { bucketName: 'unit', endpoint: 'https://not.a.url', key: 'test', + public: false, region: 'us-east-1', secretAccessKey: '123' }; @@ -29,6 +30,7 @@ const bucket2: Bucket = { bucketName: 'unit2', endpoint: 'https://not.a.url', key: 'test', + public: false, region: 'us-east-1', secretAccessKey: '456' };