Browse Source

Nc feat/user management (#8369)

* fix: source filter

Signed-off-by: mertmit <mertmit99@gmail.com>

* feat: sso cloud apis - WIP

* feat: admin panel menu option

* feat: UI integration - WIP

* feat: UI integration - SSO

* feat: domain verification

* feat: workspace upgrade and sso page - WIP

* feat: domain adding and verification - WIP

* feat: domain adding and verification

* fix: domain validation corrections

* chore: lint

* feat(nc-gui): organization settings page

* feat(nc-gui): organization members page

* fix(nc-gui): some more changes

* fix(nc-gui): refactor collaborators ui

* feat(nc-gui): dashboard ui

* feat(nc-gui): bases page

* feat(nocodb): wired up ui and apis. wip

* fix(nc-gui): some more fixes

* fix(nc-gui): move ws to org immediately after creation

* fix(nc-gui): some more bug fixes

* feat(nocodb): transfer workspace ownership

* fix(nc-gui): load roles if baseId is provided in prop

* fix(nc-gui): show only org workspaces

* fix(nc-gui): some more fixes

* fix(nc-gui): rename base

* fix(nc-gui): invite fixes

* feat: restrict access to org level user(SSO login)

* fix: include org and client info in token

* fix: include org and client info in refresh token

* refactor: minor ui corrections

* refactor: add a generic component for copying

* refactor: ui correction and cleanup

* fix: refresh token update

* fix: ui corrections

* fix: if user signin using unverified domain show error in sso page rather than showing the json with error

* fix: for all sso related exceptions redirect to sso ui page with error

* chore: lint

* fix: show admin panel option only for user who have permission

* fix: redirect to sso login page on logout based on current user info

* test: sso - playwright test

* fix: duplicate attribute

* test: playwright

* fix: missing import

* test: playwright - WIP

* test: playwright - Cloud sso login flow

* fix: error handling

* test: playwright - sso auth flow tests

* fix: show upgrade option only for workspace owner

* test: user invite tests corrections

* test: user invite tests corrections

* test: user management correction

* test: playwright - use regex for path match

* fix: delete existing provider if any

* test: combine sso tests to run serially

* test: playwright - title name correction

* test: playwright - reset sso client from sso tests only

* test: playwright - page navigation correction

* refactor: by default navigate to org settings page on org creation and disable org image upload

* refactor: reverify domain after 7 days and update role names to avoid confusion between  org and cloud org roles

* fix: corrections

* fix: show org level roles in members section

* refactor: disable org update by default

* test: unit tests for org admin apis

* chore: lint

* fix: review comments

* chore: lint and cleanup

---------

Signed-off-by: mertmit <mertmit99@gmail.com>
Co-authored-by: mertmit <mertmit99@gmail.com>
Co-authored-by: DarkPhoenix2704 <anbarasun123@gmail.com>
nc-fix/test-cal
Pranav C 5 months ago committed by GitHub
parent
commit
2ee3edee58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      packages/nc-gui/assets/nc-icons/arrow-up-right.svg
  2. 5
      packages/nc-gui/assets/nc-icons/control-panel.svg
  3. 4
      packages/nc-gui/assets/nc-icons/home.svg
  4. 10
      packages/nc-gui/assets/nc-icons/office.svg
  5. 11
      packages/nc-gui/assets/nc-icons/slash.svg
  6. 4
      packages/nc-gui/assets/nc-icons/workspace.svg
  7. 1
      packages/nc-gui/components.d.ts
  8. 3
      packages/nc-gui/components/dashboard/Sidebar/EEMenuOption.vue
  9. 6
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  10. 4
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  11. 3
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  12. 208
      packages/nc-gui/components/dlg/InviteDlg.vue
  13. 3
      packages/nc-gui/components/general/BaseIconColorPicker.vue
  14. 26
      packages/nc-gui/components/general/CopyButton.vue
  15. 2
      packages/nc-gui/components/general/WorkspaceIcon.vue
  16. 7
      packages/nc-gui/components/nc/Badge.vue
  17. 2
      packages/nc-gui/components/nc/ErrorBoundary.vue
  18. 2
      packages/nc-gui/components/nc/Select.vue
  19. 128
      packages/nc-gui/components/project/AccessSettings.vue
  20. 48
      packages/nc-gui/components/project/View.vue
  21. 8
      packages/nc-gui/components/roles/Badge.vue
  22. 8
      packages/nc-gui/components/roles/Selector.vue
  23. 4
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  24. 2
      packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue
  25. 184
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  26. 40
      packages/nc-gui/components/workspace/Settings.vue
  27. 91
      packages/nc-gui/components/workspace/View.vue
  28. 23
      packages/nc-gui/composables/useOrganization.ts
  29. 11
      packages/nc-gui/composables/useUserSorts.ts
  30. 2
      packages/nc-gui/context/index.ts
  31. 68
      packages/nc-gui/lang/en.json
  32. 1
      packages/nc-gui/lib/acl.ts
  33. 2
      packages/nc-gui/lib/types.ts
  34. 18
      packages/nc-gui/middleware/02.auth.global.ts
  35. 5
      packages/nc-gui/store/workspace.ts
  36. 28
      packages/nc-gui/utils/datetimeUtils.ts
  37. 12
      packages/nc-gui/utils/iconUtils.ts
  38. 1
      packages/nc-gui/utils/index.ts
  39. 193
      packages/nocodb-sdk/src/lib/Api.ts
  40. 16
      packages/nocodb-sdk/src/lib/enums.ts
  41. 21
      packages/nocodb/src/filters/global-exception/global-exception.filter.ts
  42. 9
      packages/nocodb/src/helpers/catchError.ts
  43. 2
      packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts
  44. 2
      packages/nocodb/src/models/UserRefreshToken.ts
  45. 6
      packages/nocodb/src/services/users/helpers.ts
  46. 3
      packages/nocodb/src/strategies/authtoken.strategy/authtoken.strategy.ts
  47. 3
      packages/nocodb/tests/unit/rest/index.test.ts
  48. 12
      tests/playwright/pages/Account/Authentication.ts
  49. 6
      tests/playwright/pages/Base.ts
  50. 2
      tests/playwright/pages/Dashboard/ProjectView/AccessSettingsPage.ts
  51. 20
      tests/playwright/pages/Dashboard/TreeView.ts
  52. 15
      tests/playwright/pages/OrgAdmin/Bases.ts
  53. 15
      tests/playwright/pages/OrgAdmin/Dashboard.ts
  54. 51
      tests/playwright/pages/OrgAdmin/Domain.ts
  55. 15
      tests/playwright/pages/OrgAdmin/Members.ts
  56. 46
      tests/playwright/pages/OrgAdmin/OpenIDLoginPage.ts
  57. 42
      tests/playwright/pages/OrgAdmin/SAMLLoginPage.ts
  58. 200
      tests/playwright/pages/OrgAdmin/SSO.ts
  59. 37
      tests/playwright/pages/OrgAdmin/SSOLoginPage.ts
  60. 15
      tests/playwright/pages/OrgAdmin/Settings.ts
  61. 15
      tests/playwright/pages/OrgAdmin/Workspaces.ts
  62. 29
      tests/playwright/pages/OrgAdmin/index.ts
  63. 25
      tests/playwright/pages/WorkspacePage/CollaborationPage.ts
  64. 7
      tests/playwright/setup/index.ts

4
packages/nc-gui/assets/nc-icons/arrow-up-right.svg

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.66666 11.3334L11.3333 4.66675" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66666 4.66675H11.3333V11.3334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

5
packages/nc-gui/assets/nc-icons/control-panel.svg

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 14V6" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 6H14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 597 B

4
packages/nc-gui/assets/nc-icons/home.svg

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 5.99992L8 1.33325L14 5.99992V13.3333C14 13.6869 13.8595 14.026 13.6095 14.2761C13.3594 14.5261 13.0203 14.6666 12.6667 14.6666H3.33333C2.97971 14.6666 2.64057 14.5261 2.39052 14.2761C2.14048 14.026 2 13.6869 2 13.3333V5.99992Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 14.6667V8H10V14.6667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 573 B

10
packages/nc-gui/assets/nc-icons/office.svg

@ -0,0 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_130_18151)">
<path d="M31 31.3606H0.999999C0.800999 31.3606 0.639999 31.1996 0.639999 31.0006V1.00063C0.639999 0.801625 0.800999 0.640625 0.999999 0.640625H19C19.199 0.640625 19.36 0.801625 19.36 1.00063V3.64062H25C25.199 3.64062 25.36 3.80163 25.36 4.00063V7.64062H31C31.199 7.64062 31.36 7.80163 31.36 8.00063V31.0006C31.36 31.1996 31.199 31.3606 31 31.3606ZM19.36 30.6406H30.64V8.36062H19.36V30.6406ZM12.36 30.6406H18.64V1.36063H1.36V30.6406H7.64V23.0006C7.64 22.8016 7.801 22.6406 8 22.6406H12C12.199 22.6406 12.36 22.8016 12.36 23.0006V30.6406ZM8.36 30.6406H11.64V23.3606H8.36V30.6406ZM19.36 7.64062H24.639V4.36063H19.36V7.64062ZM27.36 25.0006H26.64V23.0006H27.361L27.36 25.0006ZM23.36 25.0006H22.64V23.0006H23.361L23.36 25.0006ZM16.36 25.0006H15.64V23.0006H16.36V25.0006ZM4.36 25.0006H3.64V23.0006H4.36V25.0006ZM27.36 19.0006H26.64V17.0006H27.361L27.36 19.0006ZM23.36 19.0006H22.64V17.0006H23.361L23.36 19.0006ZM16.36 19.0006H15.64V17.0006H16.36V19.0006ZM12.36 19.0006H11.64V17.0006H12.36V19.0006ZM8.36 19.0006H7.64V17.0006H8.36V19.0006ZM4.36 19.0006H3.64V17.0006H4.36V19.0006ZM27.36 13.0006H26.64V11.0006H27.361L27.36 13.0006ZM23.36 13.0006H22.64V11.0006H23.361L23.36 13.0006ZM16.36 13.0006H15.64V11.0006H16.36V13.0006ZM12.36 13.0006H11.64V11.0006H12.36V13.0006ZM8.36 13.0006H7.64V11.0006H8.36V13.0006ZM4.36 13.0006H3.64V11.0006H4.36V13.0006ZM16.36 7.00063H15.64V5.00063H16.36V7.00063ZM12.36 7.00063H11.64V5.00063H12.36V7.00063ZM8.36 7.00063H7.64V5.00063H8.36V7.00063ZM4.36 7.00063H3.64V5.00063H4.36V7.00063Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_130_18151">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

11
packages/nc-gui/assets/nc-icons/slash.svg

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="slash" clip-path="url(#clip0_311_1327)">
<path id="Vector" d="M7.99998 14.6668C11.6819 14.6668 14.6666 11.6821 14.6666 8.00016C14.6666 4.31826 11.6819 1.3335 7.99998 1.3335C4.31808 1.3335 1.33331 4.31826 1.33331 8.00016C1.33331 11.6821 4.31808 14.6668 7.99998 14.6668Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M3.28662 3.28662L12.7133 12.7133" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_311_1327">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 732 B

4
packages/nc-gui/assets/nc-icons/workspace.svg

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="15" height="15" rx="3.5" fill="none"/>
<rect x="0.5" y="0.5" width="15" height="15" rx="3.5" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

1
packages/nc-gui/components.d.ts vendored

@ -52,6 +52,7 @@ declare module 'vue' {
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARate: typeof import('ant-design-vue/es')['Rate']
AResult: typeof import('ant-design-vue/es')['Result']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']

3
packages/nc-gui/components/dashboard/Sidebar/EEMenuOption.vue

@ -0,0 +1,3 @@
<template>
<span></span>
</template>

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

@ -20,12 +20,14 @@ const { isMobileMode } = useGlobal()
const logout = async () => {
isLoggingOut.value = true
try {
const isSsoUser = !!(user?.value as any)?.sso_client_id
await signOut(false)
// No need as all stores are cleared on signout
// await clearWorkspaces()
await navigateTo('/signin')
await navigateTo(isSsoUser ? '/sso' : '/signin')
} catch (e) {
console.error(e)
} finally {
@ -167,6 +169,8 @@ onMounted(() => {
<NcDivider />
<DashboardSidebarEEMenuOption v-if="isEeUI" />
<nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile">
<NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem>
</nuxt-link>

4
packages/nc-gui/components/dashboard/settings/DataSources.vue

@ -290,11 +290,11 @@ const isEditBaseModalOpen = computed({
<template>
<div class="flex flex-row w-full h-full nc-data-sources-view">
<div class="flex flex-col w-full overflow-auto">
<div class="flex flex-row w-full justify-end mt-6 mb-5">
<div class="flex flex-row w-full justify-end mt-6.5 mb-2">
<NcButton
v-if="dataSourcesAwakened"
size="large"
class="z-10 !rounded-lg !px-2 mr-2.5"
class="z-10 !px-2"
type="primary"
@click="vState = DataSourcesSubTab.New"
>

3
packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue

@ -86,8 +86,7 @@ const customFormState = ref<ProjectCreateForm>({
const clientTypes = computed(() => {
return _clientTypes.filter((type) => {
// return appInfo.value?.ee || type.value !== ClientType.SNOWFLAKE
return type.value !== ClientType.SNOWFLAKE
return ![ClientType.SNOWFLAKE, ClientType.DATABRICKS].includes(type.value)
})
})

208
packages/nc-gui/components/project/ShareBaseDlg.vue → packages/nc-gui/components/dlg/InviteDlg.vue

@ -1,30 +1,43 @@
<script setup lang="ts">
import type { RoleLabels } from 'nocodb-sdk'
import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk'
<script lang="ts" setup>
import { ProjectRoles, type RoleLabels, WorkspaceUserRoles } from 'nocodb-sdk'
import type { User } from '#imports'
import { extractEmail } from '~/helpers/parsers/parserHelpers'
const props = defineProps<{
modelValue: boolean
type?: 'base' | 'workspace' | 'organization'
baseId?: string
emails?: string[]
workspaceId?: string
}>()
const emit = defineEmits(['update:modelValue'])
const dialogShow = useVModel(props, 'modelValue', emit)
const inviteData = reactive({
email: '',
roles: ProjectRoles.NO_ACCESS,
})
const { baseRoles } = useRoles()
const { baseRoles, workspaceRoles } = useRoles()
const basesStore = useBases()
const { activeProjectId } = storeToRefs(basesStore)
const workspaceStore = useWorkspace()
const { createProjectUser } = basesStore
const { inviteCollaborator: inviteWsCollaborator } = workspaceStore
const dialogShow = useVModel(props, 'modelValue', emit)
const orderedRoles = computed(() => {
return props.type === 'base' ? ProjectRoles : WorkspaceUserRoles
})
const userRoles = computed(() => {
return props.type === 'base' ? baseRoles.value : workspaceRoles.value
})
const inviteData = reactive({
email: '',
roles: orderedRoles.value.NO_ACCESS,
})
const divRef = ref<HTMLDivElement>()
const focusRef = ref<HTMLInputElement>()
@ -35,23 +48,44 @@ const emailValidation = reactive({
message: '',
})
const allowedRoles = ref<ProjectRoles[]>([])
const singleEmailValue = ref('')
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)
const emailBadges = ref<Array<string>>([])
const allowedRoles = ref<[]>([])
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
watch(dialogShow, async (newVal) => {
if (newVal) {
try {
// todo: enable after discussing with anbu
// const currentRoleIndex = Object.values(orderedRoles.value).findIndex(
// (role) => userRoles.value && Object.keys(userRoles.value).includes(role),
// )
// if (currentRoleIndex !== -1) {
allowedRoles.value = Object.values(orderedRoles.value) // .slice(currentRoleIndex + 1)
// }
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
if (props.emails) {
emailBadges.value = props.emails
}
setTimeout(() => {
focusOnDiv()
}, 100)
} else {
emailBadges.value = []
inviteData.email = ''
singleEmailValue.value = ''
}
})
const singleEmailValue = ref('')
const emailBadges = ref<Array<string>>([])
const insertOrUpdateString = (str: string) => {
// Check if the string already exists in the array
@ -84,7 +118,7 @@ const emailInputValidation = (input: string, isBulkEmailCopyPaste: boolean = fal
return true
}
const isInvitButtonDiabled = computed(() => {
const isInviteButtonDisabled = computed(() => {
if (!emailBadges.value.length && !singleEmailValue.value.length) {
return true
}
@ -95,7 +129,7 @@ const isInvitButtonDiabled = computed(() => {
watch(inviteData, (newVal) => {
// when user only want to enter a single email
// we dont convert that as badge
// we don't convert that as badge
const isSingleEmailValid = validateEmail(newVal.email)
if (isSingleEmailValid && !emailBadges.value.length) {
@ -105,7 +139,7 @@ watch(inviteData, (newVal) => {
}
singleEmailValue.value = ''
// when user enters multiple emails comma sepearted or space sepearted
// when user enters multiple emails comma separated or space separated
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()
@ -140,12 +174,6 @@ const handleEnter = () => {
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) {
@ -197,7 +225,9 @@ const onPaste = (e: ClipboardEvent) => {
inviteData.email = ''
}
const inviteProjectCollaborator = async () => {
const workSpaces = ref<NcWorkspace[]>([])
const inviteCollaborator = async () => {
try {
const payloadData = singleEmailValue.value || emailBadges.value.join(',')
if (!payloadData.includes(',')) {
@ -207,10 +237,19 @@ const inviteProjectCollaborator = async () => {
emailValidation.message = 'invalid email'
}
}
await createProjectUser(activeProjectId.value!, {
email: payloadData,
roles: inviteData.roles,
} as unknown as User)
if (props.type === 'base' && props.baseId) {
await createProjectUser(props.baseId!, {
email: payloadData,
roles: inviteData.roles,
} as unknown as User)
} else if (props.type === 'workspace' && props.workspaceId) {
await inviteWsCollaborator(payloadData, inviteData.roles, props.workspaceId)
} else if (props.type === 'organization') {
// TODO: Add support for Bulk Workspace Invite
for (const workspace of workSpaces.value) {
await inviteWsCollaborator(payloadData, inviteData.roles, workspace.id)
}
}
message.success('Invitation sent successfully')
inviteData.email = ''
@ -223,40 +262,70 @@ const inviteProjectCollaborator = async () => {
}
}
const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role as ProjectRoles)
const organizationStore = useOrganization()
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 () => {
if (props.type === 'organization') {
await listWorkspaces()
}
})
const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role as ProjectRoles | WorkspaceUserRoles)
</script>
<template>
<NcModal
v-model:visible="dialogShow"
:show-separator="false"
:header="$t('activity.createTable')"
:show-separator="false"
size="medium"
class="nc-invite-dlg"
@keydown.esc="dialogShow = false"
>
<template #header>
<div class="flex flex-row items-center gap-x-2">
{{ $t('activity.addMember') }}
{{
type === 'organization'
? $t('labels.addMembersToOrganization')
: type === 'base'
? $t('activity.addMember')
: $t('activity.inviteToWorkspace')
}}
</div>
</template>
<div class="flex items-center justify-between gap-3 mt-2">
<div class="flex w-full flex-col">
<div class="flex w-full gap-4 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-auto 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"
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"
@blur="isDivFocused = false"
@click="focusOnDiv"
>
<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"
class="border-1 text-gray-800 first:ml-1 bg-gray-100 rounded-md flex items-center px-2 py-1"
>
{{ email }}
<component
@ -272,38 +341,65 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
:placeholder="$t('activity.enterEmail')"
class="w-full min-w-36 outline-none px-2"
data-testid="email-input"
@keyup.enter="handleEnter"
@blur="isDivFocused = false"
@keyup.enter="handleEnter"
@paste.prevent="onPaste"
/>
</div>
<RolesSelector
size="lg"
class="nc-invite-role-selector"
:description="false"
:on-role-change="onRoleChange"
:role="inviteData.roles"
:roles="allowedRoles"
:on-role-change="onRoleChange"
:description="false"
class="!min-w-[152px] nc-invite-role-selector"
size="lg"
/>
</div>
<span v-if="emailValidation.isError && emailValidation.message" class="ml-2 text-red-500 text-[10px] mt-1.5">{{
emailValidation.message
}}</span>
<template v-if="type === 'organization'">
<NcSelect :placeholder="$t('labels.selectWorkspace')" size="middle" @change="addToList">
<a-select-option v-for="workspace in workSpaceSelectList" :key="workspace.id" :value="workspace.id">
{{ workspace.title }}
</a-select-option>
</NcSelect>
<div class="flex flex-wrap gap-2">
<NcBadge v-for="workspace in workSpaces" :key="workspace.id">
<div class="px-2 flex gap-2 items-center py-1">
<GeneralWorkspaceIcon :workspace="workspace" hide-label size="small" />
<span class="text-gray-600">
{{ workspace.title }}
</span>
<component :is="iconMap.close" class="w-3 h-3" @click="removeWorkspace(workspace.id)" />
</div>
</NcBadge>
</div>
</template>
</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"
:disabled="isInviteButtonDisabled || emailValidation.isError"
size="medium"
:disabled="isInvitButtonDiabled || emailValidation.isError"
@click="inviteProjectCollaborator"
type="primary"
class="nc-invite-btn"
@click="inviteCollaborator"
>
{{ $t('activity.inviteToBase') }}
{{ type === 'base' ? $t('activity.inviteToBase') : $t('activity.inviteToWorkspace') }}
</NcButton>
</div>
</div>
</NcModal>
</template>
<style lang="scss" scoped>
:deep(.nc-invite-role-selector .nc-role-badge) {
@apply w-full;
}
</style>

3
packages/nc-gui/components/general/BaseIconColorPicker.vue

@ -7,7 +7,7 @@ const props = withDefaults(
defineProps<{
type?: NcProjectType | string
modelValue?: string
size?: 'small' | 'medium' | 'large' | 'xlarge'
size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge'
readonly?: boolean
iconClass?: string
}>(),
@ -62,6 +62,7 @@ watch(
:class="{
'hover:bg-gray-500 hover:bg-opacity-15 cursor-pointer': !readonly,
'bg-gray-500 bg-opacity-15': isOpen,
'h-5 w-5 text-base': size === 'xsmall',
'h-6 w-6 text-lg': size === 'small',
'h-8 w-8 text-xl': size === 'medium',
'h-10 w-10 text-2xl': size === 'large',

26
packages/nc-gui/components/general/CopyButton.vue

@ -0,0 +1,26 @@
<script setup lang="ts">
import { useCopy } from '~/composables/useCopy'
const props = defineProps<{
content?: string
timeout?: number
}>()
const { copy } = useCopy()
const copied = ref(false)
const copyContent = async () => {
await copy(props.content || '')
copied.value = true
setTimeout(() => {
copied.value = false
}, props.timeout || 2000)
}
</script>
<template>
<NcButton size="xsmall" type="text" @click="copyContent">
<MdiCheck v-if="copied" class="h-3.5" />
<component :is="iconMap.copy" v-else class="text-gray-800" />
</NcButton>
</template>

2
packages/nc-gui/components/general/WorkspaceIcon.vue

@ -6,6 +6,7 @@ const props = defineProps<{
workspace: WorkspaceType | undefined
hideLabel?: boolean
size?: 'small' | 'medium' | 'large'
isRounded?: boolean
}>()
const workspaceColor = computed(() => {
@ -24,6 +25,7 @@ const size = computed(() => props.size || 'medium')
'min-w-4 w-4 h-4 rounded': size === 'small',
'min-w-6 w-6 h-6 rounded-md': size === 'medium',
'min-w-10 w-10 h-10 rounded-lg !text-base': size === 'large',
'!rounded-[50%]': props.isRounded,
}"
:style="{ backgroundColor: workspaceColor }"
>

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

@ -4,17 +4,18 @@ const props = withDefaults(
color?: string
border?: boolean
size?: 'sm' | 'md' | 'lg'
rounded?: 'sm' | 'md' | 'lg'
}>(),
{
border: true,
size: 'sm',
rounded: 'md',
},
)
</script>
<template>
<div
class="rounded-md px-1 flex items-center"
:class="{
'border-purple-500 bg-purple-100': props.color === 'purple',
'border-blue-500 bg-blue-100': props.color === 'blue',
@ -28,7 +29,11 @@ const props = withDefaults(
'h-6': props.size === 'sm',
'h-8': props.size === 'md',
'h-10': props.size === 'lg',
'rounded-sm': props.rounded === 'sm',
'rounded-md': props.rounded === 'md',
'rounded-lg': props.rounded === 'lg',
}"
class="px-1 flex items-center"
>
<slot />
</div>

2
packages/nc-gui/components/nc/ErrorBoundary.vue

@ -20,7 +20,7 @@ export default {
onErrorCaptured((err) => {
if (import.meta.client && (!nuxtApp.isHydrating || !nuxtApp.payload.serverRendered)) {
console.log('UI Error :', err)
console.error('UI Error :', err)
emit('error', err)
error.value = err
return false

2
packages/nc-gui/components/nc/Select.vue

@ -3,6 +3,7 @@ const props = defineProps<{
value?: string | string[]
placeholder?: string
mode?: 'multiple' | 'tags'
size?: 'small' | 'middle' | 'large'
dropdownClassName?: string
showSearch?: boolean
// filterOptions is a function
@ -44,6 +45,7 @@ const onChange = (value: string) => {
<template>
<a-select
v-model:value="vModel"
:size="size"
:allow-clear="allowClear"
:disabled="loading"
:dropdown-class-name="dropdownClassName"

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

@ -1,27 +1,44 @@
<script lang="ts" setup>
import {
OrderedProjectRoles,
OrgUserRoles,
ProjectRoles,
WorkspaceRolesToProjectRoles,
extractRolesObj,
parseStringDateTime,
timeAgo,
} from 'nocodb-sdk'
import type { Roles, WorkspaceUserRoles } from 'nocodb-sdk'
import { OrderedProjectRoles, OrgUserRoles, ProjectRoles, WorkspaceRolesToProjectRoles } from 'nocodb-sdk'
import type { User } from '#imports'
import { isEeUI, storeToRefs, useUserSorts } from '#imports'
const props = defineProps<{
baseId?: string
}>()
const basesStore = useBases()
const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore
const { activeProjectId } = storeToRefs(basesStore)
const { activeProjectId, bases } = storeToRefs(basesStore)
const { orgRoles, baseRoles } = useRoles()
const { orgRoles, baseRoles, loadRoles } = useRoles()
const { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = useUserSorts('Project')
const isSuper = computed(() => orgRoles.value?.[OrgUserRoles.SUPER_ADMIN])
const orgStore = useOrg()
const { orgId } = storeToRefs(orgStore)
const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const { $api } = useNuxtApp()
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 {
@ -56,8 +73,9 @@ const sortedCollaborators = computed(() => {
const loadCollaborators = async () => {
try {
if (!currentBase.value) return
const { users, totalRows } = await getBaseUsers({
baseId: activeProjectId.value!,
baseId: currentBase.value.id!,
...(!userSearchText.value ? {} : ({ searchText: userSearchText.value } as any)),
force: true,
})
@ -69,12 +87,11 @@ const loadCollaborators = async () => {
.map((user: any) => ({
...user,
base_roles: user.roles,
roles: extractRolesObj(user.main_roles)?.[OrgUserRoles.SUPER_ADMIN]
? OrgUserRoles.SUPER_ADMIN
: user.roles ??
(user.workspace_roles
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS
: ProjectRoles.NO_ACCESS),
roles:
user.roles ??
(user.workspace_roles
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS
: ProjectRoles.NO_ACCESS),
})),
]
} catch (e: any) {
@ -93,7 +110,7 @@ const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles &&
isEeUI)
) {
await removeProjectUser(activeProjectId.value!, currentCollaborator as unknown as User)
await removeProjectUser(currentBase.value.id!, currentCollaborator as unknown as User)
if (
currentCollaborator.workspace_roles &&
WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles &&
@ -105,11 +122,11 @@ const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
}
} else if (currentCollaborator.base_roles) {
currentCollaborator.roles = roles
await updateProjectUser(activeProjectId.value!, currentCollaborator as unknown as User)
await updateProjectUser(currentBase.value.id!, currentCollaborator as unknown as User)
} else {
currentCollaborator.roles = roles
currentCollaborator.base_roles = roles
await createProjectUser(activeProjectId.value!, currentCollaborator as unknown as User)
await createProjectUser(currentBase.value.id!, currentCollaborator as unknown as User)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
@ -142,24 +159,50 @@ watch(isInviteModalVisible, () => {
loadCollaborators()
}
})
watch(currentBase, () => {
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
:class="{
'px-6 ': isAdminPanel,
}"
class="nc-collaborator-table-container mt-4 nc-access-settings-view h-[calc(100vh-8rem)]"
>
<div v-if="isAdminPanel" class="font-bold w-full !mb-5 text-2xl" data-rec="true">
<div class="flex items-center gap-3">
<!-- TODO: @DarkPhoenix2704 -->
<NuxtLink
:href="`/admin/${orgId}/bases`"
class="!hover:(text-black underline-gray-600) !text-black !underline-transparent ml-0.75 max-w-1/4"
>
{{ $t('objects.projects') }}
</NuxtLink>
<span class="text-2xl"> / </span>
<GeneralBaseIconColorPicker readonly />
<span class="text-base">
{{ currentBase?.title }}
</span>
</div>
</div>
<LazyDlgInviteDlg v-model:model-value="isInviteModalVisible" :base-id="currentBase?.id" type="base" />
<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 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')">
<div class="w-full flex flex-row justify-between items-center max-w-350 mt-6.5 mb-2 pr-0.25">
<a-input v-model:value="userSearchText" :placeholder="$t('title.searchMembers')" class="!max-w-90 !rounded-md mr-4">
<template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template>
</a-input>
<NcButton size="small" @click="isInviteModalVisible = true">
<div class="flex gap-1">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" class="w-4 h-4" />
{{ $t('activity.addMembers') }}
</div>
@ -188,7 +231,7 @@ watch(isInviteModalVisible, () => {
<div class="text-gray-700 user-access-grid flex items-center space-x-2">
<span>
{{ $t('general.access') }}
{{ $t('general.role') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div>
@ -203,17 +246,16 @@ watch(isInviteModalVisible, () => {
>
<div class="flex gap-3 items-center users-email-grid">
<GeneralUserIcon size="base" :email="collab.email" />
<NcTooltip v-if="collab.display_name">
<template #title>
<div class="flex flex-col">
<div class="flex gap-3">
<span class="text-gray-800 capitalize font-semibold">
{{ collab.display_name || collab.email.slice(0, collab.email.indexOf('@')) }}
</span>
</div>
<span class="text-xs text-gray-600">
{{ collab.email }}
</template>
<span class="truncate">
{{ collab.display_name }}
</span>
</NcTooltip>
<span v-else class="truncate">
{{ collab.email }}
</span>
</div>
</div>
<div class="user-access-grid">
<template v-if="accessibleRoles.includes(collab.roles)">
@ -230,7 +272,7 @@ watch(isInviteModalVisible, () => {
/>
</template>
<template v-else>
<RolesBadge :role="collab.roles" />
<RolesBadge :border="false" :role="collab.roles" />
</template>
</div>
<div class="date-joined-grid">
@ -252,6 +294,18 @@ watch(isInviteModalVisible, () => {
</template>
<style scoped lang="scss">
.ant-input::placeholder {
@apply text-gray-500;
}
.ant-input:placeholder-shown {
@apply text-gray-500 !text-md;
}
.ant-input-affix-wrapper {
@apply px-4 rounded-lg py-2 w-84 border-1 focus:border-brand-500 border-gray-200 !ring-0;
}
.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;
}

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

@ -3,22 +3,36 @@ import { useTitle } from '@vueuse/core'
import NcLayout from '~icons/nc-icons/layout'
import { isEeUI } from '#imports'
const props = defineProps<{
baseId: string
}>()
const basesStore = useBases()
const { openedProject, activeProjectId, basesUser } = storeToRefs(basesStore)
const { openedProject, activeProjectId, basesUser, bases } = storeToRefs(basesStore)
const { activeTables, activeTable } = storeToRefs(useTablesStore())
const { activeWorkspace, workspaceUserCount } = storeToRefs(useWorkspace())
const { navigateToProjectPage } = useBase()
const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const router = useRouter()
const route = router.currentRoute
const { $e } = useNuxtApp()
const { $e, $api } = useNuxtApp()
/* const defaultBase = computed(() => {
return openedProject.value?.sources?.[0]
}) */
const currentBase = computed(async () => {
let base
if (props.baseId) {
base = bases.value.get(props.baseId)
if (!base) base = await $api.base.read(props.baseId!)
} else {
base = openedProject.value
}
return base
})
const { isUIAllowed, baseRoles } = useRoles()
@ -37,7 +51,7 @@ const userCount = computed(() =>
watch(
() => route.value.query?.page,
(newVal, oldVal) => {
if (route.value.name !== 'index-typeOrId-baseId-index-index') return
// if (route.value.name !== 'index-typeOrId-baseId-index-index') return
if (newVal && newVal !== oldVal) {
if (newVal === 'collaborator') {
projectPageTab.value = 'collaborator'
@ -46,11 +60,14 @@ watch(
} else {
projectPageTab.value = 'allTable'
}
return
}
projectPageTab.value = 'allTable'
if (isAdminPanel.value) {
projectPageTab.value = 'collaborator'
} else {
projectPageTab.value = 'allTable'
}
},
{ immediate: true },
)
@ -66,11 +83,11 @@ watch(projectPageTab, () => {
})
watch(
() => [openedProject.value?.id, openedProject.value?.title],
() => [currentBase.value?.id, currentBase.value?.title],
() => {
if (activeTable.value?.title) return
useTitle(`${openedProject.value?.title ?? activeWorkspace.value?.title ?? 'NocoDB'}`)
useTitle(`${currentBase.value?.title ?? activeWorkspace.value?.title ?? 'NocoDB'}`)
},
{
immediate: true,
@ -81,17 +98,18 @@ watch(
<template>
<div class="h-full nc-base-view">
<div
v-if="!isAdminPanel"
class="flex flex-row pl-2 pr-2 gap-1 border-b-1 border-gray-200 justify-between w-full"
:class="{ 'nc-table-toolbar-mobile': isMobileMode, 'h-[var(--topbar-height)]': !isMobileMode }"
>
<div class="flex flex-row items-center gap-x-3">
<GeneralOpenLeftSidebarBtn />
<div class="flex flex-row items-center h-full gap-x-2.5">
<GeneralProjectIcon :type="openedProject?.type" :color="parseProp(openedProject?.meta).iconColor" />
<GeneralProjectIcon :color="parseProp(currentBase?.meta).iconColor" :type="currentBase?.type" />
<NcTooltip class="flex font-medium text-sm capitalize truncate max-w-150" show-on-truncate-only>
<template #title> {{ openedProject?.title }}</template>
<template #title> {{ currentBase?.title }}</template>
<span class="truncate">
{{ openedProject?.title }}
{{ currentBase?.title }}
</span>
</NcTooltip>
</div>
@ -105,7 +123,7 @@ watch(
}"
>
<a-tabs v-model:activeKey="projectPageTab" class="w-full">
<a-tab-pane key="allTable">
<a-tab-pane v-if="!isAdminPanel" key="allTable">
<template #tab>
<div class="tab-title" data-testid="proj-view-tab__all-tables">
<NcLayout />
@ -143,7 +161,7 @@ watch(
</div>
</div>
</template>
<ProjectAccessSettings />
<ProjectAccessSettings :base-id="currentBase.id" />
</a-tab-pane>
<a-tab-pane v-if="isUIAllowed('sourceCreate')" key="data-source">
<template #tab>

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

@ -8,6 +8,8 @@ const props = withDefaults(
clickable?: boolean
inherit?: boolean
border?: boolean
showIcon?: boolean
iconOnly?: boolean
size?: 'sm' | 'md' | 'lg'
}>(),
{
@ -15,6 +17,8 @@ const props = withDefaults(
inherit: false,
border: true,
size: 'sm',
iconOnly: false,
showIcon: true,
},
)
@ -60,8 +64,8 @@ const roleProperties = computed(() => {
}"
>
<div class="flex items-center gap-2">
<GeneralIcon :icon="roleProperties.icon" />
<span class="flex whitespace-nowrap">
<GeneralIcon v-if="showIcon" :icon="roleProperties.icon" />
<span v-if="!iconOnly" class="flex whitespace-nowrap">
{{ $t(`objects.roleType.${roleProperties.label}`) }}
</span>
</div>

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

@ -1,11 +1,12 @@
<script lang="ts" setup>
import { RoleDescriptions } from 'nocodb-sdk'
import type { RoleLabels } from 'nocodb-sdk'
import { RoleDescriptions } from 'nocodb-sdk'
import type { SelectValue } from 'ant-design-vue/es/select'
import { toRef } from '#imports'
const props = withDefaults(
defineProps<{
border?: boolean
role: keyof typeof RoleLabels
roles: (keyof typeof RoleLabels)[]
description?: boolean
@ -14,6 +15,7 @@ const props = withDefaults(
size?: 'sm' | 'md' | 'lg'
}>(),
{
border: true,
description: true,
size: 'sm',
},
@ -36,7 +38,7 @@ function onChangeRole(val: SelectValue) {
<template>
<div ref="dropdownRef" size="lg" class="nc-roles-selector relative" @click="isDropdownOpen = !isDropdownOpen">
<RolesBadge data-testid="roles" :role="roleRef" :inherit="inheritRef === role" :size="sizeRef" clickable />
<RolesBadge :border="false" :inherit="inheritRef === role" :role="roleRef" :size="sizeRef" clickable data-testid="roles" />
<a-select
:value="roleRef"
:open="isDropdownOpen"
@ -54,7 +56,7 @@ function onChangeRole(val: SelectValue) {
class="flex flex-col nc-role-select-dropdown gap-1"
>
<div class="flex items-center justify-between">
<RolesBadge :class="`nc-role-select-${rl}`" :role="rl" :inherit="inheritRef === rl" :border="false" />
<RolesBadge :border="false" :class="`nc-role-select-${rl}`" :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>

4
packages/nc-gui/components/smartsheet/expanded-form/Comments.vue

@ -261,8 +261,8 @@ const onClickAudit = () => {
{{ log.description.substring(log.description.indexOf(':') + 1) }}
</div>
<div v-if="log.id === editLog?.id" class="flex justify-end gap-1">
<NcButton type="secondary" size="sm" @click="onCancel"> Cancel </NcButton>
<NcButton v-e="['a:row-expand:comment:save']" size="sm" @click="onEditComment"> Save </NcButton>
<NcButton size="small" type="secondary" @click="onCancel"> Cancel </NcButton>
<NcButton v-e="['a:row-expand:comment:save']" size="small" @click="onEditComment"> Save </NcButton>
</div>
</div>
</div>

2
packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue

@ -12,8 +12,6 @@ const emits = defineEmits(['created'])
const { isParentOpen, columns } = toRefs(props)
const activeView = inject(ActiveViewInj, ref())
const meta = inject(MetaInj, ref())
const { showSystemFields, metaColumnById } = useViewColumnsOrThrow()

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

@ -1,6 +1,10 @@
<script lang="ts" setup>
import { OrderedWorkspaceRoles, WorkspaceUserRoles, parseStringDateTime, timeAgo } from 'nocodb-sdk'
import { storeToRefs, useUserSorts, useWorkspace } from '#imports'
import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk'
import { IsAdminPanelInj, storeToRefs, useUserSorts, useWorkspace } from '#imports'
const props = defineProps<{
workspaceId?: string
}>()
const { workspaceRoles, loadRoles } = useRoles()
@ -8,12 +12,22 @@ const workspaceStore = useWorkspace()
const { removeCollaborator, updateCollaborator: _updateCollaborator } = workspaceStore
const { collaborators, workspaceRole } = storeToRefs(workspaceStore)
const { collaborators, activeWorkspace: _activeWorkspace, workspaces } = storeToRefs(workspaceStore)
const currentWorkspace = computed(() => {
return props.workspaceId ? workspaces.value.get(props.workspaceId) : _activeWorkspace.value
})
const { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = useUserSorts('Workspace')
const userSearchText = ref('')
const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const { isUIAllowed } = useRoles()
const inviteDlg = ref(false)
const filterCollaborators = computed(() => {
if (!userSearchText.value) return collaborators.value ?? []
@ -26,13 +40,34 @@ const filterCollaborators = computed(() => {
)
})
const selected = reactive<{
[key: number]: boolean
}>({})
const toggleSelectAll = (value: boolean) => {
filterCollaborators.value.forEach((_, i) => {
selected[i] = value
})
}
const sortedCollaborators = computed(() => {
return handleGetSortedData(filterCollaborators.value, sorts.value)
})
const selectAll = computed({
get: () =>
Object.values(selected).every((v) => v) &&
Object.keys(selected).length > 0 &&
Object.values(selected).length === sortedCollaborators.value.length,
set: (value) => {
toggleSelectAll(value)
},
})
const updateCollaborator = async (collab: any, roles: WorkspaceUserRoles) => {
try {
await _updateCollaborator(collab.id, roles)
console.log()
await _updateCollaborator(collab.id, roles, currentWorkspace.value.id)
message.success('Successfully updated user role')
collaborators.value?.forEach((collaborator) => {
@ -54,81 +89,89 @@ const accessibleRoles = computed<WorkspaceUserRoles[]>(() => {
})
onMounted(async () => {
await loadRoles()
await loadRoles(null, {}, currentWorkspace.value?.id)
loadSorts()
})
</script>
<template>
<div class="nc-collaborator-table-container mt-4 mx-6 h-[calc(100vh-12rem)]">
<div class="w-full flex justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2">
<div class="text-xl">Invite Members By Email</div>
<DlgInviteDlg v-model:model-value="inviteDlg" :workspace-id="currentWorkspace.id" type="workspace" />
<div class="nc-collaborator-table-container mt-4 h-[calc(100vh-10rem)]">
<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">
<template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template>
</a-input>
<NcButton data-testid="nc-add-member-btn" @click="inviteDlg = true">
<div class="flex items-center gap-2">
<component :is="iconMap.plus" class="!h-4 !w-4" />
{{ $t('labels.addMember') }}
</div>
</NcButton>
</div>
<WorkspaceInviteSection v-if="workspaceRole !== WorkspaceUserRoles.VIEWER" />
<div v-if="!filterCollaborators?.length" class="w-full h-full flex flex-col items-center justify-center mt-36">
<a-empty description="No members found" />
</div>
<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-row bg-gray-50 min-h-12 items-center">
<div class="text-gray-700 users-email-grid w-3/8 ml-10 mr-3 flex items-center space-x-2">
<div class="flex flex-col rounded-lg overflow-hidden border-1 max-w-350 max-h-[calc(100%-4rem)]">
<div class="flex flex-row bg-gray-50 min-h-11 items-center border-b-1">
<div class="py-3 px-6"><NcCheckbox v-model:checked="selectAll" /></div>
<div class="text-gray-700 w-[30rem] users-email-grid flex items-center space-x-2">
<span>
{{ $t('objects.users') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.email" field="email" :handle-user-sort="saveOrUpdate" />
<LazyAccountUserMenu :direction="sortDirection.email" :handle-user-sort="saveOrUpdate" field="email" />
</div>
<div class="text-gray-700 user-access-grid w-2/8 mr-3 flex items-center space-x-2">
<div class="text-gray-700 w-full flex-1 px-6 py-3 flex items-center space-x-2">
<span>
{{ $t('general.access') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div>
<div class="text-gray-700 date-joined-grid w-2/8 mr-3">{{ $t('title.dateJoined') }}</div>
<div class="text-gray-700 user-access-grid w-1/8">Actions</div>
<div class="text-gray-700 w-full flex-1 px-6 py-3">{{ $t('title.dateJoined') }}</div>
<div class="text-gray-700 w-full text-right flex-1 px-6 py-3">{{ $t('labels.actions') }}</div>
</div>
<div class="flex flex-col nc-scrollbar-md">
<div
v-for="(collab, i) of sortedCollaborators"
:key="i"
class="flex flex-row border-b-1 py-1 min-h-14 items-center justify-around last"
class="user-row flex hover:bg-gray-50 flex-row last:border-b-0 border-b-1 py-1 min-h-14 items-center"
>
<div class="flex gap-3 items-center users-email-grid w-3/8 ml-10">
<GeneralUserIcon size="base" :name="collab.email" :email="collab.email" />
<NcTooltip v-if="collab.display_name">
<template #title>
<div class="py-3 px-6">
<NcCheckbox v-model:checked="selected[i]" />
</div>
<div class="flex gap-3 w-[30rem] items-center users-email-grid">
<GeneralUserIcon :email="collab.email" size="base" />
<div class="flex flex-col">
<div class="flex gap-3">
<span class="text-gray-800 capitalize font-semibold">
{{ collab.display_name || collab.email.slice(0, collab.email.indexOf('@')) }}
</span>
</div>
<span class="text-xs text-gray-600">
{{ collab.email }}
</template>
<span class="truncate">
{{ collab.display_name }}
</span>
</NcTooltip>
<span v-else class="truncate">
{{ collab.email }}
</span>
</div>
</div>
<div class="user-access-grid w-2/8">
<template v-if="accessibleRoles.includes(collab.roles)">
<div class="w-[30px]">
<div class="w-full flex-1 px-6 py-3">
<div class="w-[30px]">
<template v-if="accessibleRoles.includes(collab.roles)">
<RolesSelector
:description="false"
:on-role-change="(role) => updateCollaborator(collab, role)"
:role="collab.roles"
:roles="accessibleRoles"
:description="false"
class="cursor-pointer"
:on-role-change="(role) => updateCollaborator(collab, role)"
/>
</div>
</template>
<template v-else>
<RolesBadge :role="collab.roles" class="cursor-default" />
</template>
</template>
<template v-else>
<RolesBadge :border="false" :role="collab.roles" class="cursor-default" />
</template>
</div>
</div>
<div class="date-joined-grid w-2/8 flex justify-start">
<div class="w-full flex-1 px-6 py-3">
<NcTooltip class="max-w-full">
<template #title>
{{ parseStringDateTime(collab.created_at) }}
@ -138,14 +181,35 @@ onMounted(async () => {
</span>
</NcTooltip>
</div>
<div class="w-1/8 pl-6">
<NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER" :trigger="['click']">
<MdiDotsVertical
class="border-1 !text-gray-600 h-5.5 w-5.5 rounded outline-0 p-0.5 nc-workspace-menu transform transition-transform !text-gray-400 cursor-pointer hover:(!text-gray-500 bg-gray-100)"
/>
<div class="w-full justify-end flex-1 flex px-6 py-3">
<NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER">
<NcButton size="small" type="secondary">
<component :is="iconMap.threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem class="!text-red-500 !hover:bg-red-50" @click="removeCollaborator(collab.id)">
<template v-if="isAdminPanel">
<NcMenuItem data-testid="nc-admin-org-user-delete">
<GeneralIcon class="text-gray-800" icon="signout" />
<span>{{ $t('labels.signOutUser') }}</span>
</NcMenuItem>
<a-menu-divider class="my-1.5" />
</template>
<NcMenuItem
v-if="isUIAllowed('transferWorkspaceOwnership')"
data-testid="nc-admin-org-user-assign-admin"
@click="updateCollaborator(collab, WorkspaceUserRoles.OWNER)"
>
<GeneralIcon class="text-gray-800" icon="user" />
<span>{{ $t('labels.assignAs') }}</span>
<RolesBadge :border="false" :show-icon="false" role="owner" />
</NcMenuItem>
<NcMenuItem
class="!text-red-500 !hover:bg-red-50"
@click="removeCollaborator(collab.id, currentWorkspace.id)"
>
<MaterialSymbolsDeleteOutlineRounded />
Remove user
</NcMenuItem>
@ -154,15 +218,15 @@ onMounted(async () => {
</NcDropdown>
</div>
</div>
<div v-if="sortedCollaborators.length === 1" class="pt-12 pb-4 px-2 flex flex-col items-center gap-6 text-center">
<div class="text-2xl text-gray-800 font-bold">
{{ $t('placeholder.inviteYourTeam') }}
</div>
<div class="text-sm text-gray-700">
{{ $t('placeholder.inviteYourTeamLabel') }}
</div>
<img src="~assets/img/placeholder/invite-team.png" class="!w-[30rem] flex-none" />
</div>
<div v-if="sortedCollaborators.length === 1" class="pt-12 pb-4 px-2 flex flex-col items-center gap-6 text-center">
<div class="text-2xl text-gray-800 font-bold">
{{ $t('placeholder.inviteYourTeam') }}
</div>
<div class="text-sm text-gray-700">
{{ $t('placeholder.inviteYourTeamLabel') }}
</div>
<img alt="Invite Team" class="!w-[30rem] flex-none" src="~assets/img/placeholder/invite-team.png" />
</div>
</div>
</div>
@ -170,6 +234,18 @@ onMounted(async () => {
</template>
<style scoped lang="scss">
.ant-input::placeholder {
@apply text-gray-500;
}
.ant-input:placeholder-shown {
@apply text-gray-500 !text-md;
}
.ant-input-affix-wrapper {
@apply px-4 rounded-lg py-2 w-84 border-1 focus:border-brand-500 border-gray-200 !ring-0;
}
.badge-text {
@apply text-[14px] pt-1 text-center;
}

40
packages/nc-gui/components/workspace/Settings.vue

@ -1,13 +1,17 @@
<script lang="ts" setup>
import { ref, storeToRefs, useGlobal, useI18n, useWorkspace, watch } from '#imports'
const props = defineProps<{
workspaceId?: string
}>()
const { signOut } = useGlobal()
const { t } = useI18n()
const { deleteWorkspace, navigateToWorkspace, updateWorkspace } = useWorkspace()
const { workspacesList, activeWorkspaceId, activeWorkspace, workspaces } = storeToRefs(useWorkspace())
const { workspacesList, activeWorkspace, workspaces } = storeToRefs(useWorkspace())
const formValidator = ref()
@ -33,19 +37,29 @@ const formRules = {
],
}
const currentWorkspace = computed(() => {
return props.workspaceId ? workspaces.value.get(props.workspaceId) : activeWorkspace.value
})
const onDelete = async () => {
isDeleting.value = true
try {
await deleteWorkspace(activeWorkspaceId.value, { skipStateUpdate: true })
await deleteWorkspace(currentWorkspace.value.id, { skipStateUpdate: true })
isConfirmed.value = false
isDeleting.value = false
// We only remove the delete workspace from the list after the api call is successful
workspaces.value.delete(activeWorkspaceId.value)
workspaces.value.delete(currentWorkspace.value.id)
if (workspacesList.value.length > 1) {
await navigateToWorkspace(workspacesList.value[0].id)
// WorkspaceId is provided from the admin Panel. If deleted navigate to the workspace list page
if (!props.workspaceId) {
await navigateToWorkspace(workspacesList.value[0].id)
} else {
// #TODO: @DarkPhoenix2704
// Navigate BackPage
}
} else {
// As signin page will clear the workspaces, we need to check if there are more than one workspace
await signOut(false)
@ -69,7 +83,7 @@ const titleChange = async () => {
isErrored.value = false
try {
await updateWorkspace(activeWorkspaceId.value, {
await updateWorkspace(currentWorkspace.value.id, {
title: form.value.title,
})
} catch (e: any) {
@ -81,9 +95,9 @@ const titleChange = async () => {
}
watch(
() => activeWorkspace.value.title,
() => currentWorkspace.value.id,
() => {
form.value.title = activeWorkspace.value.title
form.value.title = currentWorkspace.value.title
},
{
immediate: true,
@ -94,11 +108,7 @@ watch(
() => form.value.title,
async () => {
try {
if (form.value.title !== activeWorkspace.value?.title) {
isCancelButtonVisible.value = true
} else {
isCancelButtonVisible.value = false
}
isCancelButtonVisible.value = form.value.title !== currentWorkspace.value?.title
isErrored.value = !(await formValidator.value.validate())
} catch (e: any) {
isErrored.value = true
@ -107,7 +117,7 @@ watch(
)
const onCancel = () => {
form.value.title = activeWorkspace.value?.title
form.value.title = currentWorkspace.value?.title
}
</script>
@ -140,7 +150,7 @@ const onCancel = () => {
v-e="['c:workspace:settings:rename']"
type="primary"
html-type="submit"
:disabled="isErrored || (form.title && form.title === activeWorkspace.title)"
:disabled="isErrored || (form.title && form.title === currentWorkspace.title)"
:loading="isDeleting"
data-testid="nc-workspace-settings-settings-rename-submit"
>
@ -175,7 +185,7 @@ const onCancel = () => {
</template>
<style lang="scss" scoped>
.item {
.item-card {
@apply p-6 rounded-2xl border-1 max-w-180 mt-10 min-w-100 w-full;
}
</style>

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

@ -1,5 +1,10 @@
<script lang="ts" setup>
import { useTitle } from '@vueuse/core'
import { storeToRefs } from '#imports'
const props = defineProps<{
workspaceId?: string
}>()
const router = useRouter()
const route = router.currentRoute
@ -7,21 +12,38 @@ const route = router.currentRoute
const { isUIAllowed } = useRoles()
const workspaceStore = useWorkspace()
const { activeWorkspace, workspaces } = storeToRefs(workspaceStore)
const { loadCollaborators } = workspaceStore
const { activeWorkspace: _activeWorkspace, workspaces } = storeToRefs(workspaceStore)
const { loadCollaborators, loadWorkspace } = workspaceStore
const orgStore = useOrg()
const { orgId } = storeToRefs(orgStore)
const currentWorkspace = computedAsync(async () => {
let ws
if (props.workspaceId) {
ws = workspaces.value.get(props.workspaceId)
if (!ws) {
await loadWorkspace(props.workspaceId)
ws = workspaces.value.get(props.workspaceId)
}
} else {
ws = _activeWorkspace.value
}
return ws
})
const tab = computed({
get() {
return route.value.query?.tab ?? 'collaborators'
},
set(tab: string) {
if (tab === 'collaborators') loadCollaborators()
if (tab === 'collaborators') loadCollaborators({} as any, props.workspaceId)
router.push({ query: { ...route.value.query, tab } })
},
})
watch(
() => activeWorkspace.value?.title,
() => currentWorkspace.value?.title,
(title: string) => {
if (!title) return
@ -35,26 +57,40 @@ watch(
)
onMounted(() => {
until(() => activeWorkspace.value?.id)
until(() => currentWorkspace.value?.id)
.toMatch((v) => !!v)
.then(() => {
until(() => workspaces.value)
.toMatch((v) => v.has(activeWorkspace.value.id))
.then(() => {
loadCollaborators()
})
.then(async () => {
await loadCollaborators({} as any, currentWorkspace.value.id)
})
})
</script>
<template>
<div v-if="activeWorkspace" class="flex flex-col nc-workspace-settings">
<div class="flex gap-2 items-center min-w-0 p-6">
<GeneralWorkspaceIcon :workspace="activeWorkspace" />
<h1 class="text-3xl font-weight-bold tracking-[0.5px] mb-0 nc-workspace-title truncate min-w-10 capitalize">
{{ activeWorkspace?.title }}
<div v-if="currentWorkspace" class="flex w-full px-6 max-w-[97.5rem] flex-col nc-workspace-settings">
<div v-if="!props.workspaceId" class="flex gap-2 items-center min-w-0 py-6">
<GeneralWorkspaceIcon :workspace="currentWorkspace" />
<h1 class="text-3xl capitalize font-weight-bold tracking-[0.5px] mb-0 nc-workspace-title truncate min-w-10 capitalize">
{{ currentWorkspace?.title }}
</h1>
</div>
<div v-else>
<div class="font-bold w-full !mb-5 text-2xl" data-rec="true">
<div class="flex items-center gap-3">
<NuxtLink
:href="`/admin/${orgId}/workspaces`"
class="!hover:(text-black underline-gray-600) !text-black !underline-transparent ml-0.75 max-w-1/4"
>
{{ $t('labels.workspaces') }}
</NuxtLink>
<span class="text-2xl"> / </span>
<GeneralWorkspaceIcon :workspace="currentWorkspace" hide-label />
<span class="text-base capitalize">
{{ currentWorkspace?.title }}
</span>
</div>
</div>
</div>
<NcTabs v-model:activeKey="tab">
<template v-if="isUIAllowed('workspaceSettings')">
@ -65,7 +101,7 @@ onMounted(() => {
Members
</div>
</template>
<WorkspaceCollaboratorsList />
<WorkspaceCollaboratorsList :workspace-id="currentWorkspace.id" />
</a-tab-pane>
</template>
@ -77,7 +113,7 @@ onMounted(() => {
Settings
</div>
</template>
<WorkspaceSettings />
<WorkspaceSettings :workspace-id="currentWorkspace.id" />
</a-tab-pane>
</template>
</NcTabs>
@ -90,7 +126,24 @@ onMounted(() => {
font-size: 0.7rem;
}
.tab {
@apply flex flex-row items-center gap-x-2;
}
:deep(.ant-tabs-nav) {
@apply !pl-0;
}
:deep(.ant-tabs-nav-list) {
@apply !ml-3;
@apply !gap-5;
}
:deep(.ant-tabs-tab) {
@apply !pt-0 !pb-2.5 !ml-0;
}
.ant-tabs-content {
@apply !h-full;
}
.ant-tabs-content-top {
@apply !h-full;
}
</style>

23
packages/nc-gui/composables/useOrganization.ts

@ -0,0 +1,23 @@
export const useOrganization = () => {
const workspaces = ref([])
const members = ref([])
const bases = ref([])
const { orgId } = storeToRefs(useOrg())
const listWorkspaces = async (..._args: any) => {}
const fetchOrganizationMembers = async (..._args: any) => {}
const fetchOrganizationBases = async (..._args: any) => {}
return {
orgId,
workspaces,
listWorkspaces,
fetchOrganizationMembers,
fetchOrganizationBases,
bases,
members,
}
}

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

@ -9,7 +9,7 @@ import { useGlobal } from '#imports'
* @param {string} roleType - The type of role for which user sorts are managed ('Workspace', 'Org', or 'Project').
* @returns {object} An object containing reactive values and functions related to user sorts.
*/
export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project') {
export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project' | 'Organization') {
const clone = rfdc()
const { user } = useGlobal()
@ -110,6 +110,8 @@ export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project') {
userRoleOrder = Object.values(OrderedOrgRoles)
} else if (roleType === 'Project') {
userRoleOrder = Object.values(OrderedProjectRoles)
} else if (roleType === 'Organization') {
userRoleOrder = Object.values(OrderedOrgRoles)
}
data = clone(data)
@ -136,6 +138,13 @@ export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project') {
return b[sortsConfig.field]?.localeCompare(a[sortsConfig.field])
}
}
case 'title': {
if (sortsConfig.direction === 'asc') {
return a[sortsConfig.field] - b[sortsConfig.field]
} else {
return b[sortsConfig.field] - a[sortsConfig.field]
}
}
}
return 0

2
packages/nc-gui/context/index.ts

@ -57,3 +57,5 @@ export const TreeViewInj: InjectionKey<{
export const CalendarViewTypeInj: InjectionKey<Ref<'week' | 'month' | 'day' | 'year'>> = Symbol('calendar-view-type-injection')
export const JsonExpandInj: InjectionKey<Ref<boolean>> = Symbol('json-expand-injection')
export const AllFiltersInj: InjectionKey<Ref<Record<string, FilterType[]>>> = Symbol('all-filters-injection')
export const IsAdminPanelInj: InjectionKey<Ref<boolean>> = Symbol('is-admin-panel-injection')

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

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "Quit",
"home": "Home",
"load": "Load",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown",
"list": "List",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Day",
"week": "Week",
"month": "Month",
@ -247,6 +252,7 @@
"viewer": "Viewer",
"noaccess": "No Access",
"superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer"
},
@ -313,6 +319,10 @@
"isNotNull": "is not null"
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)",
"docs": "Docs",
"forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results"
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace":"-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "SAML",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by",
"previous": "Previous",
"nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Paste operation is not supported on the active cell",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "Your search for {search} found no results",

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

@ -36,6 +36,7 @@ const rolePermissions = {
newUser: true,
viewCreateOrEdit: true,
baseReorder: true,
orgAdminPanel: true,
},
},
[OrgUserRoles.VIEWER]: {

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

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

18
packages/nc-gui/middleware/02.auth.global.ts

@ -37,6 +37,8 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
const { allRoles, loadRoles } = useRoles()
await checkForRedirect()
/** If baseHostname defined block home page access under subdomains, and redirect to workspace page */
if (
state.appInfo.value.baseHostName &&
@ -196,3 +198,19 @@ async function tryShortTokenAuth(api: Api<any>, signIn: Actions['signIn']) {
window.location.reload()
}
}
/** Check if url contains redirect param and redirect to the url if found */
async function checkForRedirect() {
if (window.location.search && /\bui-redirect=/.test(window.location.search)) {
let url
try {
url = decodeURIComponent(window.location.search.split('=')[1])
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
const newURL = window.location.href.split('?')[0]
window.history.pushState('object', document.title, `${newURL}#${url}`)
window.location.reload()
}
}

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

@ -2,6 +2,7 @@ import type { BaseType } from 'nocodb-sdk'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { message } from 'ant-design-vue'
import { isString } from '@vue/shared'
import type { ThemeConfig } from '#imports'
import {
computed,
navigateTo,
@ -13,7 +14,6 @@ import {
useRouter,
useTheme,
} from '#imports'
import type { ThemeConfig } from '#imports'
export const useWorkspace = defineStore('workspaceStore', () => {
const basesStore = useBases()
@ -92,6 +92,8 @@ export const useWorkspace = defineStore('workspaceStore', () => {
const loadWorkspace = async (..._args: any) => {}
const moveToOrg = async (..._args: any) => {}
async function populateWorkspace(..._args: any) {
isWorkspaceLoading.value = true
@ -265,6 +267,7 @@ export const useWorkspace = defineStore('workspaceStore', () => {
workspaceUserCount,
getPlanLimit,
workspaceRole,
moveToOrg,
}
})

28
packages/nc-gui/utils/datetimeUtils.ts

@ -0,0 +1,28 @@
import dayjs from 'dayjs'
import { dateFormats, timeFormats } from 'nocodb-sdk'
export function parseStringDateTime(
v: string,
dateTimeFormat: string = `${dateFormats[0]} ${timeFormats[0]}`,
toLocal: boolean = true,
) {
const dayjsObj = toLocal ? dayjs(v).local() : dayjs(v)
if (dayjsObj.isValid()) {
v = dayjsObj.format(dateTimeFormat)
} else {
v = toLocal ? dayjs(v, dateTimeFormat).local().format(dateTimeFormat) : dayjs(v, dateTimeFormat).format(dateTimeFormat)
}
return v
}
export const timeAgo = (date: any) => {
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(date)) {
// if there is no timezone info, consider as UTC
// e.g. 2023-01-01 08:00:00 (MySQL)
date += '+00:00'
}
// show in local time
return dayjs(date).fromNow()
}

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

@ -125,6 +125,9 @@ import NcArrowLeft from '~icons/nc-icons/arrow-left'
import NcArrowRight from '~icons/nc-icons/arrow-right'
import NcUpload from '~icons/nc-icons/upload'
import NcDownload from '~icons/nc-icons/download'
import NcOffice from '~icons/nc-icons/office'
import NcArrowUpRight from '~icons/nc-icons/arrow-up-right'
import NcSlash from '~icons/nc-icons/slash'
// import NcProjectGray from '~icons/nc-icons/project-gray'
import NcPhoneCall from '~icons/nc-icons/phone-call'
import NcItalic from '~icons/nc-icons/italic'
@ -132,6 +135,9 @@ import NcBold from '~icons/nc-icons/bold'
import NcUnderline from '~icons/nc-icons/underline'
import NcCrop from '~icons/nc-icons/crop'
import NcLink from '~icons/nc-icons/link'
import NcControlPanel from '~icons/nc-icons/control-panel'
import NcHome from '~icons/nc-icons/home'
import NcWorkspace from '~icons/nc-icons/workspace'
import NcCellBarcode from '~icons/nc-icons/cell-barcode'
import NcCellCheckbox from '~icons/nc-icons/cell-checkbox'
@ -325,6 +331,11 @@ import NcHelp from '~icons/nc-icons/help'
} as const */
export const iconMap = {
slash: NcSlash,
arrowUpRight: NcArrowUpRight,
ncWorkspace: NcWorkspace,
controlPanel: NcControlPanel,
home: NcHome,
cellBarcode: NcCellBarcode,
cellCheckbox: NcCellCheckbox,
cellDate: NcCellDate,
@ -358,6 +369,7 @@ export const iconMap = {
cellSystemText: NcCellSystemText,
cellAttachment: NcCellAttachment,
office: NcOffice,
sort: Sort,
group: Group,
filter: Filter,

1
packages/nc-gui/utils/index.ts

@ -24,5 +24,6 @@ export * from './parseUtils'
export * from './cell'
export * from './workerUtils'
export * from './parsersUtils'
export * from './datetimeUtils'
export const isEeUI = false

193
packages/nocodb-sdk/src/lib/Api.ts

@ -296,7 +296,8 @@ export interface SourceType {
| 'oracledb'
| 'pg'
| 'snowflake'
| 'sqlite3';
| 'sqlite3'
| 'databricks';
}
/**
@ -342,7 +343,8 @@ export interface BaseReqType {
| 'oracledb'
| 'pg'
| 'snowflake'
| 'sqlite3';
| 'sqlite3'
| 'databricks';
}
/**
@ -2914,6 +2916,87 @@ export type NestedListCopyPasteOrDeleteAllReqType = {
fk_related_model_id: string;
}[];
/**
* Model for Kanban Column Request
*/
export interface KanbanColumnReqType {
/** Title */
title?: string;
/** Is this column shown? */
show?: BoolType;
/**
* Column Order
* @example 1
*/
order?: number;
}
/**
* Model for Gallery Column Request
*/
export interface GalleryColumnReqType {
/** Show */
show?: BoolType;
/**
* Order
* @example 1
*/
order?: number;
}
/**
* Model for Calendar Column Request
*/
export interface CalendarColumnReqType {
/** Is this column shown? */
show?: BoolType;
/** Is this column shown as bold? */
bold?: BoolType;
/** Is this column shown as italic? */
italic?: BoolType;
/** Is this column shown underlines? */
underline?: BoolType;
/**
* Column Order
* @example 1
*/
order?: number;
}
export interface ExtensionType {
/** Unique ID */
id?: IdType;
/** Unique Base ID */
base_id?: IdType;
/** Unique User ID */
fk_user_id?: IdType;
/** Extension ID */
extension_id?: string;
/** Extension Title */
title?: string;
/** Key Value Store for the extension */
kv_store?: MetaType;
/** Meta data for the extension */
meta?: MetaType;
/** Order of the extension */
order?: number;
}
export interface ExtensionReqType {
/** Unique Base ID */
base_id?: IdType;
/** Extension Title */
title?: string;
/** Extension ID */
extension_id?: string;
/** Key Value Store for the extension */
kv_store?: MetaType;
/** Meta data for the extension */
meta?: MetaType;
/** Order of the extension */
order?: number;
}
import type {
AxiosInstance,
AxiosRequestConfig,
@ -9819,7 +9902,8 @@ export class Api<
| 'oracledb'
| 'pg'
| 'snowflake'
| 'sqlite3';
| 'sqlite3'
| 'databricks';
connection?: {
host?: string;
port?: string;
@ -9880,7 +9964,7 @@ export class Api<
* DB Type
* @example mysql2
*\
client?: "mssql" | "mysql" | "mysql2" | "oracledb" | "pg" | "snowflake" | "sqlite3",
client?: "mssql" | "mysql" | "mysql2" | "oracledb" | "pg" | "snowflake" | "sqlite3" | "databricks",
\** Connection Config *\
connection?: {
\** DB User *\
@ -9926,7 +10010,8 @@ export class Api<
| 'oracledb'
| 'pg'
| 'snowflake'
| 'sqlite3';
| 'sqlite3'
| 'databricks';
/** Connection Config */
connection?: {
/** DB User */
@ -11454,6 +11539,104 @@ export class Api<
...params,
}),
};
extensions = {
/**
* @description Get all extensions for a given base
*
* @tags Extensions
* @name List
* @summary Get Extensions
* @request GET:/api/v2/extensions/{baseId}
* @response `200` `{
list?: (object)[],
}` OK
*/
list: (baseId: IdType, params: RequestParams = {}) =>
this.request<
{
list?: object[];
},
any
>({
path: `/api/v2/extensions/${baseId}`,
method: 'GET',
format: 'json',
...params,
}),
/**
* @description Create a new extension for a given base
*
* @tags Extensions
* @name Create
* @summary Create Extension
* @request POST:/api/v2/extensions/{baseId}
* @response `200` `any` OK
*/
create: (baseId: IdType, data: object, params: RequestParams = {}) =>
this.request<any, any>({
path: `/api/v2/extensions/${baseId}`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* @description Get extension details
*
* @tags Extensions
* @name Read
* @summary Get Extension
* @request GET:/api/v2/extensions/{extensionId}
* @response `200` `object` OK
*/
read: (extensionId: IdType, params: RequestParams = {}) =>
this.request<object, any>({
path: `/api/v2/extensions/${extensionId}`,
method: 'GET',
format: 'json',
...params,
}),
/**
* @description Update extension details
*
* @tags Extensions
* @name Update
* @summary Update Extension
* @request PATCH:/api/v2/extensions/{extensionId}
* @response `200` `any` OK
*/
update: (extensionId: IdType, data: object, params: RequestParams = {}) =>
this.request<any, any>({
path: `/api/v2/extensions/${extensionId}`,
method: 'PATCH',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* @description Delete extension
*
* @tags Extensions
* @name Delete
* @summary Delete Extension
* @request DELETE:/api/v2/extensions/{extensionId}
* @response `200` `any` OK
*/
delete: (extensionId: IdType, params: RequestParams = {}) =>
this.request<any, any>({
path: `/api/v2/extensions/${extensionId}`,
method: 'DELETE',
format: 'json',
...params,
}),
};
jobs = {
/**
* @description Listen for job events

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

@ -4,6 +4,12 @@ export enum OrgUserRoles {
VIEWER = 'org-level-viewer',
}
export enum CloudOrgUserRoles {
CREATOR = 'cloud-org-level-creator',
VIEWER = 'cloud-org-level-viewer',
OWNER = 'cloud-org-level-owner',
}
export enum ProjectRoles {
OWNER = 'owner',
CREATOR = 'creator',
@ -182,6 +188,9 @@ export const RoleLabels = {
[OrgUserRoles.SUPER_ADMIN]: 'superAdmin',
[OrgUserRoles.CREATOR]: 'creator',
[OrgUserRoles.VIEWER]: 'viewer',
[CloudOrgUserRoles.OWNER]: 'owner',
[CloudOrgUserRoles.CREATOR]: 'creator',
[CloudOrgUserRoles.VIEWER]: 'viewer',
};
export const RoleColors = {
@ -200,6 +209,9 @@ export const RoleColors = {
[ProjectRoles.NO_ACCESS]: 'red',
[OrgUserRoles.CREATOR]: 'blue',
[OrgUserRoles.VIEWER]: 'yellow',
[CloudOrgUserRoles.OWNER]: 'purple',
[CloudOrgUserRoles.CREATOR]: 'blue',
[CloudOrgUserRoles.VIEWER]: 'yellow',
};
export const RoleDescriptions = {
@ -240,6 +252,10 @@ export const RoleIcons = {
[OrgUserRoles.SUPER_ADMIN]: 'role_super',
[OrgUserRoles.CREATOR]: 'role_creator',
[OrgUserRoles.VIEWER]: 'role_viewer',
[CloudOrgUserRoles.OWNER]: 'role_owner',
[CloudOrgUserRoles.CREATOR]: 'role_creator',
[CloudOrgUserRoles.VIEWER]: 'role_viewer',
};
export const WorkspaceRolesToProjectRoles = {

21
packages/nocodb/src/filters/global-exception/global-exception.filter.ts

@ -12,6 +12,7 @@ import {
Forbidden,
NcBaseErrorv2,
NotFound,
SsoError,
Unauthorized,
UnprocessableEntity,
} from '~/helpers/catchError';
@ -48,6 +49,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
exception instanceof Forbidden ||
exception instanceof NotFound ||
exception instanceof UnprocessableEntity ||
exception instanceof SsoError ||
exception instanceof NotFoundException ||
exception instanceof ThrottlerException ||
exception instanceof ExternalError ||
@ -69,6 +71,25 @@ export class GlobalExceptionFilter implements ExceptionFilter {
);
}
// if sso error then redirect to ui with error in query parameter
if (
exception instanceof SsoError ||
request.route?.path === '/sso/:clientId/redirect'
) {
if (!(exception instanceof SsoError)) {
this.logger.warn(exception.message, exception.stack);
}
// encode the query parameter
const redirectUrl = `${
request.dashboardUrl
}?ui-redirect=${encodeURIComponent(
`/sso?error=${encodeURIComponent(exception.message)}`,
)}`;
return response.redirect(redirectUrl);
}
// API not found
if (exception instanceof NotFoundException) {
this.logger.debug(exception.message, exception.stack);

9
packages/nocodb/src/helpers/catchError.ts

@ -413,6 +413,8 @@ export class Forbidden extends NcBaseError {}
export class NotFound extends NcBaseError {}
export class SsoError extends NcBaseError {}
export class ExternalError extends NcBaseError {
constructor(error: Error) {
super(error.message);
@ -578,6 +580,7 @@ export class NcBaseErrorv2 extends NcBaseError {
error: NcErrorType;
code: number;
details?: any;
constructor(error: NcErrorType, args?: NcErrorArgs) {
const errorHelper = generateError(error, args);
super(errorHelper.message);
@ -726,4 +729,10 @@ export class NcError {
static notAllowed(message = 'Not allowed') {
throw new NotAllowed(message);
}
static emailDomainNotAllowed(domain: string) {
throw new SsoError(
`Email domain ${domain} is not allowed for this organization`,
);
}
}

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

@ -230,6 +230,7 @@ export class AclMiddleware implements NestInterceptor {
'blockApiTokenAccess',
context.getHandler(),
);
const scope = this.reflector.get<string>('scope', context.getHandler());
const req = context.switchToHttp().getRequest();
@ -237,7 +238,6 @@ export class AclMiddleware implements NestInterceptor {
if (!req.user?.isAuthorized) {
NcError.unauthorized('Invalid token');
}
const userScopeRole =
req.user.roles?.[OrgUserRoles.SUPER_ADMIN] === true
? OrgUserRoles.SUPER_ADMIN

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

@ -1,4 +1,3 @@
import process from 'process';
import dayjs from 'dayjs';
import Noco from '~/Noco';
import { extractProps } from '~/helpers/extractProps';
@ -69,6 +68,7 @@ export default class UserRefreshToken {
return insertObj;
}
// todo: caching
static async updateOldToken(
oldToken: string,
newToken: string,

6
packages/nocodb/src/services/users/helpers.ts

@ -4,9 +4,13 @@ import type User from '~/models/User';
import type { NcConfig } from '~/interface/config';
import type { Response } from 'express';
export function genJwt(user: User, config: NcConfig) {
export function genJwt(
user: User & { extra?: Record<string, any> },
config: NcConfig,
) {
return jwt.sign(
{
...(user.extra || {}),
email: user.email,
id: user.id,
roles: user.roles,

3
packages/nocodb/src/strategies/authtoken.strategy/authtoken.strategy.ts

@ -48,6 +48,9 @@ export class AuthTokenStrategy extends PassportStrategy(Strategy, 'authtoken') {
...(dbUser.workspace_roles
? { workspace_roles: extractRolesObj(dbUser.workspace_roles) }
: {}),
...(dbUser.org_roles
? { org_roles: extractRolesObj(dbUser.org_roles) }
: {}),
});
}
return callback(null, sanitiseUserObj(user));

3
packages/nocodb/tests/unit/rest/index.test.ts

@ -14,9 +14,11 @@ import formulaTests from './tests/formula.test';
let workspaceTest = () => {};
let ssoTest = () => {};
let cloudOrgTest = () => {};
if (process.env.EE === 'true') {
workspaceTest = require('./tests/ee/workspace.test').default;
ssoTest = require('./tests/ee/sso.test').default;
cloudOrgTest = require('./tests/ee/cloud-org.test').default;
}
// import layoutTests from './tests/layout.test';
// import widgetTest from './tests/widget.test';
@ -36,6 +38,7 @@ function restTests() {
workspaceTest();
formulaTests();
ssoTest();
cloudOrgTest();
// Enable for dashboard feature
// widgetTest();

12
tests/playwright/pages/Account/Authentication.ts

@ -15,7 +15,7 @@ export class AccountAuthenticationPage extends BasePage {
await this.waitForResponse({
uiAction: () => this.rootPage.goto('/#/account/authentication', { waitUntil: 'networkidle' }),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/sso-client',
requestUrlPathToMatch: /\/sso-client/,
});
}
@ -42,7 +42,7 @@ export class AccountAuthenticationPage extends BasePage {
await this.waitForResponse({
uiAction: () => this.rootPage.locator(`[data-test-id="nc-${provider}-delete"]`).click(),
httpMethodsToMatch: ['DELETE'],
requestUrlPathToMatch: `/api/v2/sso-client/`,
requestUrlPathToMatch: /\/sso-client/,
});
}
@ -50,7 +50,7 @@ export class AccountAuthenticationPage extends BasePage {
await this.waitForResponse({
uiAction: () => this.get().locator(`.nc-${provider}-${title}-enable .nc-switch`).click(),
httpMethodsToMatch: ['PATCH'],
requestUrlPathToMatch: `/api/v2/sso-client/`,
requestUrlPathToMatch: /\/\w+\/sso-client/,
});
}
@ -97,7 +97,7 @@ export class AccountAuthenticationPage extends BasePage {
await this.waitForResponse({
uiAction: () => samlModal.locator('[data-test-id="nc-saml-submit"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/sso-client',
requestUrlPathToMatch: /\/sso-client/,
});
}
@ -158,7 +158,7 @@ export class AccountAuthenticationPage extends BasePage {
await this.waitForResponse({
uiAction: () => oidcModal.locator('[data-test-id="nc-oidc-save-btn"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/sso-client',
requestUrlPathToMatch: /\/sso-client/,
});
}
@ -177,7 +177,7 @@ export class AccountAuthenticationPage extends BasePage {
await this.waitForResponse({
uiAction: () => googleModal.locator('[data-test-id="nc-google-save-btn"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/sso-client',
requestUrlPathToMatch: /\/sso-client/,
});
}

6
tests/playwright/pages/Base.ts

@ -31,7 +31,7 @@ export default abstract class BasePage {
responseStatusCodeToMatch = 200,
}: {
uiAction: () => Promise<any>;
requestUrlPathToMatch: string;
requestUrlPathToMatch: string | RegExp;
httpMethodsToMatch?: string[];
responseJsonMatcher?: ResponseSelector;
timeout?: number;
@ -40,7 +40,9 @@ export default abstract class BasePage {
const [res] = await Promise.all([
this.rootPage.waitForResponse(
res =>
res.url().includes(requestUrlPathToMatch) &&
(requestUrlPathToMatch instanceof RegExp
? requestUrlPathToMatch.test(res.url())
: res.url().includes(requestUrlPathToMatch)) &&
res.status() === responseStatusCodeToMatch &&
httpMethodsToMatch.includes(res.request().method()),
timeout ? { timeout } : undefined

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

@ -19,7 +19,7 @@ export class AccessSettingsPage extends BasePage {
for (let i = 0; i < userCount; i++) {
const user = this.get().locator('.user-row').nth(i);
const userEmail = (await user.locator('.users-email-grid').innerText()).split('\n')[1];
const userEmail = (await user.locator('.users-email-grid').innerText()).split('\n').pop();
if (userEmail === email) {
const roleDropdown = user.locator('.nc-roles-selector');

20
tests/playwright/pages/Dashboard/TreeView.ts

@ -111,26 +111,24 @@ export class TreeViewPage extends BasePage {
}
await this.get().getByTestId(`nc-tbl-title-${title}`).waitFor({ state: 'visible' });
if (networkResponse === true) {
await this.waitForResponse({
uiAction: () =>
this.get()
.getByTestId(`nc-tbl-title-${title}`)
.click({
position: {
x: 10,
y: 10,
},
}),
uiAction: () => this.get().getByTestId(`nc-tbl-title-${title}`).closest('.nc-base-tree-tbl').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: `/api/v1/db/data/noco`,
responseJsonMatcher: json => json.pageInfo,
});
await this.dashboard.waitForTabRender({ title, mode });
} else {
await this.get().getByTestId(`nc-tbl-title-${title}`).click();
await this.get().locator(`[data-testid="nc-tbl-title-${title}"]`).click({
// x:10, y:10
});
// todo: remove this after fixing the issue
await this.rootPage.waitForTimeout(1000);
await this.get().locator(`[data-testid="nc-tbl-title-${title}"]`).click({
// x:10, y:10
});
}
}

15
tests/playwright/pages/OrgAdmin/Bases.ts

@ -0,0 +1,15 @@
import BasePage from '../Base';
import { OrgAdminPage } from './index';
export class Bases extends BasePage {
readonly orgAdminPage: OrgAdminPage;
constructor(orgAdminPage: OrgAdminPage) {
super(orgAdminPage.rootPage);
this.orgAdminPage = orgAdminPage;
}
get() {
return this.rootPage.locator('[data-test-id="nc-admin-bases"]');
}
}

15
tests/playwright/pages/OrgAdmin/Dashboard.ts

@ -0,0 +1,15 @@
import BasePage from '../Base';
import { OrgAdminPage } from './index';
export class Dashboard extends BasePage {
readonly orgAdminPage: OrgAdminPage;
constructor(orgAdminPage: OrgAdminPage) {
super(orgAdminPage.rootPage);
this.orgAdminPage = orgAdminPage;
}
get() {
return this.rootPage.locator('[data-test-id="nc-admin-dashboard"]');
}
}

51
tests/playwright/pages/OrgAdmin/Domain.ts

@ -0,0 +1,51 @@
import BasePage from '../Base';
import { OrgAdminPage } from './index';
export class Domain extends BasePage {
readonly orgAdminPage: OrgAdminPage;
constructor(orgAdminPage: OrgAdminPage) {
super(orgAdminPage.rootPage);
this.orgAdminPage = orgAdminPage;
}
get() {
return this.rootPage.locator('[data-test-id="nc-org-domain"]');
}
async addDomain(domainName: string) {
await this.get().locator('[data-test-id="nc-org-domain-add"]').click();
await this.rootPage.locator('[data-test-id="nc-org-domain-name"]').fill(domainName);
await this.waitForResponse({
uiAction: () => this.rootPage.locator('[data-test-id="nc-org-domain-submit"]').click(),
httpMethodsToMatch: ['PATCH', 'POST'],
requestUrlPathToMatch: /api\/v2\/domains\/\w+/,
});
}
async openOptionMenu(domainName: string) {
await this.rootPage
.locator(`[data-test-id="nc-domain-${domainName}"]`)
.locator('[data-test-id="nc-domain-more-option"]')
.click();
}
async verifyDomain(domainName: string) {
await this.openOptionMenu(domainName);
await this.waitForResponse({
uiAction: () => this.rootPage.locator('[data-test-id="nc-domain-verify"]').click(),
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: `/api/v2/org-domain/${domainName}`,
});
}
async deleteDomain(domainName: string) {
await this.openOptionMenu(domainName);
await this.waitForResponse({
uiAction: () => this.rootPage.locator('[data-test-id="nc-domain-delete"]').click(),
httpMethodsToMatch: ['DELETE'],
requestUrlPathToMatch: `/api/v2/org-domain/${domainName}`,
});
}
}

15
tests/playwright/pages/OrgAdmin/Members.ts

@ -0,0 +1,15 @@
import BasePage from '../Base';
import { OrgAdminPage } from './index';
export class Members extends BasePage {
readonly orgAdminPage: OrgAdminPage;
constructor(orgAdminPage: OrgAdminPage) {
super(orgAdminPage.rootPage);
this.orgAdminPage = orgAdminPage;
}
get() {
return this.rootPage.locator('[data-test-id="nc-admin-members"]');
}
}

46
tests/playwright/pages/OrgAdmin/OpenIDLoginPage.ts

@ -0,0 +1,46 @@
import { Page } from '@playwright/test';
import BasePage from '../Base';
import { ProjectsPage } from '../ProjectsPage';
import { CloudSSOLoginPage } from './SSOLoginPage';
export class CloudOpenIDLoginPage extends BasePage {
readonly projectsPage: ProjectsPage;
readonly ssoLoginPage: CloudSSOLoginPage;
constructor(rootPage: Page) {
super(rootPage);
this.projectsPage = new ProjectsPage(rootPage);
this.ssoLoginPage = new CloudSSOLoginPage(rootPage);
}
async goto(_title = 'test', email: string) {
await this.ssoLoginPage.goto(email);
await this.ssoLoginPage.signIn({ email });
// // reload page to get latest app info
// await this.rootPage.reload({ waitUntil: 'networkidle' });
// // click sign in with SAML
// await this.rootPage.locator(`button:has-text("Sign in with ${title}")`).click();
}
get() {
return this.rootPage.locator('html');
}
async signIn({ email }: { email: string }) {
const signIn = this.get();
await signIn.locator('[name="login"]').waitFor();
await signIn.locator(`[name="login"]`).fill(email);
await signIn.locator(`[name="password"]`).fill('dummy-password');
await signIn.locator(`[type="submit"]`).click();
const authorize = this.get();
await Promise.all([
this.rootPage.waitForNavigation({ url: /localhost:3000/ }),
authorize.locator(`[type="submit"]`).click(),
]);
await this.rootPage.locator(`[data-testid="nc-sidebar-userinfo"]:has-text("${email.split('@')[0]}")`);
}
}

42
tests/playwright/pages/OrgAdmin/SAMLLoginPage.ts

@ -0,0 +1,42 @@
import { Page } from '@playwright/test';
import BasePage from '../Base';
import { ProjectsPage } from '../ProjectsPage';
import { CloudSSOLoginPage } from './SSOLoginPage';
export class CloudSAMLLoginPage extends BasePage {
readonly projectsPage: ProjectsPage;
readonly ssoLoginPage: CloudSSOLoginPage;
constructor(rootPage: Page) {
super(rootPage);
this.projectsPage = new ProjectsPage(rootPage);
this.ssoLoginPage = new CloudSSOLoginPage(rootPage);
}
async goto(_title = 'test', email: string) {
await this.ssoLoginPage.goto(email);
await this.ssoLoginPage.signIn({ email });
// // reload page to get latest app info
// await this.rootPage.reload({ waitUntil: 'networkidle' });
// // click sign in with SAML
// await this.rootPage.locator(`button:has-text("Sign in with ${title}")`).click();
}
get() {
return this.rootPage.locator('html');
}
async signIn({ email }: { email: string }) {
const signIn = this.get();
await signIn.locator('#userName').waitFor();
await signIn.locator(`#userName`).fill(email);
await signIn.locator(`#email`).fill(email);
await Promise.all([
this.rootPage.waitForNavigation({ url: /localhost:3000/ }),
signIn.locator(`#btn-sign-in`).click(),
]);
await this.rootPage.locator(`[data-testid="nc-sidebar-userinfo"]:has-text("${email.split('@')[0]}")`);
}
}

200
tests/playwright/pages/OrgAdmin/SSO.ts

@ -0,0 +1,200 @@
import BasePage from '../Base';
import { ProjectsPage } from '../ProjectsPage';
import { expect } from '@playwright/test';
import { Domain } from './Domain';
import { OrgAdminPage } from './index';
export class CloudSSO extends BasePage {
readonly projectsPage: ProjectsPage;
readonly domain: Domain;
readonly orgAdminPage: OrgAdminPage;
constructor(orgAdminPage: OrgAdminPage) {
super(orgAdminPage.rootPage);
this.domain = new Domain(orgAdminPage);
this.orgAdminPage = orgAdminPage;
}
async goto() {
// wait for 2 seconds to make sure the page is loaded
// await this.rootPage.waitForTimeout(2000);
// console.log(await this.rootPage.locator('[data-test-id="nc-org-sso-settings"]').count());
await this.waitForResponse({
uiAction: () => this.rootPage.locator('[data-test-id="nc-org-sso-settings"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/orgs/',
});
}
get() {
return this.rootPage.locator('html');
}
async verifySAMLProviderCount({ count }: { count: number }) {
await expect.poll(async () => await this.get().locator('.nc-saml-provider').count()).toBe(count);
}
async verifyOIDCProviderCount({ count }: { count: number }) {
await expect.poll(async () => await this.get().locator('.nc-oidc-provider').count()).toBe(count);
}
async deleteExistingClientIfAny(provider: 'saml' | 'oidc' | 'google', title: string) {
if (
!(await this.rootPage
.locator(provider === 'google' ? '.nc-google-more-option' : `.nc-${provider}-${title}-more-option`)
.count())
)
return;
await this.deleteProvider(provider, title);
}
async getProvider(provider: 'saml' | 'oidc', title: string) {
return this.rootPage.locator(`[data-test-id="nc-${provider}-provider-${title}"]`);
}
async deleteProvider(provider: 'saml' | 'oidc' | 'google', title: string) {
await this.rootPage
.locator(provider === 'google' ? '.nc-google-more-option' : `.nc-${provider}-${title}-more-option`)
.click();
await this.waitForResponse({
uiAction: () => this.rootPage.locator(`[data-test-id="nc-${provider}-delete"]`).click(),
httpMethodsToMatch: ['DELETE'],
requestUrlPathToMatch: /\/api\/v2\/orgs\/\w+\/sso-client/,
});
}
async toggleProvider(provider: 'saml' | 'oidc' | 'google', title: string) {
await this.waitForResponse({
uiAction: () => this.get().locator(`.nc-${provider}-${title}-enable .nc-switch`).click(),
httpMethodsToMatch: ['PATCH'],
requestUrlPathToMatch: /\/api\/v2\/orgs\/\w+\/sso-client/,
});
}
async selectScope({ type }: { type: string[] }) {
await this.rootPage.locator('.ant-select-selector').click();
await this.rootPage.locator('.ant-select-selection-search-input[aria-expanded="true"]').waitFor();
for (const t of type) {
await this.rootPage.locator('.rc-virtual-list-holder-inner > div').locator(`text="${t}"`).click();
}
}
async createSAMLProvider(
p: { title: string; url?: string; xml?: string },
setupRedirectUrlCbk?: (params: { redirectUrl: string; audience: string }) => Promise<void>
) {
const newSamlBtn = this.get().locator('[data-test-id="nc-new-saml-provider"]');
await newSamlBtn.click();
const samlModal = this.rootPage.locator('.nc-saml-modal');
// wait until redirect url is generated
await samlModal.locator('[data-test-id="nc-saml-redirect-url"]:has-text("http://")').waitFor();
if (setupRedirectUrlCbk) {
const redirectUrl = (
await samlModal.locator('[data-test-id="nc-saml-redirect-url"]:has-text("http://")').textContent()
).trim();
const audience = (
await samlModal.locator('[data-test-id="nc-saml-issuer-url"]:has-text("http://")').textContent()
).trim();
await setupRedirectUrlCbk({ redirectUrl, audience });
}
await samlModal.locator('[data-test-id="nc-saml-title"]').fill(p.title);
if (p.url) {
await samlModal.locator('[data-test-id="nc-saml-metadata-url"]').fill(p.url);
}
if (p.xml) {
await samlModal.locator('[data-test-id="nc-saml-xml"]').fill(p.xml);
}
await this.waitForResponse({
uiAction: () => samlModal.locator('[data-test-id="nc-saml-submit"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: /\/api\/v2\/orgs\/\w+\/sso-clients/,
});
}
async createOIDCProvider(
p: {
issuer: string;
title: string;
clientId: string;
clientSecret: string;
authUrl: string;
userInfoUrl: string;
tokenUrl: string;
jwkUrl: string;
scopes: Array<string>;
userAttributes: string;
},
setupRedirectUrlCbk?: (params: { redirectUrl: string }) => Promise<void>
) {
const newOIDCBtn = this.get().locator('[data-test-id="nc-new-oidc-provider"]');
await newOIDCBtn.click();
const oidcModal = this.rootPage.locator('.nc-oidc-modal');
// wait until redirect url is generated
await oidcModal.locator('[data-test-id="nc-openid-redirect-url"]:has-text("http://")').waitFor();
if (setupRedirectUrlCbk) {
const redirectUrl = (
await oidcModal.locator('[data-test-id="nc-openid-redirect-url"]:has-text("http://")').textContent()
).trim();
await setupRedirectUrlCbk({ redirectUrl });
}
await oidcModal.locator('[data-test-id="nc-oidc-title"]').fill(p.title);
await oidcModal.locator('[data-test-id="nc-oidc-issuer"]').fill(p.issuer);
await oidcModal.locator('[data-test-id="nc-oidc-client-id"]').fill(p.clientId);
await oidcModal.locator('[data-test-id="nc-oidc-client-secret"]').fill(p.clientSecret);
await oidcModal.locator('[data-test-id="nc-oidc-auth-url"]').fill(p.authUrl);
await oidcModal.locator('[data-test-id="nc-oidc-token-url"]').fill(p.tokenUrl);
await oidcModal.locator('[data-test-id="nc-oidc-user-info-url"]').fill(p.userInfoUrl);
await oidcModal.locator('[data-test-id="nc-oidc-jwk-url"]').fill(p.jwkUrl);
await this.selectScope({
type: p.scopes,
locator: oidcModal.locator('[data-test-id="nc-oidc-scope"]'),
} as any);
await oidcModal.locator('[data-test-id="nc-oidc-user-attribute"]').fill(p.userAttributes);
await this.waitForResponse({
uiAction: () => oidcModal.locator('[data-test-id="nc-oidc-save-btn"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: /\/api\/v2\/orgs\/\w+\/sso-clients/,
});
}
async createGoogleProvider(p: { clientId: string; clientSecret: string }) {
await this.rootPage.locator(`.nc-google-more-option`).click();
await this.rootPage.locator(`[data-test-id="nc-google-edit"]`).click();
const googleModal = this.rootPage.locator('.nc-google-modal');
// wait until redirect url is generated
await googleModal.locator('[data-test-id="nc-google-redirect-url"]:has-text("http://")').waitFor();
await googleModal.locator('[data-test-id="nc-google-client-id"]').fill(p.clientId);
await googleModal.locator('[data-test-id="nc-google-client-secret"]').fill(p.clientSecret);
await this.waitForResponse({
uiAction: () => googleModal.locator('[data-test-id="nc-google-save-btn"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: /\/api\/v2\/orgs\/\w+\/sso-clients/,
});
}
}

37
tests/playwright/pages/OrgAdmin/SSOLoginPage.ts

@ -0,0 +1,37 @@
import { Page } from '@playwright/test';
import BasePage from '../Base';
import { ProjectsPage } from '../ProjectsPage';
export class CloudSSOLoginPage extends BasePage {
readonly projectsPage: ProjectsPage;
constructor(rootPage: Page) {
super(rootPage);
this.projectsPage = new ProjectsPage(rootPage);
}
async goto(_email: string) {
// reload page to get latest app info
await this.rootPage.goto('/#/sso');
// click sign in with SAML
// await this.rootPage.locator(`button:has-text("Sign in with ${title}")`).click();
}
get() {
return this.rootPage.locator('html');
}
async signIn({ email }: { email: string }) {
const signIn = this.get();
await signIn.locator('[data-testid="nc-form-org-sso-signin__email"]').waitFor();
await signIn.locator('[data-testid="nc-form-org-sso-signin__email"]').fill(email);
await Promise.all([
this.rootPage.waitForNavigation({ url: /localhost:3000/ }),
signIn.getByTestId('nc-form-signin__submit').click(),
]);
await this.rootPage.locator(`[data-testid="nc-sidebar-userinfo"]:has-text("${email.split('@')[0]}")`);
}
}

15
tests/playwright/pages/OrgAdmin/Settings.ts

@ -0,0 +1,15 @@
import BasePage from '../Base';
import { OrgAdminPage } from './index';
export class Settings extends BasePage {
readonly orgAdminPage: OrgAdminPage;
constructor(orgAdminPage: OrgAdminPage) {
super(orgAdminPage.rootPage);
this.orgAdminPage = orgAdminPage;
}
get() {
return this.rootPage.locator('[data-test-id="nc-admin-settings"]');
}
}

15
tests/playwright/pages/OrgAdmin/Workspaces.ts

@ -0,0 +1,15 @@
import BasePage from '../Base';
import { OrgAdminPage } from './index';
export class Workspaces extends BasePage {
readonly orgAdminPage: OrgAdminPage;
constructor(orgAdminPage: OrgAdminPage) {
super(orgAdminPage.rootPage);
this.orgAdminPage = orgAdminPage;
}
get() {
return this.rootPage.locator('[data-test-id="nc-admin-workspaces"]');
}
}

29
tests/playwright/pages/OrgAdmin/index.ts

@ -0,0 +1,29 @@
import { Page } from '@playwright/test';
import BasePage from '../Base';
import { CloudSSO } from './SSO';
export class OrgAdminPage extends BasePage {
readonly ssoPage: CloudSSO;
constructor(page: Page) {
super(page);
this.ssoPage = new CloudSSO(this);
}
get() {
return this.rootPage.locator('body');
}
async goto() {
await this.rootPage.goto('/');
await this.rootPage.getByTestId('nc-sidebar-userinfo').click();
await this.rootPage.waitForTimeout(1000);
if ((await this.rootPage.getByTestId('nc-sidebar-upgrade-workspace-to-org').count()) > 0) {
await this.rootPage.getByTestId('nc-sidebar-upgrade-workspace-to-org').first().click();
} else {
await this.rootPage.getByTestId('nc-sidebar-org-admin-panel').click();
}
await this.rootPage.waitForNavigation({ url: /\/#\/admin\/\w+/ });
}
}

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

@ -9,17 +9,11 @@ import { Locator } from '@playwright/test';
export class CollaborationPage extends BasePage {
readonly workspace: WorkspacePage;
readonly button_addUser: Locator;
readonly input_email: Locator;
readonly selector_role: Locator;
readonly list_collaborators: Locator;
constructor(workspace: WorkspacePage) {
super(workspace.rootPage);
this.workspace = workspace;
this.button_addUser = this.get().locator('button.ant-btn.ant-btn-primary');
this.input_email = this.get().locator('input[id="email"]');
this.selector_role = this.get().locator('.ant-select-selector');
this.list_collaborators = this.get().locator('.nc-collaborators-list-table');
}
@ -34,13 +28,24 @@ export class CollaborationPage extends BasePage {
async addUsers(email: string, role: string) {
await this.waitFor({ state: 'visible' });
// click add user button to open modal
await this.get().getByTestId('nc-add-member-btn').click();
const inviteModal = this.rootPage.locator('.nc-invite-dlg');
await inviteModal.waitFor({ state: 'visible' });
const input_email = inviteModal.locator('input[id="email"]');
const selector_role = inviteModal.locator('.ant-select-selector');
const button_addUser = inviteModal.locator('.nc-invite-btn');
// email
await this.input_email.fill(email);
await input_email.fill(email);
await this.rootPage.keyboard.press('Enter');
// role
await this.selector_role.first().click();
const menu = this.rootPage.locator('.nc-role-select-dropdown:visible');
await selector_role.first().click();
const menu = this.rootPage.locator('.nc-role-selector-dropdown:visible');
await menu.locator(`.nc-role-select-workspace-level-${role.toLowerCase()}:visible`).first().click();
// submit
@ -48,7 +53,7 @@ export class CollaborationPage extends BasePage {
// allow button to be enabled
await this.rootPage.waitForTimeout(500);
await this.rootPage.keyboard.press('Enter');
await this.button_addUser.click();
await button_addUser.click();
await this.verifyToast({ message: 'Invitation sent successfully' });
await this.rootPage.waitForTimeout(500);
}

7
tests/playwright/setup/index.ts

@ -169,12 +169,14 @@ async function localInit({
baseType = ProjectTypes.DATABASE,
isSuperUser = false,
dbType,
resetSsoClients = false,
}: {
workerId: string;
isEmptyProject?: boolean;
baseType?: ProjectTypes;
isSuperUser?: boolean;
dbType?: string;
resetSsoClients?: boolean;
}) {
const parallelId = process.env.TEST_PARALLEL_INDEX;
@ -210,7 +212,7 @@ async function localInit({
// console.log(process.env.TEST_WORKER_INDEX, process.env.TEST_PARALLEL_INDEX);
// delete sso-clients
if (isEE() && api['ssoClient'] && isSuperUser) {
if (resetSsoClients && isEE() && api['ssoClient'] && isSuperUser) {
const clients = await api.ssoClient.list();
for (const client of clients.list) {
try {
@ -355,12 +357,14 @@ const setup = async ({
isEmptyProject = false,
isSuperUser = false,
url,
resetSsoClients = false,
}: {
baseType?: ProjectTypes;
page: Page;
isEmptyProject?: boolean;
isSuperUser?: boolean;
url?: string;
resetSsoClients?: boolean;
}): Promise<NcContext> => {
console.time('Setup');
@ -384,6 +388,7 @@ const setup = async ({
baseType,
isSuperUser,
dbType,
resetSsoClients,
});
} catch (e) {
console.error(`Error resetting base: ${process.env.TEST_PARALLEL_INDEX}`, e);

Loading…
Cancel
Save