mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
425 lines
13 KiB
425 lines
13 KiB
<script lang="ts" setup> |
|
import type { Roles, WorkspaceUserRoles } from 'nocodb-sdk' |
|
import { OrderedProjectRoles, OrgUserRoles, ProjectRoles, WorkspaceRolesToProjectRoles } from 'nocodb-sdk' |
|
|
|
const props = defineProps<{ |
|
baseId?: string |
|
}>() |
|
|
|
const basesStore = useBases() |
|
const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore |
|
const { activeProjectId, bases, basesUser } = storeToRefs(basesStore) |
|
|
|
const { orgRoles, baseRoles, loadRoles } = useRoles() |
|
|
|
const { sorts, sortDirection, loadSorts, handleGetSortedData, saveOrUpdate: saveOrUpdateUserSort } = useUserSorts('Project') |
|
|
|
const isSuper = computed(() => orgRoles.value?.[OrgUserRoles.SUPER_ADMIN]) |
|
|
|
const orgStore = useOrg() |
|
const { orgId, org } = storeToRefs(orgStore) |
|
|
|
const isAdminPanel = inject(IsAdminPanelInj, ref(false)) |
|
|
|
const { $api } = useNuxtApp() |
|
|
|
const { t } = useI18n() |
|
|
|
const currentBase = computedAsync(async () => { |
|
let base |
|
if (props.baseId) { |
|
await loadRoles(props.baseId) |
|
base = bases.value.get(props.baseId) |
|
if (!base) { |
|
base = await $api.base.read(props.baseId!) |
|
} |
|
} else { |
|
base = bases.value.get(activeProjectId.value) |
|
} |
|
return base |
|
}) |
|
|
|
const isInviteModalVisible = ref(false) |
|
|
|
interface Collaborators { |
|
id: string |
|
email: string |
|
main_roles: OrgUserRoles |
|
roles: ProjectRoles |
|
base_roles: Roles |
|
workspace_roles: WorkspaceUserRoles |
|
created_at: string |
|
display_name: string | null |
|
} |
|
const collaborators = ref<Collaborators[]>([]) |
|
const totalCollaborators = ref(0) |
|
const userSearchText = ref('') |
|
|
|
const isLoading = ref(false) |
|
const accessibleRoles = ref<(typeof ProjectRoles)[keyof typeof ProjectRoles][]>([]) |
|
|
|
const filteredCollaborators = computed(() => |
|
collaborators.value.filter( |
|
(collab) => |
|
collab.display_name?.toLowerCase()?.includes(userSearchText.value.toLowerCase()) || |
|
collab.email.toLowerCase().includes(userSearchText.value.toLowerCase()), |
|
), |
|
) |
|
|
|
const sortedCollaborators = computed(() => { |
|
return handleGetSortedData(filteredCollaborators.value, sorts.value) |
|
}) |
|
|
|
const loadCollaborators = async () => { |
|
try { |
|
if (!currentBase.value) return |
|
const { users, totalRows } = await getBaseUsers({ |
|
baseId: currentBase.value.id!, |
|
...(!userSearchText.value ? {} : ({ searchText: userSearchText.value } as any)), |
|
force: true, |
|
}) |
|
|
|
totalCollaborators.value = totalRows |
|
collaborators.value = [ |
|
...users |
|
.filter((u: any) => !u?.deleted) |
|
.map((user: any) => ({ |
|
...user, |
|
base_roles: user.roles, |
|
roles: |
|
user.roles ?? |
|
(user.workspace_roles |
|
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS |
|
: ProjectRoles.NO_ACCESS), |
|
})), |
|
] |
|
} catch (e: any) { |
|
message.error(await extractSdkResponseErrorMsg(e)) |
|
} |
|
} |
|
|
|
const isOwnerOrCreator = computed(() => { |
|
return baseRoles.value?.[ProjectRoles.OWNER] || baseRoles.value?.[ProjectRoles.CREATOR] |
|
}) |
|
|
|
const updateCollaborator = async (collab: any, roles: ProjectRoles) => { |
|
const currentCollaborator = collaborators.value.find((coll) => coll.id === collab.id)! |
|
|
|
try { |
|
if (!roles || (roles === ProjectRoles.NO_ACCESS && !isEeUI)) { |
|
await removeProjectUser(currentBase.value.id!, currentCollaborator as unknown as User) |
|
if ( |
|
currentCollaborator.workspace_roles && |
|
WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles && |
|
isEeUI |
|
) { |
|
currentCollaborator.roles = WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] |
|
} else { |
|
currentCollaborator.roles = ProjectRoles.NO_ACCESS |
|
} |
|
currentCollaborator.base_roles = null |
|
} else if (currentCollaborator.base_roles) { |
|
currentCollaborator.roles = roles |
|
await updateProjectUser(currentBase.value.id!, currentCollaborator as unknown as User) |
|
} else { |
|
currentCollaborator.roles = roles |
|
currentCollaborator.base_roles = roles |
|
await createProjectUser(currentBase.value.id!, currentCollaborator as unknown as User) |
|
} |
|
|
|
let currentBaseUsers = basesUser.value.get(currentBase.value.id) |
|
|
|
if (currentBaseUsers?.length) { |
|
currentBaseUsers = currentBaseUsers.map((user) => { |
|
if (user.id === currentCollaborator.id) { |
|
user.roles = currentCollaborator.roles as any |
|
} |
|
return user |
|
}) |
|
|
|
basesUser.value.set(currentBase.value.id, currentBaseUsers) |
|
} |
|
} catch (e: any) { |
|
message.error(await extractSdkResponseErrorMsg(e)) |
|
loadCollaborators() |
|
} |
|
} |
|
|
|
onMounted(async () => { |
|
isLoading.value = true |
|
try { |
|
await loadCollaborators() |
|
const currentRoleIndex = OrderedProjectRoles.findIndex( |
|
(role) => baseRoles.value && Object.keys(baseRoles.value).includes(role), |
|
) |
|
if (isSuper.value) { |
|
accessibleRoles.value = OrderedProjectRoles.slice(0) |
|
} else if (currentRoleIndex !== -1) { |
|
accessibleRoles.value = OrderedProjectRoles.slice(currentRoleIndex) |
|
} |
|
loadSorts() |
|
} catch (e: any) { |
|
message.error(await extractSdkResponseErrorMsg(e)) |
|
} finally { |
|
isLoading.value = false |
|
} |
|
}) |
|
|
|
const selected = reactive<{ |
|
[key: string]: boolean |
|
}>({}) |
|
|
|
const toggleSelectAll = (value: boolean) => { |
|
filteredCollaborators.value.forEach((_) => { |
|
selected[_.id] = value |
|
}) |
|
} |
|
|
|
// const isSomeSelected = computed(() => Object.values(selected).some((v) => v)) |
|
|
|
const selectAll = computed({ |
|
get: () => |
|
Object.values(selected).every((v) => v) && |
|
Object.keys(selected).length > 0 && |
|
Object.values(selected).length === filteredCollaborators.value.length, |
|
set: (value) => { |
|
toggleSelectAll(value) |
|
}, |
|
}) |
|
watch(isInviteModalVisible, () => { |
|
if (!isInviteModalVisible.value) { |
|
loadCollaborators() |
|
} |
|
}) |
|
|
|
watch(currentBase, () => { |
|
loadCollaborators() |
|
}) |
|
|
|
const orderBy = computed<Record<string, SordDirectionType>>({ |
|
get: () => { |
|
return sortDirection.value |
|
}, |
|
set: (value: Record<string, SordDirectionType>) => { |
|
// Check if value is an empty object |
|
if (Object.keys(value).length === 0) { |
|
saveOrUpdateUserSort({}) |
|
return |
|
} |
|
|
|
const [field, direction] = Object.entries(value)[0] |
|
|
|
saveOrUpdateUserSort({ |
|
field, |
|
direction, |
|
}) |
|
}, |
|
}) |
|
|
|
const columns = [ |
|
{ |
|
key: 'select', |
|
title: '', |
|
width: 70, |
|
minWidth: 70, |
|
}, |
|
{ |
|
key: 'email', |
|
title: t('objects.users'), |
|
minWidth: 220, |
|
dataIndex: 'email', |
|
showOrderBy: true, |
|
}, |
|
{ |
|
key: 'role', |
|
title: t('general.role'), |
|
basis: '30%', |
|
minWidth: 272, |
|
dataIndex: 'roles', |
|
showOrderBy: true, |
|
}, |
|
{ |
|
key: 'created_at', |
|
title: t('title.dateJoined'), |
|
basis: '25%', |
|
minWidth: 200, |
|
}, |
|
] as NcTableColumnProps[] |
|
|
|
const customRow = (record: Record<string, any>) => ({ |
|
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> |
|
|
|
<template> |
|
<div |
|
class="nc-collaborator-table-container nc-access-settings-view flex flex-col" |
|
:class="{ |
|
'h-[calc(100vh_-_100px)]': !isAdminPanel, |
|
}" |
|
> |
|
<div v-if="isAdminPanel"> |
|
<div class="nc-breadcrumb px-2"> |
|
<div class="nc-breadcrumb-item"> |
|
{{ org.title }} |
|
</div> |
|
<GeneralIcon icon="ncSlash1" class="nc-breadcrumb-divider" /> |
|
<NuxtLink |
|
:href="`/admin/${orgId}/bases`" |
|
class="!hover:(text-gray-800 underline-gray-600) flex items-center !text-gray-700 !underline-transparent max-w-1/4" |
|
> |
|
<div class="nc-breadcrumb-item"> |
|
{{ $t('objects.projects') }} |
|
</div> |
|
</NuxtLink> |
|
<GeneralIcon icon="ncSlash1" class="nc-breadcrumb-divider" /> |
|
<div class="nc-breadcrumb-item active truncate capitalize"> |
|
{{ currentBase?.title }} |
|
</div> |
|
</div> |
|
<NcPageHeader> |
|
<template #icon> |
|
<div class="nc-page-header-icon flex justify-center items-center h-5 w-5"> |
|
<GeneralBaseIconColorPicker readonly /> |
|
</div> |
|
</template> |
|
<template #title> |
|
<span data-rec="true" class="capitalize"> |
|
{{ currentBase?.title }} |
|
</span> |
|
</template> |
|
</NcPageHeader> |
|
</div> |
|
|
|
<div class="nc-content-max-w h-full flex flex-col items-center gap-6 px-6 pt-6"> |
|
<div v-if="!isAdminPanel" class="w-full flex justify-between items-center max-w-350 gap-3"> |
|
<a-input |
|
v-model:value="userSearchText" |
|
:placeholder="$t('title.searchMembers')" |
|
:disabled="isLoading" |
|
allow-clear |
|
class="nc-input-border-on-value !max-w-90 !h-8 !px-3 !py-1 !rounded-lg" |
|
> |
|
<template #prefix> |
|
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" /> |
|
</template> |
|
</a-input> |
|
|
|
<NcButton :disabled="isLoading" size="small" @click="isInviteModalVisible = true"> |
|
<div class="flex items-center gap-1"> |
|
<component :is="iconMap.plus" class="w-4 h-4" /> |
|
{{ $t('activity.addMembers') }} |
|
</div> |
|
</NcButton> |
|
</div> |
|
|
|
<NcTable |
|
v-model:order-by="orderBy" |
|
:is-data-loading="isLoading" |
|
:columns="columns" |
|
:data="sortedCollaborators" |
|
:bordered="false" |
|
:custom-row="customRow" |
|
class="flex-1 nc-collaborators-list max-w-350" |
|
> |
|
<template #emptyText> |
|
<a-empty :description="$t('title.noMembersFound')" /> |
|
</template> |
|
|
|
<template #headerCell="{ column }"> |
|
<template v-if="column.key === 'select'"> |
|
<NcCheckbox v-model:checked="selectAll" :disabled="!sortedCollaborators.length" /> |
|
</template> |
|
<template v-else> |
|
{{ column.title }} |
|
</template> |
|
</template> |
|
|
|
<template #bodyCell="{ column, record }"> |
|
<template v-if="column.key === 'select'"> |
|
<NcCheckbox v-model:checked="selected[record.id]" /> |
|
</template> |
|
|
|
<div v-if="column.key === 'email'" class="w-full flex gap-3 items-center users-email-grid"> |
|
<GeneralUserIcon size="base" :email="record.email" :name="record.display_name" class="flex-none" /> |
|
<div class="flex flex-col flex-1 max-w-[calc(100%_-_44px)]"> |
|
<div class="flex gap-3"> |
|
<NcTooltip class="truncate max-w-full text-gray-800 capitalize font-semibold" show-on-truncate-only> |
|
<template #title> |
|
{{ record.display_name || record.email.slice(0, record.email.indexOf('@')) }} |
|
</template> |
|
{{ record.display_name || record.email.slice(0, record.email.indexOf('@')) }} |
|
</NcTooltip> |
|
</div> |
|
<NcTooltip class="truncate max-w-full text-xs text-gray-600" show-on-truncate-only> |
|
<template #title> |
|
{{ record.email }} |
|
</template> |
|
{{ record.email }} |
|
</NcTooltip> |
|
</div> |
|
</div> |
|
<div v-if="column.key === 'role'"> |
|
<template v-if="isDeleteOrUpdateAllowed(record) && isOwnerOrCreator && accessibleRoles.includes(record.roles)"> |
|
<RolesSelector |
|
:role="record.roles" |
|
:roles="accessibleRoles" |
|
:inherit=" |
|
isEeUI && record.workspace_roles && WorkspaceRolesToProjectRoles[record.workspace_roles] |
|
? WorkspaceRolesToProjectRoles[record.workspace_roles] |
|
: null |
|
" |
|
show-inherit |
|
:description="false" |
|
:on-role-change="(role) => updateCollaborator(record, role as ProjectRoles)" |
|
/> |
|
</template> |
|
<template v-else> |
|
<RolesBadge :border="false" :role="record.roles" /> |
|
</template> |
|
</div> |
|
<div v-if="column.key === 'created_at'"> |
|
<NcTooltip class="max-w-full"> |
|
<template #title> |
|
{{ parseStringDateTime(record.created_at) }} |
|
</template> |
|
<span> |
|
{{ timeAgo(record.created_at) }} |
|
</span> |
|
</NcTooltip> |
|
</div> |
|
</template> |
|
</NcTable> |
|
</div> |
|
|
|
<LazyDlgInviteDlg v-model:model-value="isInviteModalVisible" :base-id="currentBase?.id" type="base" /> |
|
</div> |
|
</template> |
|
|
|
<style scoped lang="scss"> |
|
:deep(.ant-input::placeholder) { |
|
@apply text-gray-500; |
|
} |
|
|
|
.color-band { |
|
@apply w-6 h-6 left-0 top-2.5 rounded-full flex justify-center uppercase text-white font-weight-bold text-xs items-center; |
|
} |
|
|
|
:deep(.nc-collaborator-role-select .ant-select-selector) { |
|
@apply !rounded; |
|
} |
|
|
|
.nc-page-header-icon { |
|
:deep(svg) { |
|
@apply h-4.5 w-4.5; |
|
} |
|
} |
|
</style>
|
|
|