Browse Source

Nc feat/user management (#9219)

* feat: api changes for user management

* refactor: gift banner behaviour change

* feat: user management api and ui changes

* feat: introduce invited_by info

* test: verify roles by checking datasource tab since access settings page will be available for all users now

* feat: allow owner role update only if there is more than one owner exist

* fix: role update behaviour correction

* fix: base owner invite issue

* fix: reload user roles state on changing roles of active user

* refactor: show disabled button if not avail

* refactor: hide dropdown and action menu options based on roles

* refactor: migration file name

* refactor: disable or hide option based on number of owners

* refactor: hide user list in shared base

* fix: review correction
pull/9230/head
Pranav C 4 months ago committed by GitHub
parent
commit
a0d87c5a4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      packages/nc-gui/components/dashboard/Sidebar/TopSection.vue
  2. 24
      packages/nc-gui/components/dlg/InviteDlg.vue
  3. 6
      packages/nc-gui/components/general/Gift.vue
  4. 1
      packages/nc-gui/components/nc/Badge.vue
  5. 26
      packages/nc-gui/components/project/AccessSettings.vue
  6. 4
      packages/nc-gui/components/project/View.vue
  7. 4
      packages/nc-gui/components/roles/Badge.vue
  8. 16
      packages/nc-gui/components/roles/Selector.vue
  9. 51
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  10. 2
      packages/nc-gui/components/workspace/View.vue
  11. 2
      packages/nc-gui/lib/acl.ts
  12. 11
      packages/nc-gui/store/bases.ts
  13. 2
      packages/nocodb-sdk/src/lib/enums.ts
  14. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  15. 16
      packages/nocodb/src/meta/migrations/v2/nc_059_invited_by.ts
  16. 13
      packages/nocodb/src/models/BaseUser.ts
  17. 1
      packages/nocodb/src/models/Integration.ts
  18. 2
      packages/nocodb/src/models/PresignedUrl.ts
  19. 1
      packages/nocodb/src/services/app-hooks/app-hooks.service.ts
  20. 65
      packages/nocodb/src/services/base-users/base-users.service.ts
  21. 2
      packages/nocodb/src/services/integrations.service.ts
  22. 4
      tests/playwright/pages/Dashboard/ProjectView/index.ts

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

@ -57,7 +57,7 @@ const navigateToIntegrations = () => {
<DashboardSidebarTopSectionHeader /> <DashboardSidebarTopSectionHeader />
<NcButton <NcButton
v-if="isUIAllowed('workspaceSettings')" v-if="isUIAllowed('workspaceSettings') || isUIAllowed('workspaceCollaborators')"
v-e="['c:team:settings']" v-e="['c:team:settings']"
type="text" type="text"
size="xsmall" size="xsmall"

24
packages/nc-gui/components/dlg/InviteDlg.vue

@ -16,6 +16,8 @@ const basesStore = useBases()
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
const { baseRoles, workspaceRoles } = useRoles()
const { createProjectUser } = basesStore const { createProjectUser } = basesStore
const { inviteCollaborator: inviteWsCollaborator } = workspaceStore const { inviteCollaborator: inviteWsCollaborator } = workspaceStore
@ -25,6 +27,9 @@ const dialogShow = useVModel(props, 'modelValue', emit)
const orderedRoles = computed(() => { const orderedRoles = computed(() => {
return props.type === 'base' ? ProjectRoles : WorkspaceUserRoles return props.type === 'base' ? ProjectRoles : WorkspaceUserRoles
}) })
const userRoles = computed(() => {
return props.type === 'base' ? baseRoles?.value : workspaceRoles?.value
})
const inviteData = reactive({ const inviteData = reactive({
email: '', email: '',
@ -47,6 +52,8 @@ const emailBadges = ref<Array<string>>([])
const allowedRoles = ref<[]>([]) const allowedRoles = ref<[]>([])
const disabledRoles = ref<[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const organizationStore = useOrganization() const organizationStore = useOrganization()
@ -77,13 +84,15 @@ const focusOnDiv = () => {
watch(dialogShow, async (newVal) => { watch(dialogShow, async (newVal) => {
if (newVal) { if (newVal) {
try { try {
// todo: enable after discussing with anbu const rolesArr = Object.values(orderedRoles.value)
// const currentRoleIndex = Object.values(orderedRoles.value).findIndex( const currentRoleIndex = rolesArr.findIndex((role) => userRoles.value && Object.keys(userRoles.value).includes(role))
// (role) => userRoles.value && Object.keys(userRoles.value).includes(role), if (currentRoleIndex !== -1) {
// ) allowedRoles.value = rolesArr.slice(currentRoleIndex)
// if (currentRoleIndex !== -1) { disabledRoles.value = rolesArr.slice(0, currentRoleIndex)
allowedRoles.value = Object.values(orderedRoles.value) // .slice(currentRoleIndex + 1) } else {
// } allowedRoles.value = rolesArr
disabledRoles.value = []
}
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
@ -350,6 +359,7 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
:description="false" :description="false"
:on-role-change="onRoleChange" :on-role-change="onRoleChange"
:role="inviteData.roles" :role="inviteData.roles"
:disabled-roles="disabledRoles"
:roles="allowedRoles" :roles="allowedRoles"
class="!min-w-[152px] nc-invite-role-selector" class="!min-w-[152px] nc-invite-role-selector"
size="lg" size="lg"

6
packages/nc-gui/components/general/Gift.vue

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { OrgUserRoles, extractRolesObj } from 'nocodb-sdk'
const { appInfo, giftBannerDismissedCount, user } = useGlobal() const { appInfo, giftBannerDismissedCount, user } = useGlobal()
const { $e } = useNuxtApp()
const isBannerClosed = ref(true) const isBannerClosed = ref(true)
const confirmDialog = ref(false) const confirmDialog = ref(false)
const hideImage = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0) < 780 const hideImage = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0) < 780
@ -11,7 +11,6 @@ const isAvailable = computed(() => {
return ( return (
!isEeUI && !isEeUI &&
user.value?.email && user.value?.email &&
[OrgUserRoles.CREATOR, OrgUserRoles.SUPER_ADMIN].some((r) => extractRolesObj(user.value?.roles)?.[r]) &&
!/^[a-zA-Z0-9._%+-]+@(gmail|yahoo|hotmail|outlook|aol|icloud|qq|163|126|sina|nocodb)(\.com)?$/i.test(user.value?.email) && !/^[a-zA-Z0-9._%+-]+@(gmail|yahoo|hotmail|outlook|aol|icloud|qq|163|126|sina|nocodb)(\.com)?$/i.test(user.value?.email) &&
(!giftBannerDismissedCount.value || giftBannerDismissedCount.value < 5) (!giftBannerDismissedCount.value || giftBannerDismissedCount.value < 5)
) )
@ -27,6 +26,7 @@ if (giftBannerDismissedCount.value) {
const open = () => { const open = () => {
giftBannerDismissedCount.value++ giftBannerDismissedCount.value++
$e('a:claim:gift:coupon')
window.open(appInfo.value?.giftUrl, '_blank', 'noopener,noreferrer') window.open(appInfo.value?.giftUrl, '_blank', 'noopener,noreferrer')
} }

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

@ -24,6 +24,7 @@ const props = withDefaults(
'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-red-500 bg-red-100': props.color === 'red',
'border-maroon-500 bg-maroon-50': props.color === 'maroon', 'border-maroon-500 bg-maroon-50': props.color === 'maroon',
'border-gray-500 bg-gray-50': props.color === 'grey',
'border-gray-300': !props.color, 'border-gray-300': !props.color,
'border-1': props.border, 'border-1': props.border,
'h-6': props.size === 'sm', 'h-6': props.size === 'sm',

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

@ -98,17 +98,15 @@ const loadCollaborators = async () => {
} }
} }
const isOwnerOrCreator = computed(() => {
return baseRoles.value?.[ProjectRoles.OWNER] || baseRoles.value?.[ProjectRoles.CREATOR]
})
const updateCollaborator = async (collab: any, roles: ProjectRoles) => { const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
const currentCollaborator = collaborators.value.find((coll) => coll.id === collab.id)! const currentCollaborator = collaborators.value.find((coll) => coll.id === collab.id)!
try { try {
if ( if (!roles || (roles === ProjectRoles.NO_ACCESS && !isEeUI)) {
!roles ||
(roles === ProjectRoles.NO_ACCESS && !isEeUI) ||
(currentCollaborator.workspace_roles &&
WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles &&
isEeUI)
) {
await removeProjectUser(currentBase.value.id!, currentCollaborator as unknown as User) await removeProjectUser(currentBase.value.id!, currentCollaborator as unknown as User)
if ( if (
currentCollaborator.workspace_roles && currentCollaborator.workspace_roles &&
@ -155,9 +153,9 @@ onMounted(async () => {
(role) => baseRoles.value && Object.keys(baseRoles.value).includes(role), (role) => baseRoles.value && Object.keys(baseRoles.value).includes(role),
) )
if (isSuper.value) { if (isSuper.value) {
accessibleRoles.value = OrderedProjectRoles.slice(1) accessibleRoles.value = OrderedProjectRoles.slice(0)
} else if (currentRoleIndex !== -1) { } else if (currentRoleIndex !== -1) {
accessibleRoles.value = OrderedProjectRoles.slice(currentRoleIndex + 1) accessibleRoles.value = OrderedProjectRoles.slice(currentRoleIndex)
} }
loadSorts() loadSorts()
} catch (e: any) { } catch (e: any) {
@ -251,6 +249,14 @@ const columns = [
const customRow = (record: Record<string, any>) => ({ const customRow = (record: Record<string, any>) => ({
class: `${selected[record.id] ? 'selected' : ''} user-row`, class: `${selected[record.id] ? 'selected' : ''} user-row`,
}) })
const isOnlyOneOwner = computed(() => {
return collaborators.value?.filter((collab) => collab.roles === ProjectRoles.OWNER).length === 1
})
const isDeleteOrUpdateAllowed = (user) => {
return !(isOnlyOneOwner.value && user.roles === ProjectRoles.OWNER)
}
</script> </script>
<template> <template>
@ -367,7 +373,7 @@ const customRow = (record: Record<string, any>) => ({
</div> </div>
</div> </div>
<div v-if="column.key === 'role'"> <div v-if="column.key === 'role'">
<template v-if="accessibleRoles.includes(record.roles)"> <template v-if="isDeleteOrUpdateAllowed(record) && isOwnerOrCreator && accessibleRoles.includes(record.roles)">
<RolesSelector <RolesSelector
:role="record.roles" :role="record.roles"
:roles="accessibleRoles" :roles="accessibleRoles"

4
packages/nc-gui/components/project/View.vue

@ -14,7 +14,7 @@ const { openedProject, activeProjectId, basesUser, bases } = storeToRefs(basesSt
const { activeTables, activeTable } = storeToRefs(useTablesStore()) const { activeTables, activeTable } = storeToRefs(useTablesStore())
const { activeWorkspace } = storeToRefs(useWorkspace()) const { activeWorkspace } = storeToRefs(useWorkspace())
const { navigateToProjectPage } = useBase() const { navigateToProjectPage, isSharedBase } = useBase()
const isAdminPanel = inject(IsAdminPanelInj, ref(false)) const isAdminPanel = inject(IsAdminPanelInj, ref(false))
@ -160,7 +160,7 @@ watch(
<!-- <a-tab-pane v-if="defaultBase" key="erd" tab="Base ERD" force-render class="pt-4 pb-12"> <!-- <a-tab-pane v-if="defaultBase" key="erd" tab="Base ERD" force-render class="pt-4 pb-12">
<ErdView :source-id="defaultBase!.id" class="!h-full" /> <ErdView :source-id="defaultBase!.id" class="!h-full" />
</a-tab-pane> --> </a-tab-pane> -->
<a-tab-pane v-if="isUIAllowed('newUser', { roles: baseRoles })" key="collaborator"> <a-tab-pane v-if="isUIAllowed('newUser', { roles: baseRoles }) && !isSharedBase" key="collaborator">
<template #tab> <template #tab>
<div class="tab-title" data-testid="proj-view-tab__access-settings"> <div class="tab-title" data-testid="proj-view-tab__access-settings">
<GeneralIcon icon="users" class="!h-3.5 !w-3.5" /> <GeneralIcon icon="users" class="!h-3.5 !w-3.5" />

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

@ -10,6 +10,7 @@ const props = withDefaults(
showIcon?: boolean showIcon?: boolean
iconOnly?: boolean iconOnly?: boolean
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
disabled?: boolean
}>(), }>(),
{ {
clickable: false, clickable: false,
@ -33,7 +34,7 @@ const roleProperties = computed(() => {
const icon = RoleIcons[role] const icon = RoleIcons[role]
const label = RoleLabels[role] const label = RoleLabels[role]
return { return {
color, color: props.disabled ? 'grey' : color,
icon, icon,
label, label,
} }
@ -58,6 +59,7 @@ const roleProperties = computed(() => {
'text-yellow-700': roleProperties.color === 'yellow', 'text-yellow-700': roleProperties.color === 'yellow',
'text-red-700': roleProperties.color === 'red', 'text-red-700': roleProperties.color === 'red',
'text-maroon-700': roleProperties.color === 'maroon', 'text-maroon-700': roleProperties.color === 'maroon',
'text-gray-400': !roleProperties.color === 'grey',
'text-gray-300': !roleProperties.color, 'text-gray-300': !roleProperties.color,
sizeSelect, sizeSelect,
}" }"

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

@ -7,6 +7,7 @@ const props = withDefaults(
defineProps<{ defineProps<{
role: keyof typeof RoleLabels role: keyof typeof RoleLabels
roles: (keyof typeof RoleLabels)[] roles: (keyof typeof RoleLabels)[]
disabledRoles?: (keyof typeof RoleLabels)[]
onRoleChange: (role: keyof typeof RoleLabels) => void onRoleChange: (role: keyof typeof RoleLabels) => void
border?: boolean border?: boolean
description?: boolean description?: boolean
@ -73,6 +74,21 @@ function onChangeRole(val: SelectValue) {
class="py-1 !absolute top-0 left-0 w-20 h-full z-10 text-xs opacity-0" class="py-1 !absolute top-0 left-0 w-20 h-full z-10 text-xs opacity-0"
@change="onChangeRole" @change="onChangeRole"
> >
<a-select-option v-for="rl in props.disabledRoles || []" :key="rl" :value="rl" disabled>
<div
:class="{
'w-full': descriptionRef,
'w-[200px]': !descriptionRef,
}"
class="flex flex-col nc-role-select-dropdown gap-1"
>
<div class="flex items-center justify-between">
<RolesBadge disabled :border="false" :inherit="inheritRef === rl" :role="rl" />
<GeneralIcon v-if="rl === roleRef" icon="check" class="text-primary" />
</div>
<div v-if="descriptionRef" class="text-gray-500 text-xs">{{ RoleDescriptions[rl] }}</div>
</div>
</a-select-option>
<a-select-option v-for="rl in props.roles" :key="rl" v-e="['c:workspace:settings:user-role-change']" :value="rl"> <a-select-option v-for="rl in props.roles" :key="rl" v-e="['c:workspace:settings:user-role-change']" :value="rl">
<div <div
:class="{ :class="{

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

@ -1,5 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
/* eslint-disable @typescript-eslint/consistent-type-imports */
import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk' import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
@ -32,6 +31,10 @@ const userSearchText = ref('')
const isAdminPanel = inject(IsAdminPanelInj, ref(false)) const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const isOnlyOneOwner = computed(() => {
return collaborators.value?.filter((collab) => collab.roles === WorkspaceUserRoles.OWNER).length === 1
})
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const { t } = useI18n() const { t } = useI18n()
@ -77,26 +80,27 @@ const selectAll = computed({
const updateCollaborator = async (collab: any, roles: WorkspaceUserRoles) => { const updateCollaborator = async (collab: any, roles: WorkspaceUserRoles) => {
if (!currentWorkspace.value || !currentWorkspace.value.id) return if (!currentWorkspace.value || !currentWorkspace.value.id) return
try { const res = await _updateCollaborator(collab.id, roles, currentWorkspace.value.id)
await _updateCollaborator(collab.id, roles, currentWorkspace.value.id) if (!res) return
message.success('Successfully updated user role') message.success('Successfully updated user role')
collaborators.value?.forEach((collaborator) => { collaborators.value?.forEach((collaborator) => {
if (collaborator.id === collab.id) { if (collaborator.id === collab.id) {
collaborator.roles = roles collaborator.roles = roles
} }
}) })
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
} }
const isOwnerOrCreator = computed(() => {
return workspaceRoles.value[WorkspaceUserRoles.OWNER] || workspaceRoles.value[WorkspaceUserRoles.CREATOR]
})
const accessibleRoles = computed<WorkspaceUserRoles[]>(() => { const accessibleRoles = computed<WorkspaceUserRoles[]>(() => {
const currentRoleIndex = OrderedWorkspaceRoles.findIndex( const currentRoleIndex = OrderedWorkspaceRoles.findIndex(
(role) => workspaceRoles.value && Object.keys(workspaceRoles.value).includes(role), (role) => workspaceRoles.value && Object.keys(workspaceRoles.value).includes(role),
) )
if (currentRoleIndex === -1) return [] if (currentRoleIndex === -1) return []
return OrderedWorkspaceRoles.slice(currentRoleIndex + 1).filter((r) => r) return OrderedWorkspaceRoles.slice(currentRoleIndex).filter((r) => r)
}) })
onMounted(async () => { onMounted(async () => {
@ -163,6 +167,10 @@ const columns = [
const customRow = (_record: Record<string, any>, recordIndex: number) => ({ const customRow = (_record: Record<string, any>, recordIndex: number) => ({
class: `${selected[recordIndex] ? 'selected' : ''} last:!border-b-0`, class: `${selected[recordIndex] ? 'selected' : ''} last:!border-b-0`,
}) })
const isDeleteOrUpdateAllowed = (user) => {
return !(isOnlyOneOwner.value && user.roles === WorkspaceUserRoles.OWNER)
}
</script> </script>
<template> <template>
@ -240,7 +248,9 @@ const customRow = (_record: Record<string, any>, recordIndex: number) => ({
</div> </div>
</div> </div>
<div v-if="column.key === 'role'"> <div v-if="column.key === 'role'">
<template v-if="accessibleRoles.includes(record.roles as WorkspaceUserRoles)"> <template
v-if="isDeleteOrUpdateAllowed(record) && isOwnerOrCreator && accessibleRoles.includes(record.roles as WorkspaceUserRoles)"
>
<RolesSelector <RolesSelector
:description="false" :description="false"
:on-role-change="(role) => updateCollaborator(record, role as WorkspaceUserRoles)" :on-role-change="(role) => updateCollaborator(record, role as WorkspaceUserRoles)"
@ -265,7 +275,7 @@ const customRow = (_record: Record<string, any>, recordIndex: number) => ({
</div> </div>
<div v-if="column.key === 'action'"> <div v-if="column.key === 'action'">
<NcDropdown v-if="record.roles !== WorkspaceUserRoles.OWNER"> <NcDropdown v-if="isOwnerOrCreator">
<NcButton size="small" type="secondary"> <NcButton size="small" type="secondary">
<component :is="iconMap.threeDotVertical" /> <component :is="iconMap.threeDotVertical" />
</NcButton> </NcButton>
@ -281,15 +291,20 @@ const customRow = (_record: Record<string, any>, recordIndex: number) => ({
</template> </template>
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('transferWorkspaceOwnership')" v-if="isUIAllowed('transferWorkspaceOwnership')"
:disabled="!isDeleteOrUpdateAllowed(record)"
data-testid="nc-admin-org-user-assign-admin" data-testid="nc-admin-org-user-assign-admin"
@click="updateCollaborator(record, WorkspaceUserRoles.OWNER)" @click="updateCollaborator(record, WorkspaceUserRoles.OWNER)"
> >
<GeneralIcon class="text-gray-800" icon="user" /> <GeneralIcon :class="{ 'text-gray-800': isDeleteOrUpdateAllowed(record) }" icon="user" />
<span>{{ $t('labels.assignAs') }}</span> <span>{{ $t('labels.assignAs') }}</span>
<RolesBadge :border="false" :show-icon="false" role="owner" /> <RolesBadge :border="false" :show-icon="false" role="owner" :disabled="!isDeleteOrUpdateAllowed(record)" />
</NcMenuItem> </NcMenuItem>
<NcMenuItem class="!text-red-500 !hover:bg-red-50" @click="removeCollaborator(record.id, currentWorkspace?.id)"> <NcMenuItem
:disabled="!isDeleteOrUpdateAllowed(record)"
:class="{ '!text-red-500 !hover:bg-red-50': isDeleteOrUpdateAllowed(record) }"
@click="removeCollaborator(record.id, currentWorkspace?.id)"
>
<MaterialSymbolsDeleteOutlineRounded /> <MaterialSymbolsDeleteOutlineRounded />
Remove user Remove user
</NcMenuItem> </NcMenuItem>

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

@ -120,7 +120,7 @@ onMounted(() => {
<template #leftExtra> <template #leftExtra>
<div class="w-3"></div> <div class="w-3"></div>
</template> </template>
<template v-if="isUIAllowed('workspaceSettings')"> <template v-if="isUIAllowed('workspaceCollaborators')">
<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 pb-1 pt-2 gap-x-1.5"> <div class="flex flex-row items-center pb-1 pt-2 gap-x-1.5">

2
packages/nc-gui/lib/acl.ts

@ -72,7 +72,6 @@ const rolePermissions = {
jsonImport: true, jsonImport: true,
excelImport: true, excelImport: true,
settingsPage: true, settingsPage: true,
newUser: true,
webhook: true, webhook: true,
fieldEdit: true, fieldEdit: true,
fieldAlter: true, fieldAlter: true,
@ -119,6 +118,7 @@ const rolePermissions = {
commentList: true, commentList: true,
commentCount: true, commentCount: true,
auditListRow: true, auditListRow: true,
newUser: true,
}, },
}, },
[ProjectRoles.NO_ACCESS]: { [ProjectRoles.NO_ACCESS]: {

11
packages/nc-gui/store/bases.ts

@ -7,6 +7,10 @@ import { isString } from '@vue/shared'
export const useBases = defineStore('basesStore', () => { export const useBases = defineStore('basesStore', () => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { user: currentUser } = useGlobal()
const { loadRoles } = useRoles()
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const bases = ref<Map<string, NcProject>>(new Map()) const bases = ref<Map<string, NcProject>>(new Map())
@ -89,6 +93,13 @@ export const useBases = defineStore('basesStore', () => {
const updateProjectUser = async (baseId: string, user: User) => { const updateProjectUser = async (baseId: string, user: User) => {
await api.auth.baseUserUpdate(baseId, user.id, user as ProjectUserReqType) await api.auth.baseUserUpdate(baseId, user.id, user as ProjectUserReqType)
// reload roles if updating roles of current user
if (user.id === currentUser.value?.id) {
loadRoles(baseId).catch(() => {
// ignore
})
}
} }
const removeProjectUser = async (baseId: string, user: User) => { const removeProjectUser = async (baseId: string, user: User) => {

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

@ -22,9 +22,9 @@ export enum ProjectRoles {
export enum WorkspaceUserRoles { export enum WorkspaceUserRoles {
OWNER = 'workspace-level-owner', OWNER = 'workspace-level-owner',
CREATOR = 'workspace-level-creator', CREATOR = 'workspace-level-creator',
VIEWER = 'workspace-level-viewer',
EDITOR = 'workspace-level-editor', EDITOR = 'workspace-level-editor',
COMMENTER = 'workspace-level-commenter', COMMENTER = 'workspace-level-commenter',
VIEWER = 'workspace-level-viewer',
NO_ACCESS = 'workspace-level-no-access', NO_ACCESS = 'workspace-level-no-access',
} }

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -45,6 +45,7 @@ import * as nc_055_junction_pk from '~/meta/migrations/v2/nc_055_junction_pk';
import * as nc_056_integration from '~/meta/migrations/v2/nc_056_integration'; import * as nc_056_integration from '~/meta/migrations/v2/nc_056_integration';
import * as nc_057_file_references from '~/meta/migrations/v2/nc_057_file_references'; import * as nc_057_file_references from '~/meta/migrations/v2/nc_057_file_references';
import * as nc_058_button_colum from '~/meta/migrations/v2/nc_058_button_colum'; import * as nc_058_button_colum from '~/meta/migrations/v2/nc_058_button_colum';
import * as nc_059_invited_by from '~/meta/migrations/v2/nc_059_invited_by';
// Create a custom migration source class // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -101,6 +102,7 @@ export default class XcMigrationSourcev2 {
'nc_056_integration', 'nc_056_integration',
'nc_057_file_references', 'nc_057_file_references',
'nc_058_button_colum', 'nc_058_button_colum',
'nc_059_invited_by',
]); ]);
} }
@ -204,6 +206,8 @@ export default class XcMigrationSourcev2 {
return nc_057_file_references; return nc_057_file_references;
case 'nc_058_button_colum': case 'nc_058_button_colum':
return nc_058_button_colum; return nc_058_button_colum;
case 'nc_059_invited_by':
return nc_059_invited_by;
} }
} }
} }

16
packages/nocodb/src/meta/migrations/v2/nc_059_invited_by.ts

@ -0,0 +1,16 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.PROJECT_USERS, (table) => {
table.string('invited_by', 20).index();
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.PROJECT_USERS, (table) => {
table.dropColumn('invited_by');
});
};
export { up, down };

13
packages/nocodb/src/models/BaseUser.ts

@ -20,6 +20,7 @@ export default class BaseUser {
base_id: string; base_id: string;
fk_user_id: string; fk_user_id: string;
roles?: string; roles?: string;
invited_by?: string;
constructor(data: BaseUser) { constructor(data: BaseUser) {
Object.assign(this, data); Object.assign(this, data);
@ -35,7 +36,7 @@ export default class BaseUser {
ncMeta = Noco.ncMeta, ncMeta = Noco.ncMeta,
) { ) {
const insertObj = baseUsers.map((baseUser) => const insertObj = baseUsers.map((baseUser) =>
extractProps(baseUser, ['fk_user_id', 'base_id', 'roles']), extractProps(baseUser, ['fk_user_id', 'base_id', 'roles', 'invited_by']),
); );
if (!insertObj.length) { if (!insertObj.length) {
@ -84,6 +85,7 @@ export default class BaseUser {
'fk_user_id', 'fk_user_id',
'base_id', 'base_id',
'roles', 'roles',
'invited_by',
]); ]);
const { base_id, fk_user_id } = await ncMeta.metaInsert2( const { base_id, fk_user_id } = await ncMeta.metaInsert2(
@ -179,13 +181,7 @@ export default class BaseUser {
let { list: baseUsers } = cachedList; let { list: baseUsers } = cachedList;
const { isNoneList } = cachedList; const { isNoneList } = cachedList;
const fullVersionCols = [ const fullVersionCols = ['invite_token', 'created_at'];
'invite_token',
'main_roles',
'created_at',
'base_id',
'roles',
];
if (!isNoneList && !baseUsers.length) { if (!isNoneList && !baseUsers.length) {
const queryBuilder = ncMeta const queryBuilder = ncMeta
@ -479,6 +475,7 @@ export default class BaseUser {
return await this.insert(context, { return await this.insert(context, {
base_id: baseId, base_id: baseId,
fk_user_id: userId, fk_user_id: userId,
invited_by: baseUser.invited_by,
}); });
} }
} }

1
packages/nocodb/src/models/Integration.ts

@ -2,7 +2,6 @@ import CryptoJS from 'crypto-js';
import type { IntegrationsType, SourceType } from 'nocodb-sdk'; import type { IntegrationsType, SourceType } from 'nocodb-sdk';
import type { BoolType, IntegrationType } from 'nocodb-sdk'; import type { BoolType, IntegrationType } from 'nocodb-sdk';
import type { NcContext } from '~/interface/config'; import type { NcContext } from '~/interface/config';
import type { Condition } from '~/db/CustomKnex';
import { MetaTable, RootScopes } from '~/utils/globals'; import { MetaTable, RootScopes } from '~/utils/globals';
import Noco from '~/Noco'; import Noco from '~/Noco';
import { extractProps } from '~/helpers/extractProps'; import { extractProps } from '~/helpers/extractProps';

2
packages/nocodb/src/models/PresignedUrl.ts

@ -4,7 +4,7 @@ import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
import Noco from '~/Noco'; import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache'; import NocoCache from '~/cache/NocoCache';
import { CacheGetType, CacheScope } from '~/utils/globals'; import { CacheGetType, CacheScope } from '~/utils/globals';
import { isPreviewAllowed, getPathFromUrl } from '~/helpers/attachmentHelpers'; import { getPathFromUrl, isPreviewAllowed } from '~/helpers/attachmentHelpers';
function roundExpiry(date) { function roundExpiry(date) {
const msInHour = 10 * 60 * 1000; const msInHour = 10 * 60 * 1000;

1
packages/nocodb/src/services/app-hooks/app-hooks.service.ts

@ -37,7 +37,6 @@ import type {
ViewColumnEvent, ViewColumnEvent,
ViewEvent, ViewEvent,
WebhookEvent, WebhookEvent,
WebhookTriggerEvent,
WelcomeEvent, WelcomeEvent,
} from '~/services/app-hooks/interfaces'; } from '~/services/app-hooks/interfaces';
import type { IntegrationEvent } from '~/services/app-hooks/interfaces'; import type { IntegrationEvent } from '~/services/app-hooks/interfaces';

65
packages/nocodb/src/services/base-users/base-users.service.ts

@ -65,6 +65,7 @@ export class BaseUsersService {
if ( if (
![ ![
ProjectRoles.OWNER,
ProjectRoles.CREATOR, ProjectRoles.CREATOR,
ProjectRoles.EDITOR, ProjectRoles.EDITOR,
ProjectRoles.COMMENTER, ProjectRoles.COMMENTER,
@ -122,6 +123,7 @@ export class BaseUsersService {
base_id: param.baseId, base_id: param.baseId,
fk_user_id: user.id, fk_user_id: user.id,
roles: param.baseUser.roles || 'editor', roles: param.baseUser.roles || 'editor',
invited_by: param.req?.user?.id,
}); });
this.appHooksService.emit(AppEvents.PROJECT_INVITE, { this.appHooksService.emit(AppEvents.PROJECT_INVITE, {
@ -147,6 +149,7 @@ export class BaseUsersService {
base_id: param.baseId, base_id: param.baseId,
fk_user_id: user.id, fk_user_id: user.id,
roles: param.baseUser.roles, roles: param.baseUser.roles,
invited_by: param.req?.user?.id,
}); });
this.appHooksService.emit(AppEvents.PROJECT_INVITE, { this.appHooksService.emit(AppEvents.PROJECT_INVITE, {
@ -213,12 +216,9 @@ export class BaseUsersService {
return NcError.baseNotFound(param.baseId); return NcError.baseNotFound(param.baseId);
} }
if (param.baseUser.roles.includes(ProjectRoles.OWNER)) {
NcError.badRequest('Owner cannot be updated');
}
if ( if (
![ ![
ProjectRoles.OWNER,
ProjectRoles.CREATOR, ProjectRoles.CREATOR,
ProjectRoles.EDITOR, ProjectRoles.EDITOR,
ProjectRoles.COMMENTER, ProjectRoles.COMMENTER,
@ -246,9 +246,19 @@ export class BaseUsersService {
); );
} }
if ( // if old role is owner and there is only one owner then restrict to update
getProjectRolePower(targetUser) >= getProjectRolePower(param.req.user) if (extractRolesObj(targetUser.base_roles)?.[ProjectRoles.OWNER]) {
) { const baseUsers = await BaseUser.getUsersList(context, {
base_id: param.baseId,
});
if (
baseUsers.filter((u) => u.roles?.includes(ProjectRoles.OWNER))
.length === 1
)
NcError.badRequest('At least one owner is required');
}
if (getProjectRolePower(targetUser) > getProjectRolePower(param.req.user)) {
NcError.badRequest(`Insufficient privilege to update user`); NcError.badRequest(`Insufficient privilege to update user`);
} }
@ -288,16 +298,49 @@ export class BaseUsersService {
NcError.badRequest("Admin can't delete themselves!"); NcError.badRequest("Admin can't delete themselves!");
} }
const user = await User.get(param.userId);
if (!user) {
NcError.userNotFound(param.userId);
}
if (!param.req.user?.base_roles?.owner) { if (!param.req.user?.base_roles?.owner) {
const user = await User.get(param.userId);
if (user.roles?.split(',').includes('super')) if (user.roles?.split(',').includes('super'))
NcError.forbidden( NcError.forbidden(
'Insufficient privilege to delete a super admin user.', 'Insufficient privilege to delete a super admin user.',
); );
}
const baseUser = await BaseUser.get(context, base_id, param.userId); const baseUser = await User.getWithRoles(context, param.userId, {
if (baseUser?.roles?.split(',').includes('owner')) baseId: base_id,
NcError.forbidden('Insufficient privilege to delete a owner user.'); });
// check if user have access to delete user based on role power
if (
getProjectRolePower(baseUser.base_roles) >
getProjectRolePower(param.req.user)
) {
NcError.badRequest('Insufficient privilege to delete user');
}
// if old role is owner and there is only one owner then restrict to delete
if (extractRolesObj(baseUser.base_roles)?.[ProjectRoles.OWNER]) {
const baseUsers = await BaseUser.getUsersList(context, {
base_id: param.baseId,
});
if (
baseUsers.filter((u) => u.roles?.includes(ProjectRoles.OWNER))
.length === 1
)
NcError.badRequest('At least one owner is required');
}
// block self delete if user is owner or super
if (
param.req.user.id === param.userId &&
param.req.user.roles.includes('owner')
) {
NcError.badRequest("Admin can't delete themselves!");
} }
await BaseUser.delete(context, base_id, param.userId); await BaseUser.delete(context, base_id, param.userId);

2
packages/nocodb/src/services/integrations.service.ts

@ -5,7 +5,7 @@ import type { NcContext, NcRequest } from '~/interface/config';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service'; import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { validatePayload } from '~/helpers'; import { validatePayload } from '~/helpers';
import { Base, Integration } from '~/models'; import { Base, Integration } from '~/models';
import { NcBaseError, NcError } from '~/helpers/catchError' import { NcBaseError, NcError } from '~/helpers/catchError';
import { Source } from '~/models'; import { Source } from '~/models';
import { CacheScope, MetaTable, RootScopes } from '~/utils/globals'; import { CacheScope, MetaTable, RootScopes } from '~/utils/globals';
import Noco from '~/Noco'; import Noco from '~/Noco';

4
tests/playwright/pages/Dashboard/ProjectView/index.ts

@ -55,9 +55,9 @@ export class ProjectViewPage extends BasePage {
if (role.toLowerCase() === 'creator' || role.toLowerCase() === 'owner') { if (role.toLowerCase() === 'creator' || role.toLowerCase() === 'owner') {
await this.tab_accessSettings.waitFor({ state: 'visible' }); await this.tab_accessSettings.waitFor({ state: 'visible' });
expect(await this.tab_accessSettings.isVisible()).toBeTruthy(); expect(await this.tab_dataSources.isVisible()).toBeTruthy();
} else { } else {
expect(await this.tab_accessSettings.isVisible()).toBeFalsy(); expect(await this.tab_dataSources.isVisible()).toBeFalsy();
} }
await this.tables.verifyAccess(role); await this.tables.verifyAccess(role);

Loading…
Cancel
Save