Browse Source

Merge pull request #6378 from nocodb/nc-fix/inherited-role

feat: new role badges and selector
pull/6475/head
mertmit 1 year ago committed by GitHub
parent
commit
c0e687e17b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      packages/nc-gui/components/dashboard/Sidebar/TopSection.vue
  2. 14
      packages/nc-gui/components/nc/Badge.vue
  3. 125
      packages/nc-gui/components/project/AccessSettings.vue
  4. 57
      packages/nc-gui/components/project/InviteProjectCollabSection.vue
  5. 74
      packages/nc-gui/components/roles/Badge.vue
  6. 57
      packages/nc-gui/components/roles/Selector.vue
  7. 71
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  8. 38
      packages/nc-gui/components/workspace/InviteSection.vue
  9. 11
      packages/nc-gui/components/workspace/View.vue
  10. 8
      packages/nc-gui/composables/useRoles/index.ts
  11. 6
      packages/nc-gui/middleware/auth.global.ts
  12. 13
      packages/nc-gui/store/users.ts
  13. 19
      packages/nc-gui/store/workspace.ts
  14. 13
      packages/nc-gui/utils/iconUtils.ts
  15. 42
      packages/nocodb-sdk/src/lib/enums.ts
  16. 2
      packages/nocodb/src/guards/global/global.guard.ts
  17. 3
      packages/nocodb/src/models/User.ts
  18. 4
      packages/nocodb/src/services/project-users/project-users.service.ts
  19. 2
      packages/nocodb/src/strategies/google.strategy/google.strategy.ts
  20. 2
      packages/nocodb/src/utils/roleHelper.ts
  21. 6
      tests/playwright/pages/Dashboard/ProjectView/AccessSettingsPage.ts
  22. 4
      tests/playwright/pages/WorkspacePage/CollaborationPage.ts
  23. 2
      tests/playwright/setup/index.ts

6
packages/nc-gui/components/dashboard/Sidebar/TopSection.vue

@ -2,9 +2,11 @@
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
const projectStore = useProject() const projectStore = useProject()
const { isUIAllowed } = useRoles()
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const { isWorkspaceLoading, isWorkspaceOwnerOrCreator, isWorkspaceSettingsPageOpened } = storeToRefs(workspaceStore) const { isWorkspaceLoading, isWorkspaceSettingsPageOpened } = storeToRefs(workspaceStore)
const { navigateToWorkspaceSettings } = workspaceStore const { navigateToWorkspaceSettings } = workspaceStore
@ -45,7 +47,7 @@ const navigateToSettings = () => {
<DashboardSidebarTopSectionHeader /> <DashboardSidebarTopSectionHeader />
<NcButton <NcButton
v-if="isWorkspaceOwnerOrCreator" v-if="isUIAllowed('workspaceSettings')"
type="text" type="text"
size="small" size="small"
class="nc-sidebar-top-button" class="nc-sidebar-top-button"

14
packages/nc-gui/components/nc/Badge.vue

@ -1,19 +1,27 @@
<script lang="ts" setup> <script lang="ts" setup>
const props = defineProps<{ const props = withDefaults(
defineProps<{
color: string color: string
}>() border?: boolean
}>(),
{
border: true,
},
)
</script> </script>
<template> <template>
<div <div
class="border-1 h-6 rounded-md px-1" class="h-6 rounded-md px-1"
:class="{ :class="{
'border-purple-500 bg-purple-100': props.color === 'purple', 'border-purple-500 bg-purple-100': props.color === 'purple',
'border-blue-500 bg-blue-100': props.color === 'blue', 'border-blue-500 bg-blue-100': props.color === 'blue',
'border-green-500 bg-green-100': props.color === 'green', 'border-green-500 bg-green-100': props.color === 'green',
'border-orange-500 bg-orange-100': props.color === 'orange', 'border-orange-500 bg-orange-100': props.color === 'orange',
'border-yellow-500 bg-yellow-100': props.color === 'yellow', 'border-yellow-500 bg-yellow-100': props.color === 'yellow',
'border-red-500 bg-red-100': props.color === 'red',
'border-gray-300': !props.color, 'border-gray-300': !props.color,
'border-1': props.border,
}" }"
> >
<slot /> <slot />

125
packages/nc-gui/components/project/AccessSettings.vue

@ -1,15 +1,25 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { WorkspaceUserType } from 'nocodb-sdk' import type { OrgUserRoles, WorkspaceUserRoles } from 'nocodb-sdk'
import { OrderedProjectRoles, ProjectRoles, RoleColors, RoleLabels } from 'nocodb-sdk' import { OrderedProjectRoles, ProjectRoles, WorkspaceRolesToProjectRoles } from 'nocodb-sdk'
import InfiniteLoading from 'v3-infinite-loading' import InfiniteLoading from 'v3-infinite-loading'
import { isEeUI, storeToRefs, stringToColour, timeAgo, useGlobal } from '#imports' import { isEeUI, storeToRefs, stringToColour, timeAgo } from '#imports'
const { user } = useGlobal()
const projectsStore = useProjects() const projectsStore = useProjects()
const { getProjectUsers, createProjectUser, updateProjectUser, removeProjectUser } = projectsStore const { getProjectUsers, createProjectUser, updateProjectUser, removeProjectUser } = projectsStore
const { activeProjectId } = storeToRefs(projectsStore) const { activeProjectId } = storeToRefs(projectsStore)
const collaborators = ref<WorkspaceUserType[]>([]) const { projectRoles } = useRoles()
const collaborators = ref<
{
id: string
email: string
main_roles: OrgUserRoles
roles: ProjectRoles
workspace_roles: WorkspaceUserRoles
created_at: string
}[]
>([])
const totalCollaborators = ref(0) const totalCollaborators = ref(0)
const userSearchText = ref('') const userSearchText = ref('')
const currentPage = ref(0) const currentPage = ref(0)
@ -34,9 +44,12 @@ const loadCollaborators = async () => {
...collaborators.value, ...collaborators.value,
...users.map((user: any) => ({ ...users.map((user: any) => ({
...user, ...user,
projectRoles: user.roles, project_roles: user.roles,
// TODO: Remove this hack and make the values consistent with the backend roles:
roles: user.roles ?? (RoleLabels[user.workspace_roles as string] as string)?.toLowerCase() ?? ProjectRoles.NO_ACCESS, user.roles ??
(user.workspace_roles
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS
: ProjectRoles.NO_ACCESS),
})), })),
] ]
} catch (e: any) { } catch (e: any) {
@ -62,19 +75,40 @@ const loadListData = async ($state: any) => {
$state.loaded() $state.loaded()
} }
const updateCollaborator = async (collab, roles) => { const reloadCollabs = async () => {
currentPage.value = 0
collaborators.value = []
await loadCollaborators()
}
const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
try { try {
if (!roles || roles === 'inherit' || (roles === ProjectRoles.NO_ACCESS && !isEeUI)) { if (
!roles ||
(roles === ProjectRoles.NO_ACCESS && !isEeUI) ||
(collab.workspace_roles && WorkspaceRolesToProjectRoles[collab.workspace_roles as WorkspaceUserRoles] === roles && isEeUI)
) {
await removeProjectUser(activeProjectId.value!, collab) await removeProjectUser(activeProjectId.value!, collab)
collab.projectRoles = null if (
} else if (collab.projectRoles) { collab.workspace_roles &&
WorkspaceRolesToProjectRoles[collab.workspace_roles as WorkspaceUserRoles] === roles &&
isEeUI
) {
collab.roles = WorkspaceRolesToProjectRoles[collab.workspace_roles as WorkspaceUserRoles]
} else {
collab.roles = ProjectRoles.NO_ACCESS
}
} else if (collab.project_roles) {
collab.roles = roles
await updateProjectUser(activeProjectId.value!, collab) await updateProjectUser(activeProjectId.value!, collab)
} else { } else {
collab.roles = roles
await createProjectUser(activeProjectId.value!, collab) await createProjectUser(activeProjectId.value!, collab)
collab.projectRoles = roles
} }
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} finally {
reloadCollabs()
} }
} }
@ -101,22 +135,13 @@ watchDebounced(
}, },
) )
const reloadCollabs = async () => {
currentPage.value = 0
collaborators.value = []
await loadCollaborators()
}
const userProjectRole = computed<(typeof ProjectRoles)[keyof typeof ProjectRoles]>(() => {
const projectUser = collaborators.value?.find((collab) => collab.id === user.value?.id)
return projectUser?.projectRoles
})
onMounted(async () => { onMounted(async () => {
isLoading.value = true isLoading.value = true
try { try {
await loadCollaborators() await loadCollaborators()
const currentRoleIndex = OrderedProjectRoles.findIndex((role) => role === userProjectRole.value) const currentRoleIndex = OrderedProjectRoles.findIndex(
(role) => projectRoles.value && Object.keys(projectRoles.value).includes(role),
)
if (currentRoleIndex !== -1) { if (currentRoleIndex !== -1) {
accessibleRoles.value = OrderedProjectRoles.slice(currentRoleIndex + 1) accessibleRoles.value = OrderedProjectRoles.slice(currentRoleIndex + 1)
} }
@ -180,41 +205,23 @@ onMounted(async () => {
{{ timeAgo(collab.created_at) }} {{ timeAgo(collab.created_at) }}
</div> </div>
<div class="w-1/5 roles"> <div class="w-1/5 roles">
<div class="nc-collaborator-role-select"> <div class="nc-collaborator-role-select p-2">
<NcSelect <template v-if="accessibleRoles.includes(collab.roles)">
v-model:value="collab.roles" <RolesSelector
class="w-35 !rounded px-1" :role="collab.roles"
:virtual="true" :roles="accessibleRoles"
:placeholder="$t('labels.noAccess')" :inherit="
:disabled="collab.id === user?.id || (collab.roles && !accessibleRoles.includes(collab.roles))" isEeUI && collab.workspace_roles && WorkspaceRolesToProjectRoles[collab.workspace_roles]
@change="(value) => updateCollaborator(collab, value)" ? WorkspaceRolesToProjectRoles[collab.workspace_roles]
> : null
<template #suffixIcon> "
<MdiChevronDown /> :description="false"
:on-role-change="(role: ProjectRoles) => updateCollaborator(collab, role)"
/>
</template> </template>
<a-select-option v-if="collab.id === user?.id" :value="userProjectRole"> <template v-else>
<NcBadge :color="RoleColors[userProjectRole]"> <RolesBadge class="!bg-white" :role="collab.roles" />
<p class="badge-text">{{ RoleLabels[userProjectRole] }}</p>
</NcBadge>
</a-select-option>
<a-select-option v-if="collab.roles && !accessibleRoles.includes(collab.roles)" :value="collab.roles">
<NcBadge :color="RoleColors[collab.roles]">
<p class="badge-text">{{ RoleLabels[collab.roles] }}</p>
</NcBadge>
</a-select-option>
<template v-for="role of accessibleRoles" :key="`role-option-${role}`">
<a-select-option :value="role">
<NcBadge :color="RoleColors[role]">
<p class="badge-text">{{ RoleLabels[role] }}</p>
</NcBadge>
</a-select-option>
</template> </template>
<a-select-option v-if="isEeUI" value="inherit">
<NcBadge color="white">
<p class="badge-text">Inherit</p>
</NcBadge>
</a-select-option>
</NcSelect>
</div> </div>
</div> </div>
<div class="w-1/5"></div> <div class="w-1/5"></div>
@ -238,7 +245,7 @@ onMounted(async () => {
<style scoped lang="scss"> <style scoped lang="scss">
.badge-text { .badge-text {
@apply text-[14px] pt-1 text-center; @apply text-[14px] flex items-center justify-center gap-1 pt-0.5;
} }
.nc-collaborators-list { .nc-collaborators-list {

57
packages/nc-gui/components/project/InviteProjectCollabSection.vue

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ProjectRoles } from 'nocodb-sdk' import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, useDashboard, useManageUsers } from '#imports' import { extractSdkResponseErrorMsg, useDashboard, useManageUsers } from '#imports'
const emit = defineEmits(['invited']) const emit = defineEmits(['invited'])
@ -15,6 +15,8 @@ const { inviteUser } = useManageUsers()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const { projectRoles } = useRoles()
const usersData = ref<{ const usersData = ref<{
invite_token?: string invite_token?: string
email?: string email?: string
@ -47,6 +49,22 @@ const inviteUrl = computed(() =>
usersData.value?.invite_token ? `${dashboardUrl.value}#/signup/${usersData.value.invite_token}` : null, usersData.value?.invite_token ? `${dashboardUrl.value}#/signup/${usersData.value.invite_token}` : null,
) )
// allow only lower roles to be assigned
const allowedRoles = ref<ProjectRoles[]>([])
onMounted(async () => {
try {
const currentRoleIndex = OrderedProjectRoles.findIndex(
(role) => projectRoles.value && Object.keys(projectRoles.value).includes(role),
)
if (currentRoleIndex !== -1) {
allowedRoles.value = OrderedProjectRoles.slice(currentRoleIndex).filter((r) => r && r !== ProjectRoles.OWNER)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
})
const { copy } = useCopy(true) const { copy } = useCopy(true)
const { t } = useI18n() const { t } = useI18n()
@ -117,36 +135,13 @@ const copyUrl = async () => {
class="!max-w-130 !rounded" class="!max-w-130 !rounded"
/> />
<NcSelect v-model:value="inviteData.roles" class="min-w-30 !rounded px-1" data-testid="roles"> <RolesSelector
<template #suffixIcon> class="px-1"
<MdiChevronDown /> :role="inviteData.roles"
</template> :roles="allowedRoles"
<a-select-option v-for="(role, index) in projectRoles" :key="index" :value="role" class="nc-role-option"> :on-role-change="(role: ProjectRoles) => (inviteData.roles = role)"
<!-- <div :description="true"
class="flex flex-row h-full justify-start items-center" />
:data-testid="`nc-share-invite-user-role-option-${role}`"
>
<div class="px-2 py-1 flex rounded-full text-xs capitalize">
{{ role }}
</div>
</div> -->
<NcBadge v-if="role === ProjectRoles.OWNER" color="purple">
<p class="badge-text">{{ role }}</p>
</NcBadge>
<NcBadge v-if="role === ProjectRoles.CREATOR" color="blue">
<p class="badge-text">{{ role }}</p>
</NcBadge>
<NcBadge v-if="role === ProjectRoles.EDITOR" color="green">
<p class="badge-text">{{ role }}</p>
</NcBadge>
<NcBadge v-if="role === ProjectRoles.COMMENTER" color="orange">
<p class="badge-text">{{ role }}</p>
</NcBadge>
<NcBadge v-if="role === ProjectRoles.VIEWER" color="yellow">
<p class="badge-text">{{ role }}</p>
</NcBadge>
</a-select-option>
</NcSelect>
<a-button <a-button
type="primary" type="primary"

74
packages/nc-gui/components/roles/Badge.vue

@ -0,0 +1,74 @@
<script lang="ts" setup>
import { RoleColors, RoleIcons, RoleLabels } from 'nocodb-sdk'
import { toRef } from '#imports'
const props = withDefaults(
defineProps<{
role: keyof typeof RoleLabels
clickable?: boolean
inherit?: boolean
border?: boolean
}>(),
{
clickable: false,
inherit: false,
border: true,
},
)
const roleRef = toRef(props, 'role')
const clickableRef = toRef(props, 'clickable')
const inheritRef = toRef(props, 'inherit')
const borderRef = toRef(props, 'border')
const roleProperties = computed(() => {
const role = roleRef.value
const color = RoleColors[role]
const icon = RoleIcons[role]
const label = RoleLabels[role]
return {
color,
icon,
label,
}
})
</script>
<template>
<div
class="flex items-center !border-0"
:class="{
'cursor-pointer': clickableRef,
}"
style="width: fit-content"
>
<NcBadge class="!h-auto !px-[8px]" :color="roleProperties.color" :border="borderRef">
<div
class="badge-text flex items-center gap-[4px]"
:class="{
'text-purple-500': roleProperties.color === 'purple',
'text-blue-500': roleProperties.color === 'blue',
'text-green-500': roleProperties.color === 'green',
'text-orange-500': roleProperties.color === 'orange',
'text-yellow-500': roleProperties.color === 'yellow',
'text-red-500': roleProperties.color === 'red',
'text-gray-300': !roleProperties.color,
}"
>
<GeneralIcon :icon="roleProperties.icon" />
{{ roleProperties.label }}
<GeneralIcon v-if="clickableRef" icon="arrowDown" />
</div>
</NcBadge>
<div class="flex-1"></div>
<!--
<a-tooltip v-if="inheritRef" placement="bottom">
<div class="text-gray-400 text-xs p-1 rounded-md">Workspace Role</div>
</a-tooltip>
-->
</div>
</template>
<style scoped lang="scss"></style>

57
packages/nc-gui/components/roles/Selector.vue

@ -0,0 +1,57 @@
<script lang="ts" setup>
import { RoleDescriptions } from 'nocodb-sdk'
import type { RoleLabels } from 'nocodb-sdk'
import { toRef } from '#imports'
const props = withDefaults(
defineProps<{
role: keyof typeof RoleLabels
roles: (keyof typeof RoleLabels)[]
description?: boolean
inherit?: string
onRoleChange: (role: keyof typeof RoleLabels) => void
}>(),
{
description: true,
},
)
const roleRef = toRef(props, 'role')
const inheritRef = toRef(props, 'inherit')
const descriptionRef = toRef(props, 'description')
</script>
<template>
<NcDropdown>
<RolesBadge class="border-1" data-testid="roles" :role="roleRef" :inherit="inheritRef === role" clickable />
<template #overlay>
<div class="nc-role-select-dropdown flex flex-col gap-[4px] p-1">
<div class="flex flex-col gap-[4px]">
<div v-for="rl in props.roles" :key="rl" :value="rl" :selected="rl === roleRef" @click="props.onRoleChange(rl)">
<div
class="flex flex-col py-[3px] px-[8px] gap-[4px] bg-transparent cursor-pointer"
:class="{
'w-[350px]': descriptionRef,
'w-[200px]': !descriptionRef,
}"
>
<div class="flex items-center justify-between">
<RolesBadge
class="!bg-white hover:!bg-gray-100"
:class="`nc-role-select-${rl}`"
:role="rl"
:inherit="inheritRef === rl"
:border="false"
/>
<GeneralIcon v-if="rl === roleRef" icon="check" />
</div>
<div v-if="descriptionRef" class="text-gray-500">{{ RoleDescriptions[rl] }}</div>
</div>
</div>
</div>
</div>
</template>
</NcDropdown>
</template>
<style scoped lang="scss"></style>

71
packages/nc-gui/components/workspace/CollaboratorsList.vue

@ -1,14 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { OrderedWorkspaceRoles, RoleColors, RoleLabels, WorkspaceUserRoles } from 'nocodb-sdk' import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk'
import { storeToRefs, stringToColour, timeAgo, useWorkspace } from '#imports' import { storeToRefs, stringToColour, timeAgo, useWorkspace } from '#imports'
const { user } = useGlobal() const { workspaceRoles, loadRoles } = useRoles()
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
const { removeCollaborator, updateCollaborator: _updateCollaborator } = workspaceStore const { removeCollaborator, updateCollaborator: _updateCollaborator } = workspaceStore
const { collaborators, workspaceRole } = storeToRefs(workspaceStore) const { collaborators } = storeToRefs(workspaceStore)
const userSearchText = ref('') const userSearchText = ref('')
const filterCollaborators = computed(() => { const filterCollaborators = computed(() => {
@ -19,7 +19,8 @@ const filterCollaborators = computed(() => {
return collaborators.value.filter((collab) => collab.email!.includes(userSearchText.value)) return collaborators.value.filter((collab) => collab.email!.includes(userSearchText.value))
}) })
const updateCollaborator = async (collab) => { const updateCollaborator = async (collab: any, roles: WorkspaceUserRoles) => {
collab.roles = roles
try { try {
await _updateCollaborator(collab.id, collab.roles) await _updateCollaborator(collab.id, collab.roles)
message.success('Successfully updated user role') message.success('Successfully updated user role')
@ -29,14 +30,21 @@ const updateCollaborator = async (collab) => {
} }
const accessibleRoles = computed<WorkspaceUserRoles[]>(() => { const accessibleRoles = computed<WorkspaceUserRoles[]>(() => {
const currentRoleIndex = OrderedWorkspaceRoles.findIndex((role) => role === workspaceRole.value) const currentRoleIndex = OrderedWorkspaceRoles.findIndex(
return OrderedWorkspaceRoles.slice(currentRoleIndex + 1) (role) => workspaceRoles.value && Object.keys(workspaceRoles.value).includes(role),
)
if (currentRoleIndex === -1) return []
return OrderedWorkspaceRoles.slice(currentRoleIndex + 1).filter((r) => r)
})
onMounted(async () => {
await loadRoles()
}) })
</script> </script>
<template> <template>
<div class="nc-collaborator-table-container mt-4 mx-6"> <div class="nc-collaborator-table-container mt-4 mx-6">
<WorkspaceInviteSection v-if="workspaceRole !== WorkspaceUserRoles.VIEWER" /> <WorkspaceInviteSection v-if="workspaceRoles !== WorkspaceUserRoles.VIEWER" />
<div class="w-full h-1 border-t-1 border-gray-100 opacity-50 mt-6"></div> <div class="w-full h-1 border-t-1 border-gray-100 opacity-50 mt-6"></div>
<div class="w-full flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2"> <div class="w-full flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2">
<div class="text-xl">Collaborators</div> <div class="text-xl">Collaborators</div>
@ -74,47 +82,18 @@ const accessibleRoles = computed<WorkspaceUserRoles[]>(() => {
{{ timeAgo(collab.created_at) }} {{ timeAgo(collab.created_at) }}
</td> </td>
<td class="w-1/5 roles"> <td class="w-1/5 roles">
<div v-if="collab.roles === WorkspaceUserRoles.OWNER" class="nc-collaborator-role-select"> <div class="nc-collaborator-role-select">
<a-select v-model:value="collab.roles" class="w-30 !rounded px-1" disabled> <template v-if="accessibleRoles.includes(collab.roles)">
<template #suffixIcon> <RolesSelector
<MdiChevronDown /> :role="collab.roles"
</template> :roles="accessibleRoles"
<a-select-option :value="WorkspaceUserRoles.OWNER"> :description="false"
<NcBadge color="purple"> :on-role-change="(role: WorkspaceUserRoles) => updateCollaborator(collab, role)"
<p class="badge-text">Owner</p> />
</NcBadge>
</a-select-option>
</a-select>
</div>
<div v-else class="nc-collaborator-role-select">
<NcSelect
v-model:value="collab.roles"
class="w-30 !rounded px-1"
:disabled="collab.id === user?.id || !accessibleRoles.includes(collab.roles)"
@change="updateCollaborator(collab)"
>
<template #suffixIcon>
<MdiChevronDown />
</template> </template>
<a-select-option v-if="collab.id === user?.id" :value="workspaceRole"> <template v-else>
<NcBadge :color="RoleColors[workspaceRole]"> <RolesBadge class="!bg-white" :role="collab.roles" />
<p class="badge-text">{{ RoleLabels[workspaceRole] }}</p>
</NcBadge>
</a-select-option>
<a-select-option v-if="!accessibleRoles.includes(collab.roles)" :value="collab.roles">
<NcBadge :color="RoleColors[collab.roles]">
<p class="badge-text">{{ RoleLabels[collab.roles] }}</p>
</NcBadge>
</a-select-option>
<template v-for="role of accessibleRoles" :key="`role-option-${role}`">
<a-select-option v-if="role" :value="role">
<NcBadge :color="RoleColors[role]">
<p class="badge-text">{{ RoleLabels[role] }}</p>
</NcBadge>
</a-select-option>
</template> </template>
</NcSelect>
</div> </div>
</td> </td>
<td class="w-1/5"> <td class="w-1/5">

38
packages/nc-gui/components/workspace/InviteSection.vue

@ -10,7 +10,8 @@ const inviteData = reactive({
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
const { inviteCollaborator: _inviteCollaborator } = workspaceStore const { inviteCollaborator: _inviteCollaborator } = workspaceStore
const { isInvitingCollaborators, workspaceRole } = storeToRefs(workspaceStore) const { isInvitingCollaborators } = storeToRefs(workspaceStore)
const { workspaceRoles } = useRoles()
const inviteCollaborator = async () => { const inviteCollaborator = async () => {
try { try {
@ -23,9 +24,19 @@ const inviteCollaborator = async () => {
} }
// allow only lower roles to be assigned // allow only lower roles to be assigned
const allowedRoles = computed<WorkspaceUserRoles[]>(() => { const allowedRoles = ref<WorkspaceUserRoles[]>([])
const currentRoleIndex = OrderedWorkspaceRoles.findIndex((role) => role === workspaceRole.value)
return OrderedWorkspaceRoles.slice(currentRoleIndex + 1) onMounted(async () => {
try {
const currentRoleIndex = OrderedWorkspaceRoles.findIndex(
(role) => workspaceRoles.value && Object.keys(workspaceRoles.value).includes(role),
)
if (currentRoleIndex !== -1) {
allowedRoles.value = OrderedWorkspaceRoles.slice(currentRoleIndex + 1).filter((r) => r)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}) })
</script> </script>
@ -41,18 +52,13 @@ const allowedRoles = computed<WorkspaceUserRoles[]>(() => {
class="!max-w-130 !rounded" class="!max-w-130 !rounded"
/> />
<NcSelect v-model:value="inviteData.roles" class="min-w-30 !rounded px-1" data-testid="roles"> <RolesSelector
<template #suffixIcon> class="px-1"
<MdiChevronDown /> :role="inviteData.roles"
</template> :roles="allowedRoles"
<template v-for="role of allowedRoles" :key="`role-option-${role}`"> :on-role-change="(role: WorkspaceUserRoles) => (inviteData.roles = role)"
<a-select-option v-if="role" :value="role"> :description="true"
<NcBadge :color="RoleColors[role]"> />
<p class="badge-text">{{ RoleLabels[role] }}</p>
</NcBadge>
</a-select-option>
</template>
</NcSelect>
<a-button <a-button
type="primary" type="primary"

11
packages/nc-gui/components/workspace/View.vue

@ -1,13 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useTitle } from '@vueuse/core' import { useTitle } from '@vueuse/core'
import type { WorkspaceType } from 'nocodb-sdk' import type { WorkspaceType } from 'nocodb-sdk'
import { isEeUI } from '#imports'
const router = useRouter() const router = useRouter()
const route = router.currentRoute const route = router.currentRoute
const { isUIAllowed } = useRoles()
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
const { isWorkspaceOwnerOrCreator, isWorkspaceOwner, activeWorkspace, workspaces } = storeToRefs(workspaceStore) const { activeWorkspace, workspaces } = storeToRefs(workspaceStore)
const { loadCollaborators } = workspaceStore const { loadCollaborators } = workspaceStore
const tab = computed({ const tab = computed({
@ -61,7 +62,7 @@ onMounted(() => {
</div> </div>
<NcTabs v-model:activeKey="tab"> <NcTabs v-model:activeKey="tab">
<template v-if="isWorkspaceOwnerOrCreator"> <template v-if="isUIAllowed('workspaceSettings')">
<a-tab-pane key="collaborators" class="w-full"> <a-tab-pane key="collaborators" class="w-full">
<template #tab> <template #tab>
<div class="flex flex-row items-center px-2 pb-1 gap-x-1.5"> <div class="flex flex-row items-center px-2 pb-1 gap-x-1.5">
@ -73,7 +74,7 @@ onMounted(() => {
</a-tab-pane> </a-tab-pane>
</template> </template>
<template v-if="isWorkspaceOwner && isEeUI"> <template v-if="isUIAllowed('workspaceBilling')">
<a-tab-pane key="billing" class="w-full"> <a-tab-pane key="billing" class="w-full">
<template #tab> <template #tab>
<div class="flex flex-row items-center px-2 pb-1 gap-x-1.5"> <div class="flex flex-row items-center px-2 pb-1 gap-x-1.5">
@ -84,7 +85,7 @@ onMounted(() => {
<WorkspaceBilling /> <WorkspaceBilling />
</a-tab-pane> </a-tab-pane>
</template> </template>
<template v-if="isWorkspaceOwner && isEeUI"> <template v-if="isUIAllowed('workspaceManage')">
<a-tab-pane key="settings" class="w-full"> <a-tab-pane key="settings" class="w-full">
<template #tab> <template #tab>
<div class="flex flex-row items-center px-2 pb-1 gap-x-1.5" data-testid="nc-workspace-settings-tab-settings"> <div class="flex flex-row items-center px-2 pb-1 gap-x-1.5" data-testid="nc-workspace-settings-tab-settings">

8
packages/nc-gui/composables/useRoles/index.ts

@ -111,6 +111,14 @@ export const useRoles = createSharedComposable(() => {
} else if (projectId) { } else if (projectId) {
const res = await api.auth.me({ project_id: projectId }) const res = await api.auth.me({ project_id: projectId })
user.value = {
...user.value,
roles: res.roles,
project_roles: res.project_roles,
} as typeof User
} else {
const res = await api.auth.me({})
user.value = { user.value = {
...user.value, ...user.value,
roles: res.roles, roles: res.roles,

6
packages/nc-gui/middleware/auth.global.ts

@ -34,7 +34,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
const { api } = useApi({ useGlobalInstance: true }) const { api } = useApi({ useGlobalInstance: true })
const { allRoles } = useRoles() const { allRoles, loadRoles } = useRoles()
/** If baseHostname defined block home page access under subdomains, and redirect to workspace page */ /** If baseHostname defined block home page access under subdomains, and redirect to workspace page */
if ( if (
@ -92,9 +92,9 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
/** if users are accessing the projects without having enough permissions, redirect to My Projects page */ /** if users are accessing the projects without having enough permissions, redirect to My Projects page */
if (to.params.projectId && from.params.projectId !== to.params.projectId) { if (to.params.projectId && from.params.projectId !== to.params.projectId) {
const user = await api.auth.me({ project_id: to.params.projectId as string }) await loadRoles()
if (user?.roles?.guest) { if (state.user.value?.roles?.guest) {
message.error("You don't have enough permission to access the project.") message.error("You don't have enough permission to access the project.")
return navigateTo('/') return navigateTo('/')

13
packages/nc-gui/store/users.ts

@ -3,6 +3,7 @@ import { acceptHMRUpdate, defineStore } from 'pinia'
export const useUsers = defineStore('userStore', () => { export const useUsers = defineStore('userStore', () => {
const { api } = useApi() const { api } = useApi()
const { user } = useGlobal() const { user } = useGlobal()
const { loadRoles } = useRoles()
const updateUserProfile = async ({ const updateUserProfile = async ({
attrs, attrs,
@ -21,17 +22,7 @@ export const useUsers = defineStore('userStore', () => {
} }
} }
const loadCurrentUser = async () => { const loadCurrentUser = loadRoles
const res = await api.auth.me()
user.value = {
...user.value,
...res,
roles: res.roles,
project_roles: res.project_roles,
workspace_roles: res.workspace_roles,
}
}
watch( watch(
() => user.value?.id, () => user.value?.id,

19
packages/nc-gui/store/workspace.ts

@ -64,22 +64,6 @@ export const useWorkspace = defineStore('workspaceStore', () => {
} }
}) })
/** getters */
const isWorkspaceCreator = computed(() => {
// todo: type correction
return orgRoles.value?.[OrgUserRoles.CREATOR]
})
const isWorkspaceOwner = computed(() => {
// todo: type correction
return orgRoles.value?.[OrgUserRoles.CREATOR]
})
const isWorkspaceOwnerOrCreator = computed(() => {
// todo: type correction
return orgRoles.value?.[OrgUserRoles.CREATOR]
})
/** actions */ /** actions */
const loadWorkspaces = async (_ignoreError = false) => {} const loadWorkspaces = async (_ignoreError = false) => {}
@ -239,8 +223,6 @@ export const useWorkspace = defineStore('workspaceStore', () => {
removeCollaborator, removeCollaborator,
updateCollaborator, updateCollaborator,
collaborators, collaborators,
isWorkspaceCreator,
isWorkspaceOwner,
isInvitingCollaborators, isInvitingCollaborators,
isCollaboratorsLoading, isCollaboratorsLoading,
addToFavourite, addToFavourite,
@ -257,7 +239,6 @@ export const useWorkspace = defineStore('workspaceStore', () => {
clearWorkspaces, clearWorkspaces,
upgradeActiveWorkspace, upgradeActiveWorkspace,
navigateToWorkspace, navigateToWorkspace,
isWorkspaceOwnerOrCreator,
setLoadingState, setLoadingState,
navigateToWorkspaceSettings, navigateToWorkspaceSettings,
lastPopulatedWorkspaceId, lastPopulatedWorkspaceId,

13
packages/nc-gui/utils/iconUtils.ts

@ -76,6 +76,13 @@ import Up from '~icons/material-symbols/keyboard-arrow-up-rounded'
import Down from '~icons/material-symbols/keyboard-arrow-down-rounded' import Down from '~icons/material-symbols/keyboard-arrow-down-rounded'
import PhTriangleFill from '~icons/ph/triangle-fill' import PhTriangleFill from '~icons/ph/triangle-fill'
// Roles
import MaterialSymbolsManageAccountsOutline from '~icons/material-symbols/manage-accounts-outline'
// account
import MdiCommentAccountOutline from '~icons/mdi/comment-account-outline'
import MaterialSymbolsPersonSearchOutline from '~icons/material-symbols/person-search-outline'
import MaterialSymbolsBlock from '~icons/material-symbols/block'
// keep it for reference // keep it for reference
// todo: remove it after all icons are migrated // todo: remove it after all icons are migrated
/* export const iconMapOld = { /* export const iconMapOld = {
@ -400,6 +407,12 @@ export const iconMap = {
magic1: MdiMagicStaff, magic1: MdiMagicStaff,
workspace: h('span', { class: 'material-symbols' }, 'dataset'), workspace: h('span', { class: 'material-symbols' }, 'dataset'),
notification: NcNotification, notification: NcNotification,
role_owner: MaterialSymbolsManageAccountsOutline,
role_creator: MaterialSymbolsManageAccountsOutline,
role_editor: h('span', { class: 'material-symbols' }, 'person'),
role_commenter: MdiCommentAccountOutline,
role_viewer: MaterialSymbolsPersonSearchOutline,
role_no_access: MaterialSymbolsBlock,
} }
export const getMdiIcon = (type: string): any => { export const getMdiIcon = (type: string): any => {

42
packages/nocodb-sdk/src/lib/enums.ts

@ -194,6 +194,48 @@ export const RoleColors = {
[OrgUserRoles.VIEWER]: 'yellow', [OrgUserRoles.VIEWER]: 'yellow',
}; };
export const RoleDescriptions = {
[WorkspaceUserRoles.OWNER]: 'Full access to workspace',
[WorkspaceUserRoles.CREATOR]: 'Can create projects, sync tables, views, setup web-hooks and more',
[WorkspaceUserRoles.EDITOR]: 'Can edit data in workspace projects',
[WorkspaceUserRoles.COMMENTER]: 'Can view and comment data in workspace projects',
[WorkspaceUserRoles.VIEWER]: 'Can view data in workspace projects',
[ProjectRoles.OWNER]: 'Full access to project',
[ProjectRoles.CREATOR]: 'Can create tables, views, setup webhook, invite collaborators and more',
[ProjectRoles.EDITOR]: 'Can view, add & modify records, add comments on them',
[ProjectRoles.COMMENTER]: 'Can view records and add comment on them',
[ProjectRoles.VIEWER]: 'Can only view records',
[ProjectRoles.NO_ACCESS]: 'Cannot access this project',
[OrgUserRoles.SUPER_ADMIN]: 'Full access to all',
[OrgUserRoles.CREATOR]: 'Can create projects, sync tables, views, setup web-hooks and more',
[OrgUserRoles.VIEWER]: 'Can only view projects',
};
export const RoleIcons = {
[WorkspaceUserRoles.OWNER]: 'role_owner',
[WorkspaceUserRoles.CREATOR]: 'role_creator',
[WorkspaceUserRoles.EDITOR]: 'role_editor',
[WorkspaceUserRoles.COMMENTER]: 'role_commenter',
[WorkspaceUserRoles.VIEWER]: 'role_viewer',
[ProjectRoles.OWNER]: 'role_owner',
[ProjectRoles.CREATOR]: 'role_creator',
[ProjectRoles.EDITOR]: 'role_editor',
[ProjectRoles.COMMENTER]: 'role_commenter',
[ProjectRoles.VIEWER]: 'role_viewer',
[ProjectRoles.NO_ACCESS]: 'role_no_access',
[OrgUserRoles.SUPER_ADMIN]: 'role_owner',
[OrgUserRoles.CREATOR]: 'role_creator',
[OrgUserRoles.VIEWER]: 'role_viewer',
};
export const WorkspaceRolesToProjectRoles = {
[WorkspaceUserRoles.OWNER]: ProjectRoles.OWNER,
[WorkspaceUserRoles.CREATOR]: ProjectRoles.CREATOR,
[WorkspaceUserRoles.EDITOR]: ProjectRoles.EDITOR,
[WorkspaceUserRoles.COMMENTER]: ProjectRoles.COMMENTER,
[WorkspaceUserRoles.VIEWER]: ProjectRoles.VIEWER,
};
export const OrderedWorkspaceRoles = [ export const OrderedWorkspaceRoles = [
WorkspaceUserRoles.OWNER, WorkspaceUserRoles.OWNER,
WorkspaceUserRoles.CREATOR, WorkspaceUserRoles.CREATOR,

2
packages/nocodb/src/guards/global/global.guard.ts

@ -51,7 +51,7 @@ export class GlobalGuard extends AuthGuard(['jwt']) {
return this.authenticate(req, { return this.authenticate(req, {
...req.user, ...req.user,
isAuthorized: true, isAuthorized: true,
roles: req.user.roles === 'owner' ? 'owner,creator' : req.user.roles, roles: req.user.roles,
}); });
} }
} else if (req.headers['xc-shared-base-id']) { } else if (req.headers['xc-shared-base-id']) {

3
packages/nocodb/src/models/User.ts

@ -263,8 +263,7 @@ export default class User implements UserType {
const projectRoles = await new Promise((resolve) => { const projectRoles = await new Promise((resolve) => {
if (args.projectId) { if (args.projectId) {
ProjectUser.get(args.projectId, user.id).then(async (projectUser) => { ProjectUser.get(args.projectId, user.id).then(async (projectUser) => {
let roles = projectUser?.roles; const roles = projectUser?.roles;
roles = roles === 'owner' ? 'owner,creator' : roles;
// + (user.roles ? `,${user.roles}` : ''); // + (user.roles ? `,${user.roles}` : '');
if (roles) { if (roles) {
resolve(extractRolesObj(roles)); resolve(extractRolesObj(roles));

4
packages/nocodb/src/services/project-users/project-users.service.ts

@ -290,7 +290,7 @@ export class ProjectUsersService {
NcError.badRequest("Admin can't delete themselves!"); NcError.badRequest("Admin can't delete themselves!");
} }
if (!param.req.user?.roles?.owner) { if (!param.req.user?.project_roles?.owner) {
const user = await User.get(param.userId); const user = await User.get(param.userId);
if (user.roles?.split(',').includes('super')) if (user.roles?.split(',').includes('super'))
NcError.forbidden( NcError.forbidden(
@ -298,7 +298,7 @@ export class ProjectUsersService {
); );
const projectUser = await ProjectUser.get(project_id, param.userId); const projectUser = await ProjectUser.get(project_id, param.userId);
if (projectUser?.roles?.split(',').includes('super')) if (projectUser?.roles?.split(',').includes('owner'))
NcError.forbidden('Insufficient privilege to delete a owner user.'); NcError.forbidden('Insufficient privilege to delete a owner user.');
} }

2
packages/nocodb/src/strategies/google.strategy/google.strategy.ts

@ -36,8 +36,6 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
ProjectUser.get(req.ncProjectId, user.id) ProjectUser.get(req.ncProjectId, user.id)
.then(async (projectUser) => { .then(async (projectUser) => {
user.roles = projectUser?.roles || user.roles; user.roles = projectUser?.roles || user.roles;
user.roles =
user.roles === 'owner' ? 'owner,creator' : user.roles;
// + (user.roles ? `,${user.roles}` : ''); // + (user.roles ? `,${user.roles}` : '');
done(null, sanitiseUserObj(user)); done(null, sanitiseUserObj(user));

2
packages/nocodb/src/utils/roleHelper.ts

@ -6,7 +6,7 @@ export function getProjectRolePower(user: any) {
const reverseOrderedProjectRoles = [...OrderedProjectRoles].reverse(); const reverseOrderedProjectRoles = [...OrderedProjectRoles].reverse();
if (!user.project_roles) { if (!user.project_roles) {
NcError.badRequest('Role not found'); return -1;
} }
// get most powerful role of user (TODO moving forward we will confirm that user has only one role) // get most powerful role of user (TODO moving forward we will confirm that user has only one role)

6
tests/playwright/pages/Dashboard/ProjectView/AccessSettingsPage.ts

@ -22,11 +22,11 @@ export class AccessSettingsPage extends BasePage {
if (userEmail === email) { if (userEmail === email) {
const roleDropdown = user.locator('.nc-collaborator-role-select'); const roleDropdown = user.locator('.nc-collaborator-role-select');
const selectedRole = await user.locator('.nc-collaborator-role-select').innerText(); const selectedRole = await user.locator('.nc-collaborator-role-select .badge-text').innerText();
await roleDropdown.click(); await roleDropdown.click();
const menu = this.rootPage.locator('.ant-select-dropdown:visible'); const menu = this.rootPage.locator('.nc-role-select-dropdown:visible');
const clickClbk = () => menu.locator(`.ant-select-item:has-text("${role}"):visible`).last().click(); const clickClbk = () => menu.locator(`.nc-role-select-${role.toLowerCase()}:visible`).last().click();
if (networkValidation && !selectedRole.includes(role)) { if (networkValidation && !selectedRole.includes(role)) {
await this.waitForResponse({ await this.waitForResponse({

4
tests/playwright/pages/WorkspacePage/CollaborationPage.ts

@ -39,8 +39,8 @@ export class CollaborationPage extends BasePage {
// role // role
await this.selector_role.click(); await this.selector_role.click();
await this.rootPage.waitForTimeout(500); const menu = this.rootPage.locator('.nc-role-select-dropdown:visible');
await this.rootPage.locator(`.ant-select-item-option-content:has-text("${role}"):visible`).click(); await menu.locator(`.nc-role-select-workspace-level-${role.toLowerCase()}:visible`).click();
// submit // submit

2
tests/playwright/setup/index.ts

@ -384,7 +384,7 @@ const setup = async ({
email: `user@nocodb.com`, email: `user@nocodb.com`,
password: getDefaultPwd(), password: getDefaultPwd(),
}); });
await axios.post(`http://localhost:8080/api/v1/license`, { key: '' }, { headers: { 'xc-auth': admin.data.token } }); if (!isEE()) await axios.post(`http://localhost:8080/api/v1/license`, { key: '' }, { headers: { 'xc-auth': admin.data.token } });
} catch (e) { } catch (e) {
// ignore error: some roles will not have permission for license reset // ignore error: some roles will not have permission for license reset
// console.error(`Error resetting project: ${process.env.TEST_PARALLEL_INDEX}`, e); // console.error(`Error resetting project: ${process.env.TEST_PARALLEL_INDEX}`, e);

Loading…
Cancel
Save