Browse Source

Merge pull request #8708 from nocodb/nc-feat/3100-readonly-source

Nc feat/3100 readonly source
pull/8797/head
Pranav C 4 weeks ago committed by GitHub
parent
commit
32f5c0dd2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      packages/nc-gui/components/dashboard/TreeView/AddNewTableNode.vue
  2. 16
      packages/nc-gui/components/dashboard/TreeView/BaseOptions.vue
  3. 5
      packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue
  4. 47
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  5. 19
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  6. 8
      packages/nc-gui/components/dashboard/TreeView/ViewsList.vue
  7. 9
      packages/nc-gui/components/dashboard/TreeView/index.vue
  8. 8
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  9. 95
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  10. 95
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  11. 4
      packages/nc-gui/components/project/AllTables.vue
  12. 4
      packages/nc-gui/components/smartsheet/Details.vue
  13. 2
      packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue
  14. 44
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  15. 32
      packages/nc-gui/components/smartsheet/column/UITypesOptionsWithSearch.vue
  16. 15
      packages/nc-gui/components/smartsheet/details/Fields.vue
  17. 33
      packages/nc-gui/components/smartsheet/grid/Table.vue
  18. 11
      packages/nc-gui/components/smartsheet/header/Cell.vue
  19. 49
      packages/nc-gui/components/smartsheet/header/Menu.vue
  20. 10
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  21. 2
      packages/nc-gui/components/smartsheet/toolbar/Calendar/Range.vue
  22. 4
      packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue
  23. 14
      packages/nc-gui/components/tabs/Smartsheet.vue
  24. 4
      packages/nc-gui/components/virtual-cell/components/LinkedItems.vue
  25. 4
      packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue
  26. 2
      packages/nc-gui/composables/useCalendarViewStore.ts
  27. 8
      packages/nc-gui/composables/useKanbanViewStore.ts
  28. 2
      packages/nc-gui/composables/useMapViewDataStore.ts
  29. 9
      packages/nc-gui/composables/useMetas.ts
  30. 10
      packages/nc-gui/composables/useMultiSelect/index.ts
  31. 60
      packages/nc-gui/composables/useRoles/index.ts
  32. 13
      packages/nc-gui/context/index.ts
  33. 7
      packages/nc-gui/lang/en.json
  34. 33
      packages/nc-gui/lib/acl.ts
  35. 8
      packages/nocodb-sdk/src/lib/UITypes.ts
  36. 8
      packages/nocodb-sdk/src/lib/enums.ts
  37. 1
      packages/nocodb-sdk/src/lib/index.ts
  38. 8
      packages/nocodb/src/helpers/catchError.ts
  39. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  40. 18
      packages/nocodb/src/meta/migrations/v2/nc_051_source_readonly_columns.ts
  41. 119
      packages/nocodb/src/middlewares/extract-ids/extract-ids.middleware.ts
  42. 10
      packages/nocodb/src/models/Source.ts
  43. 28
      packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.controller.ts
  44. 8
      packages/nocodb/src/schema/swagger-v2.json
  45. 8
      packages/nocodb/src/schema/swagger.json
  46. 28
      packages/nocodb/src/services/columns.service.ts
  47. 10
      packages/nocodb/src/services/forms.service.ts
  48. 12
      packages/nocodb/src/services/public-datas.service.ts
  49. 28
      packages/nocodb/src/utils/acl.ts
  50. 7
      packages/nocodb/tests/unit/factory/base.ts
  51. 2
      packages/nocodb/tests/unit/rest/index.test.ts
  52. 152
      packages/nocodb/tests/unit/rest/tests/readOnlySource.test.ts
  53. 44
      tests/playwright/pages/Dashboard/Settings/Source.ts
  54. 3
      tests/playwright/pages/Dashboard/Settings/index.ts
  55. 9
      tests/playwright/pages/Dashboard/TreeView.ts
  56. 91
      tests/playwright/tests/db/general/sourceRestrictions.spec.ts

8
packages/nc-gui/components/dashboard/TreeView/AddNewTableNode.vue

@ -112,11 +112,15 @@ function openTableCreateMagicDialog(sourceId?: string) {
close(1000) close(1000)
} }
} }
const source = computed(() => {
return base.value?.sources?.[props.sourceIndex]
})
</script> </script>
<template> <template>
<div <div
v-if="isUIAllowed('tableCreate', { roles: baseRole })" v-if="isUIAllowed('tableCreate', { roles: baseRole, source })"
class="group flex items-center gap-2 pl-2 pr-4.75 py-1 text-primary/70 hover:(text-primary/100) cursor-pointer select-none" class="group flex items-center gap-2 pl-2 pr-4.75 py-1 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
@click="emit('openTableCreateDialog')" @click="emit('openTableCreateDialog')"
> >
@ -191,7 +195,7 @@ function openTableCreateMagicDialog(sourceId?: string) {
</a-menu-item> </a-menu-item>
<a-menu-item <a-menu-item
v-if="isUIAllowed('excelImport', { roles: baseRole })" v-if="isUIAllowed('excelImport', { roles: baseRole, source: base.sources[sourceIndex] })"
key="quick-import-excel" key="quick-import-excel"
@click="openQuickImportDialog('excel', base.sources[sourceIndex].id)" @click="openQuickImportDialog('excel', base.sources[sourceIndex].id)"
> >

16
packages/nc-gui/components/dashboard/TreeView/BaseOptions.vue

@ -8,9 +8,11 @@ const props = defineProps<{
const source = toRef(props, 'source') const source = toRef(props, 'source')
const base = toRef(props, 'base')
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const baseRole = inject(ProjectRoleInj) const baseRole = computed(() => base.value.project_role || base.value.workspace_role)
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -68,7 +70,7 @@ function openQuickImportDialog(type: string) {
<template #expandIcon></template> <template #expandIcon></template>
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('airtableImport', { roles: baseRole })" v-if="isUIAllowed('airtableImport', { roles: baseRole, source })"
key="quick-import-airtable" key="quick-import-airtable"
@click="openAirtableImportDialog(source.base_id, source.id)" @click="openAirtableImportDialog(source.base_id, source.id)"
> >
@ -78,7 +80,11 @@ function openQuickImportDialog(type: string) {
</div> </div>
</NcMenuItem> </NcMenuItem>
<NcMenuItem v-if="isUIAllowed('csvImport', { roles: baseRole })" key="quick-import-csv" @click="openQuickImportDialog('csv')"> <NcMenuItem
v-if="isUIAllowed('csvImport', { roles: baseRole, source })"
key="quick-import-csv"
@click="openQuickImportDialog('csv')"
>
<div v-e="['c:import:csv']" class="flex gap-2 items-center"> <div v-e="['c:import:csv']" class="flex gap-2 items-center">
<GeneralIcon icon="csv" class="w-4 group-hover:text-black" /> <GeneralIcon icon="csv" class="w-4 group-hover:text-black" />
{{ $t('labels.csvFile') }} {{ $t('labels.csvFile') }}
@ -86,7 +92,7 @@ function openQuickImportDialog(type: string) {
</NcMenuItem> </NcMenuItem>
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('jsonImport', { roles: baseRole })" v-if="isUIAllowed('jsonImport', { roles: baseRole, source })"
key="quick-import-json" key="quick-import-json"
@click="openQuickImportDialog('json')" @click="openQuickImportDialog('json')"
> >
@ -97,7 +103,7 @@ function openQuickImportDialog(type: string) {
</NcMenuItem> </NcMenuItem>
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('excelImport', { roles: baseRole })" v-if="isUIAllowed('excelImport', { roles: baseRole, source })"
key="quick-import-excel" key="quick-import-excel"
@click="openQuickImportDialog('excel')" @click="openQuickImportDialog('excel')"
> >

5
packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue

@ -1,10 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ViewType } from 'nocodb-sdk' import { SourceRestriction, type ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
// Prop used to align the dropdown to the left in sidebar // Prop used to align the dropdown to the left in sidebar
alignLeftLevel: number | undefined alignLeftLevel: number | undefined
source: Source
}>() }>()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -125,7 +126,7 @@ async function onOpenModal({
</div> </div>
</NcMenuItem> </NcMenuItem>
<NcMenuItem @click="onOpenModal({ type: ViewTypes.FORM })"> <NcMenuItem v-if="!source.is_schema_readonly" @click="onOpenModal({ type: ViewTypes.FORM })">
<div class="item" data-testid="sidebar-view-create-form"> <div class="item" data-testid="sidebar-view-create-form">
<div class="item-inner"> <div class="item-inner">
<GeneralViewIcon :meta="{ type: ViewTypes.FORM }" /> <GeneralViewIcon :meta="{ type: ViewTypes.FORM }" />

47
packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue

@ -69,7 +69,7 @@ const { t } = useI18n()
const input = ref<HTMLInputElement>() const input = ref<HTMLInputElement>()
const baseRole = inject(ProjectRoleInj) const baseRole = computed(() => base.value.project_role || base.value.workspace_role)
const { activeProjectId } = storeToRefs(useBases()) const { activeProjectId } = storeToRefs(useBases())
@ -100,9 +100,9 @@ const baseViewOpen = computed(() => {
return routeNameAfterProjectView.split('-').length === 2 || routeNameAfterProjectView.split('-').length === 1 return routeNameAfterProjectView.split('-').length === 2 || routeNameAfterProjectView.split('-').length === 1
}) })
const showBaseOption = computed(() => { const showBaseOption = (source: SourceType) => {
return ['airtableImport', 'csvImport', 'jsonImport', 'excelImport'].some((permission) => isUIAllowed(permission)) return ['airtableImport', 'csvImport', 'jsonImport', 'excelImport'].some((permission) => isUIAllowed(permission, { source }))
}) }
const enableEditMode = () => { const enableEditMode = () => {
editMode.value = true editMode.value = true
@ -444,6 +444,10 @@ const onTableIdCopy = async () => {
message.error(e.message) message.error(e.message)
} }
} }
const getSource = (sourceId: string) => {
return base.value.sources?.find((s) => s.id === sourceId)
}
</script> </script>
<template> <template>
@ -596,7 +600,7 @@ const onTableIdCopy = async () => {
</NcMenuItem> </NcMenuItem>
</template> </template>
<template v-if="base?.sources?.[0]?.enabled && showBaseOption"> <template v-if="base?.sources?.[0]?.enabled && showBaseOption(base?.sources?.[0])">
<NcDivider /> <NcDivider />
<DashboardTreeViewBaseOptions v-model:base="base" :source="base.sources[0]" /> <DashboardTreeViewBaseOptions v-model:base="base" :source="base.sources[0]" />
</template> </template>
@ -631,7 +635,7 @@ const onTableIdCopy = async () => {
</NcDropdown> </NcDropdown>
<NcButton <NcButton
v-if="isUIAllowed('tableCreate', { roles: baseRole })" v-if="isUIAllowed('tableCreate', { roles: baseRole, source: base?.sources?.[0] })"
v-e="['c:base:create-table']" v-e="['c:base:create-table']"
:disabled="!base?.sources?.[0]?.enabled" :disabled="!base?.sources?.[0]?.enabled"
class="nc-sidebar-node-btn" class="nc-sidebar-node-btn"
@ -817,13 +821,17 @@ const onTableIdCopy = async () => {
</div> </div>
</NcMenuItem> </NcMenuItem>
<DashboardTreeViewBaseOptions v-if="showBaseOption" v-model:base="base" :source="source" /> <DashboardTreeViewBaseOptions
v-if="showBaseOption(source)"
v-model:base="base"
:source="source"
/>
</NcMenu> </NcMenu>
</template> </template>
</NcDropdown> </NcDropdown>
<NcButton <NcButton
v-if="isUIAllowed('tableCreate', { roles: baseRole })" v-if="isUIAllowed('tableCreate', { roles: baseRole, source })"
v-e="['c:source:add-table']" v-e="['c:source:add-table']"
type="text" type="text"
size="xxsmall" size="xxsmall"
@ -884,9 +892,17 @@ const onTableIdCopy = async () => {
</div> </div>
</NcTooltip> </NcTooltip>
<template v-if="isUIAllowed('tableRename') || isUIAllowed('tableDelete')"> <template
v-if="
isUIAllowed('tableRename', { source: getSource(contextMenuTarget.value?.source_id) }) ||
isUIAllowed('tableDelete', { source: getSource(contextMenuTarget.value?.source_id) })
"
>
<NcDivider /> <NcDivider />
<NcMenuItem v-if="isUIAllowed('tableRename')" @click="openRenameTableDialog(contextMenuTarget.value, true)"> <NcMenuItem
v-if="isUIAllowed('tableRename', { source: getSource(contextMenuTarget.value?.source_id) })"
@click="openRenameTableDialog(contextMenuTarget.value, true)"
>
<div v-e="['c:table:rename']" class="nc-base-option-item flex gap-2 items-center"> <div v-e="['c:table:rename']" class="nc-base-option-item flex gap-2 items-center">
<GeneralIcon icon="rename" class="text-gray-700" /> <GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }} {{ $t('objects.table') }} {{ $t('general.rename') }} {{ $t('objects.table') }}
@ -894,7 +910,10 @@ const onTableIdCopy = async () => {
</NcMenuItem> </NcMenuItem>
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('tableDuplicate') && (contextMenuBase?.is_meta || contextMenuBase?.is_local)" v-if="
isUIAllowed('tableDuplicate', { source: getSource(contextMenuTarget.value?.source_id) }) &&
(contextMenuBase?.is_meta || contextMenuBase?.is_local)
"
@click="duplicateTable(contextMenuTarget.value)" @click="duplicateTable(contextMenuTarget.value)"
> >
<div v-e="['c:table:duplicate']" class="nc-base-option-item flex gap-2 items-center"> <div v-e="['c:table:duplicate']" class="nc-base-option-item flex gap-2 items-center">
@ -903,7 +922,11 @@ const onTableIdCopy = async () => {
</div> </div>
</NcMenuItem> </NcMenuItem>
<NcDivider /> <NcDivider />
<NcMenuItem v-if="isUIAllowed('table-delete')" class="!hover:bg-red-50" @click="tableDelete"> <NcMenuItem
v-if="isUIAllowed('tableDelete', { source: getSource(contextMenuTarget.value?.source_id) })"
class="!hover:bg-red-50"
@click="tableDelete"
>
<div class="nc-base-option-item flex gap-2 items-center text-red-600"> <div class="nc-base-option-item flex gap-2 items-center text-red-600">
<GeneralIcon icon="delete" /> <GeneralIcon icon="delete" />
{{ $t('general.delete') }} {{ $t('objects.table') }} {{ $t('general.delete') }} {{ $t('objects.table') }}

19
packages/nc-gui/components/dashboard/TreeView/TableNode.vue

@ -213,6 +213,10 @@ const refreshViews = async () => {
await nextTick() await nextTick()
isExpanded.value = true isExpanded.value = true
} }
const source = computed(() => {
return base.value?.sources?.[sourceIndex.value]
})
</script> </script>
<template> <template>
@ -331,15 +335,16 @@ const refreshViews = async () => {
<template <template
v-if=" v-if="
!isSharedBase && !isSharedBase &&
(isUIAllowed('tableRename', { roles: baseRole }) || isUIAllowed('tableDelete', { roles: baseRole })) (isUIAllowed('tableRename', { roles: baseRole, source }) ||
isUIAllowed('tableDelete', { roles: baseRole, source }))
" "
> >
<NcDivider /> <NcDivider />
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('tableRename', { roles: baseRole })" v-if="isUIAllowed('tableRename', { roles: baseRole, source })"
:data-testid="`sidebar-table-rename-${table.title}`" :data-testid="`sidebar-table-rename-${table.title}`"
class="nc-table-rename" class="nc-table-rename"
@click="openRenameTableDialog(table, base.sources[sourceIndex].id)" @click="openRenameTableDialog(table, source.id)"
> >
<div v-e="['c:table:rename']" class="flex gap-2 items-center"> <div v-e="['c:table:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="rename" class="text-gray-700" /> <GeneralIcon icon="rename" class="text-gray-700" />
@ -349,9 +354,11 @@ const refreshViews = async () => {
<NcMenuItem <NcMenuItem
v-if=" v-if="
isUIAllowed('tableDuplicate') && isUIAllowed('tableDuplicate', {
source,
}) &&
base.sources?.[sourceIndex] && base.sources?.[sourceIndex] &&
(base.sources[sourceIndex].is_meta || base.sources[sourceIndex].is_local) (source.is_meta || source.is_local)
" "
:data-testid="`sidebar-table-duplicate-${table.title}`" :data-testid="`sidebar-table-duplicate-${table.title}`"
@click="duplicateTable(table)" @click="duplicateTable(table)"
@ -364,7 +371,7 @@ const refreshViews = async () => {
<NcDivider /> <NcDivider />
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('tableDelete', { roles: baseRole })" v-if="isUIAllowed('tableDelete', { roles: baseRole, source })"
:data-testid="`sidebar-table-delete-${table.title}`" :data-testid="`sidebar-table-delete-${table.title}`"
class="!text-red-500 !hover:bg-red-50 nc-table-delete" class="!text-red-500 !hover:bg-red-50 nc-table-delete"
@click="deleteTable" @click="deleteTable"

8
packages/nc-gui/components/dashboard/TreeView/ViewsList.vue

@ -75,13 +75,14 @@ function markItem(id: string) {
}, 300) }, 300)
} }
const source = computed(() => base.value?.sources?.find((b) => b.id === table.value.source_id))
const isDefaultSource = computed(() => { const isDefaultSource = computed(() => {
if (base.value?.sources?.length === 1) return true if (base.value?.sources?.length === 1) return true
const source = base.value?.sources?.find((b) => b.id === table.value.source_id) if (!source.value) return false
if (!source) return false
return isDefaultBase(source) return isDefaultBase(source.value)
}) })
/** validate view title */ /** validate view title */
@ -406,6 +407,7 @@ function onOpenModal({
'!pl-13.3 !xs:(pl-13.5)': isDefaultSource, '!pl-13.3 !xs:(pl-13.5)': isDefaultSource,
'!pl-18.6 !xs:(pl-20)': !isDefaultSource, '!pl-18.6 !xs:(pl-20)': !isDefaultSource,
}" }"
:source="source"
> >
<div <div
:class="{ :class="{

9
packages/nc-gui/components/dashboard/TreeView/index.vue

@ -23,7 +23,7 @@ const baseCreateDlg = ref(false)
const baseStore = useBase() const baseStore = useBase()
const { isSharedBase } = storeToRefs(baseStore) const { isSharedBase, base } = storeToRefs(baseStore)
const { activeTable: _activeTable } = storeToRefs(useTablesStore()) const { activeTable: _activeTable } = storeToRefs(useTablesStore())
@ -100,7 +100,8 @@ const duplicateTable = async (table: TableType) => {
const isCreateTableAllowed = computed( const isCreateTableAllowed = computed(
() => () =>
isUIAllowed('tableCreate') && base.value?.sources?.[0] &&
isUIAllowed('tableCreate', { source: base.value?.sources?.[0] }) &&
route.value.name !== 'index' && route.value.name !== 'index' &&
route.value.name !== 'index-index' && route.value.name !== 'index-index' &&
route.value.name !== 'index-index-create' && route.value.name !== 'index-index-create' &&
@ -248,9 +249,9 @@ watch(
ghost-class="ghost" ghost-class="ghost"
@change="onMove($event)" @change="onMove($event)"
> >
<template #item="{ element: base }"> <template #item="{ element: base1 }">
<div :key="base.id"> <div :key="base.id">
<ProjectWrapper :base-role="base.project_role" :base="base"> <ProjectWrapper :base-role="base1.project_role" :base="base1">
<DashboardTreeViewProjectNode /> <DashboardTreeViewProjectNode />
</ProjectWrapper> </ProjectWrapper>
</div> </div>

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

@ -260,7 +260,7 @@ const openedTab = ref('erd')
</script> </script>
<template> <template>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full" data-testid="nc-settings-datasources-tab">
<div class="px-4 py-2 flex justify-between"> <div class="px-4 py-2 flex justify-between">
<a-breadcrumb separator=">" class="w-full cursor-pointer font-weight-bold"> <a-breadcrumb separator=">" class="w-full cursor-pointer font-weight-bold">
<a-breadcrumb-item @click="activeSource = null"> <a-breadcrumb-item @click="activeSource = null">
@ -307,7 +307,7 @@ const openedTab = ref('erd')
<LazyDashboardSettingsBaseAudit :source-id="activeSource.id" /> <LazyDashboardSettingsBaseAudit :source-id="activeSource.id" />
</div> </div>
</a-tab-pane> </a-tab-pane>
<a-tab-pane v-if="!activeSource.is_meta && !activeSource.is_local" key="audit"> <a-tab-pane v-if="!activeSource.is_meta && !activeSource.is_local" key="edit">
<template #tab> <template #tab>
<div class="tab" data-testid="nc-connection-tab"> <div class="tab" data-testid="nc-connection-tab">
<div>{{ $t('labels.connectionDetails') }}</div> <div>{{ $t('labels.connectionDetails') }}</div>
@ -397,7 +397,7 @@ const openedTab = ref('erd')
<NcButton <NcButton
v-if="!sources[0].is_meta && !sources[0].is_local" v-if="!sources[0].is_meta && !sources[0].is_local"
size="small" size="small"
class="nc-action-btn cursor-pointer outline-0 !w-8 !px-1 !rounded-lg" class="nc-action-btn nc-edit-base cursor-pointer outline-0 !w-8 !px-1 !rounded-lg"
type="text" type="text"
@click.stop="baseAction(sources[0].id, DataSourcesSubTab.Edit)" @click.stop="baseAction(sources[0].id, DataSourcesSubTab.Edit)"
> >
@ -446,7 +446,7 @@ const openedTab = ref('erd')
<NcButton <NcButton
v-if="!source.is_meta && !source.is_local" v-if="!source.is_meta && !source.is_local"
size="small" size="small"
class="nc-action-btn cursor-pointer outline-0 !w-8 !px-1 !rounded-lg" class="nc-action-btn nc-delete-base cursor-pointer outline-0 !w-8 !px-1 !rounded-lg"
type="text" type="text"
@click.stop="openDeleteBase(source)" @click.stop="openDeleteBase(source)"
> >

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

@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Form, message } from 'ant-design-vue' import { Form, message } from 'ant-design-vue'
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select' import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select'
import { SourceRestriction } from 'nocodb-sdk'
import { import {
type CertTypes, type CertTypes,
ClientType, ClientType,
@ -55,6 +56,8 @@ const formState = ref<ProjectCreateForm>({
}, },
sslUse: SSLUsage.No, sslUse: SSLUsage.No,
extraParameters: [], extraParameters: [],
is_schema_readonly: true,
is_data_readonly: false,
}) })
const customFormState = ref<ProjectCreateForm>({ const customFormState = ref<ProjectCreateForm>({
@ -68,9 +71,20 @@ const customFormState = ref<ProjectCreateForm>({
extraParameters: [], extraParameters: [],
}) })
const easterEgg = ref(false)
const easterEggCount = ref(0)
const onEasterEgg = () => {
easterEggCount.value += 1
if (easterEggCount.value >= 2) {
easterEgg.value = true
}
}
const clientTypes = computed(() => { const clientTypes = computed(() => {
return _clientTypes.filter((type) => { return _clientTypes.filter((type) => {
return ![ClientType.SNOWFLAKE, ClientType.DATABRICKS].includes(type.value) return ![ClientType.SNOWFLAKE, ClientType.DATABRICKS, ...(easterEgg.value ? [] : [ClientType.MSSQL])].includes(type.value)
}) })
}) })
@ -244,6 +258,8 @@ const createSource = async () => {
config, config,
inflection_column: formState.value.inflection.inflectionColumn, inflection_column: formState.value.inflection.inflectionColumn,
inflection_table: formState.value.inflection.inflectionTable, inflection_table: formState.value.inflection.inflectionTable,
is_schema_readonly: formState.value.is_schema_readonly,
is_data_readonly: formState.value.is_data_readonly,
}) })
$poller.subscribe( $poller.subscribe(
@ -393,6 +409,26 @@ watch(
const toggleModal = (val: boolean) => { const toggleModal = (val: boolean) => {
vOpen.value = val vOpen.value = val
} }
const allowMetaWrite = computed({
get: () => !formState.value.is_schema_readonly,
set: (v) => {
formState.value.is_schema_readonly = !v
// if schema write is allowed, data write should be allowed too
if (v) {
formState.value.is_data_readonly = false
}
$e('c:source:schema-write-toggle', { allowed: !v, edit: true })
},
})
const allowDataWrite = computed({
get: () => !formState.value.is_data_readonly,
set: (v) => {
formState.value.is_data_readonly = !v
$e('c:source:data-write-toggle', { allowed: !v })
},
})
</script> </script>
<template> <template>
@ -508,6 +544,58 @@ const toggleModal = (val: boolean) => {
> >
<a-input v-model:value="formState.dataSource.searchPath[0]" /> <a-input v-model:value="formState.dataSource.searchPath[0]" />
</a-form-item> </a-form-item>
</template>
<a-form-item>
<template #label>
<div class="flex gap-1 justify-end">
<span>
{{ $t('labels.allowMetaWrite') }}
</span>
<NcTooltip>
<template #title>
<span>{{ $t('tooltip.allowMetaWrite') }}</span>
</template>
<GeneralIcon class="text-gray-500" icon="info" />
</NcTooltip>
</div>
</template>
<a-switch v-model:checked="allowMetaWrite" data-testid="nc-allow-meta-write" size="small"></a-switch>
</a-form-item>
<a-form-item>
<template #label>
<div class="flex gap-1 justify-end">
<span>
{{ $t('labels.allowDataWrite') }}
</span>
<NcTooltip>
<template #title>
<span>{{ $t('tooltip.allowDataWrite') }}</span>
</template>
<GeneralIcon class="text-gray-500" icon="info" />
</NcTooltip>
</div>
</template>
<div class="flex justify-start">
<NcTooltip :disabled="!allowMetaWrite" placement="topLeft">
<template #title>
{{ $t('tooltip.dataWriteOptionDisabled') }}
</template>
<a-switch
v-model:checked="allowDataWrite"
:disabled="allowMetaWrite"
data-testid="nc-allow-data-write"
size="small"
></a-switch>
</NcTooltip>
</div>
</a-form-item>
<template
v-if="
formState.dataSource.client !== ClientType.SQLITE &&
formState.dataSource.client !== ClientType.DATABRICKS &&
formState.dataSource.client !== ClientType.SNOWFLAKE
"
>
<div class="flex items-right justify-end gap-2"> <div class="flex items-right justify-end gap-2">
<!-- Use Connection URL --> <!-- Use Connection URL -->
<NcButton type="ghost" size="small" class="nc-extdb-btn-import-url !rounded-md" @click.stop="importURLDlg = true"> <NcButton type="ghost" size="small" class="nc-extdb-btn-import-url !rounded-md" @click.stop="importURLDlg = true">
@ -639,8 +727,8 @@ const toggleModal = (val: boolean) => {
v-model:value="formState.inflection.inflectionColumn" v-model:value="formState.inflection.inflectionColumn"
dropdown-class-name="nc-dropdown-inflection-column-name" dropdown-class-name="nc-dropdown-inflection-column-name"
> >
<a-select-option v-for="tp in inflectionTypes" :key="tp" :value="tp" <a-select-option v-for="tp in inflectionTypes" :key="tp" :value="tp">
><div class="flex items-center gap-2 justify-between"> <div class="flex items-center gap-2 justify-between">
<div>{{ tp }}</div> <div>{{ tp }}</div>
<component <component
:is="iconMap.check" :is="iconMap.check"
@ -666,6 +754,7 @@ const toggleModal = (val: boolean) => {
<a-form-item class="flex justify-end !mt-5"> <a-form-item class="flex justify-end !mt-5">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<div class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEasterEgg"></div>
<NcButton <NcButton
:type="testSuccess ? 'ghost' : 'primary'" :type="testSuccess ? 'ghost' : 'primary'"
size="small" size="small"

95
packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue

@ -45,9 +45,20 @@ const { t } = useI18n()
const editingSource = ref(false) const editingSource = ref(false)
const easterEgg = ref(false)
const easterEggCount = ref(0)
const onEasterEgg = () => {
easterEggCount.value += 1
if (easterEggCount.value >= 2) {
easterEgg.value = true
}
}
const clientTypes = computed(() => { const clientTypes = computed(() => {
return _clientTypes.filter((type) => { return _clientTypes.filter((type) => {
return ![ClientType.SNOWFLAKE, ClientType.DATABRICKS].includes(type.value) return ![ClientType.SNOWFLAKE, ClientType.DATABRICKS, ...(easterEgg.value ? [] : [ClientType.MSSQL])].includes(type.value)
}) })
}) })
@ -60,6 +71,8 @@ const formState = ref<ProjectCreateForm>({
}, },
sslUse: SSLUsage.No, sslUse: SSLUsage.No,
extraParameters: [], extraParameters: [],
is_schema_readonly: true,
is_data_readonly: false,
}) })
const customFormState = ref<ProjectCreateForm>({ const customFormState = ref<ProjectCreateForm>({
@ -71,6 +84,8 @@ const customFormState = ref<ProjectCreateForm>({
}, },
sslUse: SSLUsage.No, sslUse: SSLUsage.No,
extraParameters: [], extraParameters: [],
is_schema_readonly: true,
is_data_readonly: false,
}) })
const validators = computed(() => { const validators = computed(() => {
@ -228,6 +243,8 @@ const editBase = async () => {
config, config,
inflection_column: formState.value.inflection.inflectionColumn, inflection_column: formState.value.inflection.inflectionColumn,
inflection_table: formState.value.inflection.inflectionTable, inflection_table: formState.value.inflection.inflectionTable,
is_schema_readonly: formState.value.is_schema_readonly,
is_data_readonly: formState.value.is_data_readonly,
}) })
$e('a:source:edit:extdb') $e('a:source:edit:extdb')
@ -339,6 +356,8 @@ onMounted(async () => {
}, },
extraParameters: tempParameters, extraParameters: tempParameters,
sslUse: SSLUsage.No, sslUse: SSLUsage.No,
is_schema_readonly: activeBase.is_schema_readonly,
is_data_readonly: activeBase.is_data_readonly,
} }
updateSSLUse() updateSSLUse()
} }
@ -356,6 +375,26 @@ watch(
immediate: true, immediate: true,
}, },
) )
const allowMetaWrite = computed({
get: () => !formState.value.is_schema_readonly,
set: (v) => {
formState.value.is_schema_readonly = !v
// if schema write is allowed, data write should be allowed too
if (v) {
formState.value.is_data_readonly = false
}
$e('c:source:schema-write-toggle', { allowed: !v, edit: true })
},
})
const allowDataWrite = computed({
get: () => !formState.value.is_data_readonly,
set: (v) => {
formState.value.is_data_readonly = !v
$e('c:source:data-write-toggle', { allowed: !v, edit: true })
},
})
</script> </script>
<template> <template>
@ -372,7 +411,6 @@ watch(
<a-form-item label="Source Name" v-bind="validateInfos.title"> <a-form-item label="Source Name" v-bind="validateInfos.title">
<a-input v-model:value="formState.title" class="nc-extdb-proj-name" /> <a-input v-model:value="formState.title" class="nc-extdb-proj-name" />
</a-form-item> </a-form-item>
<a-form-item :label="$t('labels.dbType')" v-bind="validateInfos['dataSource.client']"> <a-form-item :label="$t('labels.dbType')" v-bind="validateInfos['dataSource.client']">
<a-select <a-select
v-model:value="formState.dataSource.client" v-model:value="formState.dataSource.client"
@ -524,6 +562,58 @@ watch(
> >
<a-input v-model:value="formState.dataSource.searchPath[0]" /> <a-input v-model:value="formState.dataSource.searchPath[0]" />
</a-form-item> </a-form-item>
</template>
<a-form-item>
<template #label>
<div class="flex gap-1 justify-end">
<span>
{{ $t('labels.allowMetaWrite') }}
</span>
<NcTooltip>
<template #title>
<span>{{ $t('tooltip.allowMetaWrite') }}</span>
</template>
<GeneralIcon class="text-gray-500" icon="info" />
</NcTooltip>
</div>
</template>
<a-switch v-model:checked="allowMetaWrite" data-testid="nc-allow-meta-write" size="small"></a-switch>
</a-form-item>
<a-form-item>
<template #label>
<div class="flex gap-1 justify-end">
<span>
{{ $t('labels.allowDataWrite') }}
</span>
<NcTooltip>
<template #title>
<span>{{ $t('tooltip.allowDataWrite') }}</span>
</template>
<GeneralIcon class="text-gray-500" icon="info" />
</NcTooltip>
</div>
</template>
<div class="flex justify-start">
<NcTooltip :disabled="!allowMetaWrite" placement="topLeft">
<template #title>
{{ $t('tooltip.dataWriteOptionDisabled') }}
</template>
<a-switch
v-model:checked="allowDataWrite"
:disabled="allowMetaWrite"
data-testid="nc-allow-data-write"
size="small"
></a-switch>
</NcTooltip>
</div>
</a-form-item>
<template
v-if="
formState.dataSource.client !== ClientType.SQLITE &&
formState.dataSource.client !== ClientType.DATABRICKS &&
formState.dataSource.client !== ClientType.SNOWFLAKE
"
>
<!-- Use Connection URL --> <!-- Use Connection URL -->
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<NcButton size="small" type="ghost" class="nc-extdb-btn-import-url !rounded-md" @click.stop="importURLDlg = true"> <NcButton size="small" type="ghost" class="nc-extdb-btn-import-url !rounded-md" @click.stop="importURLDlg = true">
@ -644,6 +734,7 @@ watch(
<a-form-item class="flex justify-end !mt-5"> <a-form-item class="flex justify-end !mt-5">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<div class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEasterEgg"></div>
<NcButton <NcButton
:type="testSuccess ? 'ghost' : 'primary'" :type="testSuccess ? 'ghost' : 'primary'"
size="small" size="small"

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

@ -76,7 +76,7 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
}" }"
> >
<div <div
v-if="isUIAllowed('tableCreate')" v-if="isUIAllowed('tableCreate', { source: base?.sources?.[0] })"
role="button" role="button"
class="nc-base-view-all-table-btn" class="nc-base-view-all-table-btn"
data-testid="proj-view-btn__add-new-table" data-testid="proj-view-btn__add-new-table"
@ -86,7 +86,7 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
<div class="label">{{ $t('general.new') }} {{ $t('objects.table') }}</div> <div class="label">{{ $t('general.new') }} {{ $t('objects.table') }}</div>
</div> </div>
<div <div
v-if="isUIAllowed('tableCreate')" v-if="isUIAllowed('tableCreate', { source: base?.sources?.[0] })"
v-e="['c:table:import']" v-e="['c:table:import']"
role="button" role="button"
class="nc-base-view-all-table-btn" class="nc-base-view-all-table-btn"

4
packages/nc-gui/components/smartsheet/Details.vue

@ -8,7 +8,7 @@ const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const { isUIAllowed } = useRoles() const { isUIAllowed, isDataReadOnly } = useRoles()
const { base } = storeToRefs(useBase()) const { base } = storeToRefs(useBase())
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
@ -85,7 +85,7 @@ watch(openedSubTab, () => {
</div> </div>
</a-tab-pane> </a-tab-pane>
<a-tab-pane v-if="isUIAllowed('hookList')" key="webhook"> <a-tab-pane v-if="isUIAllowed('hookList') && !isDataReadOnly" key="webhook">
<template #tab> <template #tab>
<div class="tab" data-testid="nc-webhooks-tab"> <div class="tab" data-testid="nc-webhooks-tab">
<GeneralIcon icon="webhook" class="tab-icon" :class="{}" /> <GeneralIcon icon="webhook" class="tab-icon" :class="{}" />

2
packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue

@ -996,7 +996,7 @@ watch(
</template> </template>
</NcDropdown> </NcDropdown>
<NcButton <NcButton
v-else-if="!isPublic" v-else-if="!isPublic && isUIAllowed('dataEdit')"
:class="{ :class="{
'!block': hour.isSame(selectedTime), '!block': hour.isSame(selectedTime),
'!hidden': !hour.isSame(selectedTime), '!hidden': !hour.isSame(selectedTime),

44
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -1,7 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnReqType, ColumnType } from 'nocodb-sdk' import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { UITypes, UITypesName, isLinksOrLTAR, isSelfReferencingTableColumn, isSystemColumn, isVirtualCol } from 'nocodb-sdk' import {
UITypes,
UITypesName,
isLinksOrLTAR,
isSelfReferencingTableColumn,
isSystemColumn,
isVirtualCol,
readonlyMetaAllowedTypes,
} from 'nocodb-sdk'
import MdiPlusIcon from '~icons/mdi/plus-circle-outline' import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline' import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
import MdiIdentifierIcon from '~icons/mdi/identifier' import MdiIdentifierIcon from '~icons/mdi/identifier'
@ -39,6 +46,8 @@ const { getMeta } = useMetas()
const { t } = useI18n() const { t } = useI18n()
const { isMetaReadOnly } = useRoles()
const columnLabel = computed(() => props.columnLabel || t('objects.field')) const columnLabel = computed(() => props.columnLabel || t('objects.field'))
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -125,7 +134,7 @@ const uiFilters = (t: { name: UITypes; virtual?: number; deprecated?: boolean })
} }
const uiTypesOptions = computed<typeof uiTypes>(() => { const uiTypesOptions = computed<typeof uiTypes>(() => {
return [ const types = [
...uiTypes.filter(uiFilters), ...uiTypes.filter(uiFilters),
...(!isEdit.value && meta?.value?.columns?.every((c) => !c.pk) ...(!isEdit.value && meta?.value?.columns?.every((c) => !c.pk)
? [ ? [
@ -137,6 +146,21 @@ const uiTypesOptions = computed<typeof uiTypes>(() => {
] ]
: []), : []),
] ]
// if meta is readonly, move disabled types to the end
if (isMetaReadOnly.value) {
types.sort((a, b) => {
const aDisabled = readonlyMetaAllowedTypes.includes(a.name)
const bDisabled = readonlyMetaAllowedTypes.includes(b.name)
if (aDisabled && !bDisabled) return -1
if (!aDisabled && bDisabled) return 1
return 0
})
}
return types
}) })
const onSelectType = (uidt: UITypes) => { const onSelectType = (uidt: UITypes) => {
@ -381,7 +405,12 @@ const filterOption = (input: string, option: { value: UITypes }) => {
v-model:value="formState.uidt" v-model:value="formState.uidt"
show-search show-search
class="nc-column-type-input !rounded-lg" class="nc-column-type-input !rounded-lg"
:disabled="isKanban || readOnly || (isEdit && !!onlyNameUpdateOnEditColumns.includes(column?.uidt))" :disabled="
(isMetaReadOnly && !readonlyMetaAllowedTypes.includes(formState.uidt)) ||
isKanban ||
readOnly ||
(isEdit && !!onlyNameUpdateOnEditColumns.includes(column?.uidt))
"
dropdown-class-name="nc-dropdown-column-type border-1 !rounded-lg border-gray-200" dropdown-class-name="nc-dropdown-column-type border-1 !rounded-lg border-gray-200"
:filter-option="filterOption" :filter-option="filterOption"
@dropdown-visible-change="onDropdownChange" @dropdown-visible-change="onDropdownChange"
@ -395,6 +424,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
v-for="opt of uiTypesOptions" v-for="opt of uiTypesOptions"
:key="opt.name" :key="opt.name"
:value="opt.name" :value="opt.name"
:disabled="isMetaReadOnly && !readonlyMetaAllowedTypes.includes(opt.name)"
v-bind="validateInfos.uidt" v-bind="validateInfos.uidt"
:class="{ :class="{
'ant-select-item-option-active-selected': showHoverEffectOnSelectedType && formState.uidt === opt.name, 'ant-select-item-option-active-selected': showHoverEffectOnSelectedType && formState.uidt === opt.name,
@ -403,7 +433,11 @@ const filterOption = (input: string, option: { value: UITypes }) => {
> >
<div class="w-full flex gap-2 items-center justify-between" :data-testid="opt.name"> <div class="w-full flex gap-2 items-center justify-between" :data-testid="opt.name">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<component :is="opt.icon" class="text-gray-700 w-4 h-4" /> <component
:is="opt.icon"
class="w-4 h-4"
:class="isMetaReadOnly && !readonlyMetaAllowedTypes.includes(opt.name) ? 'text-gray-300' : 'text-gray-700'"
/>
<div class="flex-1">{{ UITypesName[opt.name] }}</div> <div class="flex-1">{{ UITypesName[opt.name] }}</div>
<span v-if="opt.deprecated" class="!text-xs !text-gray-300">({{ $t('general.deprecated') }})</span> <span v-if="opt.deprecated" class="!text-xs !text-gray-300">({{ $t('general.deprecated') }})</span>
</div> </div>

32
packages/nc-gui/components/smartsheet/column/UITypesOptionsWithSearch.vue

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { UITypes, UITypesName } from 'nocodb-sdk' import { UITypes, UITypesName, readonlyMetaAllowedTypes } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{
options: typeof uiTypes options: typeof uiTypes
@ -11,6 +11,8 @@ const { options } = toRefs(props)
const searchQuery = ref('') const searchQuery = ref('')
const { isMetaReadOnly } = useRoles()
const filteredOptions = computed( const filteredOptions = computed(
() => () =>
options.value?.filter( options.value?.filter(
@ -25,7 +27,7 @@ const inputRef = ref()
const activeFieldIndex = ref(-1) const activeFieldIndex = ref(-1)
const onClick = (uidt: UITypes) => { const onClick = (uidt: UITypes) => {
if (!uidt) return if (!uidt || isDisabledUIType(uidt)) return
emits('selected', uidt) emits('selected', uidt)
} }
@ -62,6 +64,10 @@ onMounted(() => {
searchQuery.value = '' searchQuery.value = ''
activeFieldIndex.value = options.value.findIndex((o) => o.name === UITypes.SingleLineText) activeFieldIndex.value = options.value.findIndex((o) => o.name === UITypes.SingleLineText)
}) })
const isDisabledUIType = (type: UITypes) => {
return isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(type)
}
</script> </script>
<template> <template>
@ -95,25 +101,39 @@ onMounted(() => {
{{ options.length ? $t('title.noResultsMatchedYourSearch') : 'The list is empty' }} {{ options.length ? $t('title.noResultsMatchedYourSearch') : 'The list is empty' }}
</div> </div>
<div <NcTooltip
v-for="(option, index) in filteredOptions" v-for="(option, index) in filteredOptions"
:key="index" :key="index"
class="flex w-full py-2 items-center justify-between px-2 hover:bg-gray-100 cursor-pointer rounded-md" :disabled="!isDisabledUIType(option.name)"
placement="left"
>
<template #title>
{{ $t('tooltip.typeNotAllowed') }}
</template>
<div
class="flex w-full py-2 items-center justify-between px-2 rounded-md"
:class="[ :class="[
`nc-column-list-option-${index}`, `nc-column-list-option-${index}`,
{ {
'bg-gray-100 nc-column-list-option-active': activeFieldIndex === index, 'hover:bg-gray-100 cursor-pointer': !isDisabledUIType(option.name),
'bg-gray-100 nc-column-list-option-active': activeFieldIndex === index && !isDisabledUIType(option.name),
'!text-gray-400 cursor-not-allowed': isDisabledUIType(option.name),
}, },
]" ]"
:data-testid="option.name" :data-testid="option.name"
@click="onClick(option.name)" @click="onClick(option.name)"
> >
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<component :is="option.icon" class="text-gray-700 w-4 h-4" /> <component
:is="option.icon"
class="w-4 h-4"
:class="isDisabledUIType(option.name) ? '!text-gray-400' : 'text-gray-700'"
/>
<div class="flex-1 text-sm">{{ UITypesName[option.name] }}</div> <div class="flex-1 text-sm">{{ UITypesName[option.name] }}</div>
<span v-if="option.deprecated" class="!text-xs !text-gray-300">({{ $t('general.deprecated') }})</span> <span v-if="option.deprecated" class="!text-xs !text-gray-300">({{ $t('general.deprecated') }})</span>
</div> </div>
</div> </div>
</NcTooltip>
</div> </div>
</div> </div>
</template> </template>

15
packages/nc-gui/components/smartsheet/details/Fields.vue

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { diff } from 'deep-object-diff' import { diff } from 'deep-object-diff'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol, readonlyMetaAllowedTypes } from 'nocodb-sdk'
import type { ColumnType, FilterType, SelectOptionsType } from 'nocodb-sdk' import type { ColumnType, FilterType, SelectOptionsType } from 'nocodb-sdk'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { onKeyDown, useMagicKeys } from '@vueuse/core' import { onKeyDown, useMagicKeys } from '@vueuse/core'
@ -182,7 +182,18 @@ const setFieldMoveHook = (field: TableExplorerColumn, before = false) => {
} }
} }
const { isMetaReadOnly } = useRoles()
const isColumnUpdateAllowed = (column: ColumnType) => {
if (isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(column?.uidt)) return false
return true
}
const changeField = (field?: TableExplorerColumn, event?: MouseEvent) => { const changeField = (field?: TableExplorerColumn, event?: MouseEvent) => {
if (!isColumnUpdateAllowed(field)) {
return message.info(t('msg.info.schemaReadOnly'))
}
if (field && field?.pk) { if (field && field?.pk) {
// Editing primary key not supported // Editing primary key not supported
message.info(t('msg.info.editingPKnotSupported')) message.info(t('msg.info.editingPKnotSupported'))
@ -1003,7 +1014,7 @@ watch(
<div <div
v-if="field.title.toLowerCase().includes(searchQuery.toLowerCase()) && !field.pv" v-if="field.title.toLowerCase().includes(searchQuery.toLowerCase()) && !field.pv"
class="flex px-2 hover:bg-gray-100 first:rounded-t-lg border-b-1 last:rounded-b-none border-gray-200 pl-5 group" class="flex px-2 hover:bg-gray-100 first:rounded-t-lg border-b-1 last:rounded-b-none border-gray-200 pl-5 group"
:class="` ${compareCols(field, activeField) ? 'selected' : ''}`" :class="{ 'selected': compareCols(field, activeField), 'cursor-not-allowed': !isColumnUpdateAllowed(field) }"
:data-testid="`nc-field-item-${fieldState(field)?.title || field.title}`" :data-testid="`nc-field-item-${fieldState(field)?.title || field.title}`"
@click="changeField(field, $event)" @click="changeField(field, $event)"
> >

33
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -166,7 +166,7 @@ const isViewColumnsLoading = computed(() => _isViewColumnsLoading.value || !meta
const resizingColumn = ref(false) const resizingColumn = ref(false)
// #Permissions // #Permissions
const { isUIAllowed } = useRoles() const { isUIAllowed, isDataReadOnly } = useRoles()
const hasEditPermission = computed(() => isUIAllowed('dataEdit')) const hasEditPermission = computed(() => isUIAllowed('dataEdit'))
const isAddingColumnAllowed = computed(() => !readOnly.value && !isLocked.value && isUIAllowed('fieldAdd') && !isSqlView.value) const isAddingColumnAllowed = computed(() => !readOnly.value && !isLocked.value && isUIAllowed('fieldAdd') && !isSqlView.value)
@ -209,7 +209,10 @@ const isGridCellMouseDown = ref(false)
// #Context Menu // #Context Menu
const _contextMenu = ref(false) const _contextMenu = ref(false)
const contextMenu = computed({ const contextMenu = computed({
get: () => _contextMenu.value, get: () => {
if (props.data?.some((r) => r.rowMeta.selected) && isDataReadOnly.value) return false
return _contextMenu.value
},
set: (val) => { set: (val) => {
_contextMenu.value = val _contextMenu.value = val
}, },
@ -233,7 +236,13 @@ const isKeyDown = ref(false)
// #Cell - 1 // #Cell - 1
async function clearCell(ctx: { row: number; col: number } | null, skipUpdate = false) { async function clearCell(ctx: { row: number; col: number } | null, skipUpdate = false) {
if (!ctx || !hasEditPermission.value || (!isLinksOrLTAR(fields.value[ctx.col]) && isVirtualCol(fields.value[ctx.col]))) return if (
isDataReadOnly.value ||
!ctx ||
!hasEditPermission.value ||
(!isLinksOrLTAR(fields.value[ctx.col]) && isVirtualCol(fields.value[ctx.col]))
)
return
// eslint-disable-next-line @typescript-eslint/no-use-before-define // eslint-disable-next-line @typescript-eslint/no-use-before-define
if (colMeta.value[ctx.col].isReadonly) return if (colMeta.value[ctx.col].isReadonly) return
@ -916,7 +925,7 @@ const onNavigate = (dir: NavigateDir) => {
// #Cell - 2 // #Cell - 2
async function clearSelectedRangeOfCells() { async function clearSelectedRangeOfCells() {
if (!hasEditPermission.value) return if (!hasEditPermission.value || isDataReadOnly.value) return
const start = selectedRange.start const start = selectedRange.start
const end = selectedRange.end const end = selectedRange.end
@ -1278,6 +1287,7 @@ const selectedReadonly = computed(
const showFillHandle = computed( const showFillHandle = computed(
() => () =>
!isDataReadOnly.value &&
!readOnly.value && !readOnly.value &&
!editEnabled.value && !editEnabled.value &&
(!selectedRange.isEmpty() || (activeCell.row !== null && activeCell.col !== null)) && (!selectedRange.isEmpty() || (activeCell.row !== null && activeCell.col !== null)) &&
@ -2165,7 +2175,9 @@ onKeyStroke('ArrowDown', onDown)
<template #overlay> <template #overlay>
<NcMenu class="!rounded !py-0" @click="contextMenu = false"> <NcMenu class="!rounded !py-0" @click="contextMenu = false">
<NcMenuItem <NcMenuItem
v-if="isEeUI && !contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected)" v-if="
isEeUI && !contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected) && !isDataReadOnly
"
@click="emits('bulkUpdateDlg')" @click="emits('bulkUpdateDlg')"
> >
<div v-e="['a:row:update-bulk']" class="flex gap-2 items-center"> <div v-e="['a:row:update-bulk']" class="flex gap-2 items-center">
@ -2175,7 +2187,7 @@ onKeyStroke('ArrowDown', onDown)
</NcMenuItem> </NcMenuItem>
<NcMenuItem <NcMenuItem
v-if="!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected)" v-if="!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected) && !isDataReadOnly"
class="nc-base-menu-item !text-red-600 !hover:bg-red-50" class="nc-base-menu-item !text-red-600 !hover:bg-red-50"
data-testid="nc-delete-row" data-testid="nc-delete-row"
@click="deleteSelectedRows" @click="deleteSelectedRows"
@ -2222,7 +2234,7 @@ onKeyStroke('ArrowDown', onDown)
</NcMenuItem> </NcMenuItem>
<NcMenuItem <NcMenuItem
v-if="contextMenuTarget && hasEditPermission" v-if="contextMenuTarget && hasEditPermission && !isDataReadOnly"
class="nc-base-menu-item" class="nc-base-menu-item"
data-testid="context-menu-item-paste" data-testid="context-menu-item-paste"
:disabled="selectedReadonly" :disabled="selectedReadonly"
@ -2241,7 +2253,8 @@ onKeyStroke('ArrowDown', onDown)
contextMenuTarget && contextMenuTarget &&
hasEditPermission && hasEditPermission &&
selectedRange.isSingleCell() && selectedRange.isSingleCell() &&
(isLinksOrLTAR(fields[contextMenuTarget.col]) || !cellMeta[0]?.[contextMenuTarget.col].isVirtualCol) (isLinksOrLTAR(fields[contextMenuTarget.col]) || !cellMeta[0]?.[contextMenuTarget.col].isVirtualCol) &&
!isDataReadOnly
" "
class="nc-base-menu-item" class="nc-base-menu-item"
:disabled="selectedReadonly" :disabled="selectedReadonly"
@ -2256,7 +2269,7 @@ onKeyStroke('ArrowDown', onDown)
<!-- Clear cell --> <!-- Clear cell -->
<NcMenuItem <NcMenuItem
v-else-if="contextMenuTarget && hasEditPermission" v-else-if="contextMenuTarget && hasEditPermission && !isDataReadOnly"
class="nc-base-menu-item" class="nc-base-menu-item"
:disabled="selectedReadonly" :disabled="selectedReadonly"
data-testid="context-menu-item-clear" data-testid="context-menu-item-clear"
@ -2278,7 +2291,7 @@ onKeyStroke('ArrowDown', onDown)
</NcMenuItem> </NcMenuItem>
</template> </template>
<template v-if="hasEditPermission"> <template v-if="hasEditPermission && !isDataReadOnly">
<NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" /> <NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" />
<NcMenuItem <NcMenuItem
v-if="contextMenuTarget && (selectedRange.isSingleCell() || selectedRange.isSingleRow())" v-if="contextMenuTarget && (selectedRange.isSingleCell() || selectedRange.isSingleRow())"

11
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnReqType, ColumnType } from 'nocodb-sdk' import { type ColumnReqType, type ColumnType, readonlyMetaAllowedTypes } from 'nocodb-sdk'
import { UITypes, UITypesName } from 'nocodb-sdk' import { UITypes, UITypesName } from 'nocodb-sdk'
interface Props { interface Props {
@ -32,7 +32,7 @@ const isDropDownOpen = ref(false)
const column = toRef(props, 'column') const column = toRef(props, 'column')
const { isUIAllowed } = useRoles() const { isUIAllowed, isMetaReadOnly } = useRoles()
provide(ColumnInj, column) provide(ColumnInj, column)
@ -60,7 +60,12 @@ const closeAddColumnDropdown = () => {
const openHeaderMenu = (e?: MouseEvent) => { const openHeaderMenu = (e?: MouseEvent) => {
if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return
if (!isForm.value && isUIAllowed('fieldEdit') && !isMobileMode.value) { if (
!isForm.value &&
isUIAllowed('fieldEdit') &&
!isMobileMode.value &&
(!isMetaReadOnly.value || readonlyMetaAllowedTypes.includes(column.value.uidt))
) {
editColumnDropdown.value = true editColumnDropdown.value = true
} }
} }

49
packages/nc-gui/components/smartsheet/header/Menu.vue

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnReqType } from 'nocodb-sdk' import { type ColumnReqType, readonlyMetaAllowedTypes } from 'nocodb-sdk'
import { PlanLimitTypes, RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk' import { PlanLimitTypes, RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import { SmartsheetStoreEvents } from '#imports' import { SmartsheetStoreEvents } from '#imports'
@ -43,6 +43,8 @@ const { gridViewCols } = useViewColumnsOrThrow()
const { fieldsToGroupBy, groupByLimit } = useViewGroupByOrThrow(view) const { fieldsToGroupBy, groupByLimit } = useViewGroupByOrThrow(view)
const { isUIAllowed, isMetaReadOnly, isDataReadOnly } = useRoles()
const isLoading = ref<'' | 'hideOrShow' | 'setDisplay'>('') const isLoading = ref<'' | 'hideOrShow' | 'setDisplay'>('')
const setAsDisplayValue = async () => { const setAsDisplayValue = async () => {
@ -323,7 +325,11 @@ const isDeleteAllowed = computed(() => {
return column?.value && !column.value.system return column?.value && !column.value.system
}) })
const isDuplicateAllowed = computed(() => { const isDuplicateAllowed = computed(() => {
return column?.value && !column.value.system return (
column?.value &&
!column.value.system &&
((!isMetaReadOnly.value && !isDataReadOnly.value) || readonlyMetaAllowedTypes.includes(column.value?.uidt))
)
}) })
const isFilterSupported = computed( const isFilterSupported = computed(
() => () =>
@ -352,6 +358,11 @@ const filterOrGroupByThisField = (event: SmartsheetStoreEvents) => {
} }
isOpen.value = false isOpen.value = false
} }
const isColumnUpdateAllowed = computed(() => {
if (isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(column.value?.uidt)) return false
return true
})
</script> </script>
<template> <template>
@ -374,21 +385,29 @@ const filterOrGroupByThisField = (event: SmartsheetStoreEvents) => {
'min-w-[256px]': isExpandedForm, 'min-w-[256px]': isExpandedForm,
}" }"
> >
<NcMenuItem :disabled="column?.pk || isSystemColumn(column)" @click="onEditPress"> <NcMenuItem
v-if="isUIAllowed('fieldAlter')"
:disabled="column?.pk || isSystemColumn(column) || !isColumnUpdateAllowed"
@click="onEditPress"
>
<div class="nc-column-edit nc-header-menu-item"> <div class="nc-column-edit nc-header-menu-item">
<component :is="iconMap.ncEdit" class="text-gray-700" /> <component :is="iconMap.ncEdit" class="text-gray-700" />
<!-- Edit --> <!-- Edit -->
{{ $t('general.edit') }} {{ $t('general.edit') }}
</div> </div>
</NcMenuItem> </NcMenuItem>
<NcMenuItem v-if="isExpandedForm && !column?.pk" :disabled="!isDuplicateAllowed" @click="openDuplicateDlg"> <NcMenuItem
v-if="isUIAllowed('duplicateColumn') && isExpandedForm && !column?.pk"
:disabled="!isDuplicateAllowed"
@click="openDuplicateDlg"
>
<div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item"> <div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item">
<component :is="iconMap.duplicate" class="text-gray-700" /> <component :is="iconMap.duplicate" class="text-gray-700" />
<!-- Duplicate --> <!-- Duplicate -->
{{ t('general.duplicate') }} {{ t('general.duplicate') }}
</div> </div>
</NcMenuItem> </NcMenuItem>
<a-divider v-if="!column?.pv" class="!my-0" /> <a-divider v-if="isUIAllowed('fieldAlter') && !column?.pv" class="!my-0" />
<NcMenuItem v-if="!column?.pv" @click="hideOrShowField"> <NcMenuItem v-if="!column?.pv" @click="hideOrShowField">
<div v-e="['a:field:hide']" class="nc-column-insert-before nc-header-menu-item"> <div v-e="['a:field:hide']" class="nc-column-insert-before nc-header-menu-item">
<GeneralLoader v-if="isLoading === 'hideOrShow'" size="regular" /> <GeneralLoader v-if="isLoading === 'hideOrShow'" size="regular" />
@ -465,13 +484,15 @@ const filterOrGroupByThisField = (event: SmartsheetStoreEvents) => {
<NcTooltip <NcTooltip
:disabled="(isGroupBySupported && !isGroupByLimitExceeded) || isGroupedByThisField || !(isEeUI && !isPublic)" :disabled="(isGroupBySupported && !isGroupByLimitExceeded) || isGroupedByThisField || !(isEeUI && !isPublic)"
> >
<template #title>{{ <template #title
>{{
!isGroupBySupported !isGroupBySupported
? "This field type doesn't support grouping" ? "This field type doesn't support grouping"
: isGroupByLimitExceeded : isGroupByLimitExceeded
? 'Group by limit exceeded' ? 'Group by limit exceeded'
: '' : ''
}}</template> }}
</template>
<NcMenuItem <NcMenuItem
:disabled="isEeUI && !isPublic && (!isGroupBySupported || isGroupByLimitExceeded) && !isGroupedByThisField" :disabled="isEeUI && !isPublic && (!isGroupBySupported || isGroupByLimitExceeded) && !isGroupedByThisField"
@click=" @click="
@ -513,9 +534,16 @@ const filterOrGroupByThisField = (event: SmartsheetStoreEvents) => {
</NcMenuItem> </NcMenuItem>
</template> </template>
<a-divider v-if="!column?.pv" class="!my-0" /> <a-divider v-if="!column?.pv" class="!my-0" />
<NcMenuItem
<NcMenuItem v-if="!column?.pv" :disabled="!isDeleteAllowed" class="!hover:bg-red-50" @click="handleDelete"> v-if="!column?.pv && isUIAllowed('fieldDelete')"
<div class="nc-column-delete nc-header-menu-item text-red-600"> :disabled="!isDeleteAllowed || !isColumnUpdateAllowed"
class="!hover:bg-red-50"
@click="handleDelete"
>
<div
class="nc-column-delete nc-header-menu-item"
:class="{ ' text-red-600': isDeleteAllowed && isColumnUpdateAllowed }"
>
<component :is="iconMap.delete" /> <component :is="iconMap.delete" />
<!-- Delete --> <!-- Delete -->
{{ $t('general.delete') }} {{ $t('general.delete') }}
@ -548,6 +576,7 @@ const filterOrGroupByThisField = (event: SmartsheetStoreEvents) => {
:deep(.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled)) { :deep(.ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled)) {
@apply !hover:text-black text-gray-700; @apply !hover:text-black text-gray-700;
} }
:deep(.ant-dropdown-menu-item.ant-dropdown-menu-item-disabled .nc-icon) { :deep(.ant-dropdown-menu-item.ant-dropdown-menu-item-disabled .nc-icon) {
@apply text-current; @apply text-current;
} }

10
packages/nc-gui/components/smartsheet/header/VirtualCell.vue

@ -7,6 +7,7 @@ import {
type LookupType, type LookupType,
type RollupType, type RollupType,
isLinksOrLTAR, isLinksOrLTAR,
readonlyMetaAllowedTypes,
} from 'nocodb-sdk' } from 'nocodb-sdk'
import { RelationTypes, UITypes, UITypesName, substituteColumnIdWithAliasInFormula } from 'nocodb-sdk' import { RelationTypes, UITypes, UITypesName, substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
@ -36,7 +37,7 @@ provide(ColumnInj, column)
const { metas } = useMetas() const { metas } = useMetas()
const { isUIAllowed } = useRoles() const { isUIAllowed, isMetaReadOnly } = useRoles()
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
@ -122,7 +123,12 @@ const closeAddColumnDropdown = () => {
const openHeaderMenu = (e?: MouseEvent) => { const openHeaderMenu = (e?: MouseEvent) => {
if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return
if (!isForm.value && isUIAllowed('fieldEdit') && !isMobileMode.value) { if (
!isForm.value &&
isUIAllowed('fieldEdit') &&
!isMobileMode.value &&
(!isMetaReadOnly.value || readonlyMetaAllowedTypes.includes(column.value.uidt))
) {
editColumnDropdown.value = true editColumnDropdown.value = true
} }
} }

2
packages/nc-gui/components/smartsheet/toolbar/Calendar/Range.vue

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { type CalendarRangeType, UITypes, ViewTypes, isSystemColumn } from 'nocodb-sdk' import { type CalendarRangeType, UITypes, isSystemColumn } from 'nocodb-sdk'
import type { SelectProps } from 'ant-design-vue' import type { SelectProps } from 'ant-design-vue'
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())

4
packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue

@ -16,7 +16,7 @@ const props = withDefaults(
const emits = defineEmits(['rename', 'closeModal', 'delete']) const emits = defineEmits(['rename', 'closeModal', 'delete'])
const { isUIAllowed } = useRoles() const { isUIAllowed, isDataReadOnly } = useRoles()
const isPublicView = inject(IsPublicInj, ref(false)) const isPublicView = inject(IsPublicInj, ref(false))
@ -193,7 +193,7 @@ const onDelete = async () => {
<template v-if="view.type !== ViewTypes.FORM"> <template v-if="view.type !== ViewTypes.FORM">
<NcDivider /> <NcDivider />
<template v-if="isUIAllowed('csvTableImport') && !isPublicView"> <template v-if="isUIAllowed('csvTableImport') && !isPublicView && !isDataReadOnly">
<NcSubMenu key="upload"> <NcSubMenu key="upload">
<template #title> <template #title>
<div <div

14
packages/nc-gui/components/tabs/Smartsheet.vue

@ -39,6 +39,12 @@ const reloadViewMetaEventHook = createEventHook<void | boolean>()
const openNewRecordFormHook = createEventHook<void>() const openNewRecordFormHook = createEventHook<void>()
const { base } = storeToRefs(useBase())
const activeSource = computed(() => {
return meta.value?.source_id && base.value && base.value.sources?.find((source) => source.id === meta.value?.source_id)
})
useProvideKanbanViewStore(meta, activeView) useProvideKanbanViewStore(meta, activeView)
useProvideMapViewStore(meta, activeView) useProvideMapViewStore(meta, activeView)
useProvideCalendarViewStore(meta, activeView) useProvideCalendarViewStore(meta, activeView)
@ -52,9 +58,15 @@ provide(ReloadViewMetaHookInj, reloadViewMetaEventHook)
provide(OpenNewRecordFormHookInj, openNewRecordFormHook) provide(OpenNewRecordFormHookInj, openNewRecordFormHook)
provide(IsFormInj, isForm) provide(IsFormInj, isForm)
provide(TabMetaInj, activeTab) provide(TabMetaInj, activeTab)
provide(ActiveSourceInj, activeSource)
provide( provide(
ReadonlyInj, ReadonlyInj,
computed(() => !isUIAllowed('dataEdit')), computed(
() =>
!isUIAllowed('dataEdit', {
skipSourceCheck: true,
}),
),
) )
useExpandedFormDetachedProvider() useExpandedFormDetachedProvider()

4
packages/nc-gui/components/virtual-cell/components/LinkedItems.vue

@ -31,6 +31,8 @@ const readOnly = inject(ReadonlyInj, ref(false))
const filterQueryRef = ref<HTMLInputElement>() const filterQueryRef = ref<HTMLInputElement>()
const { isDataReadOnly } = useRoles()
const { isSharedBase } = storeToRefs(useBase()) const { isSharedBase } = storeToRefs(useBase())
const { const {
@ -410,7 +412,7 @@ const onFilterChange = () => {
<div class="nc-dropdown-link-record-footer bg-gray-100 p-2 rounded-b-xl flex items-center justify-between gap-3 min-h-11"> <div class="nc-dropdown-link-record-footer bg-gray-100 p-2 rounded-b-xl flex items-center justify-between gap-3 min-h-11">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<NcButton <NcButton
v-if="!isPublic" v-if="!isPublic && !isDataReadOnly"
v-e="['c:row-expand:open']" v-e="['c:row-expand:open']"
size="small" size="small"
class="!hover:(bg-white text-brand-500) !h-7 !text-small" class="!hover:(bg-white text-brand-500) !h-7 !text-small"

4
packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue

@ -21,6 +21,8 @@ const { t } = useI18n()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const { isDataReadOnly } = useRoles()
const { const {
childrenExcludedList, childrenExcludedList,
isChildrenExcludedListLinked, isChildrenExcludedListLinked,
@ -403,7 +405,7 @@ const onFilterChange = () => {
<div class="nc-dropdown-link-record-footer bg-gray-100 p-2 rounded-b-xl flex items-center justify-between min-h-11"> <div class="nc-dropdown-link-record-footer bg-gray-100 p-2 rounded-b-xl flex items-center justify-between min-h-11">
<div class="flex"> <div class="flex">
<NcButton <NcButton
v-if="!isPublic" v-if="!isPublic && !isDataReadOnly"
v-e="['c:row-expand:open']" v-e="['c:row-expand:open']"
size="small" size="small"
class="!hover:(bg-white text-brand-500) !h-7 !text-small" class="!hover:(bg-white text-brand-500) !h-7 !text-small"

2
packages/nc-gui/composables/useCalendarViewStore.ts

@ -558,7 +558,7 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
} }
async function updateCalendarMeta(updateObj: Partial<CalendarType>) { async function updateCalendarMeta(updateObj: Partial<CalendarType>) {
if (!viewMeta?.value?.id || !isUIAllowed('dataEdit') || isPublic.value) return if (!viewMeta?.value?.id || !isUIAllowed('dataEdit', { skipSourceCheck: true }) || isPublic.value) return
const updateValue = { const updateValue = {
...(typeof calendarMetaData.value.meta === 'string' ...(typeof calendarMetaData.value.meta === 'string'

8
packages/nc-gui/composables/useKanbanViewStore.ts

@ -312,7 +312,13 @@ const [useProvideKanbanViewStore, useKanbanViewStore] = useInjectionState(
} }
async function updateKanbanMeta(updateObj: Partial<KanbanType>) { async function updateKanbanMeta(updateObj: Partial<KanbanType>) {
if (!viewMeta?.value?.id || !isUIAllowed('dataEdit')) return if (
!viewMeta?.value?.id ||
!isUIAllowed('dataEdit', {
skipSourceCheck: true,
})
)
return
await $api.dbView.kanbanUpdate(viewMeta.value.id, updateObj) await $api.dbView.kanbanUpdate(viewMeta.value.id, updateObj)
} }

2
packages/nc-gui/composables/useMapViewDataStore.ts

@ -84,7 +84,7 @@ const [useProvideMapViewStore, useMapViewStore] = useInjectionState(
} }
async function updateMapMeta(updateObj: Partial<MapType>) { async function updateMapMeta(updateObj: Partial<MapType>) {
if (!viewMeta?.value?.id || !isUIAllowed('dataEdit')) return if (!viewMeta?.value?.id || !isUIAllowed('dataEdit', { skipSourceCheck: true })) return
await $api.dbView.mapUpdate(viewMeta.value.id, updateObj) await $api.dbView.mapUpdate(viewMeta.value.id, updateObj)
} }

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

@ -79,13 +79,8 @@ export const useMetas = createSharedComposable(() => {
if (!force && metas.value[tableIdOrTitle]) { if (!force && metas.value[tableIdOrTitle]) {
return metas.value[tableIdOrTitle] return metas.value[tableIdOrTitle]
} }
const modelId =
const modelId = (tables.find((t) => t.id === tableIdOrTitle) || tables.find((t) => t.title === tableIdOrTitle))?.id (tables.find((t) => t.id === tableIdOrTitle) || tables.find((t) => t.title === tableIdOrTitle))?.id || tableIdOrTitle
if (!modelId) {
console.warn(`Table '${tableIdOrTitle}' is not found in the table list`)
return null
}
const model = await $api.dbTable.read(modelId) const model = await $api.dbTable.read(modelId)
metas.value = { metas.value = {

10
packages/nc-gui/composables/useMultiSelect/index.ts

@ -67,6 +67,8 @@ export function useMultiSelect(
const { addUndo, clone, defineViewScope } = useUndoRedo() const { addUndo, clone, defineViewScope } = useUndoRedo()
const { isDataReadOnly } = useRoles()
const editEnabled = ref(_editEnabled) const editEnabled = ref(_editEnabled)
const isMouseDown = ref(false) const isMouseDown = ref(false)
@ -611,7 +613,9 @@ export function useMultiSelect(
case 'Delete': case 'Delete':
case 'Backspace': case 'Backspace':
e.preventDefault() e.preventDefault()
if (isDataReadOnly.value) {
return
}
if (selectedRange.isSingleCell()) { if (selectedRange.isSingleCell()) {
selectedRange.clear() selectedRange.clear()
@ -797,6 +801,10 @@ export function useMultiSelect(
const clearSelectedRange = selectedRange.clear.bind(selectedRange) const clearSelectedRange = selectedRange.clear.bind(selectedRange)
const handlePaste = async (e: ClipboardEvent) => { const handlePaste = async (e: ClipboardEvent) => {
if (isDataReadOnly.value) {
return
}
if (isDrawerOrModalExist() || isExpandedCellInputExist()) { if (isDrawerOrModalExist() || isExpandedCellInputExist()) {
return return
} }

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

@ -1,6 +1,7 @@
import { isString } from '@vue/shared' import { isString } from '@vue/shared'
import type { Roles, RolesObj, WorkspaceUserRoles } from 'nocodb-sdk' import { type Roles, type RolesObj, SourceRestriction, type SourceType, type WorkspaceUserRoles } from 'nocodb-sdk'
import { extractRolesObj } from 'nocodb-sdk' import { extractRolesObj } from 'nocodb-sdk'
import type { MaybeRef } from 'vue'
const hasPermission = (role: Exclude<Roles, WorkspaceUserRoles>, hasRole: boolean, permission: Permission | string) => { const hasPermission = (role: Exclude<Roles, WorkspaceUserRoles>, hasRole: boolean, permission: Permission | string) => {
const rolePermission = rolePermissions[role] const rolePermission = rolePermissions[role]
@ -24,7 +25,7 @@ const hasPermission = (role: Exclude<Roles, WorkspaceUserRoles>, hasRole: boolea
* * `allRoles` - all roles a user has (userRoles + baseRoles) * * `allRoles` - all roles a user has (userRoles + baseRoles)
* * `loadRoles` - a function to load reload user roles for scope * * `loadRoles` - a function to load reload user roles for scope
*/ */
export const useRoles = createSharedComposable(() => { export const useRolesShared = createSharedComposable(() => {
const { user } = useGlobal() const { user } = useGlobal()
const { api } = useApi() const { api } = useApi()
@ -129,7 +130,11 @@ export const useRoles = createSharedComposable(() => {
const isUIAllowed = ( const isUIAllowed = (
permission: Permission | string, permission: Permission | string,
args: { roles?: string | Record<string, boolean> | string[] | null } = {}, args: {
roles?: string | Record<string, boolean> | string[] | null
source?: MaybeRef<SourceType & { meta?: Record<string, any> }>
skipSourceCheck?: boolean
} = {},
) => { ) => {
const { roles } = args const { roles } = args
@ -141,6 +146,27 @@ export const useRoles = createSharedComposable(() => {
checkRoles = extractRolesObj(roles) checkRoles = extractRolesObj(roles)
} }
// check source level restrictions
if (
!args.skipSourceCheck &&
(sourceRestrictions[SourceRestriction.DATA_READONLY][permission] ||
sourceRestrictions[SourceRestriction.SCHEMA_READONLY][permission])
) {
const source = unref(args.source || null)
if (!source) {
console.warn('Source reference not found', permission)
return false
}
if (source?.is_data_readonly && sourceRestrictions[SourceRestriction.DATA_READONLY][permission]) {
return false
}
if (source?.is_schema_readonly && sourceRestrictions[SourceRestriction.SCHEMA_READONLY][permission]) {
return false
}
}
return Object.entries(checkRoles).some(([role, hasRole]) => return Object.entries(checkRoles).some(([role, hasRole]) =>
hasPermission(role as Exclude<Roles, WorkspaceUserRoles>, hasRole, permission), hasPermission(role as Exclude<Roles, WorkspaceUserRoles>, hasRole, permission),
) )
@ -148,3 +174,31 @@ export const useRoles = createSharedComposable(() => {
return { allRoles, orgRoles, workspaceRoles, baseRoles, loadRoles, isUIAllowed } return { allRoles, orgRoles, workspaceRoles, baseRoles, loadRoles, isUIAllowed }
}) })
type IsUIAllowedParams = Parameters<ReturnType<typeof useRolesShared>['isUIAllowed']>
/**
* Wrap the default shared composable to inject the current source if available
* which will be used to determine if a user has permission to perform an action based on the source's restrictions
*/
export const useRoles = () => {
const currentSource = inject(ActiveSourceInj, ref())
const useRolesRes = useRolesShared()
const isMetaReadOnly = computed(() => {
return currentSource.value?.is_schema_readonly || false
})
const isDataReadOnly = computed(() => {
return currentSource.value?.is_schema_readonly || false
})
return {
...useRolesRes,
isUIAllowed: (...args: IsUIAllowedParams) => {
return useRolesRes.isUIAllowed(args[0], { source: currentSource, ...(args[1] || {}) })
},
isDataReadOnly,
isMetaReadOnly,
}
}

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

@ -1,4 +1,4 @@
import type { ColumnType, FilterType, TableType, ViewType } from 'nocodb-sdk' import type { ColumnType, FilterType, SourceType, TableType, ViewType } from 'nocodb-sdk'
import type { ComputedRef, InjectionKey, Ref } from 'vue' import type { ComputedRef, InjectionKey, Ref } from 'vue'
import type { EventHook } from '@vueuse/core' import type { EventHook } from '@vueuse/core'
import type { PageSidebarNode } from '#imports' import type { PageSidebarNode } from '#imports'
@ -60,3 +60,14 @@ export const JsonExpandInj: InjectionKey<Ref<boolean>> = Symbol('json-expand-inj
export const AllFiltersInj: InjectionKey<Ref<Record<string, FilterType[]>>> = Symbol('all-filters-injection') export const AllFiltersInj: InjectionKey<Ref<Record<string, FilterType[]>>> = Symbol('all-filters-injection')
export const IsAdminPanelInj: InjectionKey<Ref<boolean>> = Symbol('is-admin-panel-injection') export const IsAdminPanelInj: InjectionKey<Ref<boolean>> = Symbol('is-admin-panel-injection')
/**
* `ActiveSourceInj` is an injection key for providing the active source context to Vue components.
* This is mainly used in useRoles composable to get source level restriction configuration in GUI.
*/
export const ActiveSourceInj: InjectionKey<
ComputedRef<
SourceType & {
meta?: Record<string, any>
}
>
> = Symbol('active-source-injection')

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

@ -456,6 +456,8 @@
"looksLikeThisStackIsEmpty": "Looks like this stack does not have any records" "looksLikeThisStackIsEmpty": "Looks like this stack does not have any records"
}, },
"labels": { "labels": {
"allowMetaWrite" : "Allow Schema Edit",
"allowDataWrite" : "Allow Data Edit",
"selectView": "Select a View", "selectView": "Select a View",
"connectionDetails": "Connection Details", "connectionDetails": "Connection Details",
"metaSync": "Meta Sync", "metaSync": "Meta Sync",
@ -1038,6 +1040,10 @@
"group": "Group" "group": "Group"
}, },
"tooltip": { "tooltip": {
"typeNotAllowed": "This datatype is not allowed due to restricted schema alterations for this source.",
"dataWriteOptionDisabled": "Data editing can only be disabled when 'Schema editing' is also disabled.",
"allowMetaWrite": "Enable this option to allow modifications to the database schema, including adding, altering, or deleting tables and columns. Use with caution, as changes may affect application functionality.",
"allowDataWrite": "Enable this option to allow updating, deleting, or inserting data within the database tables. Ideal for administrative users who need to manage data directly.",
"reachedSourceLimit": "Limited to only one data source for the moment", "reachedSourceLimit": "Limited to only one data source for the moment",
"saveChanges": "Save changes", "saveChanges": "Save changes",
"xcDB": "Create a new base", "xcDB": "Create a new base",
@ -1260,6 +1266,7 @@
} }
}, },
"info": { "info": {
"schemaReadOnly": "Schema alterations are disabled for this source",
"enterWorkspaceName": "Enter workspace name", "enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name", "enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",

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

@ -1,4 +1,4 @@
import { OrgUserRoles, ProjectRoles } from 'nocodb-sdk' import { OrgUserRoles, ProjectRoles, SourceRestriction } from 'nocodb-sdk'
const roleScopes = { const roleScopes = {
org: [OrgUserRoles.VIEWER, OrgUserRoles.CREATOR], org: [OrgUserRoles.VIEWER, OrgUserRoles.CREATOR],
@ -72,6 +72,8 @@ const rolePermissions = {
newUser: true, newUser: true,
webhook: true, webhook: true,
fieldEdit: true, fieldEdit: true,
fieldAlter: true,
fieldDelete: true,
fieldAdd: true, fieldAdd: true,
tableIconEdit: true, tableIconEdit: true,
viewCreateOrEdit: true, viewCreateOrEdit: true,
@ -117,6 +119,35 @@ const rolePermissions = {
}, },
} as Record<OrgUserRoles | ProjectRoles, Perm | '*'> } as Record<OrgUserRoles | ProjectRoles, Perm | '*'>
// excluded/restricted permissions at source level based on source restriction
// `true` means permission is restricted and `false`/missing means permission is allowed
export const sourceRestrictions = {
[SourceRestriction.DATA_READONLY]: {
dataInsert: true,
dataEdit: true,
dataDelete: true,
airtableImport: true,
csvImport: true,
jsonImport: true,
excelImport: true,
duplicateColumn: true,
duplicateModel: true,
tableDuplicate: true,
},
[SourceRestriction.SCHEMA_READONLY]: {
tableCreate: true,
tableRename: true,
tableDelete: true,
tableDuplicate: true,
airtableImport: true,
csvImport: true,
jsonImport: true,
excelImport: true,
duplicateColumn: true,
duplicateModel: true,
},
}
/* /*
We inherit include permissions from previous roles in the same scope (role order) We inherit include permissions from previous roles in the same scope (role order)
To determine role order, we use `roleScopes` object To determine role order, we use `roleScopes` object

8
packages/nocodb-sdk/src/lib/UITypes.ts

@ -254,3 +254,11 @@ export const isSelectTypeCol = (
); );
}; };
export default UITypes; export default UITypes;
export const readonlyMetaAllowedTypes = [
UITypes.Lookup,
UITypes.Rollup,
UITypes.Formula,
UITypes.Barcode,
UITypes.QrCode,
];

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

@ -326,3 +326,11 @@ export enum APIContext {
FILTERS = 'filters', FILTERS = 'filters',
SORTS = 'sorts', SORTS = 'sorts',
} }
export enum SourceRestriction {
SCHEMA_READONLY = 'is_schema_readonly',
DATA_READONLY = 'is_data_readonly',
}

1
packages/nocodb-sdk/src/lib/index.ts

@ -20,6 +20,7 @@ export {
isHiddenCol, isHiddenCol,
getEquivalentUIType, getEquivalentUIType,
isSelectTypeCol, isSelectTypeCol,
readonlyMetaAllowedTypes,
} from '~/lib/UITypes'; } from '~/lib/UITypes';
export { default as CustomAPI, FileType } from '~/lib/CustomAPI'; export { default as CustomAPI, FileType } from '~/lib/CustomAPI';
export { default as TemplateGenerator } from '~/lib/TemplateGenerator'; export { default as TemplateGenerator } from '~/lib/TemplateGenerator';

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

@ -809,4 +809,12 @@ export class NcError {
static metaError(param: { message: string; sql: string }) { static metaError(param: { message: string; sql: string }) {
throw new MetaError(param); throw new MetaError(param);
} }
static sourceDataReadOnly(name: string) {
NcError.forbidden(`Source '${name}' is read-only`);
}
static sourceMetaReadOnly(name: string) {
NcError.forbidden(`Source '${name}' schema is read-only`);
}
} }

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

@ -37,6 +37,7 @@ import * as nc_047_comment_migration from '~/meta/migrations/v2/nc_047_comment_m
import * as nc_048_view_links from '~/meta/migrations/v2/nc_048_view_links'; import * as nc_048_view_links from '~/meta/migrations/v2/nc_048_view_links';
import * as nc_049_clear_notifications from '~/meta/migrations/v2/nc_049_clear_notifications'; import * as nc_049_clear_notifications from '~/meta/migrations/v2/nc_049_clear_notifications';
import * as nc_050_tenant_isolation from '~/meta/migrations/v2/nc_050_tenant_isolation'; import * as nc_050_tenant_isolation from '~/meta/migrations/v2/nc_050_tenant_isolation';
import * as nc_051_source_readonly_columns from '~/meta/migrations/v2/nc_051_source_readonly_columns';
// Create a custom migration source class // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -85,6 +86,7 @@ export default class XcMigrationSourcev2 {
'nc_048_view_links', 'nc_048_view_links',
'nc_049_clear_notifications', 'nc_049_clear_notifications',
'nc_050_tenant_isolation', 'nc_050_tenant_isolation',
'nc_051_source_readonly_columns',
]); ]);
} }
@ -172,6 +174,8 @@ export default class XcMigrationSourcev2 {
return nc_049_clear_notifications; return nc_049_clear_notifications;
case 'nc_050_tenant_isolation': case 'nc_050_tenant_isolation':
return nc_050_tenant_isolation; return nc_050_tenant_isolation;
case 'nc_051_source_readonly_columns':
return nc_051_source_readonly_columns;
} }
} }
} }

18
packages/nocodb/src/meta/migrations/v2/nc_051_source_readonly_columns.ts

@ -0,0 +1,18 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.BASES, (table) => {
table.boolean('is_schema_readonly').defaultTo(false);
table.boolean('is_data_readonly').defaultTo(false);
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.BASES, (table) => {
table.dropColumn('is_schema_readonly');
table.dropColumn('is_data_readonly');
});
};
export { up, down };

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

@ -1,6 +1,11 @@
import { Injectable, SetMetadata, UseInterceptors } from '@nestjs/common'; import { Injectable, SetMetadata, UseInterceptors } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { extractRolesObj, OrgUserRoles, ProjectRoles } from 'nocodb-sdk'; import {
extractRolesObj,
OrgUserRoles,
ProjectRoles,
SourceRestriction,
} from 'nocodb-sdk';
import { map } from 'rxjs'; import { map } from 'rxjs';
import type { Observable } from 'rxjs'; import type { Observable } from 'rxjs';
import type { import type {
@ -28,6 +33,8 @@ import {
import rolePermissions from '~/utils/acl'; import rolePermissions from '~/utils/acl';
import { NcError } from '~/helpers/catchError'; import { NcError } from '~/helpers/catchError';
import { RootScopes } from '~/utils/globals'; import { RootScopes } from '~/utils/globals';
import { sourceRestrictions } from '~/utils/acl';
import { Source } from '~/models';
export const rolesLabel = { export const rolesLabel = {
[OrgUserRoles.SUPER_ADMIN]: 'Super Admin', [OrgUserRoles.SUPER_ADMIN]: 'Super Admin',
@ -68,8 +75,26 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
// extract base id based on request path params // extract base id based on request path params
if (params.baseName) { if (params.baseName) {
const base = await Base.getByTitleOrId(context, params.baseName); const base = await Base.getByTitleOrId(context, params.baseName);
if (!base) {
NcError.baseNotFound(params.baseName);
}
if (base) { if (base) {
req.ncBaseId = base.id; req.ncBaseId = base.id;
if (params.tableName) {
// extract model and then source id from model
const model = await Model.getByAliasOrId(context, {
base_id: base.id,
aliasOrId: params.tableName,
});
if (!model) {
NcError.tableNotFound(req.params.tableName);
}
req.ncSourceId = model?.source_id;
}
} }
} }
if (params.baseId) { if (params.baseId) {
@ -85,7 +110,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.tableNotFound(params.tableId || params.modelId); NcError.tableNotFound(params.tableId || params.modelId);
} }
req.ncBaseId = model?.base_id; req.ncBaseId = model.base_id;
req.ncSourceId = model.source_id;
} else if (params.viewId) { } else if (params.viewId) {
const view = const view =
(await View.get(context, params.viewId)) || (await View.get(context, params.viewId)) ||
@ -95,7 +121,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.viewNotFound(params.viewId); NcError.viewNotFound(params.viewId);
} }
req.ncBaseId = view?.base_id; req.ncBaseId = view.base_id;
req.ncSourceId = view.source_id;
} else if ( } else if (
params.formViewId || params.formViewId ||
params.gridViewId || params.gridViewId ||
@ -122,7 +149,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
); );
} }
req.ncBaseId = view?.base_id; req.ncBaseId = view.base_id;
req.ncSourceId = view.source_id;
} else if (params.publicDataUuid) { } else if (params.publicDataUuid) {
const view = await View.getByUUID(context, req.params.publicDataUuid); const view = await View.getByUUID(context, req.params.publicDataUuid);
@ -130,7 +158,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.viewNotFound(params.publicDataUuid); NcError.viewNotFound(params.publicDataUuid);
} }
req.ncBaseId = view?.base_id; req.ncBaseId = view.base_id;
req.ncSourceId = view.source_id;
} else if (params.sharedViewUuid) { } else if (params.sharedViewUuid) {
const view = await View.getByUUID(context, req.params.sharedViewUuid); const view = await View.getByUUID(context, req.params.sharedViewUuid);
@ -138,7 +167,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.viewNotFound(req.params.sharedViewUuid); NcError.viewNotFound(req.params.sharedViewUuid);
} }
req.ncBaseId = view?.base_id; req.ncBaseId = view.base_id;
req.ncSourceId = view.source_id;
} else if (params.sharedBaseUuid) { } else if (params.sharedBaseUuid) {
const base = await Base.getByUuid(context, req.params.sharedBaseUuid); const base = await Base.getByUuid(context, req.params.sharedBaseUuid);
@ -154,7 +184,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.genericNotFound('Webhook', params.hookId); NcError.genericNotFound('Webhook', params.hookId);
} }
req.ncBaseId = hook?.base_id; req.ncBaseId = hook.base_id;
req.ncSourceId = hook.source_id;
} else if (params.gridViewColumnId) { } else if (params.gridViewColumnId) {
const gridViewColumn = await GridViewColumn.get( const gridViewColumn = await GridViewColumn.get(
context, context,
@ -165,7 +196,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.fieldNotFound(params.gridViewColumnId); NcError.fieldNotFound(params.gridViewColumnId);
} }
req.ncBaseId = gridViewColumn?.base_id; req.ncBaseId = gridViewColumn.base_id;
req.ncSourceId = gridViewColumn.source_id;
} else if (params.formViewColumnId) { } else if (params.formViewColumnId) {
const formViewColumn = await FormViewColumn.get( const formViewColumn = await FormViewColumn.get(
context, context,
@ -176,7 +208,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.fieldNotFound(params.formViewColumnId); NcError.fieldNotFound(params.formViewColumnId);
} }
req.ncBaseId = formViewColumn?.base_id; req.ncBaseId = formViewColumn.base_id;
req.ncSourceId = formViewColumn.source_id;
} else if (params.galleryViewColumnId) { } else if (params.galleryViewColumnId) {
const galleryViewColumn = await GalleryViewColumn.get( const galleryViewColumn = await GalleryViewColumn.get(
context, context,
@ -187,7 +220,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.fieldNotFound(params.galleryViewColumnId); NcError.fieldNotFound(params.galleryViewColumnId);
} }
req.ncBaseId = galleryViewColumn?.base_id; req.ncBaseId = galleryViewColumn.base_id;
req.ncSourceId = galleryViewColumn.source_id;
} else if (params.columnId) { } else if (params.columnId) {
const column = await Column.get(context, { colId: params.columnId }); const column = await Column.get(context, { colId: params.columnId });
@ -195,7 +229,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.fieldNotFound(params.columnId); NcError.fieldNotFound(params.columnId);
} }
req.ncBaseId = column?.base_id; req.ncBaseId = column.base_id;
req.ncSourceId = column.source_id;
} else if (params.filterId) { } else if (params.filterId) {
const filter = await Filter.get(context, params.filterId); const filter = await Filter.get(context, params.filterId);
@ -203,7 +238,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.genericNotFound('Filter', params.filterId); NcError.genericNotFound('Filter', params.filterId);
} }
req.ncBaseId = filter?.base_id; req.ncBaseId = filter.base_id;
req.ncSourceId = filter.source_id;
} else if (params.filterParentId) { } else if (params.filterParentId) {
const filter = await Filter.get(context, params.filterParentId); const filter = await Filter.get(context, params.filterParentId);
@ -211,7 +247,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.genericNotFound('Filter', params.filterParentId); NcError.genericNotFound('Filter', params.filterParentId);
} }
req.ncBaseId = filter?.base_id; req.ncBaseId = filter.base_id;
req.ncSourceId = filter.source_id;
} else if (params.sortId) { } else if (params.sortId) {
const sort = await Sort.get(context, params.sortId); const sort = await Sort.get(context, params.sortId);
@ -219,7 +256,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.genericNotFound('Sort', params.sortId); NcError.genericNotFound('Sort', params.sortId);
} }
req.ncBaseId = sort?.base_id; req.ncBaseId = sort.base_id;
req.ncSourceId = sort.source_id;
} else if (params.syncId) { } else if (params.syncId) {
const syncSource = await SyncSource.get(context, req.params.syncId); const syncSource = await SyncSource.get(context, req.params.syncId);
@ -228,6 +266,7 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
} }
req.ncBaseId = syncSource.base_id; req.ncBaseId = syncSource.base_id;
req.ncSourceId = syncSource.source_id;
} else if (params.extensionId) { } else if (params.extensionId) {
const extension = await Extension.get(context, req.params.extensionId); const extension = await Extension.get(context, req.params.extensionId);
@ -258,7 +297,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.tableNotFound(req.body.fk_model_id); NcError.tableNotFound(req.body.fk_model_id);
} }
req.ncBaseId = model?.base_id; req.ncBaseId = model.base_id;
req.ncSourceId = model.source_id;
} }
// extract fk_model_id from query params only if it's audit get endpoint // extract fk_model_id from query params only if it's audit get endpoint
else if ( else if (
@ -281,7 +321,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.tableNotFound(req.query?.fk_model_id); NcError.tableNotFound(req.query?.fk_model_id);
} }
req.ncBaseId = model?.base_id; req.ncBaseId = model.base_id;
req.ncSourceId = model.source_id;
} else if ( } else if (
[ [
'/api/v1/db/meta/comment/:commentId', '/api/v1/db/meta/comment/:commentId',
@ -296,7 +337,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.genericNotFound('Comment', params.commentId); NcError.genericNotFound('Comment', params.commentId);
} }
req.ncBaseId = comment?.base_id; req.ncBaseId = comment.base_id;
req.ncSourceId = comment.source_id;
} }
// extract base id from query params only if it's userMe endpoint or webhook plugin list // extract base id from query params only if it's userMe endpoint or webhook plugin list
else if ( else if (
@ -425,6 +467,49 @@ export class AclMiddleware implements NestInterceptor {
); );
} }
// check if permission have source level permission restriction
// 1. Check if it's present in the source restriction list
// 2. If present, check if write permission is allowed
if (
sourceRestrictions[SourceRestriction.SCHEMA_READONLY][permissionName] ||
sourceRestrictions[SourceRestriction.DATA_READONLY][permissionName]
) {
let source: Source;
// if tableCreate and source ID is empty, then extract the default source from base
if (!req.ncSourceId && req.ncBaseId && permissionName === 'tableCreate') {
const sources = await Source.list(req.context, {
baseId: req.ncBaseId,
});
if (req.params.sourceId) {
source = sources.find((s) => s.id === req.params.sourceId);
} else {
source = sources.find((s) => s.isMeta()) || sources[0];
}
} else if (req.ncSourceId) {
source = await Source.get(req.context, req.ncSourceId);
}
// todo: replace with better error and this is not an expected error
if (!source) {
NcError.notFound('Source not found or source id not extracted');
}
if (
source.is_schema_readonly &&
sourceRestrictions[SourceRestriction.SCHEMA_READONLY][permissionName]
) {
NcError.sourceMetaReadOnly(source.alias);
}
if (
source.is_data_readonly &&
sourceRestrictions[SourceRestriction.DATA_READONLY][permissionName]
) {
NcError.sourceDataReadOnly(source.alias);
}
}
return next.handle().pipe( return next.handle().pipe(
map((data) => { map((data) => {
return data; return data;

10
packages/nocodb/src/models/Source.ts

@ -33,6 +33,8 @@ export default class Source implements SourceType {
alias?: string; alias?: string;
type?: DriverClient; type?: DriverClient;
is_meta?: BoolType; is_meta?: BoolType;
is_schema_readonly?: BoolType;
is_data_readonly?: BoolType;
config?: string; config?: string;
inflection_column?: string; inflection_column?: string;
inflection_table?: string; inflection_table?: string;
@ -70,6 +72,8 @@ export default class Source implements SourceType {
'order', 'order',
'enabled', 'enabled',
'meta', 'meta',
'is_schema_readonly',
'is_data_readonly',
]); ]);
insertObj.config = CryptoJS.AES.encrypt( insertObj.config = CryptoJS.AES.encrypt(
@ -130,6 +134,8 @@ export default class Source implements SourceType {
'meta', 'meta',
'deleted', 'deleted',
'fk_sql_executor_id', 'fk_sql_executor_id',
'is_schema_readonly',
'is_data_readonly',
]); ]);
if (updateObj.config) { if (updateObj.config) {
@ -144,6 +150,10 @@ export default class Source implements SourceType {
updateObj.type = oldSource.type; updateObj.type = oldSource.type;
} }
if ('meta' in updateObj) {
updateObj.meta = stringifyMetaProp(updateObj);
}
// if order is missing (possible in old versions), get next order // if order is missing (possible in old versions), get next order
if (!oldSource.order && !updateObj.order) { if (!oldSource.order && !updateObj.order) {
updateObj.order = await ncMeta.metaGetNextOrder(MetaTable.BASES, { updateObj.order = await ncMeta.metaGetNextOrder(MetaTable.BASES, {

28
packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.controller.ts

@ -8,7 +8,12 @@ import {
Req, Req,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ProjectStatus } from 'nocodb-sdk'; import {
ProjectStatus,
readonlyMetaAllowedTypes,
SourceRestriction,
} from 'nocodb-sdk';
import type { UITypes } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard'; import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { BasesService } from '~/services/bases.service'; import { BasesService } from '~/services/bases.service';
@ -20,6 +25,7 @@ import { IJobsService } from '~/modules/jobs/jobs-service.interface';
import { TenantContext } from '~/decorators/tenant-context.decorator'; import { TenantContext } from '~/decorators/tenant-context.decorator';
import { NcContext, NcRequest } from '~/interface/config'; import { NcContext, NcRequest } from '~/interface/config';
import { RootScopes } from '~/utils/globals'; import { RootScopes } from '~/utils/globals';
import { NcError } from '~/helpers/catchError';
@Controller() @Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard) @UseGuards(MetaApiLimiterGuard, GlobalGuard)
@ -212,6 +218,14 @@ export class DuplicateController {
const source = await Source.get(context, model.source_id); const source = await Source.get(context, model.source_id);
// if data/schema is readonly, then restrict duplication
if (source.is_schema_readonly) {
NcError.sourceMetaReadOnly(source.alias);
}
if (source.is_data_readonly) {
NcError.sourceDataReadOnly(source.alias);
}
const models = await source.getModels(context); const models = await source.getModels(context);
const uniqueTitle = generateUniqueName( const uniqueTitle = generateUniqueName(
@ -276,6 +290,18 @@ export class DuplicateController {
throw new Error(`Model not found!`); throw new Error(`Model not found!`);
} }
const source = await Source.get(context, model.source_id);
// check if source is readonly and column type is not allowed
if (!readonlyMetaAllowedTypes.includes(column.uidt as UITypes)) {
if (source.is_schema_readonly) {
NcError.sourceMetaReadOnly(source.alias);
}
if (source.is_data_readonly) {
NcError.sourceDataReadOnly(source.alias);
}
}
const job = await this.jobsService.add(JobTypes.DuplicateColumn, { const job = await this.jobsService.add(JobTypes.DuplicateColumn, {
context, context,
baseId: base.id, baseId: base.id,

8
packages/nocodb/src/schema/swagger-v2.json

@ -12166,6 +12166,14 @@
"$ref": "#/components/schemas/Bool", "$ref": "#/components/schemas/Bool",
"description": "Is the data source minimal db" "description": "Is the data source minimal db"
}, },
"is_schema_readonly": {
"$ref": "#/components/schemas/Bool",
"description": "Is the data source minimal db"
},
"is_data_readonly": {
"$ref": "#/components/schemas/Bool",
"description": "Is the data source minimal db"
},
"order": { "order": {
"description": "The order of the list of sources", "description": "The order of the list of sources",
"example": 1, "example": 1,

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

@ -18215,6 +18215,14 @@
"$ref": "#/components/schemas/Bool", "$ref": "#/components/schemas/Bool",
"description": "Is the data source minimal db" "description": "Is the data source minimal db"
}, },
"is_schema_readonly": {
"$ref": "#/components/schemas/Bool",
"description": "Is the data source minimal db"
},
"is_data_readonly": {
"$ref": "#/components/schemas/Bool",
"description": "Is the data source minimal db"
},
"order": { "order": {
"description": "The order of the list of sources", "description": "The order of the list of sources",
"example": 1, "example": 1,

28
packages/nocodb/src/services/columns.service.ts

@ -5,7 +5,9 @@ import {
isCreatedOrLastModifiedTimeCol, isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR, isLinksOrLTAR,
isVirtualCol, isVirtualCol,
readonlyMetaAllowedTypes,
RelationTypes, RelationTypes,
SourceRestriction,
substituteColumnAliasWithIdInFormula, substituteColumnAliasWithIdInFormula,
substituteColumnIdWithAliasInFormula, substituteColumnIdWithAliasInFormula,
UITypes, UITypes,
@ -197,6 +199,16 @@ export class ColumnsService {
Source.get(context, table.source_id), Source.get(context, table.source_id),
); );
// check if source is readonly and column type is not allowed
if (
source?.is_schema_readonly &&
(!readonlyMetaAllowedTypes.includes(column.uidt) ||
(param.column.uidt &&
!readonlyMetaAllowedTypes.includes(param.column.uidt as UITypes)))
) {
NcError.sourceMetaReadOnly(source.alias);
}
const sqlClient = await reuseOrSave('sqlClient', reuse, async () => const sqlClient = await reuseOrSave('sqlClient', reuse, async () =>
NcConnectionMgrv2.getSqlClient(source), NcConnectionMgrv2.getSqlClient(source),
); );
@ -1482,6 +1494,14 @@ export class ColumnsService {
Source.get(context, table.source_id), Source.get(context, table.source_id),
); );
// check if source is readonly and column type is not allowed
if (
source?.is_schema_readonly &&
!readonlyMetaAllowedTypes.includes(param.column.uidt as UITypes)
) {
NcError.sourceMetaReadOnly(source.alias);
}
const base = await reuseOrSave('base', reuse, async () => const base = await reuseOrSave('base', reuse, async () =>
source.getProject(context), source.getProject(context),
); );
@ -2042,6 +2062,14 @@ export class ColumnsService {
Source.get(context, table.source_id, false, ncMeta), Source.get(context, table.source_id, false, ncMeta),
); );
// check if source is readonly and column type is not allowed
if (
source?.is_schema_readonly &&
!readonlyMetaAllowedTypes.includes(column.uidt)
) {
NcError.sourceMetaReadOnly(source.alias);
}
const sqlMgr = await reuseOrSave('sqlMgr', reuse, async () => const sqlMgr = await reuseOrSave('sqlMgr', reuse, async () =>
ProjectMgrv2.getSqlMgr(context, { id: source.base_id }, ncMeta), ProjectMgrv2.getSqlMgr(context, { id: source.base_id }, ncMeta),
); );

10
packages/nocodb/src/services/forms.service.ts

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AppEvents, ViewTypes } from 'nocodb-sdk'; import { AppEvents, SourceRestriction, ViewTypes } from 'nocodb-sdk';
import type { import type {
FormUpdateReqType, FormUpdateReqType,
UserType, UserType,
@ -9,7 +9,7 @@ import type { NcContext, NcRequest } from '~/interface/config';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service'; import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { validatePayload } from '~/helpers'; import { validatePayload } from '~/helpers';
import { NcError } from '~/helpers/catchError'; import { NcError } from '~/helpers/catchError';
import { FormView, Model, View } from '~/models'; import { FormView, Model, Source, View } from '~/models';
import NocoCache from '~/cache/NocoCache'; import NocoCache from '~/cache/NocoCache';
import { CacheScope } from '~/utils/globals'; import { CacheScope } from '~/utils/globals';
@ -38,6 +38,12 @@ export class FormsService {
const model = await Model.get(context, param.tableId); const model = await Model.get(context, param.tableId);
const source = await Source.get(context, model.source_id);
if (source.is_data_readonly) {
NcError.sourceDataReadOnly(source.alias);
}
const { id } = await View.insertMetaOnly( const { id } = await View.insertMetaOnly(
context, context,
{ {

12
packages/nocodb/src/services/public-datas.service.ts

@ -1,7 +1,12 @@
import path from 'path'; import path from 'path';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { populateUniqueFileName, UITypes, ViewTypes } from 'nocodb-sdk'; import {
populateUniqueFileName,
SourceRestriction,
UITypes,
ViewTypes,
} from 'nocodb-sdk';
import slash from 'slash'; import slash from 'slash';
import { nocoExecute } from 'nc-help'; import { nocoExecute } from 'nc-help';
@ -293,6 +298,11 @@ export class PublicDatasService {
}); });
const source = await Source.get(context, model.source_id); const source = await Source.get(context, model.source_id);
if (source?.is_data_readonly) {
NcError.sourceDataReadOnly(source.alias);
}
const base = await source.getProject(context); const base = await source.getProject(context);
const baseModel = await Model.getBaseModelSQL(context, { const baseModel = await Model.getBaseModelSQL(context, {

28
packages/nocodb/src/utils/acl.ts

@ -1,4 +1,4 @@
import { OrgUserRoles, ProjectRoles } from 'nocodb-sdk'; import { OrgUserRoles, ProjectRoles, SourceRestriction } from 'nocodb-sdk';
const roleScopes = { const roleScopes = {
org: [OrgUserRoles.VIEWER, OrgUserRoles.CREATOR], org: [OrgUserRoles.VIEWER, OrgUserRoles.CREATOR],
@ -450,4 +450,30 @@ Object.values(rolePermissions).forEach((role) => {
} }
}); });
// Excluded permissions for source restrictions
// `true` means permission is restricted and `false`/missing means permission is allowed
export const sourceRestrictions = {
[SourceRestriction.SCHEMA_READONLY]: {
tableCreate: true,
tableDelete: true,
tableUpdate: true,
columnBulk: true,
},
[SourceRestriction.DATA_READONLY]: {
dataUpdate: true,
dataDelete: true,
dataInsert: true,
bulkDataInsert: true,
bulkDataUpdate: true,
bulkDataUpdateAll: true,
bulkDataDelete: true,
bulkDataDeleteAll: true,
relationDataRemove: true,
relationDataAdd: true,
nestedDataListCopyPasteOrDeleteAll: true,
nestedDataUnlink: true,
nestedDataLink: true,
},
};
export default rolePermissions; export default rolePermissions;

7
packages/nocodb/tests/unit/factory/base.ts

@ -7,7 +7,7 @@ interface ProjectArgs {
type?: string; type?: string;
} }
const sakilaProjectConfig = (context) => { const sakilaProjectConfig = (context, additionalConfig = {}) => {
let source; let source;
if ( if (
@ -38,6 +38,7 @@ const sakilaProjectConfig = (context) => {
...source, ...source,
inflection_column: 'camelize', inflection_column: 'camelize',
inflection_table: 'camelize', inflection_table: 'camelize',
...additionalConfig,
}; };
return { return {
@ -67,11 +68,11 @@ const createSharedBase = async (app, token, base, sharedBaseArgs = {}) => {
}); });
}; };
const createSakilaProject = async (context) => { const createSakilaProject = async (context, additionalConfig = {}) => {
const response = await request(context.app) const response = await request(context.app)
.post('/api/v1/db/meta/projects/') .post('/api/v1/db/meta/projects/')
.set('xc-auth', context.token) .set('xc-auth', context.token)
.send(sakilaProjectConfig(context)); .send(sakilaProjectConfig(context, additionalConfig));
return (await Base.getByTitleOrId( return (await Base.getByTitleOrId(
{ {

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

@ -12,6 +12,7 @@ import newDataApisTest from './tests/newDataApis.test';
import groupByTest from './tests/groupby.test'; import groupByTest from './tests/groupby.test';
import formulaTests from './tests/formula.test'; import formulaTests from './tests/formula.test';
import typeCastsTest from './tests/typeCasts.test'; import typeCastsTest from './tests/typeCasts.test';
import readOnlyTest from './tests/readOnlySource.test';
let workspaceTest = () => {}; let workspaceTest = () => {};
let ssoTest = () => {}; let ssoTest = () => {};
@ -41,6 +42,7 @@ function restTests() {
ssoTest(); ssoTest();
cloudOrgTest(); cloudOrgTest();
typeCastsTest(); typeCastsTest();
readOnlyTest();
// Enable for dashboard feature // Enable for dashboard feature
// widgetTest(); // widgetTest();

152
packages/nocodb/tests/unit/rest/tests/readOnlySource.test.ts

@ -0,0 +1,152 @@
import 'mocha';
import request from 'supertest';
import { beforeEach } from 'mocha';
import { Exception } from 'handlebars';
import { expect } from 'chai';
import { Base } from '../../../../src/models';
import { createTable, getTable } from '../../factory/table';
import init from '../../init';
import {
createProject,
createSakilaProject,
createSharedBase,
} from '../../factory/base';
import { RootScopes } from '../../../../src/utils/globals';
import { generateDefaultRowAttributes } from '../../factory/row';
import { defaultColumns } from '../../factory/column';
// Test case list
// 1. Create data readonly source
// 2. Create schema readonly source
function sourceTest() {
let context;
beforeEach(async function () {
console.time('#### readonlySourceTest');
context = await init();
console.timeEnd('#### readonlySourceTest');
});
it('Readonly data', async () => {
const base = await createSakilaProject(context, {
is_schema_readonly: false,
is_data_readonly: true,
});
const countryTable = await getTable({
base,
name: 'country',
});
const sakilaCtx = {
workspace_id: base.fk_workspace_id,
base_id: base.id,
};
const countryColumns = (await countryTable.getColumns(sakilaCtx)).filter(c => !c.pk);
const rowAttributes = Array(99)
.fill(0)
.map((index) =>
generateDefaultRowAttributes({ columns: countryColumns, index }),
);
await request(context.app)
.post(`/api/v1/db/data/bulk/noco/${base.id}/${countryTable.id}`)
.set('xc-auth', context.token)
.send(rowAttributes)
.expect(403);
await request(context.app)
.post(`/api/v1/db/meta/projects/${base.id}/tables`)
.set('xc-auth', context.token)
.send({
table_name: 'new_title',
title: 'new_title',
columns: defaultColumns(context),
})
.expect(200);
});
it('Readonly schema', async () => {
const base = await createSakilaProject(context, {
is_schema_readonly: true,
is_data_readonly: false,
});
const countryTable = await getTable({
base,
name: 'country',
});
const sakilaCtx = {
workspace_id: base.fk_workspace_id,
base_id: base.id,
};
const countryColumns = (await countryTable.getColumns(sakilaCtx)).filter(c => !c.pk);
const rowAttributes = Array(99)
.fill(0)
.map((index) =>
generateDefaultRowAttributes({ columns: countryColumns, index }),
);
await request(context.app)
.post(`/api/v1/db/data/bulk/noco/${base.id}/${countryTable.id}`)
.set('xc-auth', context.token)
.send(rowAttributes)
.expect(200);
await request(context.app)
.post(`/api/v1/db/meta/projects/${base.id}/tables`)
.set('xc-auth', context.token)
.send({
table_name: 'new_title',
title: 'new_title',
columns: defaultColumns(context),
})
.expect(403);
});
it('Readonly schema & data', async () => {
const base = await createSakilaProject(context, {
is_schema_readonly: true,
is_data_readonly: true,
});
const countryTable = await getTable({
base,
name: 'country',
});
const sakilaCtx = {
workspace_id: base.fk_workspace_id,
base_id: base.id,
};
const countryColumns = (await countryTable.getColumns(sakilaCtx)).filter(c => !c.pk);
const rowAttributes = Array(99)
.fill(0)
.map((index) =>
generateDefaultRowAttributes({ columns: countryColumns, index }),
);
await request(context.app)
.post(`/api/v1/db/data/bulk/noco/${base.id}/${countryTable.id}`)
.set('xc-auth', context.token)
.send(rowAttributes)
.expect(403);
await request(context.app)
.post(`/api/v1/db/meta/projects/${base.id}/tables`)
.set('xc-auth', context.token)
.send({
table_name: 'new_title',
title: 'new_title',
columns: defaultColumns(context),
})
.expect(403);
});
}
export default function () {
describe('SourceRestriction', sourceTest);
}

44
tests/playwright/pages/Dashboard/Settings/Source.ts

@ -0,0 +1,44 @@
import { expect } from '@playwright/test';
import { SettingsPage } from '.';
import BasePage from '../../Base';
export class SourcePage extends BasePage {
private readonly settings: SettingsPage;
constructor(settings: SettingsPage) {
super(settings.rootPage);
this.settings = settings;
}
get() {
return this.rootPage.getByTestId('nc-settings-datasources');
}
async openEditWindow({ sourceName }: { sourceName: string }) {
await this.get().locator('.ds-table-row', { hasText: sourceName }).click();
await this.get().getByTestId('nc-connection-tab').click();
}
async updateSchemaReadOnly({ sourceName, readOnly }: { sourceName: string; readOnly: boolean }) {
await this.openEditWindow({ sourceName });
const switchBtn = this.get().getByTestId('nc-allow-meta-write');
if (switchBtn.getAttribute('checked') !== readOnly.toString()) {
await switchBtn.click();
}
await this.saveConnection();
}
async updateDataReadOnly({ sourceName, readOnly = true }: { sourceName: string; readOnly?: boolean }) {
await this.openEditWindow({ sourceName });
const switchBtn = this.get().getByTestId('nc-allow-data-write');
if (switchBtn.getAttribute('checked') !== readOnly.toString()) {
await switchBtn.click();
}
await this.saveConnection();
}
async saveConnection() {
await this.get().locator('.nc-extdb-btn-test-connection').click();
await this.get().locator('.nc-extdb-btn-submit:enabled').click();
}
}

3
tests/playwright/pages/Dashboard/Settings/index.ts

@ -4,6 +4,7 @@ import { AuditSettingsPage } from './Audit';
import { MiscSettingsPage } from './Miscellaneous'; import { MiscSettingsPage } from './Miscellaneous';
import { TeamsPage } from './Teams'; import { TeamsPage } from './Teams';
import { DataSourcesPage } from './DataSources'; import { DataSourcesPage } from './DataSources';
import { SourcePage } from './Source';
export enum SettingTab { export enum SettingTab {
TeamAuth = 'teamAndAuth', TeamAuth = 'teamAndAuth',
@ -20,6 +21,7 @@ export enum SettingsSubTab {
export class SettingsPage extends BasePage { export class SettingsPage extends BasePage {
readonly audit: AuditSettingsPage; readonly audit: AuditSettingsPage;
readonly source: SourcePage;
readonly miscellaneous: MiscSettingsPage; readonly miscellaneous: MiscSettingsPage;
readonly dataSources: DataSourcesPage; readonly dataSources: DataSourcesPage;
readonly teams: TeamsPage; readonly teams: TeamsPage;
@ -30,6 +32,7 @@ export class SettingsPage extends BasePage {
this.miscellaneous = new MiscSettingsPage(this); this.miscellaneous = new MiscSettingsPage(this);
this.dataSources = new DataSourcesPage(this); this.dataSources = new DataSourcesPage(this);
this.teams = new TeamsPage(this); this.teams = new TeamsPage(this);
this.source = new SourcePage(this);
} }
get() { get() {

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

@ -378,4 +378,13 @@ export class TreeViewPage extends BasePage {
await this.rootPage.waitForTimeout(10000); await this.rootPage.waitForTimeout(10000);
} }
async openProjectSourceSettings(param: { title: string; context: NcContext }) {
param.title = this.scopedProjectTitle({ title: param.title, context: param.context });
await this.openProjectContextMenu({ baseTitle: param.title });
const contextMenu = this.dashboard.get().locator('.ant-dropdown-menu.nc-scrollbar-md:visible');
await contextMenu.waitFor();
await contextMenu.locator(`.ant-dropdown-menu-item:has-text("Settings")`).click();
}
} }

91
tests/playwright/tests/db/general/sourceRestrictions.spec.ts

@ -0,0 +1,91 @@
import { expect, test } from '@playwright/test';
import { DashboardPage } from '../../../pages/Dashboard';
import setup, { NcContext, unsetup } from '../../../setup';
import { Api } from 'nocodb-sdk';
import { SettingsPage } from '../../../pages/Dashboard/Settings';
test.describe('Source Restrictions', () => {
let dashboard: DashboardPage;
let settingsPage: SettingsPage;
let context: NcContext;
let api: Api<any>;
test.setTimeout(150000);
test.beforeEach(async ({ page }) => {
page.setDefaultTimeout(70000);
context = await setup({ page });
dashboard = new DashboardPage(page, context.base);
settingsPage = new SettingsPage(dashboard);
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
});
test.afterEach(async () => {
await unsetup(context);
});
test('Readonly data source', async () => {
await dashboard.treeView.openProjectSourceSettings({ title: context.base.title, context });
await settingsPage.selectTab({ tab: 'dataSources' });
await dashboard.rootPage.waitForTimeout(300);
await settingsPage.source.updateSchemaReadOnly({ sourceName: 'Default', readOnly: true });
await settingsPage.source.updateDataReadOnly({ sourceName: 'Default', readOnly: true });
await settingsPage.close();
// reload page to reflect source changes
await dashboard.rootPage.reload();
await dashboard.treeView.verifyTable({ title: 'Actor' });
// open table and verify that it is readonly
await dashboard.treeView.openTable({ title: 'Actor' });
await expect(dashboard.grid.get().locator('.nc-grid-add-new-cell')).toHaveCount(0);
await dashboard.grid.get().getByTestId(`cell-FirstName-0`).click({
button: 'right',
});
await expect(dashboard.rootPage.locator('.ant-dropdown-menu-item:has-text("Copy")')).toHaveCount(1);
await expect(dashboard.rootPage.locator('.ant-dropdown-menu-item:has-text("Delete record")')).toHaveCount(0);
});
test('Readonly schema source', async () => {
await dashboard.treeView.openProjectSourceSettings({ title: context.base.title, context });
await settingsPage.selectTab({ tab: 'dataSources' });
await dashboard.rootPage.waitForTimeout(300);
await settingsPage.source.updateSchemaReadOnly({ sourceName: 'Default', readOnly: true });
await settingsPage.close();
// reload page to reflect source changes
await dashboard.rootPage.reload();
await dashboard.treeView.verifyTable({ title: 'Actor' });
// open table and verify that it is readonly
await dashboard.treeView.openTable({ title: 'Actor' });
await dashboard.grid
.get()
.locator(`th[data-title="LastName"]`)
.first()
.locator('.nc-ui-dt-dropdown')
.scrollIntoViewIfNeeded();
await dashboard.grid.get().locator(`th[data-title="LastName"]`).first().locator('.nc-ui-dt-dropdown').click();
for (const item of ['Edit', 'Delete', 'Duplicate']) {
await expect(
await dashboard.rootPage.locator(`li[role="menuitem"]:has-text("${item}"):visible`).last()
).toBeVisible();
await expect(
await dashboard.rootPage.locator(`li[role="menuitem"]:has-text("${item}"):visible`).last()
).toHaveClass(/ant-dropdown-menu-item-disabled/);
}
});
});
Loading…
Cancel
Save