Browse Source

Merge pull request #4134 from nocodb/feat/super-admin-user-management

Feat: Super admin - user management
pull/4297/merge
navi 2 years ago committed by GitHub
parent
commit
4943c1daf1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. BIN
      packages/nc-gui/assets/img/brand/Transparent.png
  2. BIN
      packages/nc-gui/assets/img/brand/favicon-128.png
  3. BIN
      packages/nc-gui/assets/img/brand/favicon-16.png
  4. BIN
      packages/nc-gui/assets/img/brand/favicon-32.png
  5. BIN
      packages/nc-gui/assets/img/brand/favicon-64.png
  6. BIN
      packages/nc-gui/assets/img/brand/full-logo.png
  7. BIN
      packages/nc-gui/assets/img/brand/text.png
  8. BIN
      packages/nc-gui/assets/img/icon.png
  9. BIN
      packages/nc-gui/assets/img/icons/256.png
  10. 6
      packages/nc-gui/components.d.ts
  11. 8
      packages/nc-gui/components/account/AppStore.vue
  12. 45
      packages/nc-gui/components/account/License.vue
  13. 146
      packages/nc-gui/components/account/ResetPassword.vue
  14. 56
      packages/nc-gui/components/account/SignupSettings.vue
  15. 262
      packages/nc-gui/components/account/Token.vue
  16. 281
      packages/nc-gui/components/account/UserList.vue
  17. 256
      packages/nc-gui/components/account/UsersModal.vue
  18. 1
      packages/nc-gui/lang/bn_IN.json
  19. 24
      packages/nc-gui/lang/en.json
  20. 34
      packages/nc-gui/layouts/base.vue
  21. 8
      packages/nc-gui/lib/constants.ts
  22. 3
      packages/nc-gui/lib/enums.ts
  23. 39
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  24. 112
      packages/nc-gui/pages/account/index.vue
  25. 6
      packages/nc-gui/pages/account/index/[page].vue
  26. 5
      packages/nc-gui/pages/account/index/users.vue
  27. 22
      packages/nc-gui/pages/account/index/users/[[nestedPage]].vue
  28. 7
      packages/nc-gui/pages/index/index.vue
  29. 6
      packages/nc-gui/pages/signup/[[token]].vue
  30. 2
      packages/nc-gui/plugins/tele.ts
  31. 9
      packages/noco-docs/content/en/developer-resources/rest-apis.md
  32. 303
      packages/nocodb-sdk/src/lib/Api.ts
  33. 1
      packages/nocodb-sdk/src/lib/globals.ts
  34. 5
      packages/nocodb/src/enums/OrgUserRoles.ts
  35. 4
      packages/nocodb/src/lib/Noco.ts
  36. 2
      packages/nocodb/src/lib/constants/index.ts
  37. 4
      packages/nocodb/src/lib/db/sql-client/lib/KnexClient.ts
  38. 2
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSql.ts
  39. 2
      packages/nocodb/src/lib/db/sql-mgr/SqlMgr.ts
  40. 4
      packages/nocodb/src/lib/meta/NcMetaMgr.ts
  41. 2
      packages/nocodb/src/lib/meta/NcMetaMgrEE.ts
  42. 2
      packages/nocodb/src/lib/meta/NcMetaMgrv2.ts
  43. 22
      packages/nocodb/src/lib/meta/api/apiTokenApis.ts
  44. 2
      packages/nocodb/src/lib/meta/api/attachmentApis.ts
  45. 2
      packages/nocodb/src/lib/meta/api/columnApis.ts
  46. 22
      packages/nocodb/src/lib/meta/api/ee/orgTokenApis.ts
  47. 2
      packages/nocodb/src/lib/meta/api/filterApis.ts
  48. 2
      packages/nocodb/src/lib/meta/api/formViewApis.ts
  49. 2
      packages/nocodb/src/lib/meta/api/formViewColumnApis.ts
  50. 2
      packages/nocodb/src/lib/meta/api/galleryViewApis.ts
  51. 2
      packages/nocodb/src/lib/meta/api/gridViewApis.ts
  52. 2
      packages/nocodb/src/lib/meta/api/gridViewColumnApis.ts
  53. 2
      packages/nocodb/src/lib/meta/api/hookApis.ts
  54. 2
      packages/nocodb/src/lib/meta/api/hookFilterApis.ts
  55. 8
      packages/nocodb/src/lib/meta/api/index.ts
  56. 2
      packages/nocodb/src/lib/meta/api/kanbanViewApis.ts
  57. 2
      packages/nocodb/src/lib/meta/api/metaDiffApis.ts
  58. 2
      packages/nocodb/src/lib/meta/api/modelVisibilityApis.ts
  59. 40
      packages/nocodb/src/lib/meta/api/orgLicenseApis.ts
  60. 81
      packages/nocodb/src/lib/meta/api/orgTokenApis.ts
  61. 329
      packages/nocodb/src/lib/meta/api/orgUserApis.ts
  62. 2
      packages/nocodb/src/lib/meta/api/pluginApis.ts
  63. 3
      packages/nocodb/src/lib/meta/api/projectApis.ts
  64. 12
      packages/nocodb/src/lib/meta/api/projectUserApis.ts
  65. 2
      packages/nocodb/src/lib/meta/api/sharedBaseApis.ts
  66. 2
      packages/nocodb/src/lib/meta/api/sortApis.ts
  67. 2
      packages/nocodb/src/lib/meta/api/swagger/helpers/templates/paths.ts
  68. 4
      packages/nocodb/src/lib/meta/api/sync/helpers/fetchAT.ts
  69. 2
      packages/nocodb/src/lib/meta/api/sync/helpers/job.ts
  70. 2
      packages/nocodb/src/lib/meta/api/sync/syncSourceApis.ts
  71. 2
      packages/nocodb/src/lib/meta/api/tableApis.ts
  72. 2
      packages/nocodb/src/lib/meta/api/userApi/initAdminFromEnv.ts
  73. 72
      packages/nocodb/src/lib/meta/api/userApi/initStrategies.ts
  74. 19
      packages/nocodb/src/lib/meta/api/userApi/userApis.ts
  75. 2
      packages/nocodb/src/lib/meta/api/utilApis.ts
  76. 2
      packages/nocodb/src/lib/meta/api/viewApis.ts
  77. 2
      packages/nocodb/src/lib/meta/api/viewColumnApis.ts
  78. 4
      packages/nocodb/src/lib/meta/helpers/apiMetrics.ts
  79. 16
      packages/nocodb/src/lib/meta/helpers/getHandler.ts
  80. 22
      packages/nocodb/src/lib/meta/helpers/ncMetaAclMw.ts
  81. 4
      packages/nocodb/src/lib/migrations/XcMigrationSourcev2.ts
  82. 26
      packages/nocodb/src/lib/migrations/v2/nc_021_add_fields_in_token.ts
  83. 85
      packages/nocodb/src/lib/models/ApiToken.ts
  84. 2
      packages/nocodb/src/lib/models/KanbanView.ts
  85. 9
      packages/nocodb/src/lib/models/ProjectUser.ts
  86. 13
      packages/nocodb/src/lib/models/SelectOption.ts
  87. 47
      packages/nocodb/src/lib/models/Store.ts
  88. 9
      packages/nocodb/src/lib/models/SyncSource.ts
  89. 51
      packages/nocodb/src/lib/models/User.ts
  90. 2
      packages/nocodb/src/lib/utils/common/BaseApiBuilder.ts
  91. 1
      packages/nocodb/src/lib/utils/globals.ts
  92. 37
      packages/nocodb/src/lib/utils/packageVersion.ts
  93. 14
      packages/nocodb/src/lib/utils/projectAcl.ts
  94. 2
      packages/nocodb/src/lib/v1-legacy/NcProjectBuilder.ts
  95. 4
      packages/nocodb/src/lib/v1-legacy/rest/RestAuthCtrl.ts
  96. 2
      packages/nocodb/src/lib/v1-legacy/rest/RestAuthCtrlEE.ts
  97. 4
      packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts
  98. 43
      packages/nocodb/src/lib/version-upgrader/ncProjectRolesUpgrader.ts
  99. 4
      packages/nocodb/tests/unit/rest/index.test.ts
  100. 2
      packages/nocodb/tests/unit/rest/tests/auth.test.ts
  101. Some files were not shown because too many files have changed in this diff Show More

BIN
packages/nc-gui/assets/img/brand/Transparent.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

BIN
packages/nc-gui/assets/img/brand/favicon-128.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

BIN
packages/nc-gui/assets/img/brand/favicon-16.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

BIN
packages/nc-gui/assets/img/brand/favicon-32.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

BIN
packages/nc-gui/assets/img/brand/favicon-64.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

BIN
packages/nc-gui/assets/img/brand/full-logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
packages/nc-gui/assets/img/brand/text.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
packages/nc-gui/assets/img/icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

BIN
packages/nc-gui/assets/img/icons/256.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

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

@ -104,6 +104,7 @@ declare module '@vue/runtime-core' {
MaterialSymbolsWarning: typeof import('~icons/material-symbols/warning')['default']
MdiAccount: typeof import('~icons/mdi/account')['default']
MdiAccountCircle: typeof import('~icons/mdi/account-circle')['default']
MdiAccountCircleOutline: typeof import('~icons/mdi/account-circle-outline')['default']
MdiAccountOutline: typeof import('~icons/mdi/account-outline')['default']
MdiAccountPlusOutline: typeof import('~icons/mdi/account-plus-outline')['default']
MdiAccountSupervisorOutline: typeof import('~icons/mdi/account-supervisor-outline')['default']
@ -130,6 +131,7 @@ declare module '@vue/runtime-core' {
MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default']
MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
MdiClipboard: typeof import('~icons/mdi/clipboard')['default']
MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseBox: typeof import('~icons/mdi/close-box')['default']
MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
@ -140,12 +142,14 @@ declare module '@vue/runtime-core' {
MdiCommentTextOutline: typeof import('~icons/mdi/comment-text-outline')['default']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
MdiContentSave: typeof import('~icons/mdi/content-save')['default']
MdiCopy: typeof import('~icons/mdi/copy')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDatabaseOutline: typeof import('~icons/mdi/database-outline')['default']
MdiDatabaseSync: typeof import('~icons/mdi/database-sync')['default']
MdiDelete: typeof import('~icons/mdi/delete')['default']
MdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
MdiDiscord: typeof import('~icons/mdi/discord')['default']
MdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default']
MdiDownload: typeof import('~icons/mdi/download')['default']
MdiDownloadOutline: typeof import('~icons/mdi/download-outline')['default']
@ -176,6 +180,7 @@ declare module '@vue/runtime-core' {
MdiInformation: typeof import('~icons/mdi/information')['default']
MdiJson: typeof import('~icons/mdi/json')['default']
MdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
MdiKeyChainVariant: typeof import('~icons/mdi/key-chain-variant')['default']
MdiKeyChange: typeof import('~icons/mdi/key-change')['default']
MdiKeyStar: typeof import('~icons/mdi/key-star')['default']
MdiLink: typeof import('~icons/mdi/link')['default']
@ -202,6 +207,7 @@ declare module '@vue/runtime-core' {
MdiRocketLaunchOutline: typeof import('~icons/mdi/rocket-launch-outline')['default']
MdiScriptTextKeyOutline: typeof import('~icons/mdi/script-text-key-outline')['default']
MdiScriptTextOutline: typeof import('~icons/mdi/script-text-outline')['default']
MdiShieldAccountOutline: typeof import('~icons/mdi/shield-account-outline')['default']
MdiShieldKeyOutline: typeof import('~icons/mdi/shield-key-outline')['default']
MdiSlack: typeof import('~icons/mdi/slack')['default']
MdiSort: typeof import('~icons/mdi/sort')['default']

8
packages/nc-gui/components/account/AppStore.vue

@ -0,0 +1,8 @@
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">{{ $t('title.appStore') }}</div>
<div>
<LazyDashboardSettingsAppStore />
</div>
</div>
</template>

45
packages/nc-gui/components/account/License.vue

@ -0,0 +1,45 @@
<script lang="ts" setup>
import { useNuxtApp } from '#app'
import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, useApi } from '#imports'
const { api, isLoading } = useApi()
const {$e} = useNuxtApp()
let key = $ref('')
const loadLicense = async () => {
try {
const response = await api.orgLicense.get()
key = response.key
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const setLicense = async () => {
try {
await api.orgLicense.set({ key: key })
message.success('License key updated')
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:account:license')
}
loadLicense()
</script>
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">License</div>
<div>
<a-textarea v-model:value="key" placeholder="License key" class="!mt-2 !max-w-[600px]"></a-textarea>
</div>
<a-button class="mt-4" @click="setLicense" type="primary">Save license key</a-button>
</div>
</template>
<style scoped></style>

146
packages/nc-gui/components/account/ResetPassword.vue

@ -0,0 +1,146 @@
<script lang="ts" setup>
import { message, navigateTo, reactive, ref, useApi, useGlobal, useI18n } from '#imports'
const { api, error } = useApi({ useGlobalInstance: true })
const { t } = useI18n()
const { signOut } = useGlobal()
const formValidator = ref()
const form = reactive({
currentPassword: '',
password: '',
passwordRepeat: '',
})
const formRules = {
currentPassword: [
// Current password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
],
password: [
// Password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
{ min: 8, message: t('msg.error.signUpRules.passwdLength') },
],
passwordRepeat: [
// PasswordRepeat is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
// Passwords match
{
validator: (_: unknown, _v: string) => {
return new Promise((resolve, reject) => {
if (form.password === form.passwordRepeat) return resolve(true)
reject(new Error(t('msg.error.signUpRules.passwdMismatch')))
})
},
message: t('msg.error.signUpRules.passwdMismatch'),
},
],
}
const passwordChange = async () => {
const valid = formValidator.value.validate()
if (!valid) return
error.value = null
await api.auth.passwordChange({
currentPassword: form.currentPassword,
newPassword: form.password,
})
message.success(t('msg.success.passwordChanged'))
signOut()
navigateTo('/signin')
}
const resetError = () => {
if (error.value) error.value = null
}
</script>
<template>
<div class="mx-auto relative flex flex-col justify-center gap-2 w-full px-8 md:(bg-white) max-w-[900px]">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">{{ $t('activity.changePwd') }}</div>
<a-form
ref="formValidator"
data-testid="nc-user-settings-form"
layout="vertical"
class="change-password lg:max-w-3/4 w-full !mx-auto"
no-style
:model="form"
@finish="passwordChange"
>
<Transition name="layout">
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning />
{{ error }}
</div>
</div>
</Transition>
<a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword">
<a-input-password
v-model:value="form.currentPassword"
data-testid="nc-user-settings-form__current-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.current')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.new')" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
data-testid="nc-user-settings-form__new-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.new')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.confirm')" name="passwordRepeat" :rules="formRules.passwordRepeat">
<a-input-password
v-model:value="form.passwordRepeat"
data-testid="nc-user-settings-form__new-password-repeat"
size="large"
class="password"
:placeholder="$t('placeholder.password.confirm')"
@focus="resetError"
/>
</a-form-item>
<div class="text-center">
<button data-testid="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit">
<span class="flex items-center gap-2">
<MdiKeyChange />
{{ $t('activity.changePwd') }}
</span>
</button>
</div>
</a-form>
</div>
</template>
<style lang="scss">
.change-password {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
.password {
input {
@apply !border-none !m-0;
}
}
}
</style>

56
packages/nc-gui/components/account/SignupSettings.vue

@ -0,0 +1,56 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, useApi } from '#imports'
const { api } = useApi()
const { t } = useI18n()
let settings = $ref<{ invite_only_signup?: boolean }>({ invite_only_signup: false })
const loadSettings = async () => {
try {
const response = await api.orgAppSettings.get()
settings = response
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const saveSettings = async () => {
try {
await api.orgAppSettings.set(settings)
message.success(t('msg.success.settingsSaved'))
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
loadSettings()
</script>
<template>
<div data-testid="nc-app-settings">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">Settings</div>
<div class="flex justify-center">
<a-form-item>
<a-checkbox
v-model:checked="settings.invite_only_signup"
v-e="['c:account:enable-signup']"
class="nc-checkbox nc-invite-only-signup-checkbox"
name="virtual"
@change="saveSettings"
>
{{ $t('labels.inviteOnlySignup') }}
</a-checkbox>
</a-form-item>
</div>
</div>
</template>
<style scoped>
:deep(.ant-checkbox-wrapper) {
@apply !flex-row-reverse !flex !justify-start gap-4;
justify-content: start;
}
</style>

262
packages/nc-gui/components/account/Token.vue

@ -0,0 +1,262 @@
<script lang="ts" setup>
import { Empty, Modal, message } from 'ant-design-vue'
import type { ApiTokenType, RequestParams, UserType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, useApi, useCopy, useNuxtApp } from '#imports'
const { api, isLoading } = useApi()
const { $e } = useNuxtApp()
const { copy } = useCopy()
const { t } = useI18n()
let tokens = $ref<UserType[]>([])
let currentPage = $ref(1)
let showNewTokenModal = $ref(false)
const currentLimit = $ref(10)
let selectedTokenData = $ref<ApiTokenType>({})
const searchText = ref<string>('')
const pagination = reactive({
total: 0,
pageSize: 10,
})
const loadTokens = async (page = currentPage, limit = currentLimit) => {
currentPage = page
try {
const response: any = await api.orgTokens.list({
query: {
limit,
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
},
} as RequestParams)
if (!response) return
pagination.total = response.pageInfo.totalRows ?? 0
pagination.pageSize = 10
tokens = response.list as UserType[]
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
loadTokens()
const deleteToken = async (token: string) => {
Modal.confirm({
title: t('msg.info.deleteTokenConfirmation'),
type: 'warn',
onOk: async () => {
try {
// todo: delete token
await api.orgTokens.delete(token)
message.success(t('msg.success.tokenDeleted'))
await loadTokens()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:account:token:delete')
},
})
}
const generateToken = async () => {
try {
await api.orgTokens.create(selectedTokenData)
showNewTokenModal = false
// Token generated successfully
message.success(t('msg.success.tokenGenerated'))
selectedTokenData = {}
await loadTokens()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:api-token:generate')
}
const copyToken = (token: string | undefined) => {
if (!token) return
copy(token)
// Copied to clipboard
message.info(t('msg.info.copiedToClipboard'))
$e('c:api-token:copy')
}
const descriptionInput = (el) => {
el?.focus()
}
</script>
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">Token Management</div>
<div class="max-w-[900px] mx-auto p-4" data-testid="nc-token-list">
<div class="py-2 flex gap-4 items-center">
<div class="flex-grow"></div>
<MdiReload class="cursor-pointer" @click="loadTokens" />
<a-button data-testid="nc-token-create" size="small" type="primary" @click="showNewTokenModal = true">
<div class="flex items-center gap-1">
<MdiAdd />
Add new token
</div>
</a-button>
</div>
<a-table
:row-key="(record) => record.id"
:data-source="tokens"
:pagination="{ position: ['bottomCenter'] }"
:loading="isLoading"
size="small"
@change="loadTokens($event.current)"
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<!-- Created By -->
<a-table-column key="created_by" :title="$t('labels.createdBy')" data-index="created_by">
<template #default="{ text }">
<div v-if="text">
{{ text }}
</div>
<div v-else class="text-gray-400">N/A</div>
</template>
</a-table-column>
<!-- Description -->
<a-table-column key="description" :title="$t('labels.description')" data-index="description">
<template #default="{ text }">
{{ text }}
</template>
</a-table-column>
<!-- Token -->
<a-table-column key="token" :title="$t('labels.token')" data-index="token">
<template #default="{ text, record }">
<div class="w-[320px]">
<span v-if="record.show">{{ text }}</span>
<span v-else>*******************************************</span>
</div>
</template>
</a-table-column>
<!-- Actions -->
<a-table-column key="actions" :title="$t('labels.actions')" data-index="token">
<template #default="{ record }">
<div class="flex items-center gap-2">
<a-tooltip placement="bottom">
<template #title>
<span v-if="record.show"> {{ $t('general.hide') }} </span>
<span v-else> {{ $t('general.show') }} </span>
</template>
<a-button type="text" class="!rounded-md nc-toggle-token-visibility" @click="record.show = !record.show">
<template #icon>
<MaterialSymbolsVisibilityOff v-if="record.show" class="flex mx-auto h-[1.1rem]" />
<MaterialSymbolsVisibility v-else class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title> {{ $t('general.copy') }}</template>
<a-button type="text" class="!rounded-md" @click="copyToken(record.token)">
<template #icon>
<MdiContentCopy class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-dropdown
:trigger="['click']"
class="flex"
placement="bottomRight"
overlay-class-name="nc-dropdown-api-token-mgmt"
>
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<IcBaselineMoreVert class="nc-token-menu" />
</div>
</a-button>
</div>
<template #overlay>
<a-menu data-testid="nc-token-row-action-icon">
<a-menu-item>
<div class="flex flex-row items-center py-3 h-[1rem] nc-delete-token" @click="deleteToken(record.token)">
<MdiDeleteOutline class="flex" />
<div class="text-xs pl-2">{{ $t('general.remove') }}</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</a-table-column>
</a-table>
</div>
<a-modal
v-model:visible="showNewTokenModal"
:closable="false"
width="28rem"
centered
:footer="null"
wrap-class-name="nc-modal-generate-token"
>
<div class="relative flex flex-col h-full">
<a-button type="text" class="!absolute top-0 right-0 rounded-md -mt-2 -mr-3" @click="showNewTokenModal = false">
<template #icon>
<MaterialSymbolsCloseRounded class="flex mx-auto" />
</template>
</a-button>
<!-- Generate Token -->
<div class="flex flex-row justify-center w-full -mt-1 mb-3">
<a-typography-title :level="5">{{ $t('title.generateToken') }}</a-typography-title>
</div>
<!-- Description -->
<a-form
ref="form"
:model="selectedTokenData"
name="basic"
layout="vertical"
class="flex flex-col justify-center space-y-6"
no-style
autocomplete="off"
@finish="generateToken"
>
<a-input
:ref="descriptionInput"
v-model:value="selectedTokenData.description"
data-testid="nc-token-modal-description"
:placeholder="$t('labels.description')"
/>
<!-- Generate -->
<div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit" data-testid="nc-token-modal-save">
{{ $t('general.generate') }}
</a-button>
</div>
</a-form>
</div>
</a-modal>
</div>
</template>
<style scoped></style>

281
packages/nc-gui/components/account/UserList.vue

@ -0,0 +1,281 @@
<script lang="ts" setup>
import { Modal, message } from 'ant-design-vue'
import type { RequestParams, UserType } from 'nocodb-sdk'
import { Role, extractSdkResponseErrorMsg, useApi, useCopy, useDashboard, useNuxtApp } from '#imports'
import type { User } from '~/lib'
const { api, isLoading } = useApi()
const { $e } = useNuxtApp()
const { t } = useI18n()
const { dashboardUrl } = $(useDashboard())
const { copy } = useCopy()
let users = $ref<UserType[]>([])
let currentPage = $ref(1)
const currentLimit = $ref(10)
const showUserModal = ref(false)
const userMadalKey = ref(0)
const searchText = ref<string>('')
const pagination = reactive({
total: 0,
pageSize: 10,
})
const loadUsers = async (page = currentPage, limit = currentLimit) => {
currentPage = page
try {
const response: any = await api.orgUsers.list({
query: {
limit,
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
query: searchText.value,
},
} as RequestParams)
if (!response) return
pagination.total = response.pageInfo.totalRows ?? 0
pagination.pageSize = 10
users = response.list as UserType[]
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
loadUsers()
const updateRole = async (userId: string, roles: Role) => {
try {
await api.orgUsers.update(userId, {
roles,
} as unknown as UserType)
message.success(t('msg.success.roleUpdated'))
$e('a:org-user:role-updated', { role: roles })
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const deleteUser = async (userId: string) => {
Modal.confirm({
title: 'Are you sure you want to delete this user?',
type: 'warn',
content: 'On deleting, user will remove from organization and any sync source(Airtable) created by user will get removed',
onOk: async () => {
try {
await api.orgUsers.delete(userId)
message.success(t('msg.success.userDeleted'))
await loadUsers()
$e('a:org-user:user-deleted')
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
const resendInvite = async (user: User) => {
try {
await api.orgUsers.resendInvite(user.id)
// Invite email sent successfully
message.success(t('msg.success.inviteEmailSent'))
await loadUsers()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:org-user:resend-invite')
}
const copyInviteUrl = (user: User) => {
if (!user.invite_token) return
copy(`${dashboardUrl}#/signup/${user.invite_token}`)
// Invite URL copied to clipboard
message.success(t('msg.success.inviteURLCopied'))
$e('c:user:copy-url')
}
const copyPasswordResetUrl = async (user: User) => {
try {
const { reset_password_url } = await api.orgUsers.generatePasswordResetToken(user.id)
copy(reset_password_url)
// Invite URL copied to clipboard
message.success(t('msg.success.passwordResetURLCopied'))
$e('c:user:copy-url')
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<div data-testid="nc-super-user-list">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">User Management</div>
<div class="max-w-[900px] mx-auto p-4">
<div class="py-2 flex gap-4 items-center">
<a-input-search
v-model:value="searchText"
size="small"
class="max-w-[300px]"
placeholder="Filter by email"
@blur="loadUsers"
@keydown.enter="loadUsers"
>
</a-input-search>
<div class="flex-grow"></div>
<MdiReload class="cursor-pointer" @click="loadUsers" />
<a-button
data-testid="nc-super-user-invite"
size="small"
type="primary"
@click="
() => {
showUserModal = true
userMadalKey++
}
"
>
<div class="flex items-center gap-1">
<MdiAdd />
Invite new user
</div>
</a-button>
</div>
<a-table
:row-key="(record) => record.id"
:data-source="users"
:pagination="{ position: ['bottomCenter'] }"
:loading="isLoading"
size="small"
@change="loadUsers($event.current)"
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<!-- Email -->
<a-table-column key="email" :title="$t('labels.email')" data-index="email">
<template #default="{ text }">
<div>
{{ text }}
</div>
</template>
</a-table-column>
<!-- Role -->
<a-table-column key="roles" :title="$t('objects.role')" data-index="roles">
<template #default="{ record }">
<div>
<div v-if="record.roles.includes('super')" class="font-weight-bold">Super Admin</div>
<a-select
v-else
v-model:value="record.roles"
class="w-[220px] nc-user-roles"
:dropdown-match-select-width="false"
@change="updateRole(record.id, record.roles)"
>
<a-select-option
class="nc-users-list-role-option"
:value="Role.OrgLevelCreator"
:label="$t(`objects.roleType.orgLevelCreator`)"
>
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgCreator') }}
</span>
</a-select-option>
<a-select-option
class="nc-users-list-role-option"
:value="Role.OrgLevelViewer"
:label="$t(`objects.roleType.orgLevelViewer`)"
>
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgViewer') }}
</span>
</a-select-option>
</a-select>
</div>
</template>
</a-table-column>
<!-- &lt;!&ndash; Projects &ndash;&gt;
<a-table-column key="projectsCount" :title="$t('objects.projects')" data-index="projectsCount">
<template #default="{ text }">
<div>
{{ text }}
</div>
</template>
</a-table-column> -->
<!-- Actions -->
<a-table-column key="id" :title="$t('labels.actions')" data-index="id">
<template #default="{ text, record }">
<div v-if="!record.roles.includes('super')" class="flex items-center gap-2">
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-user-mgmt">
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<MdiDotsHorizontal class="nc-user-row-action" />
</div>
</a-button>
</div>
<template #overlay>
<a-menu>
<template v-if="record.invite_token">
<a-menu-item>
<!-- Resend invite Email -->
<div class="flex flex-row items-center py-3" @click="resendInvite(record)">
<MdiEmailArrowRightOutline class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.resendInvite') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="copyInviteUrl(record)">
<MdiContentCopy class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.copyInviteURL') }}</div>
</div>
</a-menu-item>
</template>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="copyPasswordResetUrl(record)">
<MdiContentCopy class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.copyPasswordResetURL') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="deleteUser(text)">
<MdiDeleteOutline data-testid="nc-super-user-delete" class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('general.delete') }}</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<span v-else></span>
</template>
</a-table-column>
</a-table>
<LazyAccountUsersModal :key="userMadalKey" :show="showUserModal" @closed="showUserModal = false" @reload="loadUsers" />
</div>
</div>
</template>

256
packages/nc-gui/components/account/UsersModal.vue

@ -0,0 +1,256 @@
<script setup lang="ts">
import type { UserType } from 'nocodb-sdk'
import {
Form,
computed,
extractSdkResponseErrorMsg,
isEmail,
message,
ref,
useCopy,
useDashboard,
useI18n,
useNuxtApp,
} from '#imports'
import type { User } from '~/lib'
import { Role } from '~/lib'
interface Props {
show: boolean
selectedUser?: User
}
interface Users {
emails: string
role: Role.OrgLevelCreator | Role.OrgLevelViewer
invitationToken?: string
}
const { show } = defineProps<Props>()
const emit = defineEmits(['closed', 'reload'])
const { t } = useI18n()
const { $api, $e } = useNuxtApp()
const { copy } = useCopy()
const { dashboardUrl } = $(useDashboard())
const usersData = $ref<Users>({ emails: '', role: Role.OrgLevelViewer, invitationToken: undefined })
const formRef = ref()
const useForm = Form.useForm
const validators = computed(() => {
return {
emails: [
{
validator: (rule: any, value: string, callback: (errMsg?: string) => void) => {
if (!value || value.length === 0) {
callback('Email is required')
return
}
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !isEmail(e))
if (invalidEmails.length > 0) {
callback(`${invalidEmails.length > 1 ? ' Invalid emails:' : 'Invalid email:'} ${invalidEmails.join(', ')} `)
} else {
callback()
}
},
},
],
}
})
const { validateInfos } = useForm(usersData, validators)
const saveUser = async () => {
$e('a:org-user:invite', { role: usersData.role })
await formRef.value?.validateFields()
try {
// todo: update sdk(swagger.json)
const res = await $api.orgUsers.add({
roles: usersData.role,
email: usersData.emails,
} as unknown as UserType)
usersData.invitationToken = res.invite_token
emit('reload')
// Successfully updated the user details
message.success(t('msg.success.userAdded'))
} catch (e: any) {
console.error(e)
message.error(await extractSdkResponseErrorMsg(e))
}
}
const inviteUrl = $computed(() => (usersData.invitationToken ? `${dashboardUrl}#/signup/${usersData.invitationToken}` : null))
const copyUrl = async () => {
if (!inviteUrl) return
await copy(inviteUrl)
// Copied shareable base url to clipboard!
message.success(t('msg.success.shareableURLCopied'))
$e('c:shared-base:copy-url')
}
const clickInviteMore = () => {
$e('c:user:invite-more')
usersData.invitationToken = undefined
usersData.role = Role.OrgLevelViewer
usersData.emails = ''
}
const emailInput = ref((el) => {
el?.focus()
})
</script>
<template>
<a-modal
:footer="null"
centered
:visible="show"
:closable="false"
width="max(50vw, 44rem)"
wrap-class-name="nc-modal-invite-user"
@cancel="emit('closed')"
>
<div class="flex flex-col">
<div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full">
<a-typography-title class="select-none" :level="4"> {{ $t('activity.inviteUser') }}</a-typography-title>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')">
<template #icon>
<MaterialSymbolsCloseRounded data-testid="nc-root-user-invite-modal-close" class="flex mx-auto" />
</template>
</a-button>
</div>
<div class="px-2 mt-1.5">
<template v-if="usersData.invitationToken">
<div class="flex flex-col mt-1 border-b-1 pb-5">
<div class="flex flex-row items-center pl-1.5 pb-1 h-[1.1rem]">
<MdiAccountOutline />
<div class="text-xs ml-0.5 mt-0.5">Copy Invite Token</div>
</div>
<a-alert class="mt-1" type="success" show-icon>
<template #message>
<div class="flex flex-row justify-between items-center py-1">
<div class="flex pl-2 text-green-700 text-xs">
{{ inviteUrl }}
</div>
<a-button type="text" class="!rounded-md -mt-0.5" @click="copyUrl">
<template #icon>
<MdiContentCopy class="flex mx-auto text-green-700 h-[1rem]" />
</template>
</a-button>
</div>
</template>
</a-alert>
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2">
{{ $t('msg.info.userInviteNoSMTP') }}
{{ usersData.invitationToken && usersData.emails }}
</div>
<div class="flex flex-row justify-start mt-4 ml-2">
<a-button size="small" outlined @click="clickInviteMore">
<div class="flex flex-row justify-center items-center space-x-0.5">
<MaterialSymbolsSendOutline class="flex mx-auto text-gray-600 h-[0.8rem]" />
<div class="text-xs text-gray-600">{{ $t('activity.inviteMore') }}</div>
</div>
</a-button>
</div>
</div>
</template>
<div v-else class="flex flex-col pb-4">
<div class="flex flex-row items-center pl-2 pb-1 h-[1rem]">
<MdiAccountOutline />
<div class="text-xs ml-0.5 mt-0.5">{{ $t('activity.inviteUser') }}</div>
</div>
<div class="border-1 py-3 px-4 rounded-md mt-1">
<a-form
ref="formRef"
:validate-on-rule-change="false"
:model="usersData"
validate-trigger="onBlur"
@finish="saveUser"
>
<div class="flex flex-row space-x-4">
<div class="flex flex-col w-3/4">
<a-form-item
v-bind="validateInfos.emails"
validate-trigger="onBlur"
name="emails"
:rules="[{ required: true, message: 'Please input email' }]"
>
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('datatype.Email') }}:</div>
<a-input
:ref="emailInput"
v-model:value="usersData.emails"
validate-trigger="onBlur"
:placeholder="$t('labels.email')"
/>
</a-form-item>
</div>
<div class="flex flex-col w-2/4">
<a-form-item name="role" :rules="[{ required: true, message: 'Role required' }]">
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('labels.selectUserRole') }}</div>
<a-select v-model:value="usersData.role" class="nc-user-roles" dropdown-class-name="nc-dropdown-user-role">
<a-select-option
class="nc-role-option"
:value="Role.OrgLevelCreator"
:label="$t(`objects.roleType.orgLevelCreator`)"
>
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgCreator') }}
</span>
</a-select-option>
<a-select-option
class="nc-role-option"
:value="Role.OrgLevelViewer"
:label="$t(`objects.roleType.orgLevelViewer`)"
>
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgViewer') }}
</span>
</a-select-option>
</a-select>
</a-form-item>
</div>
</div>
<div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit">
<div class="flex flex-row justify-center items-center space-x-1.5">
<MaterialSymbolsSendOutline class="flex h-[0.8rem]" />
<div>{{ $t('activity.invite') }}</div>
</div>
</a-button>
</div>
</a-form>
</div>
</div>
</div>
</div>
</a-modal>
</template>

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

@ -591,6 +591,7 @@
"tableDeleted": "Deleted table successfully",
"generatePublicShareableReadonlyBase": "Generate publicly shareable readonly base",
"deleteViewConfirmation": "Are you sure you want to delete this view?",
"deleteTokenConfirmation": "Are you sure you want to delete this token?",
"deleteTableConfirmation": "Do you want to delete the table",
"showM2mTables": "Show M2M Tables",
"deleteKanbanStackConfirmation": "Deleting this stack will also remove the select option `{stackToBeDeleted}` from the `{groupingField}`. The records will move to the uncategorized stack."

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

@ -104,7 +104,9 @@
"creator": "Creator",
"editor": "Editor",
"commenter": "Commenter",
"viewer": "Viewer"
"viewer": "Viewer",
"orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer"
},
"sqlVIew": "SQL View"
},
@ -200,6 +202,7 @@
"codeSnippet": "Code Snippet"
},
"labels": {
"createdBy": "Created By",
"notifyVia": "Notify Via",
"projName": "Project name",
"tableName": "Table name",
@ -296,7 +299,8 @@
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
"welcomeToNc": "Welcome to NocoDB!",
"inviteOnlySignup": "Allow signup only using invite url"
},
"activity": {
"createProject": "Create Project",
@ -341,12 +345,14 @@
"invite": "Invite",
"inviteMore": "Invite more",
"inviteTeam": "Invite Team",
"inviteUser": "Invite User",
"inviteToken": "Invite Token",
"newUser": "New User",
"editUser": "Edit user",
"deleteUser": "Remove user from project",
"resendInvite": "Resend invite E-mail",
"copyInviteURL": "Copy invite URL",
"copyPasswordResetURL": "Copy password reset URL",
"newRole": "New role",
"reloadRoles": "Reload roles",
"nextPage": "Next page",
@ -480,6 +486,10 @@
},
"msg": {
"info": {
"roles": {
"orgCreator": "Creator can create new projects and access any invited project.",
"orgViewer": "Viewer is not allowed to create new projects but they can access any invited project."
},
"footerInfo": "Rows per page",
"upload": "Select file to Upload",
"upload_sub": "or drag and drop file",
@ -653,7 +663,8 @@
"theAcceptedFileTypesAreXlsXlsxXlsmOdsOts": "The accepted file types are .xls, .xlsx, .xlsm, .ods, .ots",
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty."
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Project not accessible"
},
"toast": {
"exportMetadata": "Project metadata exported successfully",
@ -683,13 +694,16 @@
"tableDataExported": "Successfully exported all table data",
"updated": "Successfully updated",
"sharedViewDeleted": "Deleted shared view successfully",
"userDeleted": "User deleted successfully",
"viewRenamed": "View renamed successfully",
"tokenGenerated": "Token generated successfully",
"tokenDeleted": "Token deleted successfully",
"userAddedToProject": "Successfully added user to project",
"userAdded": "Successfully added user",
"userDeletedFromProject": "Successfully deleted user from project",
"inviteEmailSent": "Invite Email sent successfully",
"inviteURLCopied": "Invite URL copied to clipboard",
"passwordResetURLCopied": "Password reset URL copied to clipboard",
"shareableURLCopied": "Copied shareable base URL to clipboard!",
"embeddableHTMLCodeCopied": "Copied embeddable HTML code!",
"userDetailsUpdated": "Successfully updated the user details",
@ -699,7 +713,9 @@
"webhookTested": "Webhook tested successfully",
"columnUpdated": "Column updated",
"columnCreated": "Column created",
"passwordChanged": "Password changed successfully. Please login again."
"passwordChanged": "Password changed successfully. Please login again.",
"settingsSaved": "Settings saved successfully",
"roleUpdated": "Role updated successfully"
}
}
}

34
packages/nc-gui/layouts/base.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, navigateTo, ref, useGlobal, useNuxtApp, useRoute, useSidebar, useUIPermission } from '#imports'
import { computed, navigateTo, ref, useGlobal, useNuxtApp, useRoute, useSidebar } from '#imports'
const { signOut, signedIn, isLoading, user, currentVersion } = useGlobal()
@ -13,8 +13,6 @@ const hasSider = ref(false)
const sidebar = ref<HTMLDivElement>()
const { isUIAllowed } = useUIPermission()
const logout = () => {
signOut()
navigateTo('/signin')
@ -22,6 +20,8 @@ const logout = () => {
const { hooks } = useNuxtApp()
const isDashboard = computed(() => !!route.params.projectType)
/** when page suspensions have finished, check if a sidebar element was teleported into the layout */
hooks.hook('page:finish', () => {
if (sidebar.value) {
@ -39,7 +39,7 @@ hooks.hook('page:finish', () => {
<a-layout class="!flex-col">
<a-layout-header
v-if="!route.meta.public && signedIn && !route.meta.hideHeader"
class="flex !bg-primary items-center text-white pl-4 pr-5 shadow-lg"
class="flex !bg-primary items-center text-white !pl-2 !pr-5"
>
<div
v-if="!route.params.projectType"
@ -52,7 +52,10 @@ hooks.hook('page:finish', () => {
<template #title>
{{ currentVersion }}
</template>
<img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
<div class="flex items-center gap-2">
<img width="25" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
<img v-show="!isDashboard" width="90" alt="NocoDB" src="~/assets/img/brand/text.png" />
</div>
</a-tooltip>
</div>
@ -87,27 +90,30 @@ hooks.hook('page:finish', () => {
<template #overlay>
<a-menu class="!py-0 leading-8 !rounded">
<a-menu-item key="0" data-testid="nc-menu-accounts__user-settings" class="!rounded-t">
<nuxt-link v-e="['c:navbar:user:email']" class="nc-project-menu-item group !no-underline" to="/user">
<MdiAt class="mt-1 group-hover:text-accent" />&nbsp;
<span class="prose group-hover:text-primary"> {{ email }}</span>
<nuxt-link v-e="['c:navbar:user:email']" class="nc-project-menu-item group !no-underline" to="/account/users">
<MdiAccountCircleOutline class="mt-1 group-hover:text-accent" />&nbsp;
<div class="prose group-hover:text-primary">
<div>Account</div>
<div class="text-xs text-gray-500">{{ email }}</div>
</div>
</nuxt-link>
</a-menu-item>
<a-menu-divider class="!m-0" />
<a-menu-item v-if="isUIAllowed('appStore')" key="0" class="!rounded-t">
<!-- <a-menu-item v-if="isUIAllowed('appStore')" key="0" class="!rounded-t">
<nuxt-link
v-e="['c:settings:appstore', { page: true }]"
class="nc-project-menu-item group !no-underline"
to="/apps"
to="/admin/users"
>
<MdiStorefrontOutline class="mt-1 group-hover:text-accent" />&nbsp;
<MdiShieldAccountOutline class="mt-1 group-hover:text-accent" />&nbsp;
<span class="prose group-hover:text-primary">{{ $t('title.appStore') }}</span>
&lt;!&ndash; todo: i18n &ndash;&gt;
<span class="prose group-hover:text-primary">Account management</span>
</nuxt-link>
</a-menu-item>
<a-menu-divider class="!m-0" />
<a-menu-divider class="!m-0" /> -->
<a-menu-item key="1" class="!rounded-b group">
<div v-e="['a:navbar:user:sign-out']" class="nc-project-menu-item group" @click="logout">

8
packages/nc-gui/lib/constants.ts

@ -18,7 +18,7 @@ export const rolePermissions = {
[Role.Super]: '*',
[Role.Admin]: {} as Record<string, boolean>,
[Role.Guest]: {} as Record<string, boolean>,
[Role.User]: {
[Role.OrgLevelCreator]: {
include: {
projectCreate: true,
projectActions: true,
@ -30,11 +30,17 @@ export const rolePermissions = {
[ProjectRole.Creator]: {
exclude: {
appStore: true,
superAdminUserManagement: true,
superAdminAppSettings: true,
appLicense: true,
},
},
[ProjectRole.Owner]: {
exclude: {
appStore: true,
superAdminUserManagement: true,
superAdminAppSettings: true,
appLicense: true,
},
},
[ProjectRole.Editor]: {

3
packages/nc-gui/lib/enums.ts

@ -1,7 +1,8 @@
export enum Role {
Super = 'super',
Admin = 'admin',
User = 'user',
OrgLevelCreator = 'org-level-creator',
OrgLevelViewer = 'org-level-viewer',
Guest = 'guest',
}

39
packages/nc-gui/pages/[projectType]/[projectId]/index.vue

@ -24,6 +24,7 @@ import {
useUIPermission,
} from '#imports'
import { TabType } from '~/lib'
import { extractSdkResponseErrorMsg } from '~/utils'
definePageMeta({
hideHeader: true,
@ -152,7 +153,17 @@ onKeyStroke(
clearTabs()
onBeforeMount(async () => {
await loadProject()
try {
await loadProject()
} catch (e: any) {
if (e.response?.status === 403) {
// Project is not accessible
message.error(t('msg.error.projectNotAccessible'))
router.replace('/')
return
}
message.error(await extractSdkResponseErrorMsg(e))
}
if (!route.params.type && isUIAllowed('teamAndAuth')) {
addTab({ type: TabType.AUTH, title: t('title.teamAndAuth') })
@ -189,20 +200,20 @@ onBeforeUnmount(reset)
<div
style="height: var(--header-height)"
:class="isOpen ? 'pl-4' : ''"
class="flex items-center !bg-primary text-white px-1 gap-2"
class="flex items-center !bg-primary text-white px-1 gap-1"
>
<div
v-if="isOpen && !isSharedBase"
v-e="['c:navbar:home']"
data-testid="nc-noco-brand-icon"
class="w-[40px] min-w-[40px] transition-all duration-200 p-1 cursor-pointer transform hover:scale-105 nc-noco-brand-icon"
class="w-[29px] min-w-[29px] transition-all duration-200 py-1 pl-1 cursor-pointer transform hover:scale-105 nc-noco-brand-icon"
@click="navigateTo('/')"
>
<a-tooltip placement="bottom">
<template #title>
{{ currentVersion }}
</template>
<img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
<img width="25" class="-mr-1" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
</a-tooltip>
</div>
@ -216,7 +227,7 @@ onBeforeUnmount(reset)
<template #title>
{{ currentVersion }}
</template>
<img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
<img width="25" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
</a-tooltip>
</a>
@ -234,12 +245,12 @@ onBeforeUnmount(reset)
>
<template v-if="isOpen">
<a-tooltip v-if="project.title?.length > 12" placement="bottom">
<div class="text-lg font-semibold truncate">{{ project.title }}</div>
<div class="text-md font-semibold truncate">{{ project.title }}</div>
<template #title>
<div class="text-sm">{{ project.title }}</div>
</template>
</a-tooltip>
<div v-else class="text-lg font-semibold truncate">{{ project.title }}</div>
<div v-else class="text-md font-semibold truncate capitalize">{{ project.title }}</div>
<MdiChevronDown class="min-w-[17px] group-hover:text-accent text-md" />
</template>
@ -257,7 +268,7 @@ onBeforeUnmount(reset)
<MdiFolder class="group-hover:text-accent text-xl" />
<div class="flex flex-col">
<div class="text-lg group-hover:(!text-primary) font-semibold">
<div class="text-lg group-hover:(!text-primary) font-semibold capitalize">
<GeneralTruncateText>{{ project.title }}</GeneralTruncateText>
</div>
@ -458,10 +469,16 @@ onBeforeUnmount(reset)
<template #expandIcon></template>
<a-menu-item key="0" class="!rounded-t">
<nuxt-link v-e="['c:navbar:user:email']" class="nc-project-menu-item group !no-underline" to="/user">
<nuxt-link
v-e="['c:navbar:user:email']"
class="nc-project-menu-item group !no-underline"
to="/account/users"
>
<MdiAt class="mt-1 group-hover:text-accent" />&nbsp;
<span class="prose-sm">{{ email }}</span>
<div class="prose group-hover:text-primary">
<div>Account</div>
<div class="text-xs text-gray-500">{{ email }}</div>
</div>
</nuxt-link>
</a-menu-item>

112
packages/nc-gui/pages/account/index.vue

@ -0,0 +1,112 @@
<script lang="ts" setup>
import { navigateTo, useUIPermission } from '#imports'
const { isUIAllowed } = useUIPermission()
const $route = useRoute()
const selectedKeys = computed(() => [
/^\/account\/users\/?$/.test($route.fullPath)
? isUIAllowed('superAdminUserManagement')
? 'list'
: 'settings'
: $route.params.nestedPage ?? $route.params.page,
])
const openKeys = ref([/^\/account\/users/.test($route.fullPath) && 'users'])
</script>
<template>
<div class="mx-auto h-full">
<a-layout class="h-full overflow-y-auto flex">
<!-- Side tabs -->
<a-layout-sider>
<div class="h-full bg-white nc-user-sidebar">
<a-menu
v-model:openKeys="openKeys"
v-model:selectedKeys="selectedKeys"
:inline-indent="16"
class="tabs-menu h-full"
mode="inline"
>
<div class="text-xs text-gray-500 ml-4 pt-4 pb-2 font-weight-bold">Account Settings</div>
<a-sub-menu key="users" class="!bg-white">
<template #icon>
<MdiAccountSupervisorOutline />
</template>
<template #title>Users</template>
<a-menu-item
v-if="isUIAllowed('superAdminUserManagement')"
key="list"
class="text-xs"
@click="navigateTo('/account/users/list')"
>
<span class="ml-4">User Management</span>
</a-menu-item>
<a-menu-item key="password-reset" class="text-xs" @click="navigateTo('/account/users/password-reset')">
<span class="ml-4">Reset Password</span>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('superAdminAppSettings')"
key="settings"
class="text-xs"
@click="navigateTo('/account/users/settings')"
>
<span class="ml-4">Settings</span>
</a-menu-item>
</a-sub-menu>
<a-menu-item
key="tokens"
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)"
@click="navigateTo('/account/tokens')"
>
<div class="flex items-center space-x-2">
<MdiShieldKeyOutline />
<div class="select-none">Tokens</div>
</div>
</a-menu-item>
<a-menu-item
key="apps"
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)"
@click="navigateTo('/account/apps')"
>
<div class="flex items-center space-x-2">
<MdiStorefrontOutline />
<div class="select-none">App Store</div>
</div>
</a-menu-item>
</a-menu>
</div>
</a-layout-sider>
<!-- Sub Tabs -->
<a-layout-content class="h-auto px-4 scrollbar-thumb-gray-500">
<div class="container mx-auto">
<NuxtPage />
</div>
</a-layout-content>
</a-layout>
</div>
</template>
<style lang="scss" scoped>
:deep(.nc-user-sidebar .ant-menu-sub.ant-menu-inline) {
@apply bg-transparent;
}
:deep(.nc-user-sidebar .ant-menu-item-only-child),
:deep(.ant-menu-submenu-title) {
@apply !h-[30px] !leading-[30px];
}
:deep(.ant-menu-submenu-arrow) {
@apply !text-gray-400;
}
:deep(.ant-menu-submenu-selected .ant-menu-submenu-arrow) {
@apply !text-inherit;
}
</style>

6
packages/nc-gui/pages/account/index/[page].vue

@ -0,0 +1,6 @@
<template>
<AccountUserManagement v-if="$route.params.page === 'users'" />
<AccountToken v-else-if="$route.params.page === 'tokens'" />
<AccountAppStore v-else-if="$route.params.page === 'apps'" />
<span v-else></span>
</template>

5
packages/nc-gui/pages/account/index/users.vue

@ -0,0 +1,5 @@
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<NuxtPage />
</div>
</template>

22
packages/nc-gui/pages/account/index/users/[[nestedPage]].vue

@ -0,0 +1,22 @@
<script setup lang="ts">
import { useUIPermission } from '#imports'
const { isUIAllowed } = useUIPermission()
</script>
<template>
<template
v-if="
$route.params.nestedPage === 'password-reset' ||
(!isUIAllowed('superAdminUserManagement') && !isUIAllowed('superAdminAppSettings'))
"
>
<LazyAccountResetPassword />
</template>
<template v-else-if="$route.params.nestedPage === 'settings'">
<LazyAccountSignupSettings />
</template>
<template v-else-if="isUIAllowed('superAdminUserManagement')">
<LazyAccountUserList />
</template>
</template>

7
packages/nc-gui/pages/index/index.vue

@ -12,7 +12,7 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
class="min-h-[calc(100vh_-_var(--header-height))] bg-primary bg-opacity-5 flex flex-wrap justify-between xl:flex-nowrap gap-6 py-6 px-4 md:(px-12 pt-65px)"
>
<div class="hidden xl:(flex)">
<div>
<div v-if="route.name === 'index-index'">
<LazyGeneralSponsors />
</div>
</div>
@ -21,7 +21,10 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
<NuxtPage />
</div>
<div class="flex-1 flex gap-6 flex-col justify-center items-center md:(flex-row justify-between items-start)">
<div
:class="{ 'flex-1': route.name === 'index-index' }"
class="flex gap-6 flex-col justify-center items-center md:(flex-row justify-between items-start)"
>
<template v-if="route.name === 'index-index'">
<TransitionGroup name="page" mode="out-in">
<div key="social-card">

6
packages/nc-gui/pages/signup/[[token]].vue

@ -99,7 +99,11 @@ function resetError() {
<a-form ref="formValidator" :model="form" layout="vertical" no-style @finish="signUp">
<Transition name="layout">
<div v-if="error" class="self-center mb-4 bg-red-500 text-white rounded-lg w-3/4 mx-auto p-1">
<div
v-if="error"
class="self-center mb-4 bg-red-500 text-white rounded-lg w-3/4 mx-auto p-1"
data-testid="nc-signup-error"
>
<div class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning />
<div class="break-words">{{ error }}</div>

2
packages/nc-gui/plugins/tele.ts

@ -37,6 +37,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
socket.emit('page', {
path: to.matched[0].path + (to.query && to.query.type ? `?type=${to.query.type}` : ''),
pid: route?.params?.projectId,
})
})
@ -48,6 +49,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
event: evt,
...(data || {}),
path: route?.matched?.[0]?.path,
pid: route?.params?.projectId,
})
}
},

9
packages/noco-docs/content/en/developer-resources/rest-apis.md

@ -173,6 +173,15 @@ Currently, the default value for {orgs} is <b>noco</b>. Users will be able to ch
| Meta | Get | utils | appVersion | /api/v1/version |
| Meta | Get | utils | appHealth | /api/v1/health |
| Meta | Get | utils | aggregatedMetaInfo | /api/v1/aggregated-meta-info |
| Meta | Get | orgUsers | list | /api/v1/users |
| Meta | Post | orgUsers | add | /api/v1/users |
| Meta | Patch | orgUsers | update | /api/v1/users/{userId} |
| Meta | Delete | orgUsers | delete | /api/v1/users/{userId} |
| Meta | Get | orgTokens | list | /api/v1/tokens |
| Meta | Post | orgTokens | create | /api/v1/tokens |
| Meta | Delete | orgTokens | delete | /api/v1/tokens/{token} |
| Meta | Get | orgAppSettings | get | /api/v1/app-settings |
| Meta | Post | orgAppSettings | set | /api/v1/app-settings |
## Query params

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

@ -16,7 +16,6 @@ export interface UserType {
lastname: string;
/** @format email */
email: string;
/** @format email */
roles?: string;
/**
* @format date
@ -545,6 +544,9 @@ export interface ApiTokenType {
id?: string;
token?: string;
description?: string;
fk_user_id?: string;
created_at?: any;
updated_at?: any;
}
export interface HookLogType {
@ -1189,6 +1191,303 @@ export class Api<
...params,
}),
};
orgTokens = {
/**
* No description
*
* @tags Org tokens
* @name List
* @summary Organisation API Tokens List
* @request GET:/api/v1/tokens
* @response `200` `{
users?: {
list: ((ApiTokenType & {
created_by?: string,
}))[],
pageInfo: PaginatedType,
},
}` OK
*/
list: (params: RequestParams = {}) =>
this.request<
{
users?: {
list: (ApiTokenType & {
created_by?: string;
})[];
pageInfo: PaginatedType;
};
},
any
>({
path: `/api/v1/tokens`,
method: 'GET',
format: 'json',
...params,
}),
/**
* No description
*
* @tags Org tokens
* @name Create
* @request POST:/api/v1/tokens
* @response `200` `void` OK
*/
create: (data: ApiTokenType, params: RequestParams = {}) =>
this.request<void, any>({
path: `/api/v1/tokens`,
method: 'POST',
body: data,
type: ContentType.Json,
...params,
}),
/**
* No description
*
* @tags Org tokens
* @name Delete
* @request DELETE:/api/v1/tokens/{token}
* @response `200` `void` OK
*/
delete: (token: string, params: RequestParams = {}) =>
this.request<void, any>({
path: `/api/v1/tokens/${token}`,
method: 'DELETE',
...params,
}),
};
orgLicense = {
/**
* No description
*
* @tags Org license
* @name Get
* @summary App license get
* @request GET:/api/v1/license
* @response `200` `{
key?: string,
}` OK
*/
get: (params: RequestParams = {}) =>
this.request<
{
key?: string;
},
any
>({
path: `/api/v1/license`,
method: 'GET',
format: 'json',
...params,
}),
/**
* No description
*
* @tags Org license
* @name Set
* @summary App license get
* @request POST:/api/v1/license
* @response `200` `void` OK
*/
set: (
data: {
key?: string;
},
params: RequestParams = {}
) =>
this.request<void, any>({
path: `/api/v1/license`,
method: 'POST',
body: data,
type: ContentType.Json,
...params,
}),
};
orgAppSettings = {
/**
* No description
*
* @tags Org app settings
* @name Get
* @summary App settings get
* @request GET:/api/v1/app-settings
* @response `200` `{
invite_only_signup?: boolean,
}` OK
*/
get: (params: RequestParams = {}) =>
this.request<
{
invite_only_signup?: boolean;
},
any
>({
path: `/api/v1/app-settings`,
method: 'GET',
format: 'json',
...params,
}),
/**
* No description
*
* @tags Org app settings
* @name Set
* @summary App app settings get
* @request POST:/api/v1/app-settings
* @response `200` `void` OK
*/
set: (
data: {
invite_only_signup?: boolean;
},
params: RequestParams = {}
) =>
this.request<void, any>({
path: `/api/v1/app-settings`,
method: 'POST',
body: data,
type: ContentType.Json,
...params,
}),
};
orgUsers = {
/**
* No description
*
* @tags Org users
* @name List
* @summary Organisation Users
* @request GET:/api/v1/users
* @response `200` `{
users?: {
list: (UserType)[],
pageInfo: PaginatedType,
},
}` OK
*/
list: (params: RequestParams = {}) =>
this.request<
{
users?: {
list: UserType[];
pageInfo: PaginatedType;
};
},
any
>({
path: `/api/v1/users`,
method: 'GET',
format: 'json',
...params,
}),
/**
* No description
*
* @tags Org users
* @name Add
* @summary Organisation User Add
* @request POST:/api/v1/users
* @response `200` `any` OK
*/
add: (data: UserType, params: RequestParams = {}) =>
this.request<any, any>({
path: `/api/v1/users`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/**
* No description
*
* @tags Org users
* @name Update
* @summary Organisation User Update
* @request PATCH:/api/v1/users/{userId}
* @response `200` `void` OK
*/
update: (userId: string, data: UserType, params: RequestParams = {}) =>
this.request<void, any>({
path: `/api/v1/users/${userId}`,
method: 'PATCH',
body: data,
type: ContentType.Json,
...params,
}),
/**
* No description
*
* @tags Org users
* @name Delete
* @summary Organisation User Delete
* @request DELETE:/api/v1/users/{userId}
* @response `200` `void` OK
*/
delete: (userId: string, params: RequestParams = {}) =>
this.request<void, any>({
path: `/api/v1/users/${userId}`,
method: 'DELETE',
...params,
}),
/**
* No description
*
* @tags Org users
* @name ResendInvite
* @summary Organisation User Invite
* @request POST:/api/v1/users/{userId}/resend-invite
* @response `200` `void` OK
*/
resendInvite: (userId: string, params: RequestParams = {}) =>
this.request<void, any>({
path: `/api/v1/users/${userId}/resend-invite`,
method: 'POST',
...params,
}),
/**
* No description
*
* @tags Org users
* @name GeneratePasswordResetToken
* @summary Organisation User Generate Password Reset Token
* @request POST:/api/v1/users/{userId}/generate-reset-url
* @response `200` `{
reset_password_token?: string,
reset_password_url?: string,
}` OK
*/
generatePasswordResetToken: (userId: string, params: RequestParams = {}) =>
this.request<
{
reset_password_token?: string;
reset_password_url?: string;
},
any
>({
path: `/api/v1/users/${userId}/generate-reset-url`,
method: 'POST',
format: 'json',
...params,
}),
};
project = {
/**
* No description
@ -3572,7 +3871,7 @@ export class Api<
*/
commentCount: (
query: {
ids: any[];
ids: any;
fk_model_id: string;
},
params: RequestParams = {}

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

@ -34,6 +34,7 @@ export enum AuditOperationTypes {
WEBHOOKS = 'WEBHOOKS',
AUTHENTICATION = 'AUTHENTICATION',
TABLE_COLUMN = 'TABLE_COLUMN',
ORG_USER = 'ORG_USER',
}
export enum AuditOperationSubTypes {

5
packages/nocodb/src/enums/OrgUserRoles.ts

@ -0,0 +1,5 @@
export enum OrgUserRoles {
SUPER_ADMIN = 'super',
CREATOR = 'org-level-creator',
VIEWER = 'org-level-viewer',
}

4
packages/nocodb/src/lib/Noco.ts

@ -18,6 +18,7 @@ import { v4 as uuidv4 } from 'uuid';
import { NcConfig } from '../interface/config';
import Migrator from './db/sql-migrator/lib/KnexMigrator';
import NcConfigFactory from './utils/NcConfigFactory';
import { Tele } from 'nc-help';
import NcProjectBuilderCE from './v1-legacy/NcProjectBuilder';
import NcProjectBuilderEE from './v1-legacy/NcProjectBuilderEE';
@ -38,7 +39,6 @@ import NocoCache from './cache/NocoCache';
import registerMetaApis from './meta/api';
import NcPluginMgrv2 from './meta/helpers/NcPluginMgrv2';
import User from './models/User';
import { Tele } from 'nc-help';
import * as http from 'http';
import weAreHiring from './utils/weAreHiring';
import getInstance from './utils/getInstance';
@ -101,7 +101,7 @@ export default class Noco {
constructor() {
process.env.PORT = process.env.PORT || '8080';
// todo: move
process.env.NC_VERSION = '0098004';
process.env.NC_VERSION = '0098005';
// if env variable NC_MINIMAL_DBS is set, then disable project creation with external sources
if (process.env.NC_MINIMAL_DBS) {

2
packages/nocodb/src/lib/constants/index.ts

@ -0,0 +1,2 @@
export const NC_LICENSE_KEY = 'nc-license-key';
export const NC_APP_SETTINGS = 'nc-app-settings';

4
packages/nocodb/src/lib/db/sql-client/lib/KnexClient.ts

@ -1,5 +1,6 @@
/* eslint-disable no-constant-condition */
import { knex, Knex } from 'knex'
import { knex, Knex } from 'knex';
import { Tele } from 'nc-help';
import Debug from '../../util/Debug';
import Emit from '../../util/emit';
import Result from '../../util/Result';
@ -13,7 +14,6 @@ import mkdirp from 'mkdirp';
import Order from './order';
import * as dataHelp from './data.helper';
import SqlClient from './SqlClient';
import { Tele } from 'nc-help';
const evt = new Emit();
const log = new Debug('KnexClient');

2
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSql.ts

@ -358,7 +358,7 @@ class BaseModelSql extends BaseModel {
driver(this.tnPath).update(mappedData).where(this._wherePk(id))
);
let response = await this.nestedRead(id, this.defaultNestedQueryParams);
const response = await this.nestedRead(id, this.defaultNestedQueryParams);
await this.afterUpdate(response, trx, cookie);
return response;
} catch (e) {

2
packages/nocodb/src/lib/db/sql-mgr/SqlMgr.ts

@ -1,12 +1,12 @@
import fs from 'fs';
import path from 'path';
import url from 'url';
import { Tele } from 'nc-help';
import fsExtra from 'fs-extra';
import importFresh from 'import-fresh';
import inflection from 'inflection';
import slash from 'slash';
import { Tele } from 'nc-help';
import SqlClientFactory from '../sql-client/lib/SqlClientFactory';
// import debug from 'debug';

4
packages/nocodb/src/lib/meta/NcMetaMgr.ts

@ -11,7 +11,6 @@ import extract from 'extract-zip';
import isDocker from 'is-docker';
import multer from 'multer';
import { customAlphabet, nanoid } from 'nanoid';
import { Tele } from 'nc-help';
import slash from 'slash';
import { v4 as uuidv4 } from 'uuid';
import { ncp } from 'ncp';
@ -27,6 +26,7 @@ import ExpressXcTsRoutesBt from '../db/sql-mgr/code/routes/xc-ts/ExpressXcTsRout
import ExpressXcTsRoutesHm from '../db/sql-mgr/code/routes/xc-ts/ExpressXcTsRoutesHm';
import NcHelp from '../utils/NcHelp';
import mimetypes, { mimeIcons } from '../utils/mimeTypes';
import { packageVersion } from '../utils/packageVersion';
import projectAcl from '../utils/projectAcl';
import Noco from '../Noco';
import { GqlApiBuilder } from '../v1-legacy/gql/GqlApiBuilder';
@ -34,13 +34,13 @@ import NcPluginMgr from '../v1-legacy/plugins/NcPluginMgr';
import XcCache from '../v1-legacy/plugins/adapters/cache/XcCache';
import { RestApiBuilder } from '../v1-legacy/rest/RestApiBuilder';
import RestAuthCtrl from '../v1-legacy/rest/RestAuthCtrlEE';
import { packageVersion } from 'nc-help';
import NcMetaIO, { META_TABLES } from './NcMetaIO';
import { promisify } from 'util';
import NcTemplateParser from '../v1-legacy/templates/NcTemplateParser';
import { defaultConnectionConfig } from '../utils/NcConfigFactory';
import xcMetaDiff from './handlers/xcMetaDiff';
import { UITypes } from 'nocodb-sdk';
import { Tele } from 'nc-help';
const randomID = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 10);
const XC_PLUGIN_DET = 'XC_PLUGIN_DET';

2
packages/nocodb/src/lib/meta/NcMetaMgrEE.ts

@ -1,5 +1,5 @@
import { Tele } from 'nc-help';
import { v4 as uuidv4 } from 'uuid';
import { Tele } from 'nc-help';
import NcMetaMgr from './NcMetaMgr';

2
packages/nocodb/src/lib/meta/NcMetaMgrv2.ts

@ -4,10 +4,10 @@ import multer from 'multer';
import { NcConfig } from '../../interface/config';
import ProjectMgr from '../db/sql-mgr/ProjectMgr';
import { packageVersion } from '../utils/packageVersion';
import projectAcl from '../utils/projectAcl';
import Noco from '../Noco';
import NcPluginMgr from '../v1-legacy/plugins/NcPluginMgr';
import { packageVersion } from 'nc-help';
import NcMetaIO from './NcMetaIO';
import { defaultConnectionConfig } from '../utils/NcConfigFactory';
import ncCreateLookup from './handlersv2/ncCreateLookup';

22
packages/nocodb/src/lib/meta/api/apiTokenApis.ts

@ -1,21 +1,35 @@
import { Request, Response, Router } from 'express';
import { OrgUserRoles } from '../../../enums/OrgUserRoles';
import { Tele } from 'nc-help';
import { NcError } from '../helpers/catchError';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import ApiToken from '../../models/ApiToken';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
export async function apiTokenList(_req: Request, res: Response) {
res.json(await ApiToken.list());
export async function apiTokenList(req: Request, res: Response) {
res.json(await ApiToken.list(req['user'].id));
}
export async function apiTokenCreate(req: Request, res: Response) {
Tele.emit('evt', { evt_type: 'apiToken:created' });
res.json(await ApiToken.insert(req.body));
res.json(await ApiToken.insert({ ...req.body, fk_user_id: req['user'].id }));
}
export async function apiTokenDelete(req: Request, res: Response) {
const apiToken = await ApiToken.getByToken(req.params.apiTokenId);
if (
!req['user'].roles.includes(OrgUserRoles.SUPER_ADMIN) &&
apiToken.fk_user_id !== req['user'].id
) {
NcError.notFound('Token not found');
}
Tele.emit('evt', { evt_type: 'apiToken:deleted' });
// todo: verify token belongs to the user
res.json(await ApiToken.delete(req.params.token));
}
// todo: add reset token api to regenerate token
// deprecated apis
const router = Router({ mergeParams: true });
router.get(

2
packages/nocodb/src/lib/meta/api/attachmentApis.ts

@ -2,10 +2,10 @@
import { Request, Response, Router } from 'express';
import multer from 'multer';
import { nanoid } from 'nanoid';
import { Tele } from 'nc-help';
import path from 'path';
import slash from 'slash';
import mimetypes, { mimeIcons } from '../../utils/mimeTypes';
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import catchError from '../helpers/catchError';
import NcPluginMgrv2 from '../helpers/NcPluginMgrv2';

2
packages/nocodb/src/lib/meta/api/columnApis.ts

@ -3,8 +3,8 @@ import Model from '../../models/Model';
import ProjectMgrv2 from '../../db/sql-mgr/v2/ProjectMgrv2';
import Base from '../../models/Base';
import Column from '../../models/Column';
import validateParams from '../helpers/validateParams';
import { Tele } from 'nc-help';
import validateParams from '../helpers/validateParams';
import { customAlphabet } from 'nanoid';
import LinkToAnotherRecordColumn from '../../models/LinkToAnotherRecordColumn';

22
packages/nocodb/src/lib/meta/api/ee/orgTokenApis.ts

@ -0,0 +1,22 @@
import { OrgUserRoles } from '../../../../enums/OrgUserRoles';
import ApiToken from '../../../models/ApiToken';
import { PagedResponseImpl } from '../../helpers/PagedResponse';
export async function apiTokenListEE(req, res) {
let fk_user_id = req.user.id;
// if super admin get all tokens
if (req.user.roles.includes(OrgUserRoles.SUPER_ADMIN)) {
fk_user_id = undefined;
}
res.json(
new PagedResponseImpl(
await ApiToken.listWithCreatedBy({ ...req.query, fk_user_id }),
{
...req.query,
count: await ApiToken.count({}),
}
)
);
}

2
packages/nocodb/src/lib/meta/api/filterApis.ts

@ -1,6 +1,7 @@
import { Request, Response, Router } from 'express';
// @ts-ignore
import Model from '../../models/Model';
import { Tele } from 'nc-help';
// @ts-ignore
import { PagedResponseImpl } from '../helpers/PagedResponse';
// @ts-ignore
@ -11,7 +12,6 @@ import ProjectMgrv2 from '../../db/sql-mgr/v2/ProjectMgrv2';
import Project from '../../models/Project';
import Filter from '../../models/Filter';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
// @ts-ignore

2
packages/nocodb/src/lib/meta/api/formViewApis.ts

@ -1,6 +1,7 @@
import { Request, Response, Router } from 'express';
// @ts-ignore
import Model from '../../models/Model';
import { Tele } from 'nc-help';
// @ts-ignore
import { PagedResponseImpl } from '../helpers/PagedResponse';
import { FormType, ViewTypes } from 'nocodb-sdk';
@ -11,7 +12,6 @@ import Project from '../../models/Project';
import View from '../../models/View';
import FormView from '../../models/FormView';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
// @ts-ignore

2
packages/nocodb/src/lib/meta/api/formViewColumnApis.ts

@ -1,7 +1,7 @@
import { Request, Response, Router } from 'express';
import FormViewColumn from '../../models/FormViewColumn';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { metaApiMetrics } from '../helpers/apiMetrics';
export async function columnUpdate(req: Request, res: Response) {

2
packages/nocodb/src/lib/meta/api/galleryViewApis.ts

@ -2,8 +2,8 @@ import { Request, Response, Router } from 'express';
import { GalleryType, ViewTypes } from 'nocodb-sdk';
import View from '../../models/View';
import GalleryView from '../../models/GalleryView';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { metaApiMetrics } from '../helpers/apiMetrics';
export async function galleryViewGet(req: Request, res: Response<GalleryType>) {
res.json(await GalleryView.get(req.params.galleryViewId));

2
packages/nocodb/src/lib/meta/api/gridViewApis.ts

@ -1,6 +1,7 @@
import { Request, Router } from 'express';
// @ts-ignore
import Model from '../../models/Model';
import { Tele } from 'nc-help';
// @ts-ignore
import { PagedResponseImpl } from '../helpers/PagedResponse';
import { ViewTypes } from 'nocodb-sdk';
@ -10,7 +11,6 @@ import ProjectMgrv2 from '../../db/sql-mgr/v2/ProjectMgrv2';
import Project from '../../models/Project';
import View from '../../models/View';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
// @ts-ignore

2
packages/nocodb/src/lib/meta/api/gridViewColumnApis.ts

@ -1,7 +1,7 @@
import { Request, Response, Router } from 'express';
import GridViewColumn from '../../models/GridViewColumn';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { metaApiMetrics } from '../helpers/apiMetrics';
export async function columnList(req: Request, res: Response) {

2
packages/nocodb/src/lib/meta/api/hookApis.ts

@ -1,3 +1,4 @@
import { Tele } from 'nc-help';
import catchError from '../helpers/catchError';
import { Request, Response, Router } from 'express';
import Hook from '../../models/Hook';
@ -7,7 +8,6 @@ import { invokeWebhook } from '../helpers/webhookHelpers';
import Model from '../../models/Model';
import populateSamplePayload from '../helpers/populateSamplePayload';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
export async function hookList(

2
packages/nocodb/src/lib/meta/api/hookFilterApis.ts

@ -1,6 +1,7 @@
import { Request, Response, Router } from 'express';
// @ts-ignore
import Model from '../../models/Model';
import { Tele } from 'nc-help';
// @ts-ignore
import { PagedResponseImpl } from '../helpers/PagedResponse';
// @ts-ignore
@ -11,7 +12,6 @@ import ProjectMgrv2 from '../../db/sql-mgr/v2/ProjectMgrv2';
import Project from '../../models/Project';
import Filter from '../../models/Filter';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
// @ts-ignore

8
packages/nocodb/src/lib/meta/api/index.ts

@ -1,3 +1,7 @@
import { Tele } from 'nc-help';
import orgLicenseApis from './orgLicenseApis'
import orgTokenApis from './orgTokenApis';
import orgUserApis from './orgUserApis';
import projectApis from './projectApis';
import tableApis from './tableApis';
import columnApis from './columnApis';
@ -42,7 +46,6 @@ import {
publicDataExportApis,
publicMetaApis,
} from './publicApis';
import { Tele } from 'nc-help';
import { Server, Socket } from 'socket.io';
import passport from 'passport';
@ -87,6 +90,9 @@ export default function (router: Router, server) {
router.use(hookApis);
router.use(pluginApis);
router.use(projectUserApis);
router.use(orgUserApis);
router.use(orgTokenApis);
router.use(orgLicenseApis);
router.use(sharedBaseApis);
router.use(modelVisibilityApis);
router.use(metaDiffApis);

2
packages/nocodb/src/lib/meta/api/kanbanViewApis.ts

@ -2,8 +2,8 @@ import { Request, Response, Router } from 'express';
import { KanbanType, ViewTypes } from 'nocodb-sdk';
import View from '../../models/View';
import KanbanView from '../../models/KanbanView';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { metaApiMetrics } from '../helpers/apiMetrics';
export async function kanbanViewGet(req: Request, res: Response<KanbanType>) {

2
packages/nocodb/src/lib/meta/api/metaDiffApis.ts

@ -1,5 +1,6 @@
// // Project CRUD
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import Model from '../../models/Model';
import Project from '../../models/Project';
@ -14,7 +15,6 @@ import { getUniqueColumnAliasName } from '../helpers/getUniqueName';
import NcHelp from '../../utils/NcHelp';
import getTableNameAlias, { getColumnNameAlias } from '../helpers/getTableName';
import mapDefaultPrimaryValue from '../helpers/mapDefaultPrimaryValue';
import { Tele } from 'nc-help';
import getColumnUiType from '../helpers/getColumnUiType';
import { metaApiMetrics } from '../helpers/apiMetrics';

2
packages/nocodb/src/lib/meta/api/modelVisibilityApis.ts

@ -1,8 +1,8 @@
import Model from '../../models/Model';
import ModelRoleVisibility from '../../models/ModelRoleVisibility';
import { Router } from 'express';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import Project from '../../models/Project';
import { metaApiMetrics } from '../helpers/apiMetrics';
async function xcVisibilityMetaSetAll(req, res) {

40
packages/nocodb/src/lib/meta/api/orgLicenseApis.ts

@ -0,0 +1,40 @@
import { Router } from 'express';
import { OrgUserRoles } from '../../../enums/OrgUserRoles';
import { NC_LICENSE_KEY } from '../../constants'
import Store from '../../models/Store';
import { metaApiMetrics } from '../helpers/apiMetrics';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
async function licenseGet(_req, res) {
const license = await Store.get(NC_LICENSE_KEY);
res.json({ key: license?.value });
}
async function licenseSet(req, res) {
await Store.saveOrUpdate({ value: req.body.key, key: NC_LICENSE_KEY });
res.json({ msg: 'License key saved' });
}
const router = Router({ mergeParams: true });
router.get(
'/api/v1/license',
metaApiMetrics,
ncMetaAclMw(licenseGet, 'licenseGet', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
router.post(
'/api/v1/license',
metaApiMetrics,
ncMetaAclMw(licenseSet, 'licenseSet', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
export default router;

81
packages/nocodb/src/lib/meta/api/orgTokenApis.ts

@ -0,0 +1,81 @@
import { Request, Response, Router } from 'express';
import { OrgUserRoles } from '../../../enums/OrgUserRoles';
import ApiToken from '../../models/ApiToken';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
import { NcError } from '../helpers/catchError';
import getHandler from '../helpers/getHandler';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import { apiTokenListEE } from './ee/orgTokenApis';
async function apiTokenList(req, res) {
const fk_user_id = req.user.id;
let includeUnmappedToken = false;
if (req['user'].roles.includes(OrgUserRoles.SUPER_ADMIN)) {
includeUnmappedToken = true;
}
res.json(
new PagedResponseImpl(
await ApiToken.listWithCreatedBy({
...req.query,
fk_user_id,
includeUnmappedToken,
}),
{
...req.query,
count: await ApiToken.count({
includeUnmappedToken,
fk_user_id,
}),
}
)
);
}
export async function apiTokenCreate(req: Request, res: Response) {
Tele.emit('evt', { evt_type: 'org:apiToken:created' });
res.json(await ApiToken.insert({ ...req.body, fk_user_id: req['user'].id }));
}
export async function apiTokenDelete(req: Request, res: Response) {
const fk_user_id = req['user'].id;
const apiToken = await ApiToken.getByToken(req.params.token);
if (
!req['user'].roles.includes(OrgUserRoles.SUPER_ADMIN) &&
apiToken.fk_user_id !== fk_user_id
) {
NcError.notFound('Token not found');
}
Tele.emit('evt', { evt_type: 'org:apiToken:deleted' });
res.json(await ApiToken.delete(req.params.token));
}
const router = Router({ mergeParams: true });
router.get(
'/api/v1/tokens',
metaApiMetrics,
ncMetaAclMw(getHandler(apiTokenList, apiTokenListEE), 'apiTokenList', {
// allowedRoles: [OrgUserRoles.SUPER],
blockApiTokenAccess: true,
})
);
router.post(
'/api/v1/tokens',
metaApiMetrics,
ncMetaAclMw(apiTokenCreate, 'apiTokenCreate', {
// allowedRoles: [OrgUserRoles.SUPER],
blockApiTokenAccess: true,
})
);
router.delete(
'/api/v1/tokens/:token',
metaApiMetrics,
ncMetaAclMw(apiTokenDelete, 'apiTokenDelete', {
// allowedRoles: [OrgUserRoles.SUPER],
blockApiTokenAccess: true,
})
);
export default router;

329
packages/nocodb/src/lib/meta/api/orgUserApis.ts

@ -0,0 +1,329 @@
import { Router } from 'express';
import {
AuditOperationSubTypes,
AuditOperationTypes,
PluginCategory,
} from 'nocodb-sdk';
import { v4 as uuidv4 } from 'uuid';
import validator from 'validator';
import { OrgUserRoles } from '../../../enums/OrgUserRoles';
import { NC_APP_SETTINGS } from '../../constants';
import Audit from '../../models/Audit';
import ProjectUser from '../../models/ProjectUser';
import Store from '../../models/Store';
import SyncSource from '../../models/SyncSource';
import User from '../../models/User';
import Noco from '../../Noco';
import { MetaTable } from '../../utils/globals';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
import { NcError } from '../helpers/catchError';
import { extractProps } from '../helpers/extractProps';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import { randomTokenString } from '../helpers/stringHelpers';
import { sendInviteEmail } from './projectUserApis';
async function userList(req, res) {
res.json(
new PagedResponseImpl(await User.list(req.query), {
...req.query,
count: await User.count(req.query),
})
);
}
async function userUpdate(req, res) {
const updateBody = extractProps(req.body, ['roles']);
const user = await User.get(req.params.userId);
if (user.roles.includes(OrgUserRoles.SUPER_ADMIN)) {
NcError.badRequest('Cannot update super admin roles');
}
res.json(await User.update(req.params.userId, updateBody));
}
async function userDelete(req, res) {
const ncMeta = await Noco.ncMeta.startTransaction();
try {
const user = await User.get(req.params.userId, ncMeta);
if (user.roles.includes(OrgUserRoles.SUPER_ADMIN)) {
NcError.badRequest('Cannot delete super admin');
}
// delete project user entry and assign to super admin
const projectUsers = await ProjectUser.getProjectsList(
req.params.userId,
ncMeta
);
// todo: clear cache
// TODO: assign super admin as project owner
for (const projectUser of projectUsers) {
await ProjectUser.delete(
projectUser.project_id,
projectUser.fk_user_id,
ncMeta
);
}
// delete sync source entry
await SyncSource.deleteByUserId(req.params.userId, ncMeta);
// delete user
await User.delete(req.params.userId, ncMeta);
await ncMeta.commit();
} catch (e) {
await ncMeta.rollback(e);
throw e;
}
res.json({ msg: 'success' });
}
async function userAdd(req, res, next) {
// allow only viewer or creator role
if (
req.body.roles &&
![OrgUserRoles.VIEWER, OrgUserRoles.CREATOR].includes(req.body.roles)
) {
NcError.badRequest('Invalid role');
}
// extract emails from request body
const emails = (req.body.email || '')
.toLowerCase()
.split(/\s*,\s*/)
.map((v) => v.trim());
// check for invalid emails
const invalidEmails = emails.filter((v) => !validator.isEmail(v));
if (!emails.length) {
return NcError.badRequest('Invalid email address');
}
if (invalidEmails.length) {
NcError.badRequest('Invalid email address : ' + invalidEmails.join(', '));
}
const invite_token = uuidv4();
const error = [];
for (const email of emails) {
// add user to project if user already exist
const user = await User.getByEmail(email);
if (user) {
NcError.badRequest('User already exist');
} else {
try {
// create new user with invite token
await User.insert({
invite_token,
invite_token_expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
email,
roles: req.body.roles || OrgUserRoles.VIEWER,
token_version: randomTokenString(),
});
const count = await User.count();
Tele.emit('evt', { evt_type: 'org:user:invite', count });
await Audit.insert({
op_type: AuditOperationTypes.ORG_USER,
op_sub_type: AuditOperationSubTypes.INVITE,
user: req.user.email,
description: `invited ${email} to ${req.params.projectId} project `,
ip: req.clientIp,
});
// in case of single user check for smtp failure
// and send back token if failed
if (
emails.length === 1 &&
!(await sendInviteEmail(email, invite_token, req))
) {
return res.json({ invite_token, email });
} else {
sendInviteEmail(email, invite_token, req);
}
} catch (e) {
console.log(e);
if (emails.length === 1) {
return next(e);
} else {
error.push({ email, error: e.message });
}
}
}
}
if (emails.length === 1) {
res.json({
msg: 'success',
});
} else {
return res.json({ invite_token, emails, error });
}
}
async function userSettings(_req, _res): Promise<any> {
NcError.notImplemented();
}
async function userInviteResend(req, res): Promise<any> {
const user = await User.get(req.params.userId);
if (!user) {
NcError.badRequest(`User with id '${req.params.userId}' not found`);
}
const invite_token = uuidv4();
await User.update(user.id, {
invite_token,
invite_token_expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
const pluginData = await Noco.ncMeta.metaGet2(null, null, MetaTable.PLUGIN, {
category: PluginCategory.EMAIL,
active: true,
});
if (!pluginData) {
NcError.badRequest(
`No Email Plugin is found. Please go to App Store to configure first or copy the invitation URL to users instead.`
);
}
await sendInviteEmail(user.email, invite_token, req);
await Audit.insert({
op_type: AuditOperationTypes.ORG_USER,
op_sub_type: AuditOperationSubTypes.RESEND_INVITE,
user: user.email,
description: `resent a invite to ${user.email} `,
ip: req.clientIp,
});
res.json({ msg: 'success' });
}
async function generateResetUrl(req, res) {
const user = await User.get(req.params.userId);
if (!user) {
NcError.badRequest(`User with id '${req.params.userId}' not found`);
}
const token = uuidv4();
await User.update(user.id, {
email: user.email,
reset_password_token: token,
reset_password_expires: new Date(Date.now() + 60 * 60 * 1000),
token_version: null,
});
res.json({
reset_password_token: token,
reset_password_url: req.ncSiteUrl + `/auth/password/reset/${token}`,
});
}
async function appSettingsGet(_req, res) {
let settings = {};
try {
settings = JSON.parse((await Store.get(NC_APP_SETTINGS))?.value);
} catch {}
res.json(settings);
}
async function appSettingsSet(req, res) {
await Store.saveOrUpdate({
value: JSON.stringify(req.body),
key: NC_APP_SETTINGS,
});
res.json({ msg: 'Settings saved' });
}
const router = Router({ mergeParams: true });
router.get(
'/api/v1/users',
metaApiMetrics,
ncMetaAclMw(userList, 'userList', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
router.patch(
'/api/v1/users/:userId',
metaApiMetrics,
ncMetaAclMw(userUpdate, 'userUpdate', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
router.delete(
'/api/v1/users/:userId',
metaApiMetrics,
ncMetaAclMw(userDelete, 'userDelete', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
router.post(
'/api/v1/users',
metaApiMetrics,
ncMetaAclMw(userAdd, 'userAdd', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
router.post(
'/api/v1/users/settings',
metaApiMetrics,
ncMetaAclMw(userSettings, 'userSettings', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
router.post(
'/api/v1/users/:userId/resend-invite',
metaApiMetrics,
ncMetaAclMw(userInviteResend, 'userInviteResend', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
router.post(
'/api/v1/users/:userId/generate-reset-url',
metaApiMetrics,
ncMetaAclMw(generateResetUrl, 'generateResetUrl', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
router.get(
'/api/v1/app-settings',
metaApiMetrics,
ncMetaAclMw(appSettingsGet, 'appSettingsGet', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
router.post(
'/api/v1/app-settings',
metaApiMetrics,
ncMetaAclMw(appSettingsSet, 'appSettingsSet', {
allowedRoles: [OrgUserRoles.SUPER_ADMIN],
blockApiTokenAccess: true,
})
);
export default router;

2
packages/nocodb/src/lib/meta/api/pluginApis.ts

@ -1,10 +1,10 @@
import { Request, Response, Router } from 'express';
import { Tele } from 'nc-help';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import Plugin from '../../models/Plugin';
import { PluginType } from 'nocodb-sdk';
import NcPluginMgrv2 from '../helpers/NcPluginMgrv2';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
export async function pluginList(_req: Request, res: Response) {

3
packages/nocodb/src/lib/meta/api/projectApis.ts

@ -2,6 +2,8 @@ import { Request, Response } from 'express';
import Project from '../../models/Project';
import { ModelTypes, ProjectListType, UITypes } from 'nocodb-sdk';
import DOMPurify from 'isomorphic-dompurify';
import { packageVersion } from '../../utils/packageVersion';
import { Tele } from 'nc-help';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import syncMigration from '../helpers/syncMigration';
import { IGNORE_TABLES } from '../../utils/common/BaseApiBuilder';
@ -17,7 +19,6 @@ import ProjectUser from '../../models/ProjectUser';
import { customAlphabet } from 'nanoid';
import Noco from '../../Noco';
import isDocker from 'is-docker';
import { packageVersion, Tele } from 'nc-help';
import { NcError } from '../helpers/catchError';
import getColumnUiType from '../helpers/getColumnUiType';
import mapDefaultPrimaryValue from '../helpers/mapDefaultPrimaryValue';

12
packages/nocodb/src/lib/meta/api/projectUserApis.ts

@ -1,3 +1,5 @@
import { OrgUserRoles } from '../../../enums/OrgUserRoles';
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Router } from 'express';
import { PagedResponseImpl } from '../helpers/PagedResponse';
@ -6,7 +8,6 @@ import validator from 'validator';
import { NcError } from '../helpers/catchError';
import { v4 as uuidv4 } from 'uuid';
import User from '../../models/User';
import { Tele } from 'nc-help';
import Audit from '../../models/Audit';
import NocoCache from '../../cache/NocoCache';
import { CacheGetType, CacheScope, MetaTable } from '../../utils/globals';
@ -63,11 +64,6 @@ async function userInvite(req, res, next): Promise<any> {
);
}
// todo : provide a different role
await User.update(user.id, {
roles: 'user',
});
await ProjectUser.insert({
project_id: req.params.projectId,
fk_user_id: user.id,
@ -102,7 +98,7 @@ async function userInvite(req, res, next): Promise<any> {
invite_token,
invite_token_expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
email,
roles: 'user',
roles: OrgUserRoles.VIEWER,
token_version: randomTokenString(),
});
@ -267,7 +263,7 @@ async function projectUserInviteResend(req, res): Promise<any> {
res.json({ msg: 'success' });
}
async function sendInviteEmail(
export async function sendInviteEmail(
email: string,
token: string,
req: any

2
packages/nocodb/src/lib/meta/api/sharedBaseApis.ts

@ -1,7 +1,7 @@
import { Router } from 'express';
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { v4 as uuidv4 } from 'uuid';
import { Tele } from 'nc-help';
import Project from '../../models/Project';
import { NcError } from '../helpers/catchError';
// todo: load from config

2
packages/nocodb/src/lib/meta/api/sortApis.ts

@ -1,6 +1,7 @@
import { Request, Response, Router } from 'express';
// @ts-ignore
import Model from '../../models/Model';
import { Tele } from 'nc-help';
// @ts-ignore
import { PagedResponseImpl } from '../helpers/PagedResponse';
import { SortListType, TableReqType, TableType } from 'nocodb-sdk';
@ -10,7 +11,6 @@ import ProjectMgrv2 from '../../db/sql-mgr/v2/ProjectMgrv2';
import Project from '../../models/Project';
import Sort from '../../models/Sort';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
// @ts-ignore

2
packages/nocodb/src/lib/meta/api/swagger/helpers/templates/paths.ts

@ -8,10 +8,10 @@ import {
getNestedParams,
limitParam,
offsetParam,
shuffleParam,
referencedRowIdParam,
relationTypeParam,
rowIdParam,
shuffleParam,
sortParam,
whereParam,
} from './params';

4
packages/nocodb/src/lib/meta/api/sync/helpers/fetchAT.ts

@ -1,12 +1,12 @@
const axios = require('axios').default;
var info: any = {
const info: any = {
initialized: false,
};
async function initialize(shareId) {
info.cookie = '';
let url = `https://airtable.com/${shareId}`;
const url = `https://airtable.com/${shareId}`;
try {
const hreq = await axios

2
packages/nocodb/src/lib/meta/api/sync/helpers/job.ts

@ -1,6 +1,6 @@
import { Tele } from 'nc-help';
import FetchAT from './fetchAT';
import { UITypes } from 'nocodb-sdk';
import { Tele } from 'nc-help';
// import * as sMap from './syncMap';
import { Api } from 'nocodb-sdk';

2
packages/nocodb/src/lib/meta/api/sync/syncSourceApis.ts

@ -1,7 +1,7 @@
import { Request, Response, Router } from 'express';
import { Tele } from 'nc-help';
import SyncSource from '../../../models/SyncSource';
import { Tele } from 'nc-help';
import { PagedResponseImpl } from '../../helpers/PagedResponse';
import ncMetaAclMw from '../../helpers/ncMetaAclMw';

2
packages/nocodb/src/lib/meta/api/tableApis.ts

@ -1,7 +1,7 @@
import { Request, Response, Router } from 'express';
import Model from '../../models/Model';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import { Tele } from 'nc-help';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import DOMPurify from 'isomorphic-dompurify';
import {
AuditOperationSubTypes,

2
packages/nocodb/src/lib/meta/api/userApi/initAdminFromEnv.ts

@ -1,7 +1,6 @@
import User from '../../../models/User';
import { v4 as uuidv4 } from 'uuid';
import { promisify } from 'util';
import { Tele } from 'nc-help';
import bcrypt from 'bcryptjs';
import Noco from '../../../Noco';
@ -10,6 +9,7 @@ import ProjectUser from '../../../models/ProjectUser';
import { validatePassword } from 'nocodb-sdk';
import boxen from 'boxen';
import NocoCache from '../../../cache/NocoCache';
import { Tele } from 'nc-help';
const { isEmail } = require('validator');
const rolesLevel = { owner: 0, creator: 1, editor: 2, commenter: 3, viewer: 4 };

72
packages/nocodb/src/lib/meta/api/userApi/initStrategies.ts

@ -1,3 +1,4 @@
import { OrgUserRoles } from '../../../../enums/OrgUserRoles';
import User from '../../../models/User';
import ProjectUser from '../../../models/ProjectUser';
import { promisify } from 'util';
@ -27,20 +28,46 @@ import Plugin from '../../../models/Plugin';
export function initStrategies(router): void {
passport.use(
'authtoken',
new AuthTokenStrategy({ headerFields: ['xc-token'] }, (token, done) => {
ApiToken.getByToken(token)
.then((apiToken) => {
if (apiToken) {
done(null, { roles: 'editor' });
} else {
return done({ msg: 'Invalid tok' });
}
})
.catch((e) => {
console.log(e);
done({ msg: 'Invalid tok' });
});
})
new AuthTokenStrategy(
{ headerFields: ['xc-token'], passReqToCallback: true },
(req, token, done) => {
ApiToken.getByToken(token)
.then((apiToken) => {
if (!apiToken) {
return done({ msg: 'Invalid token' });
}
if (!apiToken.fk_user_id) return done(null, { roles: 'editor' });
User.get(apiToken.fk_user_id)
.then((user) => {
user['is_api_token'] = true;
if (req.ncProjectId) {
ProjectUser.get(req.ncProjectId, user.id)
.then(async (projectUser) => {
user.roles = projectUser?.roles || user.roles;
user.roles =
user.roles === 'owner' ? 'owner,creator' : user.roles;
// + (user.roles ? `,${user.roles}` : '');
// todo : cache
// await NocoCache.set(`${CacheScope.USER}:${key}`, user);
done(null, user);
})
.catch((e) => done(e));
} else {
return done(null, user);
}
})
.catch((e) => {
console.log(e);
done({ msg: 'User not found' });
});
})
.catch((e) => {
console.log(e);
done({ msg: 'Invalid token' });
});
}
)
);
passport.serializeUser(function (
@ -91,6 +118,19 @@ export function initStrategies(router): void {
...Noco.getConfig().auth.jwt.options,
},
async (req, jwtPayload, done) => {
// todo: improve this
if (
req.ncProjectId &&
jwtPayload.roles?.split(',').includes(OrgUserRoles.SUPER_ADMIN)
) {
return User.getByEmail(jwtPayload?.email).then(async (user) => {
return done(null, {
...user,
roles: `owner,creator,${OrgUserRoles.SUPER_ADMIN}`,
});
});
}
const keyVals = [jwtPayload?.email];
if (req.ncProjectId) {
keyVals.push(req.ncProjectId);
@ -129,7 +169,7 @@ export function initStrategies(router): void {
ProjectUser.get(req.ncProjectId, user.id)
.then(async (projectUser) => {
user.roles = projectUser?.roles || 'user';
user.roles = projectUser?.roles || user.roles;
user.roles =
user.roles === 'owner' ? 'owner,creator' : user.roles;
// + (user.roles ? `,${user.roles}` : '');
@ -247,7 +287,7 @@ export function initStrategies(router): void {
if (req.ncProjectId) {
ProjectUser.get(req.ncProjectId, user.id)
.then(async (projectUser) => {
user.roles = projectUser?.roles || 'user';
user.roles = projectUser?.roles || user.roles;
user.roles =
user.roles === 'owner' ? 'owner,creator' : user.roles;
// + (user.roles ? `,${user.roles}` : '');

19
packages/nocodb/src/lib/meta/api/userApi/userApis.ts

@ -1,13 +1,17 @@
import { Request, Response } from 'express';
import { TableType, validatePassword } from 'nocodb-sdk';
import { OrgUserRoles } from '../../../../enums/OrgUserRoles';
import { NC_APP_SETTINGS } from '../../../constants';
import Store from '../../../models/Store';
import { Tele } from 'nc-help';
import catchError, { NcError } from '../../helpers/catchError';
const { isEmail } = require('validator');
import * as ejs from 'ejs';
import bcrypt from 'bcryptjs';
import { promisify } from 'util';
import User from '../../../models/User';
import { Tele } from 'nc-help';
const { v4: uuidv4 } = require('uuid');
import Audit from '../../../models/Audit';
@ -84,10 +88,10 @@ export async function signup(req: Request, res: Response<TableType>) {
NcError.badRequest('User already exist');
}
} else {
let roles = 'user';
let roles: string = OrgUserRoles.CREATOR;
if (await User.isFirst()) {
roles = 'user,super';
roles = `${OrgUserRoles.CREATOR},${OrgUserRoles.SUPER_ADMIN}`;
// todo: update in nc_store
// roles = 'owner,creator,editor'
Tele.emit('evt', {
@ -95,10 +99,15 @@ export async function signup(req: Request, res: Response<TableType>) {
count: 1,
});
} else {
if (process.env.NC_INVITE_ONLY_SIGNUP) {
let settings: { invite_only_signup?: boolean } = {};
try {
settings = JSON.parse((await Store.get(NC_APP_SETTINGS))?.value);
} catch {}
if (settings?.invite_only_signup) {
NcError.badRequest('Not allowed to signup, contact super admin.');
} else {
roles = 'user_new';
roles = OrgUserRoles.VIEWER;
}
}

2
packages/nocodb/src/lib/meta/api/utilApis.ts

@ -1,12 +1,12 @@
// // Project CRUD
import { Request, Response } from 'express';
import { packageVersion } from 'nc-help';
import { ViewTypes } from 'nocodb-sdk';
import Project from '../../models/Project';
import Noco from '../../Noco';
import NcConnectionMgrv2 from '../../utils/common/NcConnectionMgrv2';
import { MetaTable } from '../../utils/globals';
import { packageVersion } from '../../utils/packageVersion';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import SqlMgrv2 from '../../db/sql-mgr/v2/SqlMgrv2';
import NcConfigFactory, {

2
packages/nocodb/src/lib/meta/api/viewApis.ts

@ -1,6 +1,7 @@
import { Request, Response, Router } from 'express';
// @ts-ignore
import Model from '../../models/Model';
import { Tele } from 'nc-help';
// @ts-ignore
import { PagedResponseImpl } from '../helpers/PagedResponse';
// @ts-ignore
@ -12,7 +13,6 @@ import Project from '../../models/Project';
import View from '../../models/View';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { xcVisibilityMetaGet } from './modelVisibilityApis';
import { Tele } from 'nc-help';
import { metaApiMetrics } from '../helpers/apiMetrics';
// @ts-ignore
export async function viewGet(req: Request, res: Response<Table>) {}

2
packages/nocodb/src/lib/meta/api/viewColumnApis.ts

@ -1,7 +1,7 @@
import { Request, Response, Router } from 'express';
import View from '../../models/View';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { Tele } from 'nc-help';
import ncMetaAclMw from '../helpers/ncMetaAclMw';
import { metaApiMetrics } from '../helpers/apiMetrics';
export async function columnList(req: Request, res: Response) {

4
packages/nocodb/src/lib/meta/helpers/apiMetrics.ts

@ -3,7 +3,7 @@ import { Tele } from 'nc-help';
const countMap = {};
const metrics = async (req: Request, c = 50) => {
const metrics = async (req: Request, c = 150) => {
if (!req?.route?.path) return;
const event = `a:api:${req.route.path}:${req.method}`;
countMap[event] = (countMap[event] || 0) + 1;
@ -14,7 +14,7 @@ const metrics = async (req: Request, c = 50) => {
};
const metaApiMetrics = (req: Request, _res, next) => {
metrics(req, 10).then(() => {});
metrics(req, 50).then(() => {});
next();
};
export default (req: Request, _res, next) => {

16
packages/nocodb/src/lib/meta/helpers/getHandler.ts

@ -0,0 +1,16 @@
import express from 'express';
import { NC_LICENSE_KEY } from '../../constants';
import Store from '../../models/Store';
export default function getHandler(
defaultHandler: express.Handler,
eeHandler: express.Handler
): express.Handler {
return async (...args) => {
const key = await Store.get(NC_LICENSE_KEY);
if (!key?.value) {
return defaultHandler(...args);
}
return eeHandler(...args);
};
}

22
packages/nocodb/src/lib/meta/helpers/ncMetaAclMw.ts

@ -1,22 +1,38 @@
import { OrgUserRoles } from '../../../enums/OrgUserRoles';
import projectAcl from '../../utils/projectAcl';
import { NextFunction, Request, Response } from 'express';
import catchError, { NcError } from './catchError';
import extractProjectIdAndAuthenticate from './extractProjectIdAndAuthenticate';
export default function (handlerFn, permissionName) {
export default function (
handlerFn,
permissionName,
{
allowedRoles,
blockApiTokenAccess,
}: {
allowedRoles?: (OrgUserRoles | string)[];
blockApiTokenAccess?: boolean;
} = {}
) {
return [
extractProjectIdAndAuthenticate,
catchError(async function authMiddleware(req, _res, next) {
const roles = req?.session?.passport?.user?.roles;
if (req?.session?.passport?.user?.is_api_token && blockApiTokenAccess) {
NcError.forbidden('Not allowed with API token');
}
if (
(!allowedRoles || allowedRoles.some((role) => roles?.[role])) &&
!(
roles?.creator ||
roles?.owner ||
roles?.editor ||
roles?.viewer ||
roles?.commenter ||
roles?.user ||
roles?.user_new
roles?.[OrgUserRoles.SUPER_ADMIN] ||
roles?.[OrgUserRoles.CREATOR] ||
roles?.[OrgUserRoles.VIEWER]
)
) {
NcError.unauthorized('Unauthorized access');

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

@ -8,6 +8,7 @@ import * as nc_017_add_user_token_version_column from './v2/nc_017_add_user_toke
import * as nc_018_add_meta_in_view from './v2/nc_018_add_meta_in_view';
import * as nc_019_add_meta_in_meta_tables from './v2/nc_019_add_meta_in_meta_tables';
import * as nc_020_kanban_view from './v2/nc_020_kanban_view';
import * as nc_021_add_fields_in_token from './v2/nc_021_add_fields_in_token';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -27,6 +28,7 @@ export default class XcMigrationSourcev2 {
'nc_018_add_meta_in_view',
'nc_019_add_meta_in_meta_tables',
'nc_020_kanban_view',
'nc_021_add_fields_in_token',
]);
}
@ -56,6 +58,8 @@ export default class XcMigrationSourcev2 {
return nc_019_add_meta_in_meta_tables;
case 'nc_020_kanban_view':
return nc_020_kanban_view;
case 'nc_021_add_fields_in_token':
return nc_021_add_fields_in_token;
}
}
}

26
packages/nocodb/src/lib/migrations/v2/nc_021_add_fields_in_token.ts

@ -0,0 +1,26 @@
import { Knex } from 'knex';
import { MetaTable } from '../../utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.API_TOKENS, (table) => {
table.string('fk_user_id', 20);
table.foreign('fk_user_id').references(`${MetaTable.USERS}.id`);
});
await knex.schema.alterTable(MetaTable.SYNC_SOURCE, (table) => {
table.dropForeign(['fk_user_id']);
});
};
const down = async (knex) => {
await knex.schema.alterTable(MetaTable.API_TOKENS, (table) => {
table.dropForeign(['fk_user_id']);
table.dropColumn('fk_user_id');
});
await knex.schema.alterTable(MetaTable.SYNC_SOURCE, (table) => {
table.foreign('fk_user_id').references(`${MetaTable.USERS}.id`);
});
};
export { up, down };

85
packages/nocodb/src/lib/models/ApiToken.ts

@ -11,6 +11,7 @@ import NocoCache from '../cache/NocoCache';
export default class ApiToken {
project_id?: string;
db_alias?: string;
fk_user_id?: string;
description?: string;
permissions?: string;
token?: string;
@ -29,6 +30,7 @@ export default class ApiToken {
await ncMeta.metaInsert(null, null, MetaTable.API_TOKENS, {
description: apiToken.description,
token,
fk_user_id: apiToken.fk_user_id,
});
await NocoCache.appendToList(
CacheScope.API_TOKEN,
@ -38,14 +40,17 @@ export default class ApiToken {
return this.getByToken(token);
}
static async list(ncMeta = Noco.ncMeta) {
let tokens = await NocoCache.getList(CacheScope.API_TOKEN, []);
if (!tokens.length) {
tokens = await ncMeta.metaList(null, null, MetaTable.API_TOKENS);
await NocoCache.setList(CacheScope.API_TOKEN, [], tokens);
}
static async list(userId: string, ncMeta = Noco.ncMeta) {
// let tokens = await NocoCache.getList(CacheScope.API_TOKEN, []);
// if (!tokens.length) {
const tokens = await ncMeta.metaList(null, null, MetaTable.API_TOKENS, {
condition: { fk_user_id: userId },
});
// await NocoCache.setList(CacheScope.API_TOKEN, [], tokens);
// }
return tokens?.map((t) => new ApiToken(t));
}
static async delete(token, ncMeta = Noco.ncMeta) {
await NocoCache.deepDel(
CacheScope.API_TOKEN,
@ -68,4 +73,72 @@ export default class ApiToken {
}
return data && new ApiToken(data);
}
public static async count(
{
fk_user_id,
includeUnmappedToken = false,
}: { fk_user_id?: string; includeUnmappedToken?: boolean } = {},
ncMeta = Noco.ncMeta
): Promise<number> {
const qb = ncMeta.knex(MetaTable.API_TOKENS);
if (fk_user_id) {
qb.where(`${MetaTable.API_TOKENS}.fk_user_id`, fk_user_id);
}
if (includeUnmappedToken) {
qb.orWhereNull(`${MetaTable.API_TOKENS}.fk_user_id`);
}
return (await qb.count('id', { as: 'count' }).first())?.count ?? 0;
}
public static async listWithCreatedBy(
{
limit = 10,
offset = 0,
fk_user_id,
includeUnmappedToken = false,
}: {
limit: number;
offset: number;
fk_user_id?: string;
includeUnmappedToken: boolean;
},
ncMeta = Noco.ncMeta
) {
const queryBuilder = ncMeta
.knex(MetaTable.API_TOKENS)
.offset(offset)
.limit(limit)
.select(
`${MetaTable.API_TOKENS}.id`,
`${MetaTable.API_TOKENS}.token`,
`${MetaTable.API_TOKENS}.description`,
`${MetaTable.API_TOKENS}.fk_user_id`,
`${MetaTable.API_TOKENS}.project_id`,
`${MetaTable.API_TOKENS}.created_at`,
`${MetaTable.API_TOKENS}.updated_at`
)
.select(
ncMeta
.knex(MetaTable.USERS)
.select('email')
.whereRaw(
`${MetaTable.USERS}.id = ${MetaTable.API_TOKENS}.fk_user_id`
)
.as('created_by')
);
if (fk_user_id) {
queryBuilder.where(`${MetaTable.API_TOKENS}.fk_user_id`, fk_user_id);
}
if (includeUnmappedToken) {
queryBuilder.orWhereNull(`${MetaTable.API_TOKENS}.fk_user_id`);
}
return queryBuilder;
}
}

2
packages/nocodb/src/lib/models/KanbanView.ts

@ -11,7 +11,7 @@ export default class KanbanView implements KanbanType {
base_id?: string;
fk_grp_col_id?: string;
fk_cover_image_col_id?: string;
meta?: string | object;
meta?: string | Record<string, any>;
// below fields are not in use at this moment
// keep them for time being

9
packages/nocodb/src/lib/models/ProjectUser.ts

@ -183,4 +183,13 @@ export default class ProjectUser {
project_id: projectId,
});
}
static async getProjectsList(
userId: string,
ncMeta = Noco.ncMeta
): Promise<ProjectUser[]> {
return await ncMeta.metaList2(null, null, MetaTable.PROJECT_USERS, {
condition: { fk_user_id: userId },
});
}
}

13
packages/nocodb/src/lib/models/SelectOption.ts

@ -89,10 +89,15 @@ export default class SelectOption {
title: string,
ncMeta = Noco.ncMeta
): Promise<SelectOption> {
let data = await ncMeta.metaGet2(null, null, MetaTable.COL_SELECT_OPTIONS, {
fk_column_id,
title,
});
const data = await ncMeta.metaGet2(
null,
null,
MetaTable.COL_SELECT_OPTIONS,
{
fk_column_id,
title,
}
);
return data && new SelectOption(data);
}

47
packages/nocodb/src/lib/models/Store.ts

@ -0,0 +1,47 @@
import { NcError } from '../meta/helpers/catchError';
import { extractProps } from '../meta/helpers/extractProps';
import Noco from '../Noco';
import { MetaTable } from '../utils/globals';
import { SortType } from 'nocodb-sdk';
// Store is used for storing key value pairs
export default class Store {
key?: string;
value?: string;
type?: string;
env?: string;
tag?: string;
project_id?: string;
db_alias?: string;
constructor(data: Partial<SortType>) {
Object.assign(this, data);
}
public static get(key: string, ncMeta = Noco.ncMeta): Promise<Store> {
return ncMeta.metaGet(null, null, MetaTable.STORE, { key });
}
static async saveOrUpdate(store: Store, ncMeta = Noco.ncMeta) {
if (!store.key) {
NcError.badRequest('Key is required');
}
const insertObj = extractProps(store, [
'key',
'value',
'type',
'env',
'tag',
]);
const existing = await Store.get(store.key, ncMeta);
if (existing) {
await ncMeta.metaUpdate(null, null, MetaTable.STORE, insertObj, {
key: store.key,
});
} else {
await ncMeta.metaInsert(null, null, MetaTable.STORE, insertObj);
}
}
}

9
packages/nocodb/src/lib/models/SyncSource.ts

@ -1,3 +1,4 @@
import { NcError } from '../meta/helpers/catchError';
import Noco from '../Noco';
import { MetaTable } from '../utils/globals';
import { extractProps } from '../meta/helpers/extractProps';
@ -132,4 +133,12 @@ export default class SyncSource {
syncSourceId
);
}
static async deleteByUserId(userId: string, ncMeta = Noco.ncMeta) {
if (!userId) NcError.badRequest('User Id is required');
return await ncMeta.metaDelete(null, null, MetaTable.SYNC_SOURCE, {
fk_user_id: userId,
});
}
}

51
packages/nocodb/src/lib/models/User.ts

@ -1,9 +1,10 @@
import { UserType } from 'nocodb-sdk';
import { NcError } from '../meta/helpers/catchError';
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import Noco from '../Noco';
import { extractProps } from '../meta/helpers/extractProps';
import NocoCache from '../cache/NocoCache';
import { NcError } from '../meta/helpers/catchError';
export default class User implements UserType {
id: string;
@ -64,6 +65,7 @@ export default class User implements UserType {
return this.get(id, ncMeta);
}
public static async update(id, user: Partial<User>, ncMeta = Noco.ncMeta) {
const updateObj = extractProps(user, [
'email',
@ -89,12 +91,20 @@ export default class User implements UserType {
// set email prop to avoid generation of invalid cache key
updateObj.email = (await this.get(id, ncMeta))?.email?.toLowerCase();
}
// get old user
const existingUser = await this.get(id, ncMeta);
// delete the emailbased cache to avoid unexpected behaviour since we can update email as well
await NocoCache.del(`${CacheScope.USER}:${existingUser.email}`);
// as <projectId> is unknown, delete user:<email>___<projectId> in cache
await NocoCache.delAll(CacheScope.USER, `${existingUser.email}___*`);
// get existing cache
const keys = [
// update user:<id>
`${CacheScope.USER}:${id}`,
// update user:<email>
`${CacheScope.USER}:${user.email}`,
];
for (const key of keys) {
let o = await NocoCache.get(key, CacheGetType.TYPE_OBJECT);
@ -104,12 +114,11 @@ export default class User implements UserType {
await NocoCache.set(key, o);
}
}
// as <projectId> is unknown, delete user:<email>___<projectId> in cache
await NocoCache.delAll(CacheScope.USER, `${user.email}___*`);
// set meta
return await ncMeta.metaUpdate(null, null, MetaTable.USERS, updateObj, id);
}
public static async getByEmail(_email: string, ncMeta = Noco.ncMeta) {
const email = _email?.toLowerCase();
let user =
@ -134,13 +143,24 @@ export default class User implements UserType {
return false;
}
static async count(ncMeta = Noco.ncMeta) {
return (
await ncMeta.knex(MetaTable.USERS).count('id', { as: 'count' }).first()
)?.count;
public static async count(
{
query = '',
}: {
query?: string;
} = {},
ncMeta = Noco.ncMeta
): Promise<number> {
const qb = ncMeta.knex(MetaTable.USERS);
if (query) {
qb.where('email', 'like', `%${query.toLowerCase?.()}%`);
}
return (await qb.count('id', { as: 'count' }).first()).count;
}
static async get(userId, ncMeta = Noco.ncMeta) {
static async get(userId, ncMeta = Noco.ncMeta): Promise<UserType> {
let user =
userId &&
(await NocoCache.get(
@ -187,9 +207,9 @@ export default class User implements UserType {
`${MetaTable.USERS}.lastname`,
`${MetaTable.USERS}.username`,
`${MetaTable.USERS}.email_verified`,
`${MetaTable.USERS}.invite_token`,
`${MetaTable.USERS}.created_at`,
`${MetaTable.USERS}.updated_at`,
`${MetaTable.USERS}.invite_token`,
`${MetaTable.USERS}.roles`
)
.select(
@ -210,8 +230,17 @@ export default class User implements UserType {
static async delete(userId: string, ncMeta = Noco.ncMeta) {
if (!userId) NcError.badRequest('userId is required');
const user = await this.get(userId, ncMeta);
if (!user) NcError.badRequest('User not found');
// clear all user related cache
await NocoCache.delAll(CacheScope.USER, `${userId}___*`);
await NocoCache.delAll(CacheScope.USER, `${user.email}___*`);
await NocoCache.del(`${CacheScope.USER}:${userId}`);
await NocoCache.del(`${CacheScope.USER}:${user.email}`);
await ncMeta.metaDelete(null, null, MetaTable.USERS, userId);
}
}

2
packages/nocodb/src/lib/utils/common/BaseApiBuilder.ts

@ -9,7 +9,6 @@ import {
PgClient,
SqlClient,
// SqlClientFactory,
Tele,
} from 'nc-help';
import XcDynamicChanges from '../../../interface/XcDynamicChanges';
@ -22,6 +21,7 @@ import NcProjectBuilder from '../../v1-legacy/NcProjectBuilder';
import Noco from '../../Noco';
import NcMetaIO from '../../meta/NcMetaIO';
import XcCache from '../../v1-legacy/plugins/adapters/cache/XcCache';
import { Tele } from 'nc-help';
import BaseModel from './BaseModel';
import { XcCron } from './XcCron';

1
packages/nocodb/src/lib/utils/globals.ts

@ -37,6 +37,7 @@ export enum MetaTable {
API_TOKENS = 'nc_api_tokens',
SYNC_SOURCE = 'nc_sync_source_v2',
SYNC_LOGS = 'nc_sync_logs_v2',
STORE = 'nc_store',
}
export const orderedMetaTables = [

37
packages/nocodb/src/lib/utils/packageVersion.ts

@ -0,0 +1,37 @@
import fs from 'fs';
import path from 'path';
let packageInfo: Record<string, any> = {};
try {
packageInfo = JSON.parse(
fs.readFileSync(
path.join(process.cwd(), 'node_modules', 'nocodb', 'package.json'),
'utf8'
)
);
} catch {
try {
// check within executable
packageInfo = JSON.parse(
fs.readFileSync(
path.join(
path.dirname(process['pkg']?.['defaultEntrypoint']),
'node_modules',
'nocodb',
'package.json'
),
'utf8'
)
);
} catch {
try {
packageInfo = JSON.parse(
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')
);
} catch {}
}
}
const packageVersion = packageInfo?.version;
export { packageVersion, packageInfo };

14
packages/nocodb/src/lib/utils/projectAcl.ts

@ -1,3 +1,5 @@
import { OrgUserRoles } from '../../enums/OrgUserRoles';
export default {
owner: {
exclude: {
@ -271,15 +273,21 @@ export default {
dataCount: true,
},
},
user_new: {
[OrgUserRoles.VIEWER]: {
include: {
apiTokenList: true,
apiTokenCreate: true,
apiTokenDelete: true,
passwordChange: true,
projectList: true,
},
},
super: '*',
user: {
[OrgUserRoles.SUPER_ADMIN]: '*',
[OrgUserRoles.CREATOR]: {
include: {
apiTokenList: true,
apiTokenCreate: true,
apiTokenDelete: true,
upload: true,
uploadViaURL: true,
passwordChange: true,

2
packages/nocodb/src/lib/v1-legacy/NcProjectBuilder.ts

@ -3,13 +3,13 @@ import path from 'path';
import axios from 'axios';
import { Router } from 'express';
import { Tele } from 'nc-help';
import { NcConfig } from '../../interface/config';
import SqlClientFactory from '../db/sql-client/lib/SqlClientFactory';
import Migrator from '../db/sql-migrator/lib/KnexMigrator';
import Noco from '../Noco';
import { Tele } from 'nc-help';
import { GqlApiBuilder } from './gql/GqlApiBuilder';
import { XCEeError } from '../meta/NcMetaMgr';
import { RestApiBuilder } from './rest/RestApiBuilder';

4
packages/nocodb/src/lib/v1-legacy/rest/RestAuthCtrl.ts

@ -3,7 +3,6 @@ import { promisify } from 'util';
import bcrypt from 'bcryptjs';
import * as ejs from 'ejs';
import * as jwt from 'jsonwebtoken';
import { Tele } from 'nc-help';
import passport from 'passport';
import { Strategy as AuthTokenStrategy } from 'passport-auth-token';
import { Strategy as GithubStrategy } from 'passport-github';
@ -30,6 +29,7 @@ const { isEmail } = require('validator');
import axios from 'axios';
import IEmailAdapter from '../../../interface/IEmailAdapter';
import { Tele } from 'nc-help';
import XcCache from '../plugins/adapters/cache/XcCache';
passport.serializeUser(function (
@ -315,7 +315,7 @@ export default class RestAuthCtrl {
if (apiToken) {
done(null, { roles: 'editor' });
} else {
return done({ msg: 'Invalid tok' });
return done({ msg: 'Invalid token' });
}
})
);

2
packages/nocodb/src/lib/v1-legacy/rest/RestAuthCtrlEE.ts

@ -1,8 +1,8 @@
import { Tele } from 'nc-help';
import passport from 'passport';
import { Strategy } from 'passport-jwt';
import { v4 as uuidv4 } from 'uuid';
import validator from 'validator';
import { Tele } from 'nc-help';
import XcCache from '../plugins/adapters/cache/XcCache';

4
packages/nocodb/src/lib/version-upgrader/NcUpgrader.ts

@ -2,13 +2,14 @@ import { NcConfig } from '../../interface/config';
import debug from 'debug';
import NcMetaIO from '../meta/NcMetaIO';
import { Tele } from 'nc-help';
import ncProjectEnvUpgrader from './ncProjectEnvUpgrader';
import ncProjectEnvUpgrader0011045 from './ncProjectEnvUpgrader0011045';
import ncProjectUpgraderV2_0090000 from './ncProjectUpgraderV2_0090000';
import ncDataTypesUpgrader from './ncDataTypesUpgrader';
import ncProjectRolesUpgrader from './ncProjectRolesUpgrader';
const log = debug('nc:version-upgrader');
import { Tele } from 'nc-help';
import boxen from 'boxen';
export interface NcUpgraderCtx {
@ -33,6 +34,7 @@ export default class NcUpgrader {
{ name: '0011045', handler: ncProjectEnvUpgrader0011045 },
{ name: '0090000', handler: ncProjectUpgraderV2_0090000 },
{ name: '0098004', handler: ncDataTypesUpgrader },
{ name: '0098005', handler: ncProjectRolesUpgrader },
];
if (!(await ctx.ncMeta.knexConnection?.schema?.hasTable?.('nc_store'))) {
return;

43
packages/nocodb/src/lib/version-upgrader/ncProjectRolesUpgrader.ts

@ -0,0 +1,43 @@
import { OrgUserRoles } from '../../enums/OrgUserRoles';
import { NC_APP_SETTINGS } from '../constants';
import Store from '../models/Store';
import { MetaTable } from '../utils/globals';
import { NcUpgraderCtx } from './NcUpgrader';
/** Upgrader for upgrading roles */
export default async function ({ ncMeta }: NcUpgraderCtx) {
const users = await ncMeta.metaList2(null, null, MetaTable.USERS);
for (const user of users) {
user.roles = user.roles
.split(',')
.map((r) => {
// update old role names with new roles
if (r === 'user') {
return OrgUserRoles.CREATOR;
} else if (r === 'user-new') {
return OrgUserRoles.VIEWER;
}
return r;
})
.join(',');
await ncMeta.metaUpdate(
null,
null,
MetaTable.USERS,
{ roles: user.roles },
user.id
);
}
// set invite only signup if user have environment variable set
if (process.env.NC_INVITE_ONLY_SIGNUP) {
await Store.saveOrUpdate(
{
value: '{ "invite_only_signup": true }',
key: NC_APP_SETTINGS,
},
ncMeta
);
}
}

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

@ -1,5 +1,6 @@
import 'mocha';
import authTests from './tests/auth.test';
import orgTests from './tests/org.test';
import projectTests from './tests/project.test';
import tableTests from './tests/table.test';
import tableRowTests from './tests/tableRow.test';
@ -7,6 +8,7 @@ import viewRowTests from './tests/viewRow.test';
function restTests() {
authTests();
orgTests();
projectTests();
tableTests();
tableRowTests();
@ -15,4 +17,4 @@ function restTests() {
export default function () {
describe('Rest', restTests);
}
}

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

@ -74,7 +74,7 @@ function authTests() {
.get('/api/v1/auth/user/me')
.unset('xc-auth')
.expect(200);
if (!response.body?.roles?.guest) {
return new Error('User should be guest');
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save