Browse Source

fix: user management followup (#8458)

* fix(nocodb): image upload

* fix(nc-gui): invite workspace error

* feat: bug fixes(wip)

* fix(nocodb): incorrect members list

* fix(nc-gui): update row size

* fix(nc-gui): some more changes

* fix(nc-gui): show image

* chore: cleanup
pull/8475/head
Anbarasu 7 months ago committed by GitHub
parent
commit
6c4d0482e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 136
      packages/nc-gui/components/dlg/InviteDlg.vue
  2. 64
      packages/nc-gui/components/dlg/WorkspaceDelete.vue
  3. 38
      packages/nc-gui/components/project/AccessSettings.vue
  4. 37
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  5. 4
      packages/nc-gui/components/workspace/View.vue
  6. 9
      packages/nc-gui/composables/useUserSorts.ts
  7. 1
      packages/nc-gui/lang/en.json
  8. 2
      packages/nc-gui/lib/types.ts

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

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ProjectRoles, type RoleLabels, WorkspaceUserRoles } from 'nocodb-sdk' import { ProjectRoles, type RoleLabels, WorkspaceUserRoles } from 'nocodb-sdk'
import { extractEmail } from '~/helpers/parsers/parserHelpers' import { extractEmail } from '../../helpers/parsers/parserHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@ -12,6 +12,8 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const { baseRoles, workspaceRoles } = useRoles()
const basesStore = useBases() const basesStore = useBases()
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
@ -26,6 +28,10 @@ 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: '',
roles: orderedRoles.value.NO_ACCESS, roles: orderedRoles.value.NO_ACCESS,
@ -47,6 +53,28 @@ const emailBadges = ref<Array<string>>([])
const allowedRoles = ref<[]>([]) const allowedRoles = ref<[]>([])
const isLoading = ref(false)
const organizationStore = useOrganization()
const { listWorkspaces } = organizationStore
const { workspaces } = storeToRefs(organizationStore)
const searchQuery = ref('')
const workSpaceSelectList = computed<WorkspaceType[]>(() => {
return workspaces.value.filter((w: WorkspaceType) => w.title!.toLowerCase().includes(searchQuery.value.toLowerCase()))
})
const checked = reactive<{
[key: string]: boolean
}>({})
const selectedWorkspaces = computed<WorkspaceType[]>(() => {
return workSpaceSelectList.value.filter((ws: WorkspaceType) => checked[ws.id!])
})
const focusOnDiv = () => { const focusOnDiv = () => {
focusRef.value?.focus() focusRef.value?.focus()
isDivFocused.value = true isDivFocused.value = true
@ -218,10 +246,9 @@ const onPaste = (e: ClipboardEvent) => {
inviteData.email = '' inviteData.email = ''
} }
const workSpaces = ref<NcWorkspace[]>([])
const inviteCollaborator = async () => { const inviteCollaborator = async () => {
try { try {
isLoading.value = true
const payloadData = singleEmailValue.value || emailBadges.value.join(',') const payloadData = singleEmailValue.value || emailBadges.value.join(',')
if (!payloadData.includes(',')) { if (!payloadData.includes(',')) {
const validationStatus = validateEmail(payloadData) const validationStatus = validateEmail(payloadData)
@ -239,7 +266,7 @@ const inviteCollaborator = async () => {
await inviteWsCollaborator(payloadData, inviteData.roles, props.workspaceId) await inviteWsCollaborator(payloadData, inviteData.roles, props.workspaceId)
} else if (props.type === 'organization') { } else if (props.type === 'organization') {
// TODO: Add support for Bulk Workspace Invite // TODO: Add support for Bulk Workspace Invite
for (const workspace of workSpaces.value) { for (const workspace of selectedWorkspaces.value) {
await inviteWsCollaborator(payloadData, inviteData.roles, workspace.id) await inviteWsCollaborator(payloadData, inviteData.roles, workspace.id)
} }
} }
@ -252,32 +279,17 @@ const inviteCollaborator = async () => {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} finally { } finally {
singleEmailValue.value = '' singleEmailValue.value = ''
isLoading.value = false
} }
} }
const organizationStore = useOrganization() const isOrgSelectMenuOpen = ref(false)
const { listWorkspaces } = organizationStore
const { workspaces } = storeToRefs(organizationStore)
const workSpaceSelectList = computed(() => {
return workspaces.value.filter((w) => !workSpaces.value.find((ws) => ws.id === w.id))
})
const addToList = (workspaceId: string) => {
workSpaces.value.push(workspaces.value.find((w) => w.id === workspaceId)!)
}
const removeWorkspace = (workspaceId: string) => {
workSpaces.value = workSpaces.value.filter((w) => w.id !== workspaceId)
}
onMounted(async () => { onMounted(async () => {
if (props.type === 'organization') { if (props.type === 'organization') {
await listWorkspaces() await listWorkspaces()
} }
}) })
const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role as ProjectRoles | WorkspaceUserRoles) const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role as ProjectRoles | WorkspaceUserRoles)
</script> </script>
@ -291,7 +303,7 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
@keydown.esc="dialogShow = false" @keydown.esc="dialogShow = false"
> >
<template #header> <template #header>
<div class="flex flex-row items-center gap-x-2"> <div class="flex flex-row text-2xl font-bold items-center gap-x-2">
{{ {{
type === 'organization' type === 'organization'
? $t('labels.addMembersToOrganization') ? $t('labels.addMembersToOrganization')
@ -331,6 +343,7 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
id="email" id="email"
ref="focusRef" ref="focusRef"
v-model="inviteData.email" v-model="inviteData.email"
:disabled="isLoading"
:placeholder="$t('activity.enterEmail')" :placeholder="$t('activity.enterEmail')"
class="w-full min-w-36 outline-none px-2" class="w-full min-w-36 outline-none px-2"
data-testid="email-input" data-testid="email-input"
@ -354,23 +367,71 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
}}</span> }}</span>
<template v-if="type === 'organization'"> <template v-if="type === 'organization'">
<NcSelect :placeholder="$t('labels.selectWorkspace')" size="middle" @change="addToList"> <NcDropdown v-model:visible="isOrgSelectMenuOpen">
<a-select-option v-for="workspace in workSpaceSelectList" :key="workspace.id" :value="workspace.id"> <NcButton class="!justify-between" full-width size="medium" type="secondary">
{{ workspace.title }} <div
</a-select-option> :class="{
</NcSelect> '!text-gray-600': selectedWorkspaces.length > 0,
}"
<div class="flex flex-wrap gap-2"> class="flex text-gray-500 justify-between items-center w-full"
<NcBadge v-for="workspace in workSpaces" :key="workspace.id"> >
<div class="px-2 flex gap-2 items-center py-1"> <NcTooltip class="!max-w-130 truncate" show-on-truncate-only>
<GeneralWorkspaceIcon :workspace="workspace" hide-label size="small" /> <span class="">
<span class="text-gray-600"> {{
{{ workspace.title }} selectedWorkspaces.length > 0
? selectedWorkspaces.map((w) => w.title).join(', ')
: '-select workspaces to invite to-'
}}
</span> </span>
<component :is="iconMap.close" class="w-3 h-3" @click="removeWorkspace(workspace.id)" /> <template #title>
{{
selectedWorkspaces.length > 0
? selectedWorkspaces.map((w) => w.title).join(', ')
: '-select workspaces to invite to-'
}}
</template>
</NcTooltip>
<component :is="iconMap.chevronDown" />
</div>
</NcButton>
<template #overlay>
<div class="py-2">
<div class="mx-2">
<a-input
v-model:value="searchQuery"
:class="{
'!border-brand-500': searchQuery.length > 0,
}"
class="!rounded-lg !h-8 !ring-0 !placeholder:text-gray-500 !border-gray-200 !px-4"
data-testid="nc-ws-search"
placeholder="Search workspace"
>
<template #prefix>
<component :is="iconMap.search" class="h-4 w-4 mr-1 text-gray-500" />
</template>
</a-input>
</div>
<div class="flex flex-col max-h-64 overflow-y-auto nc-scrollbar-md mt-2">
<div
v-for="ws in workSpaceSelectList"
:key="ws.id"
class="px-4 cursor-pointer hover:bg-gray-100 rounded-lg h-9.5 py-2 w-full flex gap-2"
@click="checked[ws.id!] = !checked[ws.id!]"
>
<div class="flex gap-2 capitalize items-center">
<GeneralWorkspaceIcon :hide-label="true" :workspace="ws" size="small" />
{{ ws.title }}
</div>
<div class="flex-1" />
<NcCheckbox v-model:checked="checked[ws.id!]" size="large" />
</div> </div>
</NcBadge>
</div> </div>
</div>
</template>
/>
</NcDropdown>
</template> </template>
</div> </div>
</div> </div>
@ -378,7 +439,8 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
<div class="flex gap-2"> <div class="flex gap-2">
<NcButton type="secondary" @click="dialogShow = false"> {{ $t('labels.cancel') }} </NcButton> <NcButton type="secondary" @click="dialogShow = false"> {{ $t('labels.cancel') }} </NcButton>
<NcButton <NcButton
:disabled="isInviteButtonDisabled || emailValidation.isError" :disabled="isInviteButtonDisabled || emailValidation.isError || isLoading"
:loading="isLoading"
size="medium" size="medium"
type="primary" type="primary"
class="nc-invite-btn" class="nc-invite-btn"

64
packages/nc-gui/components/dlg/WorkspaceDelete.vue

@ -0,0 +1,64 @@
<script lang="ts" setup>
const props = defineProps<{
visible: boolean
workspaceId: string
}>()
const emits = defineEmits(['update:visible'])
const visible = useVModel(props, 'visible', emits)
const workspaceStore = useWorkspace()
const { deleteWorkspace: _deleteWorkspace, loadWorkspaces, navigateToWorkspace, loadWorkspace } = workspaceStore
const { workspacesList, activeWorkspace } = storeToRefs(workspaceStore)
const { refreshCommandPalette } = useCommandPalette()
const workspace = computedAsync(async () => {
if (props.workspaceId) {
const ws = workspacesList.value.find((workspace) => workspace.id === props.workspaceId)
if (!ws) {
await loadWorkspace(props.workspaceId)
return workspacesList.value.find((workspace) => workspace.id === props.workspaceId)
}
}
return activeWorkspace.value ?? workspacesList.value[0]
})
const onDelete = async () => {
if (!workspace.value) return
try {
await _deleteWorkspace(workspace.value.id!)
await loadWorkspaces()
if (!workspacesList.value?.[0]?.id) {
return await navigateToWorkspace()
}
await navigateToWorkspace(workspacesList.value?.[0]?.id)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
refreshCommandPalette()
}
}
</script>
<template>
<GeneralDeleteModal v-model:visible="visible" :entity-name="$t('objects.workspace')" :on-delete="onDelete">
<template #entity-preview>
<div v-if="workspace" class="flex flex-row items-center py-2.25 px-2.75 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralIcon icon="workspace" />
<div
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-2.25"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ workspace.title }}
</div>
</div>
</template>
</GeneralDeleteModal>
</template>

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

@ -152,6 +152,27 @@ onMounted(async () => {
} }
}) })
const selected = reactive<{
[key: number]: boolean
}>({})
const toggleSelectAll = (value: boolean) => {
filteredCollaborators.value.forEach((_, i) => {
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, () => { watch(isInviteModalVisible, () => {
if (!isInviteModalVisible.value) { if (!isInviteModalVisible.value) {
loadCollaborators() loadCollaborators()
@ -172,11 +193,12 @@ watch(currentBase, () => {
> >
<div v-if="isAdminPanel" class="font-bold w-full !mb-5 text-2xl" data-rec="true"> <div v-if="isAdminPanel" class="font-bold w-full !mb-5 text-2xl" data-rec="true">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- TODO: @DarkPhoenix2704 -->
<NuxtLink <NuxtLink
:href="`/admin/${orgId}/bases`" :href="`/admin/${orgId}/bases`"
class="!hover:(text-black underline-gray-600) !text-black !underline-transparent ml-0.75 max-w-1/4" class="!hover:(text-black underline-gray-600) flex items-center !text-black !underline-transparent ml-0.75 max-w-1/4"
> >
<component :is="iconMap.arrowLeft" class="text-3xl" />
{{ $t('objects.projects') }} {{ $t('objects.projects') }}
</NuxtLink> </NuxtLink>
@ -218,8 +240,10 @@ watch(currentBase, () => {
<a-empty :description="$t('title.noMembersFound')" /> <a-empty :description="$t('title.noMembersFound')" />
</div> </div>
<div v-else class="nc-collaborators-list mt-6 h-full"> <div v-else class="nc-collaborators-list mt-6 h-full">
<div class="flex flex-col rounded-lg overflow-hidden border-1 max-w-350 max-h-[calc(100%-8rem)]"> <div class="flex flex-col overflow-hidden max-w-350 max-h-[calc(100%-8rem)]">
<div class="flex flex-row bg-gray-50 min-h-12 items-center border-b-1"> <div class="flex flex-row bg-gray-50 min-h-12 items-center border-b-1">
<div class="py-3 px-6"><NcCheckbox v-model:checked="selectAll" /></div>
<LazyAccountHeaderWithSorter <LazyAccountHeaderWithSorter
class="users-email-grid" class="users-email-grid"
:header="$t('objects.users')" :header="$t('objects.users')"
@ -243,8 +267,14 @@ watch(currentBase, () => {
<div <div
v-for="(collab, i) of sortedCollaborators" v-for="(collab, i) of sortedCollaborators"
:key="i" :key="i"
class="user-row flex flex-row border-b-1 py-1 min-h-14 items-center" :class="{
'bg-[#F0F3FF]': selected[collab.id],
}"
class="user-row flex hover:bg-[#F0F3FF] flex-row border-b-1 py-1 min-h-14 items-center"
> >
<div class="py-3 px-6">
<NcCheckbox v-model:checked="selected[collab.id]" />
</div>
<div class="flex gap-3 items-center users-email-grid"> <div class="flex gap-3 items-center users-email-grid">
<GeneralUserIcon size="base" :email="collab.email" /> <GeneralUserIcon size="base" :email="collab.email" />
<div class="flex flex-col"> <div class="flex flex-col">

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

@ -9,12 +9,20 @@ const { workspaceRoles, loadRoles } = useRoles()
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
const { removeCollaborator, updateCollaborator: _updateCollaborator } = workspaceStore const { removeCollaborator, updateCollaborator: _updateCollaborator, loadWorkspace } = workspaceStore
const { collaborators, activeWorkspace: _activeWorkspace, workspaces } = storeToRefs(workspaceStore) const { collaborators, activeWorkspace, workspacesList } = storeToRefs(workspaceStore)
const currentWorkspace = computed(() => { const currentWorkspace = computedAsync(async () => {
return props.workspaceId ? workspaces.value.get(props.workspaceId) : _activeWorkspace.value if (props.workspaceId) {
const ws = workspacesList.value.find((workspace) => workspace.id === props.workspaceId)
if (!ws) {
await loadWorkspace(props.workspaceId)
return workspacesList.value.find((workspace) => workspace.id === props.workspaceId)
}
}
return activeWorkspace.value ?? workspacesList.value[0]
}) })
const { sorts, loadSorts, handleGetSortedData, toggleSort } = useUserSorts('Workspace') const { sorts, loadSorts, handleGetSortedData, toggleSort } = useUserSorts('Workspace')
@ -34,8 +42,8 @@ const filterCollaborators = computed(() => {
return collaborators.value.filter( return collaborators.value.filter(
(collab) => (collab) =>
collab.display_name.toLowerCase().includes(userSearchText.value.toLowerCase()) || collab.display_name?.toLowerCase().includes(userSearchText.value.toLowerCase()) ||
collab.email.toLowerCase().includes(userSearchText.value.toLowerCase()), collab.email?.toLowerCase().includes(userSearchText.value.toLowerCase()),
) )
}) })
@ -64,8 +72,10 @@ const selectAll = computed({
}) })
const updateCollaborator = async (collab: any, roles: WorkspaceUserRoles) => { const updateCollaborator = async (collab: any, roles: WorkspaceUserRoles) => {
if (!currentWorkspace.value || !currentWorkspace.value.id) return
console.log(WorkspaceUserRoles.OWNER)
try { try {
await _updateCollaborator(collab.id, roles, currentWorkspace.value?.id) await _updateCollaborator(collab.id, roles, currentWorkspace.value.id)
message.success('Successfully updated user role') message.success('Successfully updated user role')
collaborators.value?.forEach((collaborator) => { collaborators.value?.forEach((collaborator) => {
@ -93,7 +103,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<DlgInviteDlg v-model:model-value="inviteDlg" :workspace-id="currentWorkspace?.id" type="workspace" /> <DlgInviteDlg v-if="currentWorkspace" v-model:model-value="inviteDlg" :workspace-id="currentWorkspace?.id" type="workspace" />
<div class="nc-collaborator-table-container mt-4 h-[calc(100vh-10rem)] max-w-350"> <div class="nc-collaborator-table-container mt-4 h-[calc(100vh-10rem)] max-w-350">
<div class="w-full flex justify-between mt-6.5 mb-2"> <div class="w-full flex justify-between mt-6.5 mb-2">
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search members"> <a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search members">
@ -138,7 +148,10 @@ onMounted(async () => {
<div <div
v-for="(collab, i) of sortedCollaborators" v-for="(collab, i) of sortedCollaborators"
:key="i" :key="i"
class="user-row flex hover:bg-gray-50 flex-row last:border-b-0 border-b-1 py-1 min-h-14 items-center" :class="{
'bg-[#F0F3FF]': selected[i],
}"
class="user-row flex hover:bg-[#F0F3FF] flex-row last:border-b-0 border-b-1 py-1 min-h-14 items-center"
> >
<div class="py-3 px-6"> <div class="py-3 px-6">
<NcCheckbox v-model:checked="selected[i]" /> <NcCheckbox v-model:checked="selected[i]" />
@ -149,7 +162,7 @@ onMounted(async () => {
<div class="flex flex-col"> <div class="flex flex-col">
<div class="flex gap-3"> <div class="flex gap-3">
<span class="text-gray-800 capitalize font-semibold"> <span class="text-gray-800 capitalize font-semibold">
{{ collab.display_name || collab.email.slice(0, collab.email.indexOf('@')) }} {{ collab.display_name || collab?.email?.slice(0, collab.email.indexOf('@')) }}
</span> </span>
</div> </div>
<span class="text-xs text-gray-600"> <span class="text-xs text-gray-600">
@ -159,10 +172,10 @@ onMounted(async () => {
</div> </div>
<div class="w-full flex-1 px-6 py-3"> <div class="w-full flex-1 px-6 py-3">
<div class="w-[30px]"> <div class="w-[30px]">
<template v-if="accessibleRoles.includes(collab.roles)"> <template v-if="accessibleRoles.includes(collab.roles as WorkspaceUserRoles)">
<RolesSelector <RolesSelector
:description="false" :description="false"
:on-role-change="(role) => updateCollaborator(collab, role)" :on-role-change="(role) => updateCollaborator(collab, role as WorkspaceUserRoles)"
:role="collab.roles" :role="collab.roles"
:roles="accessibleRoles" :roles="accessibleRoles"
class="cursor-pointer" class="cursor-pointer"

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

@ -77,8 +77,10 @@ onMounted(() => {
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<NuxtLink <NuxtLink
:href="`/admin/${orgId}/workspaces`" :href="`/admin/${orgId}/workspaces`"
class="!hover:(text-black underline-gray-600) !text-black !underline-transparent ml-0.75 max-w-1/4" class="!hover:(text-black underline-gray-600) flex items-center !text-black !underline-transparent ml-0.75 max-w-1/4"
> >
<component :is="iconMap.arrowLeft" class="text-3xl" />
{{ $t('labels.workspaces') }} {{ $t('labels.workspaces') }}
</NuxtLink> </NuxtLink>

9
packages/nc-gui/composables/useUserSorts.ts

@ -138,6 +138,15 @@ export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project' | 'Organi
return b[sortsConfig.field]?.localeCompare(a[sortsConfig.field]) return b[sortsConfig.field]?.localeCompare(a[sortsConfig.field])
} }
} }
case 'baseCount':
case 'workspaceCount':
case 'memberCount': {
if (sortsConfig.direction === 'asc') {
return a[sortsConfig.field] - b[sortsConfig.field]
} else {
return b[sortsConfig.field] - a[sortsConfig.field]
}
}
} }
return 0 return 0

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

@ -449,6 +449,7 @@
}, },
"labels": { "labels": {
"today": "Today", "today": "Today",
"workspace": "Workspace",
"txt": "TXT Record value", "txt": "TXT Record value",
"transferOwnership": "Transfer Ownership", "transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity", "recentActivity": "Recent Activity",

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

@ -204,7 +204,7 @@ interface SidebarTableNode extends TableType {
} }
interface UsersSortType { interface UsersSortType {
field?: 'email' | 'roles' | 'title' | 'id' field?: 'email' | 'roles' | 'title' | 'id' | 'memberCount' | 'baseCount' | 'workspaceCount'
direction?: 'asc' | 'desc' direction?: 'asc' | 'desc'
} }

Loading…
Cancel
Save