Browse Source

Merge pull request #7833 from nocodb/nc-feat/new-access-control

feat: base level share
pull/7849/head
navi 4 months ago committed by GitHub
parent
commit
9fa914a9ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 12
      packages/nc-gui/components/nc/Modal.vue
  2. 20
      packages/nc-gui/components/project/AccessSettings.vue
  3. 296
      packages/nc-gui/components/project/ShareBaseDlg.vue
  4. 2
      packages/nc-gui/components/project/View.vue
  5. 2
      packages/nc-gui/components/roles/Selector.vue
  6. 2
      packages/nc-gui/components/workspace/InviteSection.vue
  7. 2
      packages/nc-gui/composables/useRoles/index.ts
  8. 4
      packages/nc-gui/lang/en.json
  9. 9
      packages/nc-gui/lib/acl.ts
  10. 7
      packages/nocodb-sdk/src/lib/enums.ts
  11. 1
      packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts
  12. 41
      packages/nocodb/src/models/BaseUser.ts
  13. 1
      packages/nocodb/src/schema/swagger.json

12
packages/nc-gui/components/nc/Modal.vue

@ -6,19 +6,21 @@ const props = withDefaults(
size?: 'small' | 'medium' | 'large'
destroyOnClose?: boolean
maskClosable?: boolean
showSeparator?: boolean
wrapClassName?: string
}>(),
{
size: 'medium',
destroyOnClose: true,
maskClosable: true,
showSeparator: true,
wrapClassName: '',
},
)
const emits = defineEmits(['update:visible'])
const { width: propWidth, destroyOnClose, maskClosable, wrapClassName: _wrapClassName } = props
const { width: propWidth, destroyOnClose, maskClosable, wrapClassName: _wrapClassName, showSeparator } = props
const { isMobileMode } = useGlobal()
@ -98,7 +100,13 @@ const slots = useSlots()
maxHeight: height,
}"
>
<div v-if="slots.header" class="flex pb-2 mb-2 text-lg font-medium border-b-1 border-gray-100">
<div
v-if="slots.header"
:class="{
'border-b-1 border-gray-100': showSeparator,
}"
class="flex pb-2 mb-2 text-lg font-medium"
>
<slot name="header" />
</div>

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

@ -9,8 +9,8 @@ import {
timeAgo,
} from 'nocodb-sdk'
import type { Roles, WorkspaceUserRoles } from 'nocodb-sdk'
import { isEeUI, storeToRefs, useUserSorts } from '#imports'
import type { User } from '#imports'
import { isEeUI, storeToRefs, useUserSorts } from '#imports'
const basesStore = useBases()
const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore
@ -22,6 +22,8 @@ const { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = u
const isSuper = computed(() => orgRoles.value?.[OrgUserRoles.SUPER_ADMIN])
const isInviteModalVisible = ref(false)
interface Collaborators {
id: string
email: string
@ -132,20 +134,34 @@ onMounted(async () => {
isLoading.value = false
}
})
watch(isInviteModalVisible, () => {
if (!isInviteModalVisible.value) {
loadCollaborators()
}
})
</script>
<template>
<div class="nc-collaborator-table-container mt-4 nc-access-settings-view h-[calc(100vh-8rem)]">
<LazyProjectShareBaseDlg v-model:model-value="isInviteModalVisible" />
<div v-if="isLoading" class="nc-collaborators-list items-center justify-center">
<GeneralLoader size="xlarge" />
</div>
<template v-else>
<div class="w-full flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25">
<div class="w-full flex flex-row justify-between items-baseline max-w-350 mt-6.5 mb-2 pr-0.25">
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md" :placeholder="$t('title.searchMembers')">
<template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template>
</a-input>
<NcButton size="small" @click="isInviteModalVisible = true">
<div class="flex gap-1">
<component :is="iconMap.plus" class="w-4 h-4" />
{{ $t('activity.addMembers') }}
</div>
</NcButton>
</div>
<div v-if="isSearching" class="nc-collaborators-list items-center justify-center">

296
packages/nc-gui/components/project/ShareBaseDlg.vue

@ -0,0 +1,296 @@
<script setup lang="ts">
import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk'
import type { User } from '#imports'
const props = defineProps<{
modelValue: boolean
baseId: string
}>()
const emit = defineEmits(['update:modelValue'])
const dialogShow = useVModel(props, 'modelValue', emit)
const inviteData = reactive({
email: '',
roles: ProjectRoles.NO_ACCESS,
})
const { baseRoles } = useRoles()
const basesStore = useBases()
const { activeProjectId } = storeToRefs(basesStore)
const { createProjectUser } = basesStore
const divRef = ref<HTMLDivElement>()
const focusRef = ref<HTMLInputElement>()
const isDivFocused = ref(false)
const emailValidation = reactive({
isError: true,
message: '',
})
const allowedRoles = ref<ProjectRoles[]>([])
onMounted(async () => {
try {
const currentRoleIndex = OrderedProjectRoles.findIndex(
(role) => baseRoles.value && Object.keys(baseRoles.value).includes(role),
)
if (currentRoleIndex !== -1) {
allowedRoles.value = OrderedProjectRoles.slice(currentRoleIndex + 1).filter((r) => r)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
})
const singleEmailValue = ref('')
const emailBadges = ref<Array<string>>([])
const insertOrUpdateString = (str: string) => {
// Check if the string already exists in the array
const index = emailBadges.value.indexOf(str)
if (index !== -1) {
// If the string exists, remove it
emailBadges.value.splice(index, 1)
}
// Add the new string to the array
emailBadges.value.push(str)
}
const emailInputValidation = (input: string): boolean => {
if (!input.length) {
emailValidation.isError = true
emailValidation.message = 'Email should not be empty'
return false
}
if (!validateEmail(input.trim())) {
emailValidation.isError = true
emailValidation.message = 'Invalid Email'
return false
}
return true
}
const isInvitButtonDiabled = computed(() => {
if (!emailBadges.value.length && !singleEmailValue.value.length) {
return true
}
if (emailBadges.value.length && inviteData.email) {
return true
}
})
watch(inviteData, (newVal) => {
// when user only want to enter a single email
// we dont convert that as badge
const isSingleEmailValid = validateEmail(newVal.email)
if (isSingleEmailValid && !emailBadges.value.length) {
singleEmailValue.value = newVal.email
emailValidation.isError = false
return
}
singleEmailValue.value = ''
// when user enters multiple emails comma sepearted or space sepearted
const isNewEmail = newVal.email.charAt(newVal.email.length - 1) === ',' || newVal.email.charAt(newVal.email.length - 1) === ' '
if (isNewEmail && newVal.email.trim().length) {
const emailToAdd = newVal.email.split(',')[0].trim() || newVal.email.split(' ')[0].trim()
if (!validateEmail(emailToAdd)) {
emailValidation.isError = true
emailValidation.message = 'Invalid Email'
return
}
/**
if email is already enterd we delete the already
existing email and add new one
**/
if (emailBadges.value.includes(emailToAdd)) {
insertOrUpdateString(emailToAdd)
inviteData.email = ''
return
}
emailBadges.value.push(emailToAdd)
inviteData.email = ''
singleEmailValue.value = ''
}
if (!newVal.email.length && emailValidation.isError) {
emailValidation.isError = false
}
})
const handleEnter = () => {
const isEmailIsValid = emailInputValidation(inviteData.email)
if (!isEmailIsValid) return
inviteData.email += ' '
emailValidation.isError = false
emailValidation.message = ''
}
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
// remove one email per backspace
onKeyStroke('Backspace', () => {
if (isDivFocused.value && inviteData.email.length < 1) {
emailBadges.value.pop()
}
})
watch(dialogShow, (newVal) => {
if (newVal) {
setTimeout(() => {
focusOnDiv()
}, 100)
}
})
// when bulk email is pasted
const onPaste = (e: ClipboardEvent) => {
const pastedText = e.clipboardData?.getData('text')
const inputArray = pastedText?.split(',') || pastedText?.split(' ')
// if data is pasted to a already existing text in input
// we add existingInput + pasted data
if (inputArray?.length === 1 && inviteData.email.length) {
inputArray[0] = inviteData.email += inputArray[0]
}
inputArray?.forEach((el) => {
const isEmailIsValid = emailInputValidation(el)
if (!isEmailIsValid) return
/**
if email is already enterd we delete the already
existing email and add new one
**/
if (emailBadges.value.includes(el)) {
insertOrUpdateString(el)
return
}
emailBadges.value.push(el)
inviteData.email = ''
})
inviteData.email = ''
}
const inviteProjectCollaborator = async () => {
try {
const payloadData = singleEmailValue.value || emailBadges.value.join(',')
if (!payloadData.includes(',')) {
const validationStatus = validateEmail(payloadData)
if (!validationStatus) {
emailValidation.isError = true
emailValidation.message = 'invalid email'
}
}
await createProjectUser(activeProjectId.value!, {
email: payloadData,
roles: inviteData.roles,
} as unknown as User)
message.success('Invitation sent successfully')
inviteData.email = ''
emailBadges.value = []
dialogShow.value = false
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
singleEmailValue.value = ''
}
}
</script>
<template>
<NcModal
v-model:visible="dialogShow"
:show-separator="false"
:header="$t('activity.createTable')"
size="medium"
@keydown.esc="dialogShow = false"
>
<template #header>
<div class="flex flex-row items-center gap-x-2">
{{ $t('activity.addMember') }}
</div>
</template>
<div class="flex items-center justify-between gap-3 mt-2">
<div class="flex w-full flex-col">
<div class="flex justify-between gap-3 w-full">
<div
ref="divRef"
class="flex items-center border-1 gap-1 w-full overflow-x-scroll nc-scrollbar-x-md items-center h-10 rounded-lg !min-w-96"
tabindex="0"
:class="{
'border-primary/100': isDivFocused,
'p-1': emailBadges?.length > 1,
}"
@click="focusOnDiv"
@blur="isDivFocused = false"
>
<span
v-for="(email, index) in emailBadges"
:key="email"
class="border-1 text-gray-800 bg-gray-100 rounded-md flex items-center px-2 py-1"
>
{{ email }}
<component
:is="iconMap.close"
class="ml-0.5 hover:cursor-pointer mt-0.5 w-4 h-4"
@click="emailBadges.splice(index, 1)"
/>
</span>
<input
id="email"
ref="focusRef"
v-model="inviteData.email"
:placeholder="$t('activity.enterEmail')"
class="w-full min-w-36 outline-none px-2"
data-testid="email-input"
@keyup.enter="handleEnter"
@blur="isDivFocused = false"
@paste.prevent="onPaste"
/>
</div>
<RolesSelector
size="lg"
class="nc-invite-role-selector"
:role="inviteData.roles"
:roles="allowedRoles"
:on-role-change="(role: ProjectRoles) => (inviteData.roles = role)"
:description="false"
/>
</div>
<span v-if="emailValidation.isError && emailValidation.message" class="ml-2 text-red-500 text-[10px] mt-1.5">{{
emailValidation.message
}}</span>
</div>
</div>
<div class="flex mt-8 justify-end">
<div class="flex gap-2">
<NcButton type="secondary" @click="dialogShow = false"> {{ $t('labels.cancel') }} </NcButton>
<NcButton
type="primary"
size="medium"
:disabled="isInvitButtonDiabled || emailValidation.isError"
@click="inviteProjectCollaborator"
>
{{ $t('activity.inviteToBase') }}
</NcButton>
</div>
</div>
</NcModal>
</template>

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

@ -140,7 +140,7 @@ watch(
</template>
<ProjectAccessSettings />
</a-tab-pane>
<a-tab-pane v-if="isUIAllowed('baseCreate')" key="data-source">
<a-tab-pane v-if="isUIAllowed('sourceCreate')" key="data-source">
<template #tab>
<div class="tab-title" data-testid="proj-view-tab__data-sources">
<GeneralIcon icon="database" />

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

@ -11,7 +11,7 @@ const props = withDefaults(
description?: boolean
inherit?: string
onRoleChange: (role: keyof typeof RoleLabels) => void
size: 'sm' | 'md'
size: 'sm' | 'md' | 'lg'
}>(),
{
description: true,

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

@ -6,7 +6,7 @@ import { validateEmail } from '~/utils/validation'
const inviteData = reactive({
email: '',
roles: WorkspaceUserRoles.VIEWER,
roles: WorkspaceUserRoles.NO_ACCESS,
})
const focusRef = ref<HTMLInputElement>()

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

@ -1,8 +1,8 @@
import { isString } from '@vue/shared'
import type { Roles, RolesObj, WorkspaceUserRoles } from 'nocodb-sdk'
import { extractRolesObj } from 'nocodb-sdk'
import { computed, createSharedComposable, rolePermissions, useApi, useGlobal } from '#imports'
import type { Permission } from '#imports'
import { computed, createSharedComposable, rolePermissions, useApi, useGlobal } from '#imports'
const hasPermission = (role: Exclude<Roles, WorkspaceUserRoles>, hasRole: boolean, permission: Permission | string) => {
const rolePermission = rolePermissions[role]

4
packages/nc-gui/lang/en.json

@ -702,6 +702,10 @@
"clearSelection": "Clear selection"
},
"activity": {
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
"toggleSidebar": "Toggle Sidebar",

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

@ -2,7 +2,14 @@ import { OrgUserRoles, ProjectRoles } from 'nocodb-sdk'
const roleScopes = {
org: [OrgUserRoles.VIEWER, OrgUserRoles.CREATOR],
base: [ProjectRoles.VIEWER, ProjectRoles.COMMENTER, ProjectRoles.EDITOR, ProjectRoles.CREATOR, ProjectRoles.OWNER],
base: [
ProjectRoles.NO_ACCESS,
ProjectRoles.VIEWER,
ProjectRoles.COMMENTER,
ProjectRoles.EDITOR,
ProjectRoles.CREATOR,
ProjectRoles.OWNER,
],
}
interface Perm {

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

@ -19,6 +19,7 @@ export enum WorkspaceUserRoles {
VIEWER = 'workspace-level-viewer',
EDITOR = 'workspace-level-editor',
COMMENTER = 'workspace-level-commenter',
NO_ACCESS = 'workspace-level-no-access',
}
export enum AppEvents {
@ -167,6 +168,7 @@ export const RoleLabels = {
[WorkspaceUserRoles.EDITOR]: 'editor',
[WorkspaceUserRoles.COMMENTER]: 'commenter',
[WorkspaceUserRoles.VIEWER]: 'viewer',
[WorkspaceUserRoles.NO_ACCESS]: 'noaccess',
[ProjectRoles.OWNER]: 'owner',
[ProjectRoles.CREATOR]: 'creator',
[ProjectRoles.EDITOR]: 'editor',
@ -184,6 +186,7 @@ export const RoleColors = {
[WorkspaceUserRoles.EDITOR]: 'green',
[WorkspaceUserRoles.COMMENTER]: 'orange',
[WorkspaceUserRoles.VIEWER]: 'yellow',
[WorkspaceUserRoles.NO_ACCESS]: 'red',
[ProjectRoles.OWNER]: 'purple',
[ProjectRoles.CREATOR]: 'blue',
[ProjectRoles.EDITOR]: 'green',
@ -203,6 +206,7 @@ export const RoleDescriptions = {
[WorkspaceUserRoles.COMMENTER]:
'Can view and comment data in workspace bases',
[WorkspaceUserRoles.VIEWER]: 'Can view data in workspace bases',
[WorkspaceUserRoles.NO_ACCESS]: 'Cannot access this workspace',
[ProjectRoles.OWNER]: 'Full access to base',
[ProjectRoles.CREATOR]:
'Can create tables, views, setup webhook, invite collaborators and more',
@ -222,6 +226,7 @@ export const RoleIcons = {
[WorkspaceUserRoles.EDITOR]: 'role_editor',
[WorkspaceUserRoles.COMMENTER]: 'role_commenter',
[WorkspaceUserRoles.VIEWER]: 'role_viewer',
[WorkspaceUserRoles.NO_ACCESS]: 'role_no_access',
[ProjectRoles.OWNER]: 'role_owner',
[ProjectRoles.CREATOR]: 'role_creator',
[ProjectRoles.EDITOR]: 'role_editor',
@ -239,6 +244,7 @@ export const WorkspaceRolesToProjectRoles = {
[WorkspaceUserRoles.EDITOR]: ProjectRoles.EDITOR,
[WorkspaceUserRoles.COMMENTER]: ProjectRoles.COMMENTER,
[WorkspaceUserRoles.VIEWER]: ProjectRoles.VIEWER,
[WorkspaceUserRoles.NO_ACCESS]: ProjectRoles.NO_ACCESS,
};
export const OrderedWorkspaceRoles = [
@ -247,6 +253,7 @@ export const OrderedWorkspaceRoles = [
WorkspaceUserRoles.EDITOR,
WorkspaceUserRoles.COMMENTER,
WorkspaceUserRoles.VIEWER,
WorkspaceUserRoles.NO_ACCESS,
];
export const OrderedOrgRoles = [

1
packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts

@ -35,6 +35,7 @@ export const rolesLabel = {
[ProjectRoles.VIEWER]: 'Base Viewer',
[ProjectRoles.EDITOR]: 'Base Editor',
[ProjectRoles.COMMENTER]: 'Base Commenter',
[ProjectRoles.NO_ACCESS]: 'No Access',
};
export function getRolesLabels(

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

@ -26,6 +26,47 @@ export default class BaseUser {
return baseUser && new BaseUser(baseUser);
}
public static async bulkInsert(
baseUsers: Partial<BaseUser>[],
ncMeta = Noco.ncMeta,
) {
const insertObj = baseUsers.map((baseUser) =>
extractProps(baseUser, ['fk_user_id', 'base_id', 'roles']),
);
const bulkData = await ncMeta.bulkMetaInsert(
null,
null,
MetaTable.PROJECT_USERS,
insertObj,
true,
);
const uniqueFks: string[] = [
...new Set(bulkData.map((d) => d.base_id)),
] as string[];
for (const fk of uniqueFks) {
await NocoCache.deepDel(
`${CacheScope.BASE_USER}:${fk}:list`,
CacheDelDirection.PARENT_TO_CHILD,
);
}
for (const d of bulkData) {
await NocoCache.set(
`${CacheScope.BASE_USER}:${d.base_id}:${d.fk_user_id}`,
d,
);
await NocoCache.appendToList(
CacheScope.BASE_USER,
[d.base_id],
`${CacheScope.BASE_USER}:${d.base_id}:${d.fk_user_id}`,
);
}
}
public static async insert(
baseUser: Partial<BaseUser>,
ncMeta = Noco.ncMeta,

1
packages/nocodb/src/schema/swagger.json

@ -22605,7 +22605,6 @@
"type": "object",
"properties": {
"email": {
"format": "email",
"type": "string",
"description": "Base User Email"
},

Loading…
Cancel
Save