Browse Source

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

Nc feat/3100 readonly source
pull/8797/head
Pranav C 5 months 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. 54
      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. 59
      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)
}
}
const source = computed(() => {
return base.value?.sources?.[props.sourceIndex]
})
</script>
<template>
<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"
@click="emit('openTableCreateDialog')"
>
@ -191,7 +195,7 @@ function openTableCreateMagicDialog(sourceId?: string) {
</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"
@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 base = toRef(props, 'base')
const { isUIAllowed } = useRoles()
const baseRole = inject(ProjectRoleInj)
const baseRole = computed(() => base.value.project_role || base.value.workspace_role)
const { $e } = useNuxtApp()
@ -68,7 +70,7 @@ function openQuickImportDialog(type: string) {
<template #expandIcon></template>
<NcMenuItem
v-if="isUIAllowed('airtableImport', { roles: baseRole })"
v-if="isUIAllowed('airtableImport', { roles: baseRole, source })"
key="quick-import-airtable"
@click="openAirtableImportDialog(source.base_id, source.id)"
>
@ -78,7 +80,11 @@ function openQuickImportDialog(type: string) {
</div>
</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">
<GeneralIcon icon="csv" class="w-4 group-hover:text-black" />
{{ $t('labels.csvFile') }}
@ -86,7 +92,7 @@ function openQuickImportDialog(type: string) {
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('jsonImport', { roles: baseRole })"
v-if="isUIAllowed('jsonImport', { roles: baseRole, source })"
key="quick-import-json"
@click="openQuickImportDialog('json')"
>
@ -97,7 +103,7 @@ function openQuickImportDialog(type: string) {
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('excelImport', { roles: baseRole })"
v-if="isUIAllowed('excelImport', { roles: baseRole, source })"
key="quick-import-excel"
@click="openQuickImportDialog('excel')"
>

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

@ -1,10 +1,11 @@
<script lang="ts" setup>
import type { ViewType } from 'nocodb-sdk'
import { SourceRestriction, type ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
const props = defineProps<{
// Prop used to align the dropdown to the left in sidebar
alignLeftLevel: number | undefined
source: Source
}>()
const { $e } = useNuxtApp()
@ -125,7 +126,7 @@ async function onOpenModal({
</div>
</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-inner">
<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 baseRole = inject(ProjectRoleInj)
const baseRole = computed(() => base.value.project_role || base.value.workspace_role)
const { activeProjectId } = storeToRefs(useBases())
@ -100,9 +100,9 @@ const baseViewOpen = computed(() => {
return routeNameAfterProjectView.split('-').length === 2 || routeNameAfterProjectView.split('-').length === 1
})
const showBaseOption = computed(() => {
return ['airtableImport', 'csvImport', 'jsonImport', 'excelImport'].some((permission) => isUIAllowed(permission))
})
const showBaseOption = (source: SourceType) => {
return ['airtableImport', 'csvImport', 'jsonImport', 'excelImport'].some((permission) => isUIAllowed(permission, { source }))
}
const enableEditMode = () => {
editMode.value = true
@ -444,6 +444,10 @@ const onTableIdCopy = async () => {
message.error(e.message)
}
}
const getSource = (sourceId: string) => {
return base.value.sources?.find((s) => s.id === sourceId)
}
</script>
<template>
@ -596,7 +600,7 @@ const onTableIdCopy = async () => {
</NcMenuItem>
</template>
<template v-if="base?.sources?.[0]?.enabled && showBaseOption">
<template v-if="base?.sources?.[0]?.enabled && showBaseOption(base?.sources?.[0])">
<NcDivider />
<DashboardTreeViewBaseOptions v-model:base="base" :source="base.sources[0]" />
</template>
@ -631,7 +635,7 @@ const onTableIdCopy = async () => {
</NcDropdown>
<NcButton
v-if="isUIAllowed('tableCreate', { roles: baseRole })"
v-if="isUIAllowed('tableCreate', { roles: baseRole, source: base?.sources?.[0] })"
v-e="['c:base:create-table']"
:disabled="!base?.sources?.[0]?.enabled"
class="nc-sidebar-node-btn"
@ -817,13 +821,17 @@ const onTableIdCopy = async () => {
</div>
</NcMenuItem>
<DashboardTreeViewBaseOptions v-if="showBaseOption" v-model:base="base" :source="source" />
<DashboardTreeViewBaseOptions
v-if="showBaseOption(source)"
v-model:base="base"
:source="source"
/>
</NcMenu>
</template>
</NcDropdown>
<NcButton
v-if="isUIAllowed('tableCreate', { roles: baseRole })"
v-if="isUIAllowed('tableCreate', { roles: baseRole, source })"
v-e="['c:source:add-table']"
type="text"
size="xxsmall"
@ -884,9 +892,17 @@ const onTableIdCopy = async () => {
</div>
</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 />
<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">
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }} {{ $t('objects.table') }}
@ -894,7 +910,10 @@ const onTableIdCopy = async () => {
</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)"
>
<div v-e="['c:table:duplicate']" class="nc-base-option-item flex gap-2 items-center">
@ -903,7 +922,11 @@ const onTableIdCopy = async () => {
</div>
</NcMenuItem>
<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">
<GeneralIcon icon="delete" />
{{ $t('general.delete') }} {{ $t('objects.table') }}

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

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

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

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

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

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

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

@ -260,7 +260,7 @@ const openedTab = ref('erd')
</script>
<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">
<a-breadcrumb separator=">" class="w-full cursor-pointer font-weight-bold">
<a-breadcrumb-item @click="activeSource = null">
@ -307,7 +307,7 @@ const openedTab = ref('erd')
<LazyDashboardSettingsBaseAudit :source-id="activeSource.id" />
</div>
</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>
<div class="tab" data-testid="nc-connection-tab">
<div>{{ $t('labels.connectionDetails') }}</div>
@ -397,7 +397,7 @@ const openedTab = ref('erd')
<NcButton
v-if="!sources[0].is_meta && !sources[0].is_local"
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"
@click.stop="baseAction(sources[0].id, DataSourcesSubTab.Edit)"
>
@ -446,7 +446,7 @@ const openedTab = ref('erd')
<NcButton
v-if="!source.is_meta && !source.is_local"
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"
@click.stop="openDeleteBase(source)"
>

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

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { Form, message } from 'ant-design-vue'
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select'
import { SourceRestriction } from 'nocodb-sdk'
import {
type CertTypes,
ClientType,
@ -55,6 +56,8 @@ const formState = ref<ProjectCreateForm>({
},
sslUse: SSLUsage.No,
extraParameters: [],
is_schema_readonly: true,
is_data_readonly: false,
})
const customFormState = ref<ProjectCreateForm>({
@ -68,9 +71,20 @@ const customFormState = ref<ProjectCreateForm>({
extraParameters: [],
})
const easterEgg = ref(false)
const easterEggCount = ref(0)
const onEasterEgg = () => {
easterEggCount.value += 1
if (easterEggCount.value >= 2) {
easterEgg.value = true
}
}
const clientTypes = computed(() => {
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,
inflection_column: formState.value.inflection.inflectionColumn,
inflection_table: formState.value.inflection.inflectionTable,
is_schema_readonly: formState.value.is_schema_readonly,
is_data_readonly: formState.value.is_data_readonly,
})
$poller.subscribe(
@ -393,6 +409,26 @@ watch(
const toggleModal = (val: boolean) => {
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>
<template>
@ -508,6 +544,58 @@ const toggleModal = (val: boolean) => {
>
<a-input v-model:value="formState.dataSource.searchPath[0]" />
</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">
<!-- Use Connection URL -->
<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"
dropdown-class-name="nc-dropdown-inflection-column-name"
>
<a-select-option v-for="tp in inflectionTypes" :key="tp" :value="tp"
><div class="flex items-center gap-2 justify-between">
<a-select-option v-for="tp in inflectionTypes" :key="tp" :value="tp">
<div class="flex items-center gap-2 justify-between">
<div>{{ tp }}</div>
<component
:is="iconMap.check"
@ -666,6 +754,7 @@ const toggleModal = (val: boolean) => {
<a-form-item class="flex justify-end !mt-5">
<div class="flex justify-end gap-2">
<div class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEasterEgg"></div>
<NcButton
:type="testSuccess ? 'ghost' : 'primary'"
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 easterEgg = ref(false)
const easterEggCount = ref(0)
const onEasterEgg = () => {
easterEggCount.value += 1
if (easterEggCount.value >= 2) {
easterEgg.value = true
}
}
const clientTypes = computed(() => {
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,
extraParameters: [],
is_schema_readonly: true,
is_data_readonly: false,
})
const customFormState = ref<ProjectCreateForm>({
@ -71,6 +84,8 @@ const customFormState = ref<ProjectCreateForm>({
},
sslUse: SSLUsage.No,
extraParameters: [],
is_schema_readonly: true,
is_data_readonly: false,
})
const validators = computed(() => {
@ -228,6 +243,8 @@ const editBase = async () => {
config,
inflection_column: formState.value.inflection.inflectionColumn,
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')
@ -339,6 +356,8 @@ onMounted(async () => {
},
extraParameters: tempParameters,
sslUse: SSLUsage.No,
is_schema_readonly: activeBase.is_schema_readonly,
is_data_readonly: activeBase.is_data_readonly,
}
updateSSLUse()
}
@ -356,6 +375,26 @@ watch(
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>
<template>
@ -372,7 +411,6 @@ watch(
<a-form-item label="Source Name" v-bind="validateInfos.title">
<a-input v-model:value="formState.title" class="nc-extdb-proj-name" />
</a-form-item>
<a-form-item :label="$t('labels.dbType')" v-bind="validateInfos['dataSource.client']">
<a-select
v-model:value="formState.dataSource.client"
@ -524,6 +562,58 @@ watch(
>
<a-input v-model:value="formState.dataSource.searchPath[0]" />
</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 -->
<div class="flex justify-end gap-2">
<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">
<div class="flex justify-end gap-2">
<div class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEasterEgg"></div>
<NcButton
:type="testSuccess ? 'ghost' : 'primary'"
size="small"

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

@ -76,7 +76,7 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
}"
>
<div
v-if="isUIAllowed('tableCreate')"
v-if="isUIAllowed('tableCreate', { source: base?.sources?.[0] })"
role="button"
class="nc-base-view-all-table-btn"
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>
<div
v-if="isUIAllowed('tableCreate')"
v-if="isUIAllowed('tableCreate', { source: base?.sources?.[0] })"
v-e="['c:table:import']"
role="button"
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 { isUIAllowed } = useRoles()
const { isUIAllowed, isDataReadOnly } = useRoles()
const { base } = storeToRefs(useBase())
const meta = inject(MetaInj, ref())
@ -85,7 +85,7 @@ watch(openedSubTab, () => {
</div>
</a-tab-pane>
<a-tab-pane v-if="isUIAllowed('hookList')" key="webhook">
<a-tab-pane v-if="isUIAllowed('hookList') && !isDataReadOnly" key="webhook">
<template #tab>
<div class="tab" data-testid="nc-webhooks-tab">
<GeneralIcon icon="webhook" class="tab-icon" :class="{}" />

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

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

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

@ -1,7 +1,14 @@
<script lang="ts" setup>
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 MdiMinusIcon from '~icons/mdi/minus-circle-outline'
import MdiIdentifierIcon from '~icons/mdi/identifier'
@ -39,6 +46,8 @@ const { getMeta } = useMetas()
const { t } = useI18n()
const { isMetaReadOnly } = useRoles()
const columnLabel = computed(() => props.columnLabel || t('objects.field'))
const { $e } = useNuxtApp()
@ -125,7 +134,7 @@ const uiFilters = (t: { name: UITypes; virtual?: number; deprecated?: boolean })
}
const uiTypesOptions = computed<typeof uiTypes>(() => {
return [
const types = [
...uiTypes.filter(uiFilters),
...(!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) => {
@ -381,7 +405,12 @@ const filterOption = (input: string, option: { value: UITypes }) => {
v-model:value="formState.uidt"
show-search
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"
:filter-option="filterOption"
@dropdown-visible-change="onDropdownChange"
@ -395,6 +424,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
v-for="opt of uiTypesOptions"
:key="opt.name"
:value="opt.name"
:disabled="isMetaReadOnly && !readonlyMetaAllowedTypes.includes(opt.name)"
v-bind="validateInfos.uidt"
:class="{
'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="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>
<span v-if="opt.deprecated" class="!text-xs !text-gray-300">({{ $t('general.deprecated') }})</span>
</div>

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

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

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

@ -1,7 +1,7 @@
<script setup lang="ts">
import { diff } from 'deep-object-diff'
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 Draggable from 'vuedraggable'
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) => {
if (!isColumnUpdateAllowed(field)) {
return message.info(t('msg.info.schemaReadOnly'))
}
if (field && field?.pk) {
// Editing primary key not supported
message.info(t('msg.info.editingPKnotSupported'))
@ -1003,7 +1014,7 @@ watch(
<div
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="` ${compareCols(field, activeField) ? 'selected' : ''}`"
:class="{ 'selected': compareCols(field, activeField), 'cursor-not-allowed': !isColumnUpdateAllowed(field) }"
:data-testid="`nc-field-item-${fieldState(field)?.title || field.title}`"
@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)
// #Permissions
const { isUIAllowed } = useRoles()
const { isUIAllowed, isDataReadOnly } = useRoles()
const hasEditPermission = computed(() => isUIAllowed('dataEdit'))
const isAddingColumnAllowed = computed(() => !readOnly.value && !isLocked.value && isUIAllowed('fieldAdd') && !isSqlView.value)
@ -209,7 +209,10 @@ const isGridCellMouseDown = ref(false)
// #Context Menu
const _contextMenu = ref(false)
const contextMenu = computed({
get: () => _contextMenu.value,
get: () => {
if (props.data?.some((r) => r.rowMeta.selected) && isDataReadOnly.value) return false
return _contextMenu.value
},
set: (val) => {
_contextMenu.value = val
},
@ -233,7 +236,13 @@ const isKeyDown = ref(false)
// #Cell - 1
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
if (colMeta.value[ctx.col].isReadonly) return
@ -916,7 +925,7 @@ const onNavigate = (dir: NavigateDir) => {
// #Cell - 2
async function clearSelectedRangeOfCells() {
if (!hasEditPermission.value) return
if (!hasEditPermission.value || isDataReadOnly.value) return
const start = selectedRange.start
const end = selectedRange.end
@ -1278,6 +1287,7 @@ const selectedReadonly = computed(
const showFillHandle = computed(
() =>
!isDataReadOnly.value &&
!readOnly.value &&
!editEnabled.value &&
(!selectedRange.isEmpty() || (activeCell.row !== null && activeCell.col !== null)) &&
@ -2165,7 +2175,9 @@ onKeyStroke('ArrowDown', onDown)
<template #overlay>
<NcMenu class="!rounded !py-0" @click="contextMenu = false">
<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')"
>
<div v-e="['a:row:update-bulk']" class="flex gap-2 items-center">
@ -2175,7 +2187,7 @@ onKeyStroke('ArrowDown', onDown)
</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"
data-testid="nc-delete-row"
@click="deleteSelectedRows"
@ -2222,7 +2234,7 @@ onKeyStroke('ArrowDown', onDown)
</NcMenuItem>
<NcMenuItem
v-if="contextMenuTarget && hasEditPermission"
v-if="contextMenuTarget && hasEditPermission && !isDataReadOnly"
class="nc-base-menu-item"
data-testid="context-menu-item-paste"
:disabled="selectedReadonly"
@ -2241,7 +2253,8 @@ onKeyStroke('ArrowDown', onDown)
contextMenuTarget &&
hasEditPermission &&
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"
:disabled="selectedReadonly"
@ -2256,7 +2269,7 @@ onKeyStroke('ArrowDown', onDown)
<!-- Clear cell -->
<NcMenuItem
v-else-if="contextMenuTarget && hasEditPermission"
v-else-if="contextMenuTarget && hasEditPermission && !isDataReadOnly"
class="nc-base-menu-item"
:disabled="selectedReadonly"
data-testid="context-menu-item-clear"
@ -2278,7 +2291,7 @@ onKeyStroke('ArrowDown', onDown)
</NcMenuItem>
</template>
<template v-if="hasEditPermission">
<template v-if="hasEditPermission && !isDataReadOnly">
<NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" />
<NcMenuItem
v-if="contextMenuTarget && (selectedRange.isSingleCell() || selectedRange.isSingleRow())"

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

@ -1,5 +1,5 @@
<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'
interface Props {
@ -32,7 +32,7 @@ const isDropDownOpen = ref(false)
const column = toRef(props, 'column')
const { isUIAllowed } = useRoles()
const { isUIAllowed, isMetaReadOnly } = useRoles()
provide(ColumnInj, column)
@ -60,7 +60,12 @@ const closeAddColumnDropdown = () => {
const openHeaderMenu = (e?: MouseEvent) => {
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
}
}

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

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

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

@ -7,6 +7,7 @@ import {
type LookupType,
type RollupType,
isLinksOrLTAR,
readonlyMetaAllowedTypes,
} from 'nocodb-sdk'
import { RelationTypes, UITypes, UITypesName, substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
@ -36,7 +37,7 @@ provide(ColumnInj, column)
const { metas } = useMetas()
const { isUIAllowed } = useRoles()
const { isUIAllowed, isMetaReadOnly } = useRoles()
const meta = inject(MetaInj, ref())
@ -122,7 +123,12 @@ const closeAddColumnDropdown = () => {
const openHeaderMenu = (e?: MouseEvent) => {
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
}
}

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

@ -1,5 +1,5 @@
<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'
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 { isUIAllowed } = useRoles()
const { isUIAllowed, isDataReadOnly } = useRoles()
const isPublicView = inject(IsPublicInj, ref(false))
@ -193,7 +193,7 @@ const onDelete = async () => {
<template v-if="view.type !== ViewTypes.FORM">
<NcDivider />
<template v-if="isUIAllowed('csvTableImport') && !isPublicView">
<template v-if="isUIAllowed('csvTableImport') && !isPublicView && !isDataReadOnly">
<NcSubMenu key="upload">
<template #title>
<div

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

@ -39,6 +39,12 @@ const reloadViewMetaEventHook = createEventHook<void | boolean>()
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)
useProvideMapViewStore(meta, activeView)
useProvideCalendarViewStore(meta, activeView)
@ -52,9 +58,15 @@ provide(ReloadViewMetaHookInj, reloadViewMetaEventHook)
provide(OpenNewRecordFormHookInj, openNewRecordFormHook)
provide(IsFormInj, isForm)
provide(TabMetaInj, activeTab)
provide(ActiveSourceInj, activeSource)
provide(
ReadonlyInj,
computed(() => !isUIAllowed('dataEdit')),
computed(
() =>
!isUIAllowed('dataEdit', {
skipSourceCheck: true,
}),
),
)
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 { isDataReadOnly } = useRoles()
const { isSharedBase } = storeToRefs(useBase())
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="flex items-center gap-2">
<NcButton
v-if="!isPublic"
v-if="!isPublic && !isDataReadOnly"
v-e="['c:row-expand:open']"
size="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 { isDataReadOnly } = useRoles()
const {
childrenExcludedList,
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="flex">
<NcButton
v-if="!isPublic"
v-if="!isPublic && !isDataReadOnly"
v-e="['c:row-expand:open']"
size="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>) {
if (!viewMeta?.value?.id || !isUIAllowed('dataEdit') || isPublic.value) return
if (!viewMeta?.value?.id || !isUIAllowed('dataEdit', { skipSourceCheck: true }) || isPublic.value) return
const updateValue = {
...(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>) {
if (!viewMeta?.value?.id || !isUIAllowed('dataEdit')) return
if (
!viewMeta?.value?.id ||
!isUIAllowed('dataEdit', {
skipSourceCheck: true,
})
)
return
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>) {
if (!viewMeta?.value?.id || !isUIAllowed('dataEdit')) return
if (!viewMeta?.value?.id || !isUIAllowed('dataEdit', { skipSourceCheck: true })) return
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]) {
return metas.value[tableIdOrTitle]
}
const modelId = (tables.find((t) => t.id === tableIdOrTitle) || tables.find((t) => t.title === tableIdOrTitle))?.id
if (!modelId) {
console.warn(`Table '${tableIdOrTitle}' is not found in the table list`)
return null
}
const modelId =
(tables.find((t) => t.id === tableIdOrTitle) || tables.find((t) => t.title === tableIdOrTitle))?.id || tableIdOrTitle
const model = await $api.dbTable.read(modelId)
metas.value = {

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

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

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

@ -1,6 +1,7 @@
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 type { MaybeRef } from 'vue'
const hasPermission = (role: Exclude<Roles, WorkspaceUserRoles>, hasRole: boolean, permission: Permission | string) => {
const rolePermission = rolePermissions[role]
@ -24,7 +25,7 @@ const hasPermission = (role: Exclude<Roles, WorkspaceUserRoles>, hasRole: boolea
* * `allRoles` - all roles a user has (userRoles + baseRoles)
* * `loadRoles` - a function to load reload user roles for scope
*/
export const useRoles = createSharedComposable(() => {
export const useRolesShared = createSharedComposable(() => {
const { user } = useGlobal()
const { api } = useApi()
@ -129,7 +130,11 @@ export const useRoles = createSharedComposable(() => {
const isUIAllowed = (
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
@ -141,6 +146,27 @@ export const useRoles = createSharedComposable(() => {
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]) =>
hasPermission(role as Exclude<Roles, WorkspaceUserRoles>, hasRole, permission),
)
@ -148,3 +174,31 @@ export const useRoles = createSharedComposable(() => {
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 { EventHook } from '@vueuse/core'
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 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"
},
"labels": {
"allowMetaWrite" : "Allow Schema Edit",
"allowDataWrite" : "Allow Data Edit",
"selectView": "Select a View",
"connectionDetails": "Connection Details",
"metaSync": "Meta Sync",
@ -1038,6 +1040,10 @@
"group": "Group"
},
"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",
"saveChanges": "Save changes",
"xcDB": "Create a new base",
@ -1260,6 +1266,7 @@
}
},
"info": {
"schemaReadOnly": "Schema alterations are disabled for this source",
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"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 = {
org: [OrgUserRoles.VIEWER, OrgUserRoles.CREATOR],
@ -72,6 +72,8 @@ const rolePermissions = {
newUser: true,
webhook: true,
fieldEdit: true,
fieldAlter: true,
fieldDelete: true,
fieldAdd: true,
tableIconEdit: true,
viewCreateOrEdit: true,
@ -117,6 +119,35 @@ const rolePermissions = {
},
} 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)
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 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',
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,
getEquivalentUIType,
isSelectTypeCol,
readonlyMetaAllowedTypes,
} from '~/lib/UITypes';
export { default as CustomAPI, FileType } from '~/lib/CustomAPI';
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 }) {
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_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_051_source_readonly_columns from '~/meta/migrations/v2/nc_051_source_readonly_columns';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -85,6 +86,7 @@ export default class XcMigrationSourcev2 {
'nc_048_view_links',
'nc_049_clear_notifications',
'nc_050_tenant_isolation',
'nc_051_source_readonly_columns',
]);
}
@ -172,6 +174,8 @@ export default class XcMigrationSourcev2 {
return nc_049_clear_notifications;
case '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 { Reflector } from '@nestjs/core';
import { extractRolesObj, OrgUserRoles, ProjectRoles } from 'nocodb-sdk';
import {
extractRolesObj,
OrgUserRoles,
ProjectRoles,
SourceRestriction,
} from 'nocodb-sdk';
import { map } from 'rxjs';
import type { Observable } from 'rxjs';
import type {
@ -28,6 +33,8 @@ import {
import rolePermissions from '~/utils/acl';
import { NcError } from '~/helpers/catchError';
import { RootScopes } from '~/utils/globals';
import { sourceRestrictions } from '~/utils/acl';
import { Source } from '~/models';
export const rolesLabel = {
[OrgUserRoles.SUPER_ADMIN]: 'Super Admin',
@ -68,8 +75,26 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
// extract base id based on request path params
if (params.baseName) {
const base = await Base.getByTitleOrId(context, params.baseName);
if (!base) {
NcError.baseNotFound(params.baseName);
}
if (base) {
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) {
@ -85,7 +110,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
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) {
const view =
(await View.get(context, params.viewId)) ||
@ -95,7 +121,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.viewNotFound(params.viewId);
}
req.ncBaseId = view?.base_id;
req.ncBaseId = view.base_id;
req.ncSourceId = view.source_id;
} else if (
params.formViewId ||
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) {
const view = await View.getByUUID(context, req.params.publicDataUuid);
@ -130,7 +158,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.viewNotFound(params.publicDataUuid);
}
req.ncBaseId = view?.base_id;
req.ncBaseId = view.base_id;
req.ncSourceId = view.source_id;
} else if (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);
}
req.ncBaseId = view?.base_id;
req.ncBaseId = view.base_id;
req.ncSourceId = view.source_id;
} else if (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);
}
req.ncBaseId = hook?.base_id;
req.ncBaseId = hook.base_id;
req.ncSourceId = hook.source_id;
} else if (params.gridViewColumnId) {
const gridViewColumn = await GridViewColumn.get(
context,
@ -165,7 +196,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.fieldNotFound(params.gridViewColumnId);
}
req.ncBaseId = gridViewColumn?.base_id;
req.ncBaseId = gridViewColumn.base_id;
req.ncSourceId = gridViewColumn.source_id;
} else if (params.formViewColumnId) {
const formViewColumn = await FormViewColumn.get(
context,
@ -176,7 +208,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.fieldNotFound(params.formViewColumnId);
}
req.ncBaseId = formViewColumn?.base_id;
req.ncBaseId = formViewColumn.base_id;
req.ncSourceId = formViewColumn.source_id;
} else if (params.galleryViewColumnId) {
const galleryViewColumn = await GalleryViewColumn.get(
context,
@ -187,7 +220,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.fieldNotFound(params.galleryViewColumnId);
}
req.ncBaseId = galleryViewColumn?.base_id;
req.ncBaseId = galleryViewColumn.base_id;
req.ncSourceId = galleryViewColumn.source_id;
} else if (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);
}
req.ncBaseId = column?.base_id;
req.ncBaseId = column.base_id;
req.ncSourceId = column.source_id;
} else if (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);
}
req.ncBaseId = filter?.base_id;
req.ncBaseId = filter.base_id;
req.ncSourceId = filter.source_id;
} else if (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);
}
req.ncBaseId = filter?.base_id;
req.ncBaseId = filter.base_id;
req.ncSourceId = filter.source_id;
} else if (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);
}
req.ncBaseId = sort?.base_id;
req.ncBaseId = sort.base_id;
req.ncSourceId = sort.source_id;
} else if (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.ncSourceId = syncSource.source_id;
} else if (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);
}
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
else if (
@ -281,7 +321,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
NcError.tableNotFound(req.query?.fk_model_id);
}
req.ncBaseId = model?.base_id;
req.ncBaseId = model.base_id;
req.ncSourceId = model.source_id;
} else if (
[
'/api/v1/db/meta/comment/:commentId',
@ -296,7 +337,8 @@ export class ExtractIdsMiddleware implements NestMiddleware, CanActivate {
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
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(
map((data) => {
return data;

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

@ -33,6 +33,8 @@ export default class Source implements SourceType {
alias?: string;
type?: DriverClient;
is_meta?: BoolType;
is_schema_readonly?: BoolType;
is_data_readonly?: BoolType;
config?: string;
inflection_column?: string;
inflection_table?: string;
@ -70,6 +72,8 @@ export default class Source implements SourceType {
'order',
'enabled',
'meta',
'is_schema_readonly',
'is_data_readonly',
]);
insertObj.config = CryptoJS.AES.encrypt(
@ -130,6 +134,8 @@ export default class Source implements SourceType {
'meta',
'deleted',
'fk_sql_executor_id',
'is_schema_readonly',
'is_data_readonly',
]);
if (updateObj.config) {
@ -144,6 +150,10 @@ export default class Source implements SourceType {
updateObj.type = oldSource.type;
}
if ('meta' in updateObj) {
updateObj.meta = stringifyMetaProp(updateObj);
}
// if order is missing (possible in old versions), get next order
if (!oldSource.order && !updateObj.order) {
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,
UseGuards,
} 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 { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
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 { NcContext, NcRequest } from '~/interface/config';
import { RootScopes } from '~/utils/globals';
import { NcError } from '~/helpers/catchError';
@Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
@ -212,6 +218,14 @@ export class DuplicateController {
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 uniqueTitle = generateUniqueName(
@ -276,6 +290,18 @@ export class DuplicateController {
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, {
context,
baseId: base.id,

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

@ -12166,6 +12166,14 @@
"$ref": "#/components/schemas/Bool",
"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": {
"description": "The order of the list of sources",
"example": 1,

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

@ -18215,6 +18215,14 @@
"$ref": "#/components/schemas/Bool",
"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": {
"description": "The order of the list of sources",
"example": 1,

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

@ -5,7 +5,9 @@ import {
isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR,
isVirtualCol,
readonlyMetaAllowedTypes,
RelationTypes,
SourceRestriction,
substituteColumnAliasWithIdInFormula,
substituteColumnIdWithAliasInFormula,
UITypes,
@ -197,6 +199,16 @@ export class ColumnsService {
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 () =>
NcConnectionMgrv2.getSqlClient(source),
);
@ -1482,6 +1494,14 @@ export class ColumnsService {
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 () =>
source.getProject(context),
);
@ -2042,6 +2062,14 @@ export class ColumnsService {
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 () =>
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 { AppEvents, ViewTypes } from 'nocodb-sdk';
import { AppEvents, SourceRestriction, ViewTypes } from 'nocodb-sdk';
import type {
FormUpdateReqType,
UserType,
@ -9,7 +9,7 @@ import type { NcContext, NcRequest } from '~/interface/config';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { validatePayload } from '~/helpers';
import { NcError } from '~/helpers/catchError';
import { FormView, Model, View } from '~/models';
import { FormView, Model, Source, View } from '~/models';
import NocoCache from '~/cache/NocoCache';
import { CacheScope } from '~/utils/globals';
@ -38,6 +38,12 @@ export class FormsService {
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(
context,
{

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

@ -1,7 +1,12 @@
import path from 'path';
import { Injectable } from '@nestjs/common';
import { nanoid } from 'nanoid';
import { populateUniqueFileName, UITypes, ViewTypes } from 'nocodb-sdk';
import {
populateUniqueFileName,
SourceRestriction,
UITypes,
ViewTypes,
} from 'nocodb-sdk';
import slash from 'slash';
import { nocoExecute } from 'nc-help';
@ -293,6 +298,11 @@ export class PublicDatasService {
});
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 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 = {
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;

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

@ -7,7 +7,7 @@ interface ProjectArgs {
type?: string;
}
const sakilaProjectConfig = (context) => {
const sakilaProjectConfig = (context, additionalConfig = {}) => {
let source;
if (
@ -38,6 +38,7 @@ const sakilaProjectConfig = (context) => {
...source,
inflection_column: 'camelize',
inflection_table: 'camelize',
...additionalConfig,
};
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)
.post('/api/v1/db/meta/projects/')
.set('xc-auth', context.token)
.send(sakilaProjectConfig(context));
.send(sakilaProjectConfig(context, additionalConfig));
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 formulaTests from './tests/formula.test';
import typeCastsTest from './tests/typeCasts.test';
import readOnlyTest from './tests/readOnlySource.test';
let workspaceTest = () => {};
let ssoTest = () => {};
@ -41,6 +42,7 @@ function restTests() {
ssoTest();
cloudOrgTest();
typeCastsTest();
readOnlyTest();
// Enable for dashboard feature
// 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 { TeamsPage } from './Teams';
import { DataSourcesPage } from './DataSources';
import { SourcePage } from './Source';
export enum SettingTab {
TeamAuth = 'teamAndAuth',
@ -20,6 +21,7 @@ export enum SettingsSubTab {
export class SettingsPage extends BasePage {
readonly audit: AuditSettingsPage;
readonly source: SourcePage;
readonly miscellaneous: MiscSettingsPage;
readonly dataSources: DataSourcesPage;
readonly teams: TeamsPage;
@ -30,6 +32,7 @@ export class SettingsPage extends BasePage {
this.miscellaneous = new MiscSettingsPage(this);
this.dataSources = new DataSourcesPage(this);
this.teams = new TeamsPage(this);
this.source = new SourcePage(this);
}
get() {

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

@ -378,4 +378,13 @@ export class TreeViewPage extends BasePage {
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