diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 251a04a8c997cca666f1a0f06e7ce86054c9c69a..dc9685d859412869f83356767ff481053032f9ec 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -33,7 +33,7 @@ build: paths: - dist -build-publish-dev-docker-container-job: +publish-main-docker-container-job: stage: deploy image: name: gcr.io/kaniko-project/executor:v1.20.0-debug @@ -50,7 +50,7 @@ build-publish-dev-docker-container-job: --destination "${CI_REGISTRY_IMAGE}:main-${CI_COMMIT_SHA}" --destination "${CI_REGISTRY_IMAGE}:main-latest" -build-publish-docker-container-job: +publish-docker-container-job: stage: deploy image: name: gcr.io/kaniko-project/executor:v1.20.0-debug diff --git a/Dockerfile b/Dockerfile index 19606527adbac48def6672dd4f1518ac96526048..dda87caaa5cc450b2fbb2bd8d0a3fc1bb37838a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,9 +10,9 @@ RUN npm run build-only # production stage FROM nginx:stable-alpine as production-stage EXPOSE 80 -HEALTHCHECK --interval=30s --timeout=4s CMD curl --head -f http://localhost || exit 1 +HEALTHCHECK --interval=30s --timeout=2s CMD curl --head -f http://localhost || exit 1 +COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=build-stage /app/dist /usr/share/nginx/html COPY --from=build-stage /app/src/assets/env.template.js /tmp -COPY nginx.conf /etc/nginx/conf.d/default.conf CMD ["/bin/sh", "-c", "envsubst < /tmp/env.template.js > /usr/share/nginx/html/env.js && exec nginx -g 'daemon off;'"] diff --git a/src/client/resource/services/ResourceVersionService.ts b/src/client/resource/services/ResourceVersionService.ts index a4b9866b1c6df1f421e2b033f3f65ab822138216..ced4ef7ecdd1a8b80a17db35ff0f9bb0c6040656 100644 --- a/src/client/resource/services/ResourceVersionService.ts +++ b/src/client/resource/services/ResourceVersionService.ts @@ -129,11 +129,40 @@ export class ResourceVersionService { }, }); } + /** + * Deny synchronization request to cluster + * Deny the synchronization request of the resource version to the cluster. + * + * Permission `resource:sync` required. + * @param rid + * @param rvid + * @returns ResourceVersionOut Successful Response + * @throws ApiError + */ + public static resourceVersionResourceVersionSyncDeny( + rid: string, + rvid: string, + ): CancelablePromise<ResourceVersionOut> { + return __request(OpenAPI, { + method: 'PUT', + url: '/resources/{rid}/versions/{rvid}/deny', + path: { + 'rid': rid, + 'rvid': rvid, + }, + errors: { + 400: `Error decoding JWT Token`, + 403: `Not authenticated`, + 404: `Entity not Found`, + 422: `Validation Error`, + }, + }); + } /** * Synchronize resource version with cluster * Synchronize the resource version to the cluster. * - * Permission `resource:sync` required. + * Permission `resource:sync` required and `resource:sync_denied` if the status is `DENIED`. * @param rid * @param rvid * @returns ResourceVersionOut Successful Response diff --git a/src/client/workflow/models/AnonymizedWorkflowExecution.ts b/src/client/workflow/models/AnonymizedWorkflowExecution.ts index 8fadc75c02b6cd5d3a1555528b1f341d5ef1c6eb..cc68ea3be9a98c612dae5a8a1fdc646e26b039ac 100644 --- a/src/client/workflow/models/AnonymizedWorkflowExecution.ts +++ b/src/client/workflow/models/AnonymizedWorkflowExecution.ts @@ -15,7 +15,7 @@ export type AnonymizedWorkflowExecution = { /** * ID of the workflow mode this workflow execution ran in */ - workflow_mode_id?: string; + workflow_mode_id?: (string | null); /** * Hash of the git commit */ diff --git a/src/client/workflow/models/DocumentationEnum.ts b/src/client/workflow/models/DocumentationEnum.ts index 2f5b3e3d5833fb1775dd5d45ccef997b2e9b471f..5540c023bc93402dd8d236a41632abe9dbbfe904 100644 --- a/src/client/workflow/models/DocumentationEnum.ts +++ b/src/client/workflow/models/DocumentationEnum.ts @@ -8,4 +8,5 @@ export enum DocumentationEnum { OUTPUT = 'output', CHANGELOG = 'changelog', PARAMETER_SCHEMA = 'parameter_schema', + CLOWM_INFO = 'clowm_info', } diff --git a/src/client/workflow/models/WorkflowExecutionOut.ts b/src/client/workflow/models/WorkflowExecutionOut.ts index 2323c17cfb325b5652047fce1d003568f5582cb3..29bf964a4428e93d6330b90678de154661c47766 100644 --- a/src/client/workflow/models/WorkflowExecutionOut.ts +++ b/src/client/workflow/models/WorkflowExecutionOut.ts @@ -15,7 +15,7 @@ export type WorkflowExecutionOut = { /** * ID of the workflow mode this workflow execution runs in */ - mode_id?: string; + mode_id?: (string | null); /** * ID of the workflow execution */ @@ -39,7 +39,7 @@ export type WorkflowExecutionOut = { /** * Id of the workflow */ - workflow_id?: string; + workflow_id?: (string | null); /** * S3 Path where logs and reports are saved. */ diff --git a/src/client/workflow/models/WorkflowOut.ts b/src/client/workflow/models/WorkflowOut.ts index 2946f95b1b600083338889777bde0c71d130cf9b..79bc4687872e3d36d2e962038a848e6f49d1e923 100644 --- a/src/client/workflow/models/WorkflowOut.ts +++ b/src/client/workflow/models/WorkflowOut.ts @@ -27,7 +27,7 @@ export type WorkflowOut = { /** * ID of developer of the workflow */ - developer_id?: string; + developer_id?: (string | null); /** * Flag if the workflow is hosted in a private git repository */ diff --git a/src/components/NavbarTop.vue b/src/components/NavbarTop.vue index 37a3540c874d27e715d88a1b9e3b8e32cb2acbf1..0cb939d9815cc761f00d5390e64aa65ab9ccace3 100644 --- a/src/components/NavbarTop.vue +++ b/src/components/NavbarTop.vue @@ -211,7 +211,11 @@ watch( class="dropdown-menu shadow m-0" aria-labelledby="adminDropdown" > - <li><a class="dropdown-item disabled" href="#">User</a></li> + <li> + <router-link class="dropdown-item" :to="{ name: 'admin-users' }" + >Users + </router-link> + </li> <li><a class="dropdown-item disabled" href="#">Bucket</a></li> <li><a class="dropdown-item disabled" href="#">Workflow</a></li> <li> diff --git a/src/components/admin/UserRoleMark.vue b/src/components/admin/UserRoleMark.vue new file mode 100644 index 0000000000000000000000000000000000000000..b0d0a82fd9ad67a9a253a2b8ddfac42988ec4fc7 --- /dev/null +++ b/src/components/admin/UserRoleMark.vue @@ -0,0 +1,20 @@ +<script setup lang="ts"> +import { RoleEnum, type User } from "@/client/auth"; +import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; + +const props = defineProps<{ + role: RoleEnum; + user: User; +}>(); +</script> + +<template> + <font-awesome-icon + v-if="!props.user.roles?.includes(props.role)" + icon="fa-solid fa-check" + class="text-success fs-5" + /> + <font-awesome-icon v-else icon="fa-solid fa-xmark" class="text-danger fs-5" /> +</template> + +<style scoped></style> diff --git a/src/components/modals/SearchUserModal.vue b/src/components/modals/SearchUserModal.vue index 414369508f1be90c59a4dab83446ddddb89450b7..b6396eb488c24adc477b6fc2ce6c575a1e443135 100644 --- a/src/components/modals/SearchUserModal.vue +++ b/src/components/modals/SearchUserModal.vue @@ -2,10 +2,8 @@ import { reactive, ref, watch } from "vue"; import BootstrapModal from "@/components/modals/BootstrapModal.vue"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; -import { UserService } from "@/client/auth"; import type { User } from "@/client/auth"; import { useAuthStore } from "@/stores/users"; -import { useNameStore } from "@/stores/names"; const props = defineProps<{ modalId: string; @@ -15,7 +13,6 @@ const props = defineProps<{ const randomIDSuffix = Math.random().toString(16).substring(2, 8); const store = useAuthStore(); -const nameStore = useNameStore(); const textInputElement = ref<HTMLInputElement | undefined>(undefined); const formState = reactive<{ @@ -63,7 +60,8 @@ function modalShown() { function searchUser(name: string) { formState.error = false; - UserService.userListUsers(name) + store + .fetchUsers(name) .then((userSuggestions) => { if (props.filterUserSelf) { formState.potentialUsers = userSuggestions.filter( @@ -72,9 +70,6 @@ function searchUser(name: string) { } else { formState.potentialUsers = userSuggestions; } - for (const user of userSuggestions) { - nameStore.addNameToMapping(user.uid, user.display_name); - } }) .catch((err) => { formState.error = true; diff --git a/src/router/adminRoutes.ts b/src/router/adminRoutes.ts index a7a4384781a425879efc121e17f1c2ea24253195..e146270b70d3204f470c139a12c7299becaed214 100644 --- a/src/router/adminRoutes.ts +++ b/src/router/adminRoutes.ts @@ -9,4 +9,12 @@ export const adminRoutes: RouteRecordRaw[] = [ requiresAdminRole: true, }, }, + { + path: "admin/users", + name: "admin-users", + component: () => import("../views/admin/AdminUsersView.vue"), + meta: { + requiresAdminRole: true, + }, + }, ]; diff --git a/src/stores/names.ts b/src/stores/names.ts index e2b76aa70e58f926db013beb7b5f106950fd6cdd..f83bb9d2aa6c0af9e08eced3108d2ee8525efaec 100644 --- a/src/stores/names.ts +++ b/src/stores/names.ts @@ -9,7 +9,7 @@ export const useNameStore = defineStore({ nameMapping: Record<string, string>; }, getters: { - getName(): (objectID?: string) => string | undefined { + getName(): (objectID?: string | null) => string | undefined { return (objectID) => { if (objectID) { return this.nameMapping[objectID] ?? localStorage.getItem(objectID); diff --git a/src/stores/resources.ts b/src/stores/resources.ts index 03f153a607db56a037c47928699ce8ca18dc643d..e0377b4617142256c98526fb7a0a875686bbc24a 100644 --- a/src/stores/resources.ts +++ b/src/stores/resources.ts @@ -219,30 +219,37 @@ export const useResourceStore = defineStore({ return ResourceVersionService.resourceVersionResourceVersionSync( resourceVersion.resource_id, resourceVersion.resource_version_id, - ).then((changedVersion) => { - if ( - this.reviewableResourceMapping[changedVersion.resource_id] == - undefined - ) { - return changedVersion; - } - const versionIndex = this.reviewableResourceMapping[ - changedVersion.resource_id - ].versions.findIndex( - (version) => - version.resource_version_id == changedVersion.resource_version_id, + ).then(this._updateReviewableResourceVersion); + }, + denyResource( + resourceVersion: ResourceVersionOut, + ): Promise<ResourceVersionOut> { + return ResourceVersionService.resourceVersionResourceVersionSyncDeny( + resourceVersion.resource_id, + resourceVersion.resource_version_id, + ).then(this._updateReviewableResourceVersion); + }, + _updateReviewableResourceVersion( + version: ResourceVersionOut, + ): ResourceVersionOut { + if (this.reviewableResourceMapping[version.resource_id] == undefined) { + return version; + } + const versionIndex = this.reviewableResourceMapping[ + version.resource_id + ].versions.findIndex( + (version) => version.resource_version_id == version.resource_version_id, + ); + if (versionIndex > -1) { + this.reviewableResourceMapping[version.resource_id].versions[ + versionIndex + ] = version; + } else { + this.reviewableResourceMapping[version.resource_id].versions.push( + version, ); - if (versionIndex > -1) { - this.reviewableResourceMapping[changedVersion.resource_id].versions[ - versionIndex - ] = changedVersion; - } else { - this.reviewableResourceMapping[ - changedVersion.resource_id - ].versions.push(changedVersion); - } - return changedVersion; - }); + } + return version; }, setLatestResource( resourceVersion: ResourceVersionOut, diff --git a/src/stores/users.ts b/src/stores/users.ts index 928ed2956df6f5a9d322d219b329c4f0f1b59fe3..0eff50e59fa08c12400596982a060f10d00b5591 100644 --- a/src/stores/users.ts +++ b/src/stores/users.ts @@ -125,6 +125,23 @@ export const useAuthStore = defineStore({ useS3KeyStore().$reset(); useS3ObjectStore().$reset(); }, + fetchUsers( + searchString?: string, + filterRoles?: RoleEnum[], + includeRoles = false, + ): Promise<User[]> { + return UserService.userListUsers( + searchString, + filterRoles, + includeRoles, + ).then((users) => { + const nameStore = useNameStore(); + for (const user of users) { + nameStore.addNameToMapping(user.uid, user.display_name); + } + return users; + }); + }, async fetchUsernames(uids: string[]): Promise<string[]> { const nameStore = useNameStore(); const filteredIds = uids diff --git a/src/views/admin/AdminResourcesView.vue b/src/views/admin/AdminResourcesView.vue index 2906d8507f98ad88efe1b781f3c9941d06bd38f0..095336b74f2cd76e2dec1d12bd3100184a4698c3 100644 --- a/src/views/admin/AdminResourcesView.vue +++ b/src/views/admin/AdminResourcesView.vue @@ -117,6 +117,16 @@ function syncToCluster(resourceVersion: ResourceVersionOut) { }); } +function denyResource(resourceVersion: ResourceVersionOut) { + resourceState.loading = true; + resourceRepository + .denyResource(resourceVersion) + .then(replaceResourceVersion) + .finally(() => { + resourceState.loading = false; + }); +} + function resetForm() { resourceState.maintainerId = ""; resourceState.searchString = ""; @@ -226,8 +236,8 @@ function resetForm() { <tr> <td colspan="5" class="text-center fst-italic fw-light"> <template v-if="resourceState.searched" - >No resource found with specified filters</template - > + >No resource found with specified filters + </template> <template v-else>Select a filter and search for Resources</template> </td> </tr> @@ -306,7 +316,8 @@ function resetForm() { <li v-if=" version.status === Status.CLUSTER_DELETED || - version.status === Status.SYNC_REQUESTED + version.status === Status.SYNC_REQUESTED || + version.status === Status.DENIED " > <button @@ -320,6 +331,19 @@ function resetForm() { <span class="ms-1">Sync to Cluster</span> </button> </li> + <li v-if="version.status === Status.SYNC_REQUESTED"> + <button + class="dropdown-item" + type="button" + @click="denyResource(version)" + > + <font-awesome-icon + icon="fa-solid fa-xmark" + class="text-danger" + /> + <span class="ms-1">Deny Synchronization</span> + </button> + </li> <li v-if=" version.status === Status.SYNCHRONIZED || @@ -335,7 +359,12 @@ function resetForm() { <span class="ms-1">Delete on Cluster</span> </button> </li> - <li v-if="version.status === Status.CLUSTER_DELETED"> + <li + v-if=" + version.status === Status.CLUSTER_DELETED || + version.status === Status.DENIED + " + > <button class="dropdown-item text-danger align-middle" type="button" diff --git a/src/views/admin/AdminUsersView.vue b/src/views/admin/AdminUsersView.vue new file mode 100644 index 0000000000000000000000000000000000000000..0f89f1f8945bb095c330c91799b1d131da9cb38b --- /dev/null +++ b/src/views/admin/AdminUsersView.vue @@ -0,0 +1,141 @@ +<script setup lang="ts"> +import { useAuthStore } from "@/stores/users"; +import { reactive } from "vue"; +import { RoleEnum, type User } from "@/client/auth"; +import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; +import UserRoleMark from "@/components/admin/UserRoleMark.vue"; + +const userRepository = useAuthStore(); + +const userState = reactive<{ + loading: boolean; + users: User[]; + searchString: string; + userRoles: RoleEnum[]; + inspectUser?: User; + searched: boolean; +}>({ + loading: false, + users: [], + searchString: "", + userRoles: [], + inspectUser: undefined, + searched: false, +}); + +function searchUsers() { + userState.loading = true; + userRepository + .fetchUsers( + userState.searchString ? userState.searchString : undefined, + userState.userRoles ? userState.userRoles : undefined, + true, + ) + .then((users) => { + userState.users = users; + }) + .finally(() => { + userState.loading = false; + userState.searched = true; + }); +} +</script> + +<template> + <div + class="row m-2 border-bottom mb-4 justify-content-between align-items-center" + > + <h2>Manage Users</h2> + </div> + <form @submit.prevent="searchUsers" id="admin-user-search-form"> + <div class="d-flex justify-content-evenly align-content-center"> + <div class="mx-2"> + <label for="admin-user-state-select" class="form-label" + >User Roles</label + > + <select + v-model="userState.userRoles" + multiple + class="form-select mb-4 w-fit" + id="admin-user-state-select" + > + <option v-for="role in Object.values(RoleEnum)" :key="role"> + {{ role }} + </option> + </select> + </div> + <div class="flex-fill mx-2"> + <label for="admin-user-name-search" class="form-label" + >Name of a user</label + > + <div class="input-group"> + <div class="input-group-text"> + <font-awesome-icon icon="fa-solid fa-search" /> + </div> + <input + id="admin-user-name-search" + type="text" + class="form-control" + maxlength="32" + minlength="3" + v-model="userState.searchString" + placeholder="Search for user name" + /> + </div> + </div> + </div> + <button + type="submit" + class="btn btn-primary w-fit" + :disabled="userState.loading" + > + Search + </button> + </form> + <table class="table table-striped align-middle" v-if="userState.users"> + <thead> + <tr> + <th scope="col"><b>Name</b></th> + <th scope="col">UID</th> + <th scope="col" class="text-center">Normal User</th> + <th scope="col" class="text-center">Developer</th> + <th scope="col" class="text-center">Resource Maintainer</th> + <th scope="col" class="text-center">Reviewer</th> + <th scope="col" class="text-center">Admin</th> + </tr> + </thead> + <tbody v-if="userState.users.length === 0"> + <tr> + <td colspan="7" class="text-center fst-italic fw-light"> + <template v-if="userState.searched" + >No Users found with specified filters + </template> + <template v-else>Select a filter and search for Users</template> + </td> + </tr> + </tbody> + <tbody v-else> + <tr v-for="user in userState.users" :key="user.uid"> + <th scope="row">{{ user.display_name }}</th> + <td>{{ user.uid }}</td> + <td class="text-center"> + <user-role-mark :role="RoleEnum.FOREIGN_USER" :user="user" /> + </td> + <td class="text-center"> + <user-role-mark :role="RoleEnum.DEVELOPER" :user="user" /> + </td> + <td class="text-center"> + <user-role-mark :role="RoleEnum.DB_MAINTAINER" :user="user" /> + </td> + <td class="text-center"> + <user-role-mark :role="RoleEnum.REVIEWER" :user="user" /> + </td> + <td class="text-center"> + <user-role-mark :role="RoleEnum.ADMINISTRATOR" :user="user" /> + </td> + </tr> + </tbody> + </table> +</template> + +<style scoped></style> diff --git a/src/views/resources/ReviewResourceView.vue b/src/views/resources/ReviewResourceView.vue index 4e9a33a08a34f0fe619d57d0e1ca129f19e75d14..78f678528a10dcbf1b3a1dc995355a91e1194343 100644 --- a/src/views/resources/ReviewResourceView.vue +++ b/src/views/resources/ReviewResourceView.vue @@ -55,6 +55,13 @@ function syncResource(resourceVersion: ResourceVersionOut) { }); } +function denyResource(resourceVersion: ResourceVersionOut) { + resourceState.sendingRequest = true; + resourceRepository.denyResource(resourceVersion).finally(() => { + resourceState.sendingRequest = false; + }); +} + onMounted(() => { fetchResources(); new Tooltip("#refreshReviewableResourcesButton"); @@ -155,7 +162,12 @@ onMounted(() => { > Synchronize </button> - <button type="button" class="btn btn-danger btn-sm" disabled> + <button + type="button" + class="btn btn-danger btn-sm" + @click="denyResource(version)" + :disabled="resourceState.sendingRequest" + > Deny </button> </div> diff --git a/src/views/workflows/ReviewWorkflowsView.vue b/src/views/workflows/ReviewWorkflowsView.vue index f3235e3727d60333d8ff73aae4b39c752cd7b669..1ff20c478c5d69385d447584a9649908000d6220 100644 --- a/src/views/workflows/ReviewWorkflowsView.vue +++ b/src/views/workflows/ReviewWorkflowsView.vue @@ -33,8 +33,8 @@ function updateWorkflowVersionStatus( }); } -function isDefined<T>(argument: T | undefined): argument is T { - return argument !== undefined; +function isDefined<T>(argument: T | undefined | null): argument is T { + return argument != undefined; } onMounted(() => {