Browse Source

Merge branch 'develop' into l10n_develop_2

pull/7576/head
Raju Udava 8 months ago committed by GitHub
parent
commit
f8091f9d04
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      README.md
  2. 4
      packages/nc-gui/assets/nc-icons/arrow-down.svg
  3. 4
      packages/nc-gui/assets/nc-icons/arrow-up.svg
  4. 3
      packages/nc-gui/assets/nc-icons/chevron-down.svg
  5. 11
      packages/nc-gui/assets/nc-icons/copy.svg
  6. 6
      packages/nc-gui/assets/nc-icons/download.svg
  7. 6
      packages/nc-gui/assets/nc-icons/duplicate.svg
  8. 11
      packages/nc-gui/assets/nc-icons/edit.svg
  9. 2
      packages/nc-gui/assets/nc-icons/eye.svg
  10. 4
      packages/nc-gui/assets/nc-icons/paste.svg
  11. 10
      packages/nc-gui/assets/nc-icons/pencil.svg
  12. 4
      packages/nc-gui/assets/nc-icons/rename.svg
  13. 6
      packages/nc-gui/assets/nc-icons/trash.svg
  14. 6
      packages/nc-gui/assets/nc-icons/upload.svg
  15. 4
      packages/nc-gui/assets/nc-icons/user.svg
  16. 7
      packages/nc-gui/components/cell/RichText.vue
  17. 8
      packages/nc-gui/components/cell/TextArea.vue
  18. 7
      packages/nc-gui/components/cell/attachment/Modal.vue
  19. 4
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  20. 2
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  21. 2
      packages/nc-gui/components/dlg/TableRename.vue
  22. 2
      packages/nc-gui/components/nc/Pagination.vue
  23. 25
      packages/nc-gui/components/nc/Select.vue
  24. 2
      packages/nc-gui/components/smartsheet/Pagination.vue
  25. 14
      packages/nc-gui/components/smartsheet/details/Fields.vue
  26. 4
      packages/nc-gui/components/smartsheet/details/Webhooks.vue
  27. 18
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  28. 8
      packages/nc-gui/components/smartsheet/grid/Table.vue
  29. 2
      packages/nc-gui/components/smartsheet/header/Menu.vue
  30. 2
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  31. 36
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  32. 18
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  33. 15
      packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue
  34. 6
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  35. 23
      packages/nc-gui/components/virtual-cell/Formula.vue
  36. 2
      packages/nc-gui/components/workspace/ProjectList.vue
  37. 2
      packages/nc-gui/components/workspace/View.vue
  38. 3
      packages/nc-gui/composables/useApi/interceptors.ts
  39. 2
      packages/nc-gui/composables/useGlobal/state.ts
  40. 2
      packages/nc-gui/composables/useGlobal/types.ts
  41. 33
      packages/nc-gui/composables/useViewFilters.ts
  42. 2
      packages/nc-gui/lang/en.json
  43. 41
      packages/nc-gui/middleware/auth.global.ts
  44. 2
      packages/nc-gui/nuxt.config.ts
  45. 6
      packages/nc-gui/package.json
  46. 4
      packages/nc-gui/pages/account/index.vue
  47. 53
      packages/nc-gui/utils/filterUtils.ts
  48. 36
      packages/nc-gui/utils/iconUtils.ts
  49. 4
      packages/nc-gui/utils/urlUtils.ts
  50. 10
      packages/nc-gui/utils/validation.ts
  51. 2
      packages/noco-docs/docs/080.records/070.actions-on-record.md
  52. 49
      packages/noco-docs/docs/140.account-settings/030.authentication/010.overview.md
  53. 25
      packages/noco-docs/docs/140.account-settings/030.authentication/020.google-oauth.md
  54. 55
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/010.okta.md
  55. 66
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/020.auth0.md
  56. 59
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/030.ping-identity.md
  57. 63
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/040.azure-ad.md
  58. 59
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/050.keycloak.md
  59. 8
      packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/_category_.json
  60. 63
      packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/010.okta.md
  61. 56
      packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/020.auth0.md
  62. 69
      packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/030.ping-identity.md
  63. 76
      packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/040.azure-ad.md
  64. 8
      packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/_category_.json
  65. 8
      packages/noco-docs/docs/140.account-settings/030.authentication/_category_.json
  66. 0
      packages/noco-docs/docs/140.account-settings/040.oss-specific-details.md
  67. BIN
      packages/noco-docs/static/img/v2/account-settings/OIDC-2.png
  68. BIN
      packages/noco-docs/static/img/v2/account-settings/OIDC-3.png
  69. BIN
      packages/noco-docs/static/img/v2/account-settings/SAML-2.png
  70. BIN
      packages/noco-docs/static/img/v2/account-settings/SAML-3.png
  71. BIN
      packages/noco-docs/static/img/v2/account-settings/SAML-4.png
  72. BIN
      packages/noco-docs/static/img/v2/account-settings/SSO-1.png
  73. BIN
      packages/noco-docs/static/img/v2/account-settings/SSO-SignIn.png
  74. 17
      packages/nocodb-sdk/src/lib/UITypes.ts
  75. 1
      packages/nocodb-sdk/src/lib/index.ts
  76. 6
      packages/nocodb-sdk/src/lib/sqlUi/SqliteUi.ts
  77. 6
      packages/nocodb/package.json
  78. 27
      packages/nocodb/src/controllers/auth/auth.controller.ts
  79. 1
      packages/nocodb/src/db/BaseModelSqlv2.ts
  80. 82
      packages/nocodb/src/db/conditionV2.ts
  81. 4
      packages/nocodb/src/db/sortV2.ts
  82. 7
      packages/nocodb/src/db/util/DebugMgr.ts
  83. 3
      packages/nocodb/src/helpers/apiHelpers.ts
  84. 12
      packages/nocodb/src/middlewares/global/global.middleware.ts
  85. 23
      packages/nocodb/src/services/users/users.service.ts
  86. 17
      packages/nocodb/src/services/utils.service.ts
  87. 4
      packages/nocodb/src/strategies/jwt.strategy.ts
  88. 1
      packages/nocodb/src/types/express.d.ts
  89. 1
      packages/nocodb/src/utils/globals.ts
  90. 7
      packages/nocodb/tests/unit/init/cleanupMeta.ts
  91. 4
      packages/nocodb/tests/unit/init/index.ts
  92. 3
      packages/nocodb/tests/unit/rest/index.test.ts
  93. 52
      pnpm-lock.yaml
  94. 162
      tests/playwright/pages/Account/Authentication.ts
  95. 3
      tests/playwright/pages/Account/index.ts
  96. 16
      tests/playwright/pages/Dashboard/Grid/index.ts
  97. 41
      tests/playwright/pages/SsoIdpPage/OpenIDLoginPage.ts
  98. 38
      tests/playwright/pages/SsoIdpPage/SAMLLoginPage.ts
  99. 19
      tests/playwright/setup/index.ts

2
README.md

@ -250,7 +250,7 @@ Access Dashboard using: [http://localhost:8080/dashboard](http://localhost:8080/
### App Store for Workflow Automations
We provide different integrations in three main categories. See <a href="https://docs.nocodb.com/setup-and-usages/account-settings#app-store" target="_blank">App Store</a> for details.
We provide different integrations in three main categories. See <a href="https://docs.nocodb.com/account-settings/oss-specific-details/#app-store" target="_blank">App Store</a> for details.
- ⚡ &nbsp;Chat: Slack, Discord, Mattermost, and etc
- ⚡ &nbsp;Email: AWS SES, SMTP, MailerSend, and etc

4
packages/nc-gui/assets/nc-icons/arrow-down.svg

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 3.33337V12.6667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.6666 8L7.99998 12.6667L3.33331 8" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

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

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 12.6667V3.33337" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.33331 8.00004L7.99998 3.33337L12.6666 8.00004" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

3
packages/nc-gui/assets/nc-icons/chevron-down.svg

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 222 B

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

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_184_4881)">
<path d="M13.3333 6H7.33333C6.59695 6 6 6.59695 6 7.33333V13.3333C6 14.0697 6.59695 14.6667 7.33333 14.6667H13.3333C14.0697 14.6667 14.6667 14.0697 14.6667 13.3333V7.33333C14.6667 6.59695 14.0697 6 13.3333 6Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.33334 10H2.66667C2.31305 10 1.97391 9.85956 1.72386 9.60952C1.47381 9.35947 1.33334 9.02033 1.33334 8.66671V2.66671C1.33334 2.31309 1.47381 1.97395 1.72386 1.7239C1.97391 1.47385 2.31305 1.33337 2.66667 1.33337H8.66667C9.02029 1.33337 9.35943 1.47385 9.60948 1.7239C9.85953 1.97395 10 2.31309 10 2.66671V3.33337" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_184_4881">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 970 B

6
packages/nc-gui/assets/nc-icons/download.svg

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 10V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V10" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66675 6.66663L8.00008 9.99996L11.3334 6.66663" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10V2" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 10V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66675 6.66663L8.00008 9.99996L11.3334 6.66663" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10V2" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 643 B

After

Width:  |  Height:  |  Size: 658 B

6
packages/nc-gui/assets/nc-icons/duplicate.svg

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.0625 3.75H2.125C1.82663 3.75 1.54048 3.86853 1.3295 4.0795C1.11853 4.29048 1 4.57663 1 4.875V13.875C1 14.1734 1.11853 14.4595 1.3295 14.6705C1.54048 14.8815 1.82663 15 2.125 15H8.875C9.17337 15 9.45952 14.8815 9.67049 14.6705C9.88147 14.4595 10 14.1734 10 13.875V7.6875L6.0625 3.75Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.0625 3.75V7.6875H10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 3.5V1.875C5 1.57663 5.11853 1.29048 5.3295 1.0795C5.54048 0.868526 5.82663 0.75 6.125 0.75H10.0625L14 4.6875V10.875C14 11.1734 13.8815 11.4595 13.6705 11.6705C13.4595 11.8815 13.1734 12 12.875 12H10.0625" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.0625 0.75V4.6875H14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

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

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_184_5634)">
<path d="M7.33331 2.66663H2.66665C2.31302 2.66663 1.97389 2.8071 1.72384 3.05715C1.47379 3.3072 1.33331 3.64634 1.33331 3.99996V13.3333C1.33331 13.6869 1.47379 14.0261 1.72384 14.2761C1.97389 14.5262 2.31302 14.6666 2.66665 14.6666H12C12.3536 14.6666 12.6927 14.5262 12.9428 14.2761C13.1928 14.0261 13.3333 13.6869 13.3333 13.3333V8.66663" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.3333 1.66665C12.5985 1.40144 12.9582 1.25244 13.3333 1.25244C13.7084 1.25244 14.0681 1.40144 14.3333 1.66665C14.5985 1.93187 14.7475 2.29158 14.7475 2.66665C14.7475 3.04173 14.5985 3.40144 14.3333 3.66665L7.99998 9.99999L5.33331 10.6667L5.99998 7.99999L12.3333 1.66665Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_184_5634">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

2
packages/nc-gui/assets/nc-icons/eye.svg

@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.666504 8.00033C0.666504 8.00033 3.33317 2.66699 7.99984 2.66699C12.6665 2.66699 15.3332 8.00033 15.3332 8.00033C15.3332 8.00033 12.6665 13.3337 7.99984 13.3337C3.33317 13.3337 0.666504 8.00033 0.666504 8.00033Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.666656 7.99996C0.666656 7.99996 3.33332 2.66663 7.99999 2.66663C12.6667 2.66663 15.3333 7.99996 15.3333 7.99996C15.3333 7.99996 12.6667 13.3333 7.99999 13.3333C3.33332 13.3333 0.666656 7.99996 0.666656 7.99996Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 634 B

After

Width:  |  Height:  |  Size: 634 B

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

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6667 2.66663H12C12.3536 2.66663 12.6928 2.8071 12.9428 3.05715C13.1928 3.3072 13.3333 3.64634 13.3333 3.99996V13.3333C13.3333 13.6869 13.1928 14.0261 12.9428 14.2761C12.6928 14.5262 12.3536 14.6666 12 14.6666H3.99999C3.64637 14.6666 3.30723 14.5262 3.05718 14.2761C2.80713 14.0261 2.66666 13.6869 2.66666 13.3333V3.99996C2.66666 3.64634 2.80713 3.3072 3.05718 3.05715C3.30723 2.8071 3.64637 2.66663 3.99999 2.66663H5.33332" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 1.33337H6.00001C5.63182 1.33337 5.33334 1.63185 5.33334 2.00004V3.33337C5.33334 3.70156 5.63182 4.00004 6.00001 4.00004H10C10.3682 4.00004 10.6667 3.70156 10.6667 3.33337V2.00004C10.6667 1.63185 10.3682 1.33337 10 1.33337Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 965 B

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

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_184_4900)">
<path d="M11.3333 2.00004C11.5084 1.82494 11.7163 1.68605 11.9451 1.59129C12.1738 1.49653 12.419 1.44775 12.6667 1.44775C12.9143 1.44775 13.1595 1.49653 13.3883 1.59129C13.617 1.68605 13.8249 1.82494 14 2.00004C14.1751 2.17513 14.314 2.383 14.4088 2.61178C14.5035 2.84055 14.5523 3.08575 14.5523 3.33337C14.5523 3.58099 14.5035 3.82619 14.4088 4.05497C14.314 4.28374 14.1751 4.49161 14 4.66671L5 13.6667L1.33333 14.6667L2.33333 11L11.3333 2.00004Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_184_4900">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 790 B

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

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13.3334H14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 2.33328C11.2652 2.06806 11.6249 1.91907 12 1.91907C12.1857 1.91907 12.3696 1.95565 12.5412 2.02672C12.7128 2.09779 12.8687 2.20196 13 2.33328C13.1313 2.4646 13.2355 2.6205 13.3066 2.79208C13.3776 2.96367 13.4142 3.14756 13.4142 3.33328C13.4142 3.519 13.3776 3.7029 13.3066 3.87448C13.2355 4.04606 13.1313 4.20196 13 4.33328L4.66667 12.6666L2 13.3333L2.66667 10.6666L11 2.33328Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 707 B

6
packages/nc-gui/assets/nc-icons/trash.svg

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4H3.33333H14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.33333 4.00004V2.66671C5.33333 2.31309 5.4738 1.97395 5.72385 1.7239C5.9739 1.47385 6.31304 1.33337 6.66666 1.33337H9.33333C9.68695 1.33337 10.0261 1.47385 10.2761 1.7239C10.5262 1.97395 10.6667 2.31309 10.6667 2.66671V4.00004M12.6667 4.00004V13.3334C12.6667 13.687 12.5262 14.0261 12.2761 14.2762C12.0261 14.5262 11.6869 14.6667 11.3333 14.6667H4.66666C4.31304 14.6667 3.9739 14.5262 3.72385 14.2762C3.4738 14.0261 3.33333 13.687 3.33333 13.3334V4.00004H12.6667Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.33333 7.33337V11.3334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66667 7.33337V11.3334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

6
packages/nc-gui/assets/nc-icons/upload.svg

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 10V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V10" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.3334 5.33333L8.00008 2L4.66675 5.33333" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 2V10" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 10V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.3333 5.33333L8 2L4.66667 5.33333" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 2V10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 637 B

After

Width:  |  Height:  |  Size: 646 B

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

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3334 14V12.6667C13.3334 11.9594 13.0524 11.2811 12.5523 10.781C12.0522 10.281 11.3739 10 10.6667 10H5.33335C4.62611 10 3.94783 10.281 3.44774 10.781C2.94764 11.2811 2.66669 11.9594 2.66669 12.6667V14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.99998 7.33333C9.47274 7.33333 10.6666 6.13943 10.6666 4.66667C10.6666 3.19391 9.47274 2 7.99998 2C6.52722 2 5.33331 3.19391 5.33331 4.66667C5.33331 6.13943 6.52722 7.33333 7.99998 7.33333Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 707 B

7
packages/nc-gui/components/cell/RichText.vue

@ -8,7 +8,7 @@ import { generateJSON } from '@tiptap/html'
import Underline from '@tiptap/extension-underline'
import { TaskItem } from '@/helpers/dbTiptapExtensions/task-item'
import { Link } from '@/helpers/dbTiptapExtensions/links'
import { IsExpandedFormOpenInj, ReadonlyInj, RowHeightInj } from '#imports'
import { IsExpandedFormOpenInj, IsFormInj, ReadonlyInj, RowHeightInj } from '#imports'
const props = defineProps<{
value?: string | null
@ -26,6 +26,8 @@ const rowHeight = inject(RowHeightInj, ref(1 as const))
const readOnlyCell = inject(ReadonlyInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const turndownService = new TurndownService({})
turndownService.addRule('lineBreak', {
@ -199,7 +201,8 @@ watch(editorDom, () => {
'mt-2.5 flex-grow': fullMode,
'nc-scrollbar-md': !fullMode || (!fullMode && isExpandedFormOpen),
'flex-grow': isExpandedFormOpen,
[`!overflow-hidden children:line-clamp-${rowHeight}`]: !fullMode && readOnly && rowHeight && !isExpandedFormOpen,
[`!overflow-hidden children:line-clamp-${rowHeight}`]:
!fullMode && readOnly && rowHeight && !isExpandedFormOpen && !isForm,
}"
/>
</div>

8
packages/nc-gui/components/cell/TextArea.vue

@ -207,15 +207,15 @@ watch(inputWrapperRef, () => {
:class="{
'min-h-10': rowHeight !== 1 || isExpandedFormOpen,
'min-h-9': rowHeight === 1 && !isExpandedFormOpen,
'h-full': isForm,
'h-full w-full': isForm,
}"
>
<div
v-if="isRichMode"
class="w-full cursor-pointer nc-readonly-rich-text-wrapper"
:style="{
maxHeight: isExpandedFormOpen ? `${height}px` : `${25 * (rowHeight || 1)}px`,
minHeight: isExpandedFormOpen ? `${height}px` : `${25 * (rowHeight || 1)}px`,
maxHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${25 * (rowHeight || 1)}px`,
minHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${25 * (rowHeight || 1)}px`,
}"
@dblclick="onExpand"
@keydown.enter="onExpand"
@ -271,7 +271,7 @@ watch(inputWrapperRef, () => {
v-if="!isVisible"
placement="bottom"
class="!absolute right-0 hidden nc-text-area-expand-btn group-hover:block z-3"
:class="isExpandedFormOpen || isForm || isRichMode ? 'top-1' : 'bottom-1'"
:class="isExpandedFormOpen || isForm ? 'top-1' : 'bottom-1'"
>
<template #title>{{ $t('title.expand') }}</template>
<NcButton type="secondary" size="xsmall" data-testid="attachment-cell-file-picker-button" @click.stop="onExpand">

7
packages/nc-gui/components/cell/attachment/Modal.vue

@ -158,8 +158,8 @@ const handleFileDelete = (i: number) => {
<a-tooltip v-if="isSharedForm || (!readOnly && isUIAllowed('dataEdit') && !isPublic)" placement="bottom">
<template #title> {{ $t('title.renameFile') }} </template>
<div class="nc-attachment-download group-hover:(opacity-100) mr-[35px]">
<component :is="iconMap.edit" @click.stop="renameFile(item, i)" />
<div class="nc-attachment-rename group-hover:(opacity-100) mr-[35px]">
<component :is="iconMap.rename" @click.stop="renameFile(item, i)" />
</div>
</a-tooltip>
@ -249,7 +249,8 @@ const handleFileDelete = (i: number) => {
}
}
.nc-attachment-download {
.nc-attachment-download,
.nc-attachment-rename {
@apply bg-white absolute bottom-2 right-2;
@apply transition-opacity duration-150 ease-in opacity-0 hover:ring;
@apply cursor-pointer rounded shadow flex items-center p-1 border-1;

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

@ -463,7 +463,7 @@ const projectDelete = () => {
<template v-if="!isSharedBase">
<NcMenuItem v-if="isUIAllowed('baseRename')" data-testid="nc-sidebar-project-rename" @click="enableEditMode">
<div v-e="['c:base:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="edit" class="group-hover:text-black" />
<GeneralIcon icon="rename" class="group-hover:text-black" />
{{ $t('general.rename') }}
</div>
</NcMenuItem>
@ -717,7 +717,7 @@ const projectDelete = () => {
<template v-else-if="contextMenuTarget.type === 'table'">
<NcMenuItem v-if="isUIAllowed('tableRename')" @click="openRenameTableDialog(contextMenuTarget.value, true)">
<div v-e="['c:table:rename']" class="nc-base-option-item flex gap-2 items-center">
<GeneralIcon icon="edit" class="text-gray-700" />
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }}
</div>
</NcMenuItem>

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

@ -268,7 +268,7 @@ const isTableOpened = computed(() => {
@click="openRenameTableDialog(table, base.sources[sourceIndex].id)"
>
<div v-e="['c:table:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="edit" class="text-gray-700" />
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }}
</div>
</NcMenuItem>

2
packages/nc-gui/components/dlg/TableRename.vue

@ -193,7 +193,7 @@ const renameTable = async (undo = false, disableTitleDiffCheck?: boolean | undef
<NcModal v-model:visible="dialogShow" size="small">
<template #header>
<div class="flex flex-row items-center gap-x-2">
<GeneralIcon icon="table" />
<GeneralIcon icon="rename" />
{{ $t('activity.renameTable') }}
</div>
</template>

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

@ -56,7 +56,7 @@ const pagesList = computed(() => {
</script>
<template>
<div class="nc-pagination flex flex-row items-center gap-x-2">
<div v-if="totalPages > 1" class="nc-pagination flex flex-row items-center gap-x-2">
<component :is="props.firstPageTooltip && mode === 'full' ? NcTooltip : 'div'" v-if="mode === 'full'">
<template v-if="props.firstPageTooltip" #title>
{{ props.firstPageTooltip }}

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

@ -1,7 +1,8 @@
<script lang="ts" setup>
const props = defineProps<{
value?: string
value?: string | string[]
placeholder?: string
mode?: 'multiple' | 'tags'
dropdownClassName?: string
showSearch?: boolean
// filterOptions is a function
@ -31,6 +32,8 @@ const dropdownMatchSelectWidth = computed(() => props.dropdownMatchSelectWidth)
const loading = computed(() => props.loading)
const mode = computed(() => props.mode)
const vModel = useVModel(props, 'value', emits)
const onChange = (value: string) => {
@ -41,20 +44,21 @@ const onChange = (value: string) => {
<template>
<a-select
v-model:value="vModel"
:placeholder="placeholder"
class="nc-select"
:allow-clear="allowClear"
:disabled="loading"
:dropdown-class-name="dropdownClassName"
:show-search="showSearch"
:filter-option="filterOption"
:dropdown-match-select-width="dropdownMatchSelectWidth"
:allow-clear="allowClear"
:filter-option="filterOption"
:loading="loading"
:disabled="loading"
:mode="mode"
:placeholder="placeholder"
:show-search="showSearch"
class="nc-select"
@change="onChange"
>
<template #suffixIcon>
<GeneralLoader v-if="loading" />
<GeneralIcon v-else icon="arrowDown" class="text-gray-800 nc-select-expand-btn" />
<GeneralIcon v-else class="text-gray-800 nc-select-expand-btn" icon="arrowDown" />
</template>
<slot />
</a-select>
@ -82,12 +86,15 @@ const onChange = (value: string) => {
}
.ant-select-selection-item {
@apply font-medium pr-3;
@apply font-medium pr-3 rounded-md;
}
.ant-select-selection-placeholder {
@apply text-gray-600;
}
.ant-select-selection-item-remove {
@apply text-gray-800 !pb-1;
}
}
.nc-select.ant-select-focused:not(.ant-select-disabled).ant-select:not(.ant-select-customize-input) .ant-select-selector {
box-shadow: none;

2
packages/nc-gui/components/smartsheet/Pagination.vue

@ -100,7 +100,7 @@ const renderAltOrOptlKey = () => {
'ml-8': alignLeft,
}"
>
<div v-if="isViewDataLoading" class="flex flex-row justify-center item-center min-h-10 min-w-42">
<div v-if="isViewDataLoading" class="nc-pagination-skeleton flex flex-row justify-center item-center min-h-10 min-w-42">
<a-skeleton :active="true" :title="true" :paragraph="false" class="-mt-1 max-w-60" />
</div>
<NcPagination

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

@ -5,7 +5,6 @@ import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import Draggable from 'vuedraggable'
import { onKeyDown, useMagicKeys } from '@vueuse/core'
import type { ColumnType, SelectOptionsType } from 'nocodb-sdk'
import { Icon } from '@iconify/vue'
import { type Field, getUniqueColumnName, ref, useSmartsheetStoreOrThrow } from '#imports'
interface TableExplorerColumn extends ColumnType {
@ -1054,7 +1053,8 @@ watch(
data-testid="nc-field-item-action-duplicate"
@click="duplicateField(field)"
>
<Icon class="iconify text-gray-800" icon="lucide:copy" /><span>{{ $t('general.duplicate') }}</span>
<GeneralIcon icon="duplicate" class="text-gray-800" />
<span>{{ $t('general.duplicate') }}</span>
</NcMenuItem>
<NcMenuItem
v-if="!field.pv"
@ -1062,18 +1062,16 @@ watch(
data-testid="nc-field-item-action-insert-above"
@click="addField(field, true)"
>
<Icon class="iconify text-gray-800" icon="lucide:arrow-up" /><span>{{
$t('general.insertAbove')
}}</span>
<GeneralIcon icon="ncArrowUp" class="text-gray-800" />
<span>{{ $t('general.insertAbove') }}</span>
</NcMenuItem>
<NcMenuItem
key="table-explorer-insert-below"
data-testid="nc-field-item-action-insert-below"
@click="addField(field)"
>
<Icon class="iconify text-gray-800" icon="lucide:arrow-down" /><span>{{
$t('general.insertBelow')
}}</span>
<GeneralIcon icon="ncArrowDown" class="text-gray-800" />
<span>{{ $t('general.insertBelow') }}</span>
</NcMenuItem>
<a-menu-divider class="my-1.5" />

4
packages/nc-gui/components/smartsheet/details/Webhooks.vue

@ -265,7 +265,9 @@ watch(
@click="copyWebhook(hook)"
>
<template #loading> {{ $t('general.duplicating') }} </template>
<div class="flex items-center gap-x-1"><GeneralIcon icon="copy" /> {{ $t('general.duplicate') }}</div>
<div class="flex items-center gap-x-1">
<GeneralIcon icon="duplicate"></GeneralIcon> {{ $t('general.duplicate') }}
</div>
</NcButton>
<NcButton type="text" class="w-full !rounded-md !px-2" :centered="false" @click="openDeleteModal(hook.id!)">
<div class="flex items-center justify-start gap-x-1 !text-red-500">

18
packages/nc-gui/components/smartsheet/grid/GroupBy.vue

@ -241,20 +241,10 @@ const shouldRenderCell = (column) =>
<div class="flex !sticky left-[15px]">
<div class="flex items-center">
<span role="img" aria-label="right" class="anticon anticon-right ant-collapse-arrow">
<svg
focusable="false"
data-icon="right"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
:style="`${activeGroups.includes(i) ? 'transform: rotate(90deg)' : ''}`"
>
<path
d="M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z"
></path>
</svg>
<GeneralIcon
icon="chevronDown"
:style="`${activeGroups.includes(i) ? 'transform: rotate(360deg)' : 'transform: rotate(270deg)'}`"
></GeneralIcon>
</span>
</div>
<div class="flex items-center">

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

@ -1712,7 +1712,7 @@ onKeyStroke('ArrowDown', onDown)
@click="emits('bulkUpdateDlg')"
>
<div v-e="['a:row:update-bulk']" class="flex gap-2 items-center">
<component :is="iconMap.edit" />
<component :is="iconMap.ncEdit" />
{{ $t('title.updateSelectedRows') }}
</div>
</NcMenuItem>
@ -1893,11 +1893,11 @@ onKeyStroke('ArrowDown', onDown)
<template #overlay>
<div class="relative overflow-visible min-h-17 w-10">
<div
class="absolute -top-19 flex flex-col min-h-34.5 w-70 p-1.5 bg-white rounded-lg border-1 border-gray-200 justify-start overflow-hidden"
class="absolute -top-21 flex flex-col min-h-34.5 w-70 p-1.5 bg-white rounded-lg border-1 border-gray-200 justify-start overflow-hidden"
style="box-shadow: 0px 4px 6px -2px rgba(0, 0, 0, 0.06), 0px -12px 16px -4px rgba(0, 0, 0, 0.1)"
:class="{
'-left-44': !isAddNewRecordGridMode,
'-left-32': isAddNewRecordGridMode,
'-left-32.5': !isAddNewRecordGridMode,
'-left-21.5': isAddNewRecordGridMode,
}"
>
<div

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

@ -313,7 +313,7 @@ const isDuplicateAllowed = computed(() => {
<NcMenu class="flex flex-col gap-1 border-gray-200 nc-column-options">
<NcMenuItem @click="onEditPress">
<div class="nc-column-edit nc-header-menu-item">
<component :is="iconMap.edit" class="text-gray-700" />
<component :is="iconMap.ncEdit" class="text-gray-700" />
<!-- Edit -->
{{ $t('general.edit') }}
</div>

2
packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue

@ -242,7 +242,7 @@ watch(rightSidebarState, () => {
<template #overlay>
<NcMenu :data-testid="`view-sidebar-view-actions-${vModel.alias || vModel.title}`">
<NcMenuItem size="small" :centered="false" @click.stop="onDblClick">
<GeneralIcon icon="edit" />
<GeneralIcon icon="rename" />
Rename
</NcMenuItem>
<NcMenuItem size="small" :centered="false" class="nc-view-copy-icon" @click.stop="onDuplicate">

36
packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue

@ -76,6 +76,7 @@ const {
isComparisonSubOpAllowed,
loadBtLookupTypes,
btLookupTypesMap,
types,
} = useViewFilters(
activeView,
parentId?.value,
@ -114,8 +115,9 @@ const isFilterDraft = (filter: Filter, col: ColumnType) => {
}
if (
comparisonOpList(col.uidt as UITypes, col?.meta?.date_format).find((compOp) => compOp.value === filter.comparison_op)
?.ignoreVal
comparisonOpList(types.value[col.id] as UITypes, col?.meta?.date_format).find(
(compOp) => compOp.value === filter.comparison_op,
)?.ignoreVal
) {
return false
}
@ -143,7 +145,7 @@ const filterUpdateCondition = (filter: FilterType, i: number) => {
// hence remove the previous value
filter.value = null
filter.comparison_sub_op = null
} else if (isDateType(col.uidt as UITypes)) {
} else if (isDateType(types.value[col.id] as UITypes)) {
// for date / datetime,
// the input type could be decimal or datepicker / datetime picker
// hence remove the previous value
@ -173,17 +175,6 @@ const filterUpdateCondition = (filter: FilterType, i: number) => {
})
}
const types = computed(() => {
if (!meta.value?.columns?.length) {
return {}
}
return meta.value?.columns?.reduce((obj: any, col: any) => {
obj[col.id] = col.uidt
return obj
}, {})
})
watch(
() => activeView.value?.id,
(n, o) => {
@ -237,11 +228,11 @@ const selectFilterField = (filter: Filter, index: number) => {
// since the existing one may not be supported for the new field
// e.g. `eq` operator is not supported in checkbox field
// hence, get the first option of the supported operators of the new field
filter.comparison_op = comparisonOpList(col.uidt as UITypes, col?.meta?.date_format).find((compOp) =>
filter.comparison_op = comparisonOpList(types.value[col.id] as UITypes, col?.meta?.date_format).find((compOp) =>
isComparisonOpAllowed(filter, compOp),
)?.value as FilterType['comparison_op']
if (isDateType(col.uidt as UITypes) && !['blank', 'notblank'].includes(filter.comparison_op!)) {
if (isDateType(types.value[col.id] as UITypes) && !['blank', 'notblank'].includes(filter.comparison_op!)) {
if (filter.comparison_op === 'isWithin') {
filter.comparison_sub_op = 'pastNumberOfDays'
} else {
@ -319,8 +310,9 @@ const showFilterInput = (filter: Filter) => {
(op) => op.value === filter.comparison_sub_op,
)?.ignoreVal
} else {
return !comparisonOpList(col?.uidt as UITypes, col?.meta?.date_format).find((op) => op.value === filter.comparison_op)
?.ignoreVal
return !comparisonOpList(types.value[col?.id] as UITypes, col?.meta?.date_format).find(
(op) => op.value === filter.comparison_op,
)?.ignoreVal
}
}
@ -462,7 +454,7 @@ function isDateType(uidt: UITypes) {
@change="filterUpdateCondition(filter, i)"
>
<template
v-for="compOp of comparisonOpList(getColumn(filter)?.uidt, getColumn(filter)?.meta?.date_format)"
v-for="compOp of comparisonOpList(types[filter.fk_column_id], getColumn(filter)?.meta?.date_format)"
:key="compOp.value"
>
<a-select-option v-if="isComparisonOpAllowed(filter, compOp)" :value="compOp.value">
@ -481,7 +473,7 @@ function isDateType(uidt: UITypes) {
<div v-if="['blank', 'notblank'].includes(filter.comparison_op)" class="flex flex-grow"></div>
<NcSelect
v-else-if="isDateType(getColumn(filter)?.uidt)"
v-else-if="isDateType(types[filter.fk_column_id])"
v-model:value="filter.comparison_sub_op"
v-e="['c:filter:sub-comparison-op:select']"
:dropdown-match-select-width="false"
@ -529,12 +521,12 @@ function isDateType(uidt: UITypes) {
<SmartsheetToolbarFilterInput
v-if="showFilterInput(filter)"
class="nc-filter-value-select rounded-md min-w-34"
:column="getColumn(filter)"
:column="{ ...getColumn(filter), uidt: types[filter.fk_column_id] }"
:filter="filter"
@update-filter-value="(value) => updateFilterValue(value, filter, i)"
@click.stop
/>
<div v-else-if="!isDateType(getColumn(filter)?.uidt)" class="flex-grow"></div>
<div v-else-if="!isDateType(types[filter.fk_column_id])" class="flex-grow"></div>
<NcButton
v-if="!filter.readOnly"

18
packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { PlanLimitTypes, RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import { PlanLimitTypes, RelationTypes, UITypes, getEquivalentUIType, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import {
ActiveViewInj,
@ -75,8 +75,20 @@ const availableColumns = computed(() => {
})
const getColumnUidtByID = (key?: string) => {
if (!key) return ''
return columnByID.value[key]?.uidt || ''
if (!key || !columnByID.value[key]) return ''
const column = columnByID.value[key]
let uidt = column.uidt
if (column.uidt === UITypes.Formula) {
uidt =
getEquivalentUIType({
formulaColumn: column,
}) || uidt
}
return uidt || ''
}
const open = ref(false)

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

@ -3,9 +3,6 @@ import type { TableType, ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
import { LockType } from '~/lib'
import UploadIcon from '~icons/nc-icons/upload'
import DownloadIcon from '~icons/nc-icons/download'
const props = defineProps<{
view: ViewType
table: TableType
@ -167,13 +164,13 @@ const onDelete = async () => {
<NcDivider />
<template v-if="!view?.is_default">
<NcMenuItem v-if="lockType !== LockType.Locked" @click="onRenameMenuClick">
<GeneralIcon icon="edit" />
<GeneralIcon icon="rename" />
{{ $t('activity.renameView') }}
</NcMenuItem>
<NcTooltip v-else>
<template #title> {{ $t('msg.info.disabledAsViewLocked') }} </template>
<NcMenuItem class="!cursor-not-allowed !text-gray-400">
<GeneralIcon icon="edit" />
<GeneralIcon icon="rename" />
{{ $t('activity.renameView') }}
</NcMenuItem>
</NcTooltip>
@ -181,10 +178,10 @@ const onDelete = async () => {
<GeneralIcon icon="duplicate" class="nc-view-copy-icon" />
{{ $t('labels.duplicateView') }}
</NcMenuItem>
<NcDivider />
</template>
<template v-if="view.type !== ViewTypes.FORM">
<NcDivider />
<template v-if="isUIAllowed('csvTableImport') && !isPublicView">
<NcSubMenu key="upload">
<template #title>
@ -197,7 +194,7 @@ const onDelete = async () => {
]"
class="nc-base-menu-item group"
>
<UploadIcon class="w-4 h-4" />
<GeneralIcon icon="upload" />
{{ $t('general.upload') }}
</div>
</template>
@ -217,7 +214,7 @@ const onDelete = async () => {
class="nc-base-menu-item"
:class="{ disabled: lockType === LockType.Locked }"
>
<component :is="iconMap.upload" />
<component :is="iconMap.cloudUpload" />
{{ `${$t('general.upload')} ${type.toUpperCase()}` }}
</div>
</NcMenuItem>
@ -235,7 +232,7 @@ const onDelete = async () => {
]"
class="nc-base-menu-item group nc-view-context-download-option"
>
<DownloadIcon class="w-4 h-4" />
<GeneralIcon icon="download" />
{{ $t('general.download') }}
</div>
</template>

6
packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue

@ -1,7 +1,5 @@
<script lang="ts" setup>
import type { Ref } from '@vue/reactivity'
import UploadIcon from '~icons/nc-icons/upload'
import DownloadIcon from '~icons/nc-icons/download'
import {
ActiveViewInj,
IsLockedInj,
@ -120,7 +118,7 @@ useMenuCloseOnEsc(open)
<a-sub-menu key="upload">
<template #title>
<div v-e="['c:navdraw:preview-as']" class="nc-base-menu-item group">
<UploadIcon class="w-4 h-4" />
<GeneralIcon type="upload" />
{{ $t('general.upload') }}
<div class="flex-1" />
@ -147,7 +145,7 @@ useMenuCloseOnEsc(open)
<a-sub-menu key="download">
<template #title>
<div v-e="['c:download']" class="nc-base-menu-item group">
<DownloadIcon class="w-4 h-4" />
<GeneralIcon icon="download" />
{{ $t('general.download') }}
<div class="flex-1" />

23
packages/nc-gui/components/virtual-cell/Formula.vue

@ -1,8 +1,19 @@
<script lang="ts" setup>
import { handleTZ } from 'nocodb-sdk'
import { FormulaDataTypes, handleTZ } from 'nocodb-sdk'
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { CellValueInj, ColumnInj, computed, inject, renderValue, replaceUrlsWithLink, useBase } from '#imports'
import {
CellValueInj,
ColumnInj,
IsExpandedFormOpenInj,
computed,
inject,
ref,
renderValue,
replaceUrlsWithLink,
useBase,
useShowNotEditableWarning,
} from '#imports'
// todo: column type doesn't have required property `error` - throws in typecheck
const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }>
@ -19,10 +30,16 @@ const urls = computed(() => replaceUrlsWithLink(result.value))
const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activateShowEditNonEditableFieldWarning } =
useShowNotEditableWarning()
const isNumber = computed(() => (column.value.colOptions as any)?.parsed_tree?.dataType === FormulaDataTypes.NUMERIC)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
</script>
<template>
<div>
<div class="w-full" :class="{ 'text-right': isNumber && isGrid && !isExpandedFormOpen }">
<a-tooltip v-if="column && column.colOptions && column.colOptions.error" placement="bottom" class="text-orange-700">
<template #title>
<span class="font-bold">{{ column.colOptions.error }}</span>

2
packages/nc-gui/components/workspace/ProjectList.vue

@ -339,7 +339,7 @@ const setIcon = async (icon: string, base: BaseType) => {
<a-menu>
<a-menu-item @click="enableEdit(i)">
<div class="nc-menu-item-wrapper">
<GeneralIcon icon="edit" class="text-gray-700" />
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }} {{ $t('objects.project') }}
</div>
</a-menu-item>

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

@ -61,7 +61,7 @@ onMounted(() => {
<a-tab-pane key="collaborators" class="w-full">
<template #tab>
<div class="flex flex-row items-center px-2 pb-1 gap-x-1.5">
<PhUsersBold />
<GeneralIcon icon="users" class="!h-3.5 !w-3.5" />
Members
</div>
</template>

3
packages/nc-gui/composables/useApi/interceptors.ts

@ -16,7 +16,8 @@ export function addAxiosInterceptors(api: Api<any>) {
axiosInstance.interceptors.request.use((config) => {
config.headers['xc-gui'] = 'true'
if (state.token.value) config.headers['xc-auth'] = state.token.value
// Add auth header only if signed in and if `xc-short-token` header is not present (for short-lived tokens used for token generation)
if (state.token.value && !config.headers['xc-short-token']) config.headers['xc-auth'] = state.token.value
if (!config.url?.endsWith('/user/me') && !config.url?.endsWith('/admin/roles') && state.previewAs?.value) {
config.headers['xc-preview'] = state.previewAs.value

2
packages/nc-gui/composables/useGlobal/state.ts

@ -96,6 +96,8 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
googleAuthEnabled: false,
oidcAuthEnabled: false,
oidcProviderName: null,
samlAuthEnabled: false,
samlProviderName: null,
ncMin: false,
oneClick: false,
baseHasAdmin: false,

2
packages/nc-gui/composables/useGlobal/types.ts

@ -35,6 +35,8 @@ export interface AppInfo {
mainSubDomain?: string
dashboardPath: string
inviteOnlySignup: boolean
samlAuthEnabled: boolean
samlProviderName: string | null
}
export interface StoredState {

33
packages/nc-gui/composables/useViewFilters.ts

@ -1,4 +1,13 @@
import type { ColumnType, FilterType, LinkToAnotherRecordType, LookupType, ViewType } from 'nocodb-sdk'
import {
type ColumnType,
type FilterType,
FormulaDataTypes,
type FormulaType,
type LinkToAnotherRecordType,
type LookupType,
type ViewType,
getEquivalentUIType,
} from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import type { SelectProps } from 'ant-design-vue'
import { UITypes, isSystemColumn } from 'nocodb-sdk'
@ -103,8 +112,15 @@ export function useViewFilters(
}
return meta.value?.columns?.reduce((obj: any, col: any) => {
if (col.uidt === UITypes.Formula) {
const formulaUIType = getEquivalentUIType({
formulaColumn: col,
})
obj[col.id] = formulaUIType || col.uidt
}
// if column is a lookup column, then use the lookup type extracted from the column
if (btLookupTypesMap.value[col.id]) {
else if (btLookupTypesMap.value[col.id]) {
obj[col.id] = btLookupTypesMap.value[col.id].uidt
} else {
obj[col.id] = col.uidt
@ -137,9 +153,11 @@ export function useViewFilters(
},
) => {
const isNullOrEmptyOp = ['empty', 'notempty', 'null', 'notnull'].includes(compOp.value)
const uidt = types.value[filter.fk_column_id]
if (compOp.includedTypes) {
// include allowed values only if selected column type matches
if (filter.fk_column_id && compOp.includedTypes.includes(types.value[filter.fk_column_id])) {
if (filter.fk_column_id && compOp.includedTypes.includes(uidt)) {
// for 'empty', 'notempty', 'null', 'notnull',
// show them based on `showNullAndEmptyInFilter` in Base Settings
return isNullOrEmptyOp ? baseMeta.value.showNullAndEmptyInFilter : true
@ -148,7 +166,7 @@ export function useViewFilters(
}
} else if (compOp.excludedTypes) {
// include not allowed values only if selected column type not matches
if (filter.fk_column_id && !compOp.excludedTypes.includes(types.value[filter.fk_column_id])) {
if (filter.fk_column_id && !compOp.excludedTypes.includes(uidt)) {
// for 'empty', 'notempty', 'null', 'notnull',
// show them based on `showNullAndEmptyInFilter` in Base Settings
return isNullOrEmptyOp ? baseMeta.value.showNullAndEmptyInFilter : true
@ -170,12 +188,14 @@ export function useViewFilters(
excludedTypes?: UITypes[]
},
) => {
const uidt = types.value[filter.fk_column_id]
if (compOp.includedTypes) {
// include allowed values only if selected column type matches
return filter.fk_column_id && compOp.includedTypes.includes(types.value[filter.fk_column_id])
return filter.fk_column_id && compOp.includedTypes.includes(uidt)
} else if (compOp.excludedTypes) {
// include not allowed values only if selected column type not matches
return filter.fk_column_id && !compOp.excludedTypes.includes(types.value[filter.fk_column_id])
return filter.fk_column_id && !compOp.excludedTypes.includes(uidt)
}
}
@ -479,5 +499,6 @@ export function useViewFilters(
isComparisonSubOpAllowed,
loadBtLookupTypes,
btLookupTypesMap,
types
}
}

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

@ -424,7 +424,7 @@
"audience-entityId": "Audience/ Entity ID",
"redirectUrl": "Redirect URL",
"oidc": "OpenID Connect (OIDC)",
"saml": "SAML",
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"ssoSettings": "SSO Settings",

41
packages/nc-gui/middleware/auth.global.ts

@ -51,6 +51,9 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
await tryGoogleAuth(api, state.signIn)
}
/** if not signedIn try token population based on short-lived-token */
if (!state.signedIn.value) await tryShortTokenAuth(api, state.signIn)
/** if public allow all visitors */
if (to.meta.public) return
@ -155,3 +158,41 @@ async function tryGoogleAuth(api: Api<any>, signIn: Actions['signIn']) {
window.location.reload()
}
}
/**
* If short-token present, try using it to generate long-living token before navigating to the next page
*/
async function tryShortTokenAuth(api: Api<any>, signIn: Actions['signIn']) {
if (window.location.search && /\bshort-token=/.test(window.location.search)) {
let extraProps: any = {}
try {
// `extra` prop is used in our cloud implementation, so we are keeping it
const { data } = await api.instance.post(
`/auth/long-lived-token`,
{},
{
headers: {
'xc-short-token': window.location.search.split('=')[1],
} as any,
},
)
const { token, extra } = data
// if extra prop is null/undefined set it as an empty object as fallback
extraProps = extra || {}
signIn(token)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
const newURL = window.location.href.split('?')[0]
window.history.pushState(
'object',
document.title,
`${extraProps?.continueAfterSignIn ? `${newURL}#/?continueAfterSignIn=${extraProps.continueAfterSignIn}` : newURL}`,
)
window.location.reload()
}
}

2
packages/nc-gui/nuxt.config.ts

@ -103,7 +103,7 @@ export default defineNuxtConfig({
],
script: [
{
src: '/js/typesense-docsearch.js',
src: './js/typesense-docsearch.js',
},
],
},

6
packages/nc-gui/package.json

@ -106,7 +106,7 @@
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@iconify-json/ant-design": "^1.1.15",
"@iconify-json/bi": "^1.1.23",
"@iconify-json/carbon": "^1.1.28",
"@iconify-json/carbon": "^1.1.29",
"@iconify-json/cil": "^1.1.8",
"@iconify-json/clarity": "^1.1.12",
"@iconify-json/eva": "^1.1.10",
@ -114,7 +114,7 @@
"@iconify-json/ion": "^1.1.15",
"@iconify-json/la": "^1.1.8",
"@iconify-json/logos": "^1.1.42",
"@iconify-json/lucide": "^1.1.162",
"@iconify-json/lucide": "^1.1.163",
"@iconify-json/material-symbols": "^1.1.72",
"@iconify-json/mdi": "^1.1.64",
"@iconify-json/mi": "^1.1.8",
@ -139,7 +139,7 @@
"@types/splitpanes": "^2.2.6",
"@types/tinycolor2": "^1.4.6",
"@types/turndown": "^5.0.4",
"@types/validator": "^13.11.8",
"@types/validator": "^13.11.9",
"@types/vue-barcode-reader": "^0.0.3",
"@unocss/nuxt": "^0.51.13",
"@vitest/ui": "^0.18.1",

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

@ -65,7 +65,7 @@ const logout = async () => {
@click="navigateTo('/account/profile')"
>
<div class="flex items-center space-x-2">
<GeneralIcon icon="account" />
<GeneralIcon icon="user" class="!h-3.5 !w-3.5" />
<div class="select-none">{{ $t('labels.profile') }}</div>
</div>
@ -102,7 +102,7 @@ const logout = async () => {
</NcMenuItem>
<a-sub-menu key="users" class="!bg-white !my-0">
<template #icon>
<MdiAccountSupervisorOutline />
<GeneralIcon icon="users" class="!h-3.5 !w-3.5" />
</template>
<template #title>{{ $t('objects.users') }}</template>

53
packages/nc-gui/utils/filterUtils.ts

@ -4,9 +4,15 @@ const getEqText = (fieldUiType: UITypes) => {
if (isNumericCol(fieldUiType) || fieldUiType === UITypes.Time) {
return '='
} else if (
[UITypes.SingleSelect, UITypes.Collaborator, UITypes.LinkToAnotherRecord, UITypes.Date, UITypes.DateTime].includes(
fieldUiType,
)
[
UITypes.SingleSelect,
UITypes.Collaborator,
UITypes.LinkToAnotherRecord,
UITypes.Date,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.DateTime,
].includes(fieldUiType)
) {
return 'is'
}
@ -17,9 +23,15 @@ const getNeqText = (fieldUiType: UITypes) => {
if (isNumericCol(fieldUiType) || fieldUiType === UITypes.Time) {
return '!='
} else if (
[UITypes.SingleSelect, UITypes.Collaborator, UITypes.LinkToAnotherRecord, UITypes.Date, UITypes.DateTime].includes(
fieldUiType,
)
[
UITypes.SingleSelect,
UITypes.Collaborator,
UITypes.LinkToAnotherRecord,
UITypes.Date,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.DateTime,
].includes(fieldUiType)
) {
return 'is not'
}
@ -41,28 +53,28 @@ const getNotLikeText = (fieldUiType: UITypes) => {
}
const getGtText = (fieldUiType: UITypes) => {
if ([UITypes.Date, UITypes.DateTime].includes(fieldUiType)) {
if ([UITypes.Date, UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(fieldUiType)) {
return 'is after'
}
return '>'
}
const getLtText = (fieldUiType: UITypes) => {
if ([UITypes.Date, UITypes.DateTime].includes(fieldUiType)) {
if ([UITypes.Date, UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(fieldUiType)) {
return 'is before'
}
return '<'
}
const getGteText = (fieldUiType: UITypes) => {
if ([UITypes.Date, UITypes.DateTime].includes(fieldUiType)) {
if ([UITypes.Date, UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(fieldUiType)) {
return 'is on or after'
}
return '>='
}
const getLteText = (fieldUiType: UITypes) => {
if ([UITypes.Date, UITypes.DateTime].includes(fieldUiType)) {
if ([UITypes.Date, UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(fieldUiType)) {
return 'is on or before'
}
return '<='
@ -131,6 +143,8 @@ export const comparisonOpList = (
UITypes.Collaborator,
UITypes.Date,
UITypes.DateTime,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Time,
...numericUITypes,
],
@ -149,6 +163,8 @@ export const comparisonOpList = (
UITypes.Collaborator,
UITypes.Date,
UITypes.DateTime,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Time,
...numericUITypes,
],
@ -170,6 +186,8 @@ export const comparisonOpList = (
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Time,
...numericUITypes,
],
@ -191,6 +209,8 @@ export const comparisonOpList = (
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Time,
...numericUITypes,
],
@ -213,6 +233,8 @@ export const comparisonOpList = (
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Time,
],
},
@ -234,6 +256,8 @@ export const comparisonOpList = (
UITypes.Lookup,
UITypes.Date,
UITypes.DateTime,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.Time,
],
},
@ -304,7 +328,14 @@ export const comparisonOpList = (
text: getLteText(fieldUiType),
value: 'lte',
ignoreVal: false,
includedTypes: [...numericUITypes, UITypes.Date, UITypes.DateTime, UITypes.Time],
includedTypes: [
...numericUITypes,
UITypes.Date,
UITypes.DateTime,
UITypes.Time,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
],
},
{
text: 'is within',

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

@ -21,17 +21,14 @@ import TablerColumnInsertRight from '~icons/tabler/column-insert-right'
import MdiEyeCircleOutline from '~icons/mdi/eye-circle-outline'
import MsGroup from '~icons/material-symbols/groups-outline-rounded'
import MsAddBoxOutline from '~icons/nc-icons/add-box'
import MsDownloadRounded from '~icons/nc-icons/download'
import LogosAirtable from '~icons/logos/airtable'
import MsSort from '~icons/material-symbols/sort'
import MaterialSymbolsEdit from '~icons/material-symbols/edit-outline-rounded'
import MaterialDuplicate from '~icons/material-symbols/file-copy-outline-rounded'
import MaterialSymbolsWarningOutlineRounded from '~icons/material-symbols/warning-outline-rounded'
import MaterialSymbolsDragIndicator from '~icons/ri/draggable'
import PlusSquare from '~icons/nc-icons/plus-square'
import MobileShare from '~icons/nc-icons/share'
import PhLayout from '~icons/ph/layout'
import Delete from '~icons/material-symbols/delete-outline-rounded'
import Minus from '~icons/material-symbols/remove-rounded'
import Code from '~icons/material-symbols/code-rounded'
import Palette from '~icons/material-symbols/palette-outline'
@ -99,6 +96,7 @@ import NcUnStar from '~icons/nc-icons/star-remove'
import NcSearch from '~icons/nc-icons/search'
import NcCreditCard from '~icons/nc-icons/credit-card'
import NcLayers from '~icons/nc-icons/layers'
import NcUser from '~icons/nc-icons/user'
import NcUsers from '~icons/nc-icons/users'
import NcCheck from '~icons/nc-icons/check'
import NcIconsRowHeightMedium from '~icons/nc-icons/row-height-medium'
@ -110,6 +108,18 @@ import NcNotification from '~icons/material-symbols/notifications-outline'
import NcCommentHere from '~icons/nc-icons/comment-here'
import NcAddDataSource from '~icons/nc-icons/add-data-source'
import NcDatabaseIcon from '~icons/nc-icons/database'
import NcChevronDown from '~icons/nc-icons/chevron-down'
import NcTrash from '~icons/nc-icons/trash'
import NcPencil from '~icons/nc-icons/pencil'
import NcRename from '~icons/nc-icons/rename'
import NcDuplicate from '~icons/nc-icons/duplicate'
import NcEdit from '~icons/nc-icons/edit'
import NcCopy from '~icons/nc-icons/copy'
import NcPaste from '~icons/nc-icons/paste'
import NcArrowUp from '~icons/nc-icons/arrow-up'
import NcArrowDown from '~icons/nc-icons/arrow-down'
import NcUpload from '~icons/nc-icons/upload'
import NcDownload from '~icons/nc-icons/download'
// keep it for reference
// todo: remove it after all icons are migrated
@ -327,18 +337,20 @@ export const iconMap = {
accountCircle: h('span', { class: 'material-symbols' }, 'account_circle'),
phUser: PhUser,
phUsers: PhUsers,
user: NcUser,
users: NcUsers,
cloudDownload: h('span', { class: 'material-symbols' }, 'cloud_download'),
download: MsDownloadRounded,
upload: h('span', { class: 'material-symbols' }, 'cloud_upload'),
download: NcDownload,
cloudUpload: h('span', { class: 'material-symbols' }, 'cloud_upload'),
upload: NcUpload,
hook: Phishing,
erd: h('span', { class: 'material-symbols' }, 'account_tree'),
plus: h('span', { class: 'material-symbols', style: '-webkit-text-stroke: 0.3px' }, 'add'),
plusSquare: PlusSquare,
minus: Minus,
help: h('span', { class: 'material-symbols' }, 'help'),
copy: h('span', { class: 'material-symbols' }, 'content_copy'),
duplicate: MaterialDuplicate,
copy: NcCopy,
duplicate: NcDuplicate,
clipboard: h('span', { class: 'material-symbols' }, 'content_paste'),
settings: h('span', { class: 'material-symbols' }, 'settings'),
image: h('span', { class: 'material-symbols' }, 'image'),
@ -361,7 +373,7 @@ export const iconMap = {
csv: PhCsvThin, // h('span', { class: 'material-symbols' }, 'grid_on'),
code: Code,
palette: h(Palette, {}, () => 'palette'),
delete: Delete,
delete: NcTrash,
deleteListItem: h('span', { class: 'material-symbols' }, 'delete'),
import: h('span', { class: 'material-symbols' }, 'system_update_alt'),
edit: MaterialSymbolsEdit,
@ -459,7 +471,13 @@ export const iconMap = {
role_no_access: NoAccess,
commentHere: NcCommentHere,
fileImage: FileImageIcon,
paste: h('span', { class: 'material-symbols' }, 'content_paste'),
paste: NcPaste,
chevronDown: NcChevronDown,
pencil: NcPencil,
rename: NcRename,
ncEdit: NcEdit,
ncArrowUp: NcArrowUp,
ncArrowDown: NcArrowDown,
}
export const getMdiIcon = (type: string): any => {

4
packages/nc-gui/utils/urlUtils.ts

@ -27,8 +27,8 @@ export const replaceUrlsWithLink = (text: string): boolean | string => {
return found && out
}
export const isValidURL = (str: string) => {
return isURL(`${str}`)
export const isValidURL = (str: string, extraProps?) => {
return isURL(`${str}`, extraProps)
}
export const openLink = (path: string, baseURL?: string, target = '_blank') => {

10
packages/nc-gui/utils/validation.ts

@ -208,3 +208,13 @@ export const emailValidator = {
})
},
}
export const urlValidator = {
validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => {
if (!v.length || isValidURL(v)) return resolve()
reject(new Error(t('msg.error.invalidURL')))
})
},
}

2
packages/noco-docs/docs/080.records/070.actions-on-record.md

@ -44,7 +44,7 @@ You can start editing by any of the following methods
And it will automatically save on blur event or if inactive.
### Bulk Update Records
### Bulk Update Records
You can bulk update records by
1. Selecting multiple records that you wish to update together and then
2. Right-click on the index field area (first column on the grid view) and then select `Bulk Update records` option from the context menu. This will open `Bulk update` modal.

49
packages/noco-docs/docs/140.account-settings/030.authentication/010.overview.md

@ -0,0 +1,49 @@
---
title: 'Overview'
description: 'Learn about different methods available for authentication with NocoDB.'
tags: ['SSO', 'Overview']
keywords: ['SSO', 'Overview', 'Authentication', 'Email', 'Password', 'SAML', 'OIDC']
---
This section provides an overview about different mechanisms available for authentication in NocoDB.
# Email and password based
This is the default form based authentication mechanism available in NocoDB. Users can sign up using email and password and then login using the same credentials.
# Single Sign On (SSO)
SSO is a session and user authentication service that permits a user to use one set of login credentials to access multiple applications. The service authenticates the end user for all the applications the user has been given rights to and eliminates further prompts when the user switches applications during the same session.
SSO functionality is achieved by establishing a connection with an identity provider (IdP), which serves as a repository for managing users digital identities within the digital or cloud-based ecosystem. Through the use of protocols like the Security Assertion Markup Language (SAML 2.0), such as in the case of NocoDB, SSO facilitates the secure exchange of authentication data between the identity provider and the service providers.
[//]: # (### Google OAuth)
[//]: # ()
[//]: # (Google OAuth, short for Open Authorization, is a widely used and standardized protocol that facilitates secure authentication and authorization processes, particularly in the context of web and mobile applications. Developed by Google, OAuth enables users to grant third-party applications limited access to their resources without exposing their credentials. This authorization framework is based on token-based authentication, where users can log in using their Google credentials, and developers can obtain an access token to interact with Google APIs on the user's behalf.)
[//]: # ()
[//]: # (Please follow the details in the article to integrate with [Google OAuth]&#40;google-oauth&#41;)
### Security Assertion Markup Language (SAML)
The Security Assertion Markup Language (SAML) stands as a critical protocol in the realm of secure authentication and authorization processes. Developed to enable Single Sign-On (SSO) functionality, SAML facilitates the exchange of authentication and authorization data between an identity provider (IdP) and a service provider (SP). This XML-based protocol ensures the secure transfer of user identity information, allowing individuals to access multiple applications and services with a single set of credentials. SAML operates on a trust model, wherein the identity provider asserts the user's identity to the service provider, which, in turn, grants or denies access based on the provided assertions.
[//]: # (This robust framework is widely employed in various industries and platforms, contributing to the seamless and secure integration of disparate systems and applications in the digital landscape. SAML adoption is particularly evident in cloud-based services, enterprise applications, and other environments where a unified and secure authentication process is paramount.)
Please follow the details in the article below to integrate with various popular SAML providers.
1. [Okta](SAML-SSO/okta)
2. [Auth0](SAML-SSO/auth0)
3. [Ping Identity](SAML-SSO/ping-identity)
4. [Active Directory](SAML-SSO/azure-ad)
5. [Keycloak](SAML-SSO/keycloak)
### OpenID Connect (OIDC)
The OpenID Connect (OIDC) protocol is a modern authentication layer built on top of the OAuth 2.0 framework, designed to address user authentication and authorization challenges in web and mobile applications. OIDC provides a standardized and secure way for applications to verify the identity of end-users. Leveraging JSON Web Tokens (JWTs), OIDC enables the exchange of user identity information between the identity provider (IdP) and the Service provider, typically a web application.
[//]: # (One of the key advantages of OIDC is its ability to enable Single Sign-On &#40;SSO&#41; capabilities, allowing users to authenticate once and access multiple applications seamlessly. OIDC also provides a standardized set of claims, such as user profile information, making it easier for developers to integrate identity management into their applications. Widely adopted in various industries, OIDC plays a crucial role in enhancing the security and user experience of authentication processes across diverse digital platforms.)
Please follow the details in the article below to integrate with various popular OIDC providers.
1. [Okta](OIDC-SSO/okta)
2. [Auth0](OIDC-SSO/auth0)
3. [Ping Identity](OIDC-SSO/ping-identity)
4. [Active Directory](OIDC-SSO/azuire-ad)

25
packages/noco-docs/docs/140.account-settings/030.authentication/020.google-oauth.md

@ -0,0 +1,25 @@
---
title: 'Google OAuth'
description: 'Learn about different methods available for authentication with NocoDB.'
tags: ['SSO', 'Google', 'OAuth']
keywords: ['SSO', 'Overview', 'Authentication', 'Google', 'OAuth']
---
NocoDB offers a functionality that allows users to connect with Google OAuth 2.0, enabling them to log into their NocoDB accounts using their Google authentication credentials. This article provides a step-by-step guide to integrating Google OAuth 2.0 with NocoDB.
1. Copy `Redirect URI` from NocoDB
- Go to `Account Settings` > `Authentication` > `Google OAuth`
- Copy the `Redirect URI` from the `Google OAuth` section
2. Go to [Google Cloud Console](https://console.cloud.google.com/) and create a new project.
3. Visit the `OAuth consent screen` within the `APIs & Services` section.
a) Decide on the configuration and registration preferences for your application, specifying the intended user demographic
b) Click on the `Create` button
4. Set up the OAuth consent screen by providing details about the application and specifying the authorized domains where you host NocoDB.
5. Proceed to the `Credentials` screen, then click on `Create Credentials`. Choose `OAuth Client ID` from the available options to generate OAuth credentials.
6. Choose `Web application` from the options available in the `Application type` dropdown menu.
7. Configure the following
a) `Authorized JavaScript origins` refer to the HTTP origins where your web application is hosted, such as https://app.nocodb.com
b) `Authorized Redirect URIs` refer to the URIs where the user is redirected after successful authentication with Google. Paste the *Redirect URI* copied from NocoDB in step (1).
8. Click on the `Create` button to generate the OAuth credentials. Copy the `Client ID` and `Client Secret` from the OAuth 2.0 Client IDs section.
9. Go to `Account Settings` > `Authentication` > `Google OAuth` in NocoDB and paste the `Client ID` and `Client Secret` in the respective fields.

55
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/010.okta.md

@ -0,0 +1,55 @@
---
title: 'Okta'
description: 'Learn how to configure Okta as an identity provider for NocoDB.'
tags: ['SSO', 'Okta', 'SAML']
keywords: ['SSO', 'Okta', 'SAML', 'Authentication', 'Identity Provider']
---
This article briefs about the steps to configure Okta as Identity service provider for NocoDB
### NocoDB, Retrieve `SAML SSO` Configuration details
1. Go to `Account Settings`
2. Select `Authentication (SSO)`
3. Click on `New Provider` button
4. On the Popup modal, Specify a `Display name` for the provider; note that, this name will be used to display the provider on the login page
5. Retrieve `Redirect URL` & `Audience / Entity ID`; these information will be required to be configured later with the Identity Provider
![SAML SSO Configuration](/img/v2/account-settings/SSO-1.png)
![SAML SSO Configuration](/img/v2/account-settings/SAML-2.png)
![SAML SSO Configuration](/img/v2/account-settings/SAML-3.png)
### Okta, Configure NocoDB as an Application
1. Sign in to your [Okta account](https://www.okta.com/)
- Navigate to `Applications` > `Applications`
- Click `Create App Integration`
2. In the pop-up with title `Create a new app integration` choose `SAML 2.0` as the Sign-in method
3. On the `Create SAML Integration` page, in the General settings - provide a name for your app; click `Next`
4. In the `Configure SAML` section:
- Enter the `Redirect URL` copied from NocoDB in the `Single sign-on URL` field.
- Add the `Audience URI` copied from NocoDB in the `Audience URI (SP Entity ID)` field.
- Choose `Email Address` from the `Name ID format` options.
- Select `Email` from the `Application user-name` options.
- Click `Next`
5. Complete any additional information in the final step and click `Finish`
6. On your application's homepage,
- Navigate to the `Sign-on` tab
- Copy the `Metadata URL` from the `SAML 2.0` section
7. Go to the `Assignments` tab and click `Assign` to assign people or groups to this application.
### NocoDB, Configure Okta as an Identity Provider
1. Go to `Account Settings` > `Authentication (SSO)` > `SAML`
2. On the "Register SAML Identity Provider" modal, insert `Metadata URL` retrieved in step above; alternatively you can configure XML directly as well
3. `Save`
![SAML SSO Configuration](/img/v2/account-settings/SAML-4.png)
For Sign-in's, user should be able to now see `Sign in with <SSO>` option.
![SAML SSO Configuration](/img/v2/account-settings/SSO-SignIn.png)
:::note
Post sign-out, refresh page (for the first time) if you do not see `Sign in with <SSO>` option
:::

66
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/020.auth0.md

@ -0,0 +1,66 @@
---
title: 'Auth0'
description: 'Learn how to configure Auth0 as an identity provider for NocoDB.'
tags: ['SSO', 'Auth0', 'SAML']
keywords: ['SSO', 'Auth0', 'SAML', 'Authentication', 'Identity Provider']
---
This article briefs about the steps to configure Auth0 as Identity service provider for NocoDB
### NocoDB, Retrieve `SAML SSO` Configuration details
1. Go to `Account Settings`
2. Select `Authentication (SSO)`
3. Click on `New Provider` button
4. On the Popup modal, Specify a `Display name` for the provider; note that, this name will be used to display the provider on the login page
5. Retrieve `Redirect URL` & `Audience / Entity ID`; these information will be required to be configured later with the Identity Provider
![SAML SSO Configuration](/img/v2/account-settings/SSO-1.png)
![SAML SSO Configuration](/img/v2/account-settings/SAML-2.png)
![SAML SSO Configuration](/img/v2/account-settings/SAML-3.png)
### Auth0, Configure NocoDB as an Application
1. Access your [Auth0 account](https://auth0.com/)
- navigate to `Applications` > `Create Application`.
2. In the `Create Application` modal,
- choose `Regular Web Application`
- click `Create`
3. Upon successful creation, you will be directed to the `Quick Start` screen.
- Go to the `Addons` tab.
- Enable `SAML2 Web App`
4. On the `SAML2 Web App` modal,
- Paste `Redirect URL` copied in step above into `Application Callback URL` field
- In Settings, retain `nameIdentifierProbes` as `["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"]`; remove other probes if any
```json
{
"nameIdentifierProbes": [
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
]
}
```
- Click `Enable` to save the settings
5. On the `Settings` tab,
- move to `Advanced Settings` > `Endpoints` > `SAML` section,
- copy the SAML `Metadata URL`
[//]: # (3. Upon successful creation, you will be directed to the `Quick Start` screen. )
[//]: # ( - Go to the `Settings` tab.)
[//]: # ( - Paste `Redirect URI` copied in step above into `Allowed Callback URLs` field)
[//]: # ( - `Save`)
### NocoDB, Configure Auth0 as an Identity Provider
1. Go to `Account Settings` > `Authentication` > `SAML`
2. Insert `Metadata URL` retrieved in step above; alternatively you can configure XML directly as well
3. `Save`
![SAML SSO Configuration](/img/v2/account-settings/SAML-4.png)
For Sign-in's, user should be able to now see `Sign in with <SSO>` option.
![SAML SSO Configuration](/img/v2/account-settings/SSO-SignIn.png)
:::note
Post sign-out, refresh page (for the first time) if you do not see `Sign in with <SSO>` option
:::

59
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/030.ping-identity.md

@ -0,0 +1,59 @@
---
title: 'Ping Identity'
description: 'Learn how to configure Ping Identity as an identity provider for NocoDB.'
tags: ['SSO', 'Ping Identity', 'SAML']
keywords: ['SSO', 'Ping Identity', 'SAML', 'Authentication', 'Identity Provider']
---
This article briefs about the steps to configure Auth0 as Identity service provider for NocoDB
### NocoDB, Retrieve `SAML SSO` Configuration details
1. Go to `Account Settings`
2. Select `Authentication (SSO)`
3. Click on `New Provider` button
4. On the Popup modal, Specify a `Display name` for the provider; note that, this name will be used to display the provider on the login page
5. Retrieve `Redirect URL` & `Audience / Entity ID`; these information will be required to be configured later with the Identity Provider
![SAML SSO Configuration](/img/v2/account-settings/SSO-1.png)
![SAML SSO Configuration](/img/v2/account-settings/SAML-2.png)
![SAML SSO Configuration](/img/v2/account-settings/SAML-3.png)
### Ping Identity, Configure NocoDB as an Application
1. Access your [PingOne account](https://www.pingidentity.com/en/account/sign-on.html) and navigate to the homepage.
2. Click on `Add Environment` from the top right corner.
3. On the `Create Environment` screen,
- Opt for `Build your own solution`
- In the `Select solution(s) for your Environment` section, select `PingOne SSO` from `Cloud Services`
- Click `Next`
- Provide a name and description for the environment,
- Click `Next`
4. Access the newly created environment and go to `Connections` > `Applications` from the sidebar.
5. Within the Applications homepage, initiate the creation of a new application by clicking the "+" icon.
6. On the "Add Application" panel:
- Input the application name and description.
- Choose "SAML Application" as the Application Type and click "Configure."
- Within the SAML Configuration panel, opt for "Manually Enter."
- Populate the `ACS URLs` field with the `Redirect URL` retrieved from step (2) above
- Insert the `Audience URI` retrieved above in step (2) in the `Entity ID` field
- `Save`
7. In your application,
- Navigate to the `Configurations` tab
- Copy the `IDP Metadata URL`
8. On your application panel, activate user access to the application by toggling the switch in the top right corner.
### NocoDB, Configure Ping Identity as an Identity Provider
1. Go to `Account Settings` > `Authentication` > `SAML`
2. Insert `Metadata URL` retrieved in step above; alternatively you can configure XML directly as well
3. `Save`
![SAML SSO Configuration](/img/v2/account-settings/SAML-4.png)
For Sign-in's, user should be able to now see `Sign in with <SSO>` option.
![SAML SSO Configuration](/img/v2/account-settings/SSO-SignIn.png)
:::note
Post sign-out, refresh page (for the first time) if you do not see `Sign in with <SSO>` option
:::

63
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/040.azure-ad.md

@ -0,0 +1,63 @@
---
title: 'Azure AD (Entra)'
description: 'Learn how to configure Active Directory as an identity provider for NocoDB.'
tags: ['SSO', 'Active Directory', 'SAML']
keywords: ['SSO', 'Active Directory', 'SAML', 'Authentication', 'Identity Provider']
---
This article briefs about the steps to configure Active Directory as Identity service provider for NocoDB
### NocoDB, Retrieve `SAML SSO` Configuration details
1. Go to `Account Settings`
2. Select `Authentication (SSO)`
3. Click on `New Provider` button
4. On the Popup modal, Specify a `Display name` for the provider; note that, this name will be used to display the provider on the login page
5. Retrieve `Redirect URL` & `Audience / Entity ID`; these information will be required to be configured later with the Identity Provider
![SAML SSO Configuration](/img/v2/account-settings/SSO-1.png)
![SAML SSO Configuration](/img/v2/account-settings/SAML-2.png)
![SAML SSO Configuration](/img/v2/account-settings/SAML-3.png)
### Azure AD, Configure NocoDB as an Application
1. Sign in to your [Azure account](https://portal.azure.com/#allservices) and navigate to `Microsoft Entra admin center` > `Identity` > `Enterprise applications`
2. Click `+ New application`
3. On the `Browse Microsoft Entra Gallery` page, select `Create your own application` from the navigation bar.
a. Provide your application's name.
b. Choose `Integrate any other application you don't find in the gallery (Non-gallery)`
c. `Create`
4. On your application page, navigate to `Manage` > `Single sign-on` > `SAML`
5. Go to the `Basic SAML Configuration` section under `Set up Single Sign-On with SAML` and click `Edit`
a. Add the `Audience URI` under `Identifier (Entity ID)`.
b. Add the `Redirect URL` under `Replay URL (Assertion Consumer Service URL)`.
c. Click `Save`
6. In the `Attributes & Claims` section, click `Edit`
a. Edit the "Unique User Identifier (Name ID)" claim:
- Select `Email address` from the `Name identifier format` dropdown
- Choose `Attribute` as the `Source`
- In the `Source attribute`, select `user.mail`
- Click `Save`
[//]: # ( b. &#40;Optional&#41; For custom claims:)
[//]: # ( - Click Add new claim, provide details, and save.)
[//]: # ( - Ensure the claim is visible in the Additional claims section.)
[//]: # ( - Copy the claim name for later use in NocoDB SAML configurations.)
7. Go to the `SAML Certificates` section and copy the `App Federation Metadata URL`
8. on the Application's Overview page,
- Click `Users and groups`,
- Add the necessary users or groups to the application.
### NocoDB, Configure Azure AD as an Identity Provider
1. Go to `Account Settings` > `Authentication` > `SAML`
2. Insert `Metadata URL` retrieved in step above; alternatively you can configure XML directly as well
3. `Save`
![SAML SSO Configuration](/img/v2/account-settings/SAML-4.png)
For Sign-in's, user should be able to now see `Sign in with <SSO>` option.
![SAML SSO Configuration](/img/v2/account-settings/SSO-SignIn.png)
:::note
Post sign-out, refresh page (for the first time) if you do not see `Sign in with <SSO>` option
:::

59
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/050.keycloak.md

@ -0,0 +1,59 @@
---
title: 'Keycloak'
description: 'Learn how to configure Keycloak as an identity provider for NocoDB.'
tags: ['SSO', 'Keycloak', 'SAML']
keywords: ['SSO', 'Keycloak', 'SAML', 'Authentication', 'Identity Provider']
---
This article briefs about the steps to configure Keycloak as Identity service provider for NocoDB
### NocoDB, Retrieve `SAML SSO` Configuration details
1. Go to `Account Settings`
2. Select `Authentication (SSO)`
3. Click on `New Provider` button
4. On the Popup modal, Specify a `Display name` for the provider; note that, this name will be used to display the provider on the login page
5. Retrieve `Redirect URL` & `Audience / Entity ID`; these information will be required to be configured later with the Identity Provider
![SAML SSO Configuration](/img/v2/account-settings/SSO-1.png)
![SAML SSO Configuration](/img/v2/account-settings/SAML-2.png)
![SAML SSO Configuration](/img/v2/account-settings/SAML-3.png)
### Keycloak, Configure NocoDB as an Application
1. Access your Keycloak account
- navigate to `Clients` menu
- select `Clients list` tab > Click `Create client` button.
2. In the `Create Client` modal, `General Settings` tab:
- Select `SAML` as the `Client type`
- Specify `Audience/Entity ID` retrieved from NocoDB as the `Client ID`
- Click `Next`
3. In the `Create Client` modal, `Login Settings` tab,
- Specify `Redirect URL` retrieved from NocoDB as the `Valid Redirect URIs`
- Specify `Redirect URL` retrieved from NocoDB as the `Valid post logout redirect URIs`
- Click `Save`
4. On the `Client details`, `Settings` tab,
- navigate to `SAML Capabilities` section
- Specify `Name ID format` as `email`
- Enable `Force Name ID Format` and `Force POST Binding`
- navigate to `Signature and Encryption` section
- Enable `Sign Assertions`
- Click `Save`
5. On the `Client details`, `Keys` tab,
- Disable `Signing keys config` > `Client Signature Required`
6. Navigate to `Realm Settings` > `Endpoints`
- Copy `SAML 2.0 Identity Provider Metadata` URL
### NocoDB, Configure Azure AD as an Identity Provider
1. Go to `Account Settings` > `Authentication` > `SAML`Key
2. Insert `Metadata URL` retrieved in step above; alternatively you can configure XML directly as well
3. `Save`
![SAML SSO Configuration](/img/v2/account-settings/SAML-4.png)
For Sign-in's, user should be able to now see `Sign in with <SSO>` option.
![SAML SSO Configuration](/img/v2/account-settings/SSO-SignIn.png)
:::note
Post sign-out, refresh page (for the first time) if you do not see `Sign in with <SSO>` option
:::

8
packages/noco-docs/docs/140.account-settings/030.authentication/030.SAML-SSO/_category_.json

@ -0,0 +1,8 @@
{
"label": "SAML",
"collapsible": true,
"collapsed": true,
"link": {
"type": "generated-index"
}
}

63
packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/010.okta.md

@ -0,0 +1,63 @@
---
title: 'Okta'
description: 'Learn how to configure Okta as an identity provider for NocoDB.'
tags: ['SSO', 'Okta', 'OIDC']
keywords: ['SSO', 'Okta', 'OIDC', 'Authentication', 'Identity Provider']
---
This article briefs about the steps to configure Okta as Identity service provider for NocoDB
### NocoDB, Retrieve `Redirect URL`
1. Go to `Account Settings`
2. Select `Authentication (SSO)`
3. Click on `New Provider` button
4. On the Popup modal, Specify a `Display name` for the provider; note that, this name will be used to display the provider on the login page
5. Retrieve `Redirect URL`; this information will be required to be configured later with the Identity Provider
![OIDC SSO Configuration](/img/v2/account-settings/SSO-1.png)
![OIDC SSO Configuration](/img/v2/account-settings/OIDC-2.png)
![OIDC SSO Configuration](/img/v2/account-settings/OIDC-3.png)
### Okta, Configure NocoDB as an Application
1. Sign in to your [Okta account](https://www.okta.com/) and navigate to the "Get started with Okta" page.
- Click on `Add App` for the Single Sign-On option.
- On the `Browse App Integration Catalog` page, select `Create New App`
2. In the pop-up with title `Create a new app integration`
- Choose `OIDC - OpenID Connect` as the Sign-in method
- Choose `Web Application` as the Application type
3. Go to `General Settings` on the `New Web App Integration` page
- Provide your application's name.
- From the Options in the `Grant type allowed` section, select `Authorization Code` and `Refresh Token`
- Add the `Redirect URL` under `Sign-in redirect URIs`.
- From the `Assignments section`, select an option from `Controlled access` to set up the desired accessibility configuration for this application.
- `Save`
4. On your new application,
- Go to the `General` tab
- Copy the `Client ID` and `Client Secret` from the `Client Credentials` section.
5. From `Account` dropdown in navigation bar
- Copy `Okta Domain`
6. Append "./well-known/openid-configuration" to the `Okta Domain` URL & access it
- Example: https://dev-123456.okta.com/.well-known/openid-configuration
- Copy `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint` & `jwks_uri` from the JSON response
### NocoDB, Configure Okta as an Identity Provider
In NocoDB, open `Account Settings` > `Authentication` > `OIDC`. On the "Register OIDC Identity Provider" modal, insert the following information:
- Insert `Client ID` retrieved in step (6) above as `Client ID`
- Insert `Client Secret` retrieved in step (6) above as `Client Secret`
- Insert `authorization_endpoint` retrieved in step (8) above as `Authorization URL`
- Insert `token_endpoint` retrieved in step (8) above as `Token URL`
- Insert `userinfo_endpoint` retrieved in step (8) above as `Userinfo URL`
- Insert `jwks_uri` retrieved in step (8) above as `JWK Set URL`
- Set `Scope` as `openid` `profile` `email` `offline_access`
- In the Username Attribute field, indicate the name of the claim that represents the user's email. The default value is set to "email."
For Sign-in's, user should be able to now see `Sign in with <SSO>` option.
![SAML SSO Configuration](/img/v2/account-settings/SSO-SignIn.png)
:::note
Post sign-out, refresh page (for the first time) if you do not see `Sign in with <SSO>` option
:::
For information about Okta API Scopes, refer [here](https://developer.okta.com/docs/reference/api/oidc/#scopes)

56
packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/020.auth0.md

@ -0,0 +1,56 @@
---
title: 'Auth0'
description: 'Learn how to configure Auth0 as an identity provider for NocoDB.'
tags: ['SSO', 'Auth0', 'OIDC']
keywords: ['SSO', 'Auth0', 'OIDC', 'Authentication', 'Identity Provider']
---
This article briefs about the steps to configure Auth0 as Identity service provider for NocoDB
### NocoDB, Retrieve `Redirect URL`
1. Go to `Account Settings`
2. Select `Authentication (SSO)`
3. Click on `New Provider` button
4. On the Popup modal, Specify a `Display name` for the provider; note that, this name will be used to display the provider on the login page
5. Retrieve `Redirect URL`; this information will be required to be configured later with the Identity Provider
![OIDC SSO Configuration](/img/v2/account-settings/SSO-1.png)
![OIDC SSO Configuration](/img/v2/account-settings/OIDC-2.png)
![OIDC SSO Configuration](/img/v2/account-settings/OIDC-3.png)
### Auth0, Configure NocoDB as an Application
1. Access your [Auth0 account](https://auth0.com/)
- navigate to `Applications` > `Create Application`.
2. In the `Create Application` modal,
- choose `Regular Web Application`
- click `Create`
3. On Quick start screen, go to `Settings` tab
- Copy the `Client ID` and `Client Secret` from the `Basic Information` section.
4. Goto `Application URIs` section
- Add the `Redirect URL` copied from step(2) under `Allowed Callback URLs`.
- `Save Changes`
5. On the `Settings` tab, go to the `Advanced Settings` section and click on the `Endpoints` tab.
- Copy the `OAuth Authorization URL`, `OAuth Token URL`, `OAuth User Info URL` & `JSON Web Key Set URL`
### NocoDB, Configure Auth0 as an Identity Provider
1. In NocoDB, open `Account Settings` > `Authentication` > `OIDC`. On the "Register OIDC Identity Provider" modal, insert the following information:
- Insert `Client ID` retrieved in step (5) above as `Client ID`
- Insert `Client Secret` retrieved in step (5) above as `Client Secret`
- Insert `OAuth Authorization URL` retrieved in step (7) above as `Authorization URL`
- Insert `OAuth Token URL` retrieved in step (7) above as `Token URL`
- Insert `OAuth User Info URL` retrieved in step (7) above as `Userinfo URL`
- Insert `JSON Web Key Set URL` retrieved in step (7) above as `JWK Set URL`
- Set `Scope` as `openid` `profile` `email` `offline_access`
- In the Username Attribute field, indicate the name of the claim that represents the user's email. The default value is set to "email."
For Sign-in's, user should be able to now see `Sign in with <SSO>` option.
![SAML SSO Configuration](/img/v2/account-settings/SSO-SignIn.png)
:::note
Post sign-out, refresh page (for the first time) if you do not see `Sign in with <SSO>` option
:::
For information about Auth0 API Scopes, refer [here](https://auth0.com/docs/secure/tokens/refresh-tokens)

69
packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/030.ping-identity.md

@ -0,0 +1,69 @@
---
title: 'Ping Identity'
description: 'Learn how to configure Ping Identity as an identity provider for NocoDB.'
tags: ['SSO', 'Ping Identity', 'OIDC']
keywords: ['SSO', 'Ping Identity', 'OIDC', 'Authentication', 'Identity Provider']
---
This article briefs about the steps to configure Ping Identity as Identity service provider for NocoDB
### NocoDB, Retrieve `Redirect URL`
1. Go to `Account Settings`
2. Select `Authentication (SSO)`
3. Click on `New Provider` button
4. On the Popup modal, Specify a `Display name` for the provider; note that, this name will be used to display the provider on the login page
5. Retrieve `Redirect URL`; this information will be required to be configured later with the Identity Provider
![OIDC SSO Configuration](/img/v2/account-settings/SSO-1.png)
![OIDC SSO Configuration](/img/v2/account-settings/OIDC-2.png)
![OIDC SSO Configuration](/img/v2/account-settings/OIDC-3.png)
### Ping Identity, Configure NocoDB as an Application
1. Access your [PingOne account](https://www.pingidentity.com/en/account/sign-on.html) and navigate to the homepage.
2. Click on `Add Environment` from the top right corner.
3. On the `Create Environment` screen,
- Opt for `Build your own solution`
- In the `Select solution(s) for your Environment` section, select `PingOne SSO` from `Cloud Services`
- Click `Next`
- Provide a name and description for the environment,
- Click `Next`
4. Access the newly created environment and go to `Connections` > `Applications` from the sidebar.
5. Within the Applications homepage, initiate the creation of a new application by clicking the "+" icon.
6. On the "Add Application" panel:
- Input the application name and description.
- Choose "OIDC Web App" as the Application Type and click "Configure"
7. From your application,
- Go to `Configurations` tab
- Click on `Edit` button
- Check `Refresh Token` option
- Copy `Authorization URL`, `Token URL`, `Userinfo URL` & `JWK Set URL` from the `Endpoints` section
- From `Generals` dropdown, copy `Client ID` & `Client Secret`
- `Save`
8. From `Resources` tab,
- Click `Edit`
- Select `openid` `profile` `email` from `Scopes`
9. Switch toggle button in the top right corner to `On` to activate the application.
### NocoDB, Configure Ping Identity as an Identity Provider
1. In NocoDB, open `Account Settings` > `Authentication` > `OIDC`. On the "Register OIDC Identity Provider" modal, insert the following information:
- Insert `Client ID` retrieved in step (9) above as `Client ID`
- Insert `Client Secret` retrieved in step (9) above as `Client Secret`
- Insert `Authorization URL` retrieved in step (9) above as `Authorization URL`
- Insert `Token URL` retrieved in step (9) above as `Token URL`
- Insert `Userinfo URL` retrieved in step (9) above as `Userinfo URL`
- Insert `JWK Set URL` retrieved in step (9) above as `JWK Set URL`
- Set `Scope` as `openid` `profile` `email` `offline_access`
- In the Username Attribute field, indicate the name of the claim that represents the user's email. The default value is set to "email."
For Sign-in's, user should be able to now see `Sign in with <SSO>` option.
![SAML SSO Configuration](/img/v2/account-settings/SSO-SignIn.png)
:::note
Post sign-out, refresh page (for the first time) if you do not see `Sign in with <SSO>` option
:::
For information about Ping Identity API Scopes, refer [here](https://docs.pingidentity.com/r/en-us/pingone/pingone_t_edit_scopes_for_an_application)

76
packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/040.azure-ad.md

@ -0,0 +1,76 @@
---
title: 'Azure AD (Entra)'
description: 'Learn how to configure Azure AD as an identity provider for NocoDB.'
tags: ['SSO', 'Azure AD', 'OIDC']
keywords: ['SSO', 'Azure AD', 'OIDC', 'Authentication', 'Identity Provider']
---
This article briefs about the steps to configure Azure AD as Identity service provider for NocoDB
### NocoDB, Retrieve `Redirect URL`
1. Go to `Account Settings`
2. Select `Authentication (SSO)`
3. Click on `New Provider` button
4. On the Popup modal, Specify a `Display name` for the provider; note that, this name will be used to display the provider on the login page
5. Retrieve `Redirect URL`; this information will be required to be configured later with the Identity Provider
![OIDC SSO Configuration](/img/v2/account-settings/SSO-1.png)
![OIDC SSO Configuration](/img/v2/account-settings/OIDC-2.png)
![OIDC SSO Configuration](/img/v2/account-settings/OIDC-3.png)
### Azure AD, Configure NocoDB as an Application
1. Sign in to your [Azure account](https://portal.azure.com/#allservices) and navigate to `Azure Active Directory` under `Azure Services`.
2. Access `Manage Tenants` from the navigation bar, select your directory, and click `Switch`.
3. On your directory's homepage, click `+ Add` > `App Registration` from the navigation bar.
4. On the `Register an application` page,
- Provide your application's name.
- Set `Accounts in this organizational directory only` as the `Supported account types`.
- Choose `Web` as the Application type
- Add the `Redirect URL` under `Redirect URIs`.
- `Register`
5. On your application's homepage,
- Copy the `Application (client) ID`
- Click `Add a certificate or secret` under `Client credentials` section
- On `Certificates & secrets` page, go to `Client secrets` section
- Click `New client secret`
- On `Add a client secret` page,
- Add a description for the secret
- Set expiration as required
- `Add`
- Copy the `Value` of the newly created secret
6. On your application's homepage,
- Go to `Endpoints` tab
- Open `OpenID Connect metadata document` URL & copy `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint` & `jwks_uri` from the JSON response
7. Configuring scopes
- Go to `API permissions` tab
- Click `Add a permission`
- On `Request API permissions` page,
- Select `Microsoft Graph` from `Microsoft APIs`
- Select `Delegated permissions`
- Select `openid` `profile` `email` `offline_access` from `Select permissions` dropdown
- From `Users` dropdown, select `User.Read`
- `Add permissions`
- Click `Grant admin consent for this directory` from the `API permissions` page
### NocoDB, Configure Azure AD as an Identity Provider
On NocoDB, open `Account Settings` > `Authentication` > `OIDC`. On the "Register OIDC Identity Provider" modal, insert the following information:
- Insert `Application (client) ID` retrieved in step (7) above as `Client ID`
- Insert `Value` of the newly created secret retrieved in step (7) above as `Client Secret`
- Insert `authorization_endpoint` retrieved in step (8) above as `Authorization URL`
- Insert `token_endpoint` retrieved in step (8) above as `Token URL`
- Insert `userinfo_endpoint` retrieved in step (8) above as `Userinfo URL`
- Insert `jwks_uri` retrieved in step (8) above as `JWK Set URL`
- Set `Scope` as `openid` `profile` `email` `offline_access`
For Sign-in's, user should be able to now see `Sign in with <SSO>` option.
![SAML SSO Configuration](/img/v2/account-settings/SSO-SignIn.png)
:::note
Post sign-out, refresh page (for the first time) if you do not see `Sign in with <SSO>` option
:::
For information about Azure AD API Scopes, refer [here](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#offline_access)

8
packages/noco-docs/docs/140.account-settings/030.authentication/040.OIDC-SSO/_category_.json

@ -0,0 +1,8 @@
{
"label": "OpenID Connect",
"collapsible": true,
"collapsed": true,
"link": {
"type": "generated-index"
}
}

8
packages/noco-docs/docs/140.account-settings/030.authentication/_category_.json

@ -0,0 +1,8 @@
{
"label": "Authentication ☁",
"collapsible": true,
"collapsed": true,
"link": {
"type": "generated-index"
}
}

0
packages/noco-docs/docs/140.account-settings/030.oss-specific-details.md → packages/noco-docs/docs/140.account-settings/040.oss-specific-details.md

BIN
packages/noco-docs/static/img/v2/account-settings/OIDC-2.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
packages/noco-docs/static/img/v2/account-settings/OIDC-3.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

BIN
packages/noco-docs/static/img/v2/account-settings/SAML-2.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

BIN
packages/noco-docs/static/img/v2/account-settings/SAML-3.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

BIN
packages/noco-docs/static/img/v2/account-settings/SAML-4.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

BIN
packages/noco-docs/static/img/v2/account-settings/SSO-1.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
packages/noco-docs/static/img/v2/account-settings/SSO-SignIn.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

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

@ -1,4 +1,5 @@
import { ColumnReqType, ColumnType } from './Api';
import { FormulaDataTypes } from './formulaHelpers';
enum UITypes {
ID = 'ID',
@ -185,4 +186,20 @@ export function isLinksOrLTAR(
);
}
export const getEquivalentUIType = ({
formulaColumn,
}: {
formulaColumn: ColumnType;
}): void | UITypes => {
switch ((formulaColumn?.colOptions as any)?.parsed_tree?.dataType) {
case FormulaDataTypes.NUMERIC:
return UITypes.Number;
case FormulaDataTypes.DATE:
return UITypes.DateTime;
case FormulaDataTypes.LOGICAL:
case FormulaDataTypes.BOOLEAN:
return UITypes.Checkbox;
}
};
export default UITypes;

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

@ -17,6 +17,7 @@ export {
isCreatedOrLastModifiedTimeCol,
isCreatedOrLastModifiedByCol,
isHiddenCol,
getEquivalentUIType,
} from '~/lib/UITypes';
export { default as CustomAPI, FileType } from '~/lib/CustomAPI';
export { default as TemplateGenerator } from '~/lib/TemplateGenerator';

6
packages/nocodb-sdk/src/lib/sqlUi/SqliteUi.ts

@ -915,10 +915,6 @@ export class SqliteUi {
static getUnsupportedFnList() {
return [
'LOG',
'EXP',
'POWER',
'SQRT',
'XOR',
'REGEX_MATCH',
'REGEX_EXTRACT',
@ -926,8 +922,6 @@ export class SqliteUi {
'VALUE',
'COUNTA',
'COUNT',
'ROUNDDOWN',
'ROUNDUP',
'DATESTR',
'DAY',
'MONTH',

6
packages/nocodb/package.json

@ -56,13 +56,13 @@
"@nestjs/common": "^10.2.10",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.2.10",
"@nestjs/event-emitter": "^2.0.3",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.0.4",
"@nestjs/mapped-types": "^2.0.5",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.2.10",
"@nestjs/platform-socket.io": "^10.2.10",
"@nestjs/serve-static": "^4.0.0",
"@nestjs/serve-static": "^4.0.1",
"@nestjs/throttler": "^4.2.1",
"@nestjs/websockets": "^10.2.10",
"@ntegral/nestjs-sentry": "^4.0.1",

27
packages/nocodb/src/controllers/auth/auth.controller.ts

@ -18,12 +18,10 @@ import type { AppConfig } from '~/interface/config';
import { UsersService } from '~/services/users/users.service';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { randomTokenString, setTokenCookie } from '~/services/users/helpers';
import { GlobalGuard } from '~/guards/global/global.guard';
import { NcError } from '~/helpers/catchError';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { User } from '~/models';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard';
@ -91,7 +89,7 @@ export class AuthController {
@Post('/api/v1/auth/user/signout')
@HttpCode(200)
async signOut(@Req() req: Request, @Res() res: Response): Promise<any> {
if (!(req as any).isAuthenticated()) {
if (!(req as any).isAuthenticated?.()) {
NcError.forbidden('Not allowed');
}
res.json(
@ -139,7 +137,7 @@ export class AuthController {
})
@HttpCode(200)
async passwordChange(@Req() req: Request): Promise<any> {
if (!(req as any).isAuthenticated()) {
if (!(req as any).isAuthenticated?.()) {
NcError.forbidden('Not allowed');
}
@ -246,25 +244,6 @@ export class AuthController {
}
async setRefreshToken({ res, req }) {
const userId = req.user?.id;
if (!userId) return;
const user = await User.get(userId);
if (!user) return;
const refreshToken = randomTokenString();
if (!user['token_version']) {
user['token_version'] = randomTokenString();
}
await User.update(user.id, {
refresh_token: refreshToken,
email: user.email,
token_version: user['token_version'],
});
setTokenCookie(res, refreshToken);
await this.usersService.setRefreshToken({ res, req });
}
}

1
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -427,7 +427,6 @@ class BaseModelSqlv2 {
const proto = await this.getProto();
let data;
try {
data = await this.execAndParse(qb);
} catch (e) {

82
packages/nocodb/src/db/conditionV2.ts

@ -1,5 +1,6 @@
import {
FormulaDataTypes,
getEquivalentUIType,
isDateMonthFormat,
isNumericCol,
RelationTypes,
@ -568,12 +569,18 @@ const parseConditionV2 = async (
return (qb: Knex.QueryBuilder) => {
let [field, val] = [_field, _val];
// based on custom where clause(builder), we need to change the field and val
// todo: refactor this to use a better approach to make it more readable and clean
let genVal = customWhereClause ? field : val;
const dateFormat =
qb?.client?.config?.client === 'mysql2'
? 'YYYY-MM-DD HH:mm:ss'
: 'YYYY-MM-DD HH:mm:ssZ';
if (
(column.uidt === UITypes.Formula &&
getEquivalentUIType({ formulaColumn: column }) ==
UITypes.DateTime) ||
[
UITypes.Date,
UITypes.DateTime,
@ -586,82 +593,91 @@ const parseConditionV2 = async (
if (dateFormatFromMeta && isDateMonthFormat(dateFormatFromMeta)) {
// reset to 1st
now = dayjs(now).date(1);
if (val) val = dayjs(val).date(1);
if (val) genVal = dayjs(val).date(1);
}
// handle sub operation
switch (filter.comparison_sub_op) {
case 'today':
val = now;
genVal = now;
break;
case 'tomorrow':
val = now.add(1, 'day');
genVal = now.add(1, 'day');
break;
case 'yesterday':
val = now.add(-1, 'day');
genVal = now.add(-1, 'day');
break;
case 'oneWeekAgo':
val = now.add(-1, 'week');
genVal = now.add(-1, 'week');
break;
case 'oneWeekFromNow':
val = now.add(1, 'week');
genVal = now.add(1, 'week');
break;
case 'oneMonthAgo':
val = now.add(-1, 'month');
genVal = now.add(-1, 'month');
break;
case 'oneMonthFromNow':
val = now.add(1, 'month');
genVal = now.add(1, 'month');
break;
case 'daysAgo':
if (!val) return;
val = now.add(-val, 'day');
genVal = now.add(-genVal, 'day');
break;
case 'daysFromNow':
if (!val) return;
val = now.add(val, 'day');
genVal = now.add(genVal, 'day');
break;
case 'exactDate':
if (!val) return;
if (!genVal) return;
break;
// sub-ops for `isWithin` comparison
case 'pastWeek':
val = now.add(-1, 'week');
genVal = now.add(-1, 'week');
break;
case 'pastMonth':
val = now.add(-1, 'month');
genVal = now.add(-1, 'month');
break;
case 'pastYear':
val = now.add(-1, 'year');
genVal = now.add(-1, 'year');
break;
case 'nextWeek':
val = now.add(1, 'week');
genVal = now.add(1, 'week');
break;
case 'nextMonth':
val = now.add(1, 'month');
genVal = now.add(1, 'month');
break;
case 'nextYear':
val = now.add(1, 'year');
genVal = now.add(1, 'year');
break;
case 'pastNumberOfDays':
if (!val) return;
val = now.add(-val, 'day');
genVal = now.add(-genVal, 'day');
break;
case 'nextNumberOfDays':
if (!val) return;
val = now.add(val, 'day');
if (!genVal) return;
genVal = now.add(genVal, 'day');
break;
}
if (dayjs.isDayjs(val)) {
if (dayjs.isDayjs(genVal)) {
// turn `val` in dayjs object format to string
val = val.format(dateFormat).toString();
genVal = genVal.format(dateFormat).toString();
// keep YYYY-MM-DD only for date
val = column.uidt === UITypes.Date ? val.substring(0, 10) : val;
genVal =
column.uidt === UITypes.Date ? genVal.substring(0, 10) : genVal;
}
}
if (isNumericCol(column.uidt) && typeof val === 'string') {
if (isNumericCol(column.uidt) && typeof genVal === 'string') {
// convert to number
val = +val;
genVal = +genVal;
}
// if customWhereClause(builder) is provided, replace field with raw value
// or assign value to val
if (customWhereClause) {
field = knex.raw('?', [genVal]);
} else {
val = genVal;
}
switch (filter.comparison_op) {
@ -681,6 +697,9 @@ const parseConditionV2 = async (
) {
qb = qb.where(field, val);
} else if (
(column.uidt === UITypes.Formula &&
getEquivalentUIType({ formulaColumn: column }) ==
UITypes.DateTime) ||
column.ct === 'timestamp' ||
column.ct === 'date' ||
column.ct === 'datetime'
@ -692,6 +711,9 @@ const parseConditionV2 = async (
}
} else {
if (
(column.uidt === UITypes.Formula &&
getEquivalentUIType({ formulaColumn: column }) ==
UITypes.DateTime) ||
[
UITypes.DateTime,
UITypes.CreatedTime,
@ -1038,18 +1060,24 @@ const parseConditionV2 = async (
case 'isWithin': {
let now = dayjs(new Date()).format(dateFormat).toString();
now = column.uidt === UITypes.Date ? now.substring(0, 10) : now;
// switch between arg based on customWhereClause(builder)
const [firstArg, rangeArg] = [
customWhereClause ? val : field,
customWhereClause ? field : val,
];
switch (filter.comparison_sub_op) {
case 'pastWeek':
case 'pastMonth':
case 'pastYear':
case 'pastNumberOfDays':
qb = qb.whereBetween(field, [val, now]);
qb = qb.whereBetween(firstArg, [rangeArg, now]);
break;
case 'nextWeek':
case 'nextMonth':
case 'nextYear':
case 'nextNumberOfDays':
qb = qb.whereBetween(field, [now, val]);
qb = qb.whereBetween(firstArg, [now, rangeArg]);
break;
}
}

4
packages/nocodb/src/db/sortV2.ts

@ -65,9 +65,11 @@ export default async function sortV2(
(
await column.getColOptions<FormulaColumn>()
).formula,
alias,
null,
model,
column,
{},
alias
)
).builder;
qb.orderBy(builder, sort.direction || 'asc', nulls);

7
packages/nocodb/src/db/util/DebugMgr.ts

@ -89,10 +89,7 @@ export default class DebugMgr {
}
}
static disableAll(namespace) {
for (const key in levels) {
debug.disable(`${namespace}_${levels[key]}`);
this.refreshNamespace(namespace);
}
static disableAll() {
debug.disable();
}
}

3
packages/nocodb/src/helpers/apiHelpers.ts

@ -45,7 +45,8 @@ export const validatePayload = (schema: string, payload: any) => {
// If the request body is not valid, throw error
if (!valid) {
const errors: ErrorObject[] | null | undefined = ajv.errors;
const errors: ErrorObject[] | null | undefined =
ajv.errors || validate.errors;
// If the request body is invalid, throw error with error message and errors
NcError.ajvValidationError({

12
packages/nocodb/src/middlewares/global/global.middleware.ts

@ -1,15 +1,27 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { NestMiddleware } from '@nestjs/common';
import type { AppConfig } from '~/interface/config';
import Noco from '~/Noco';
@Injectable()
export class GlobalMiddleware implements NestMiddleware {
constructor(protected readonly config: ConfigService<AppConfig>) {}
use(req: any, res: any, next: () => void) {
req.ncSiteUrl =
Noco.config?.envs?.[Noco.env]?.publicUrl ||
Noco.config?.publicUrl ||
req.protocol + '://' + req.get('host');
req.ncFullUrl = req.protocol + '://' + req.get('host') + req.originalUrl;
const dashboardPath = this.config.get('dashboardPath', {
infer: true,
});
// used for playwright tests so env is not documented
req.dashboardUrl =
process.env.NC_DASHBOARD_URL || req.ncSiteUrl + dashboardPath;
next();
}
}

23
packages/nocodb/src/services/users/users.service.ts

@ -555,4 +555,27 @@ export class UsersService {
return base;
}
async setRefreshToken({ res, req }) {
const userId = req.user?.id;
if (!userId) return;
const user = await User.get(userId);
if (!user) return;
const refreshToken = randomTokenString();
if (!user['token_version']) {
user['token_version'] = randomTokenString();
}
await User.update(user.id, {
refresh_token: refreshToken,
email: user.email,
token_version: user['token_version'],
});
setTokenCookie(res, refreshToken);
}
}

17
packages/nocodb/src/services/utils.service.ts

@ -1,3 +1,4 @@
import process from 'process';
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { compareVersions, validate } from 'compare-versions';
@ -371,18 +372,18 @@ export class UtilsService {
settings = JSON.parse((await Store.get(NC_APP_SETTINGS, true))?.value);
} catch {}
const oidcAuthEnabled = !!(
process.env.NC_OIDC_ISSUER &&
process.env.NC_OIDC_AUTHORIZATION_URL &&
process.env.NC_OIDC_TOKEN_URL &&
process.env.NC_OIDC_USERINFO_URL &&
process.env.NC_OIDC_CLIENT_ID &&
process.env.NC_OIDC_CLIENT_SECRET
const oidcAuthEnabled = ['openid', 'oidc'].includes(
process.env.NC_SSO?.toLowerCase(),
);
const oidcProviderName = oidcAuthEnabled
? process.env.NC_OIDC_PROVIDER_NAME ?? 'OpenID Connect'
: null;
const samlAuthEnabled = process.env.NC_SSO?.toLowerCase() === 'saml';
const samlProviderName = samlAuthEnabled
? process.env.NC_SSO_SAML_PROVIDER_NAME ?? 'SAML'
: null;
const result = {
authType: 'jwt',
baseHasAdmin,
@ -422,6 +423,8 @@ export class UtilsService {
mainSubDomain: this.configService.get('mainSubDomain', { infer: true }),
dashboardPath: this.configService.get('dashboardPath', { infer: true }),
inviteOnlySignup: settings.invite_only_signup,
samlProviderName,
samlAuthEnabled,
};
return result;

4
packages/nocodb/src/strategies/jwt.strategy.ts

@ -14,7 +14,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(req, jwtPayload) {
if (!jwtPayload?.email) return jwtPayload;
if (!jwtPayload?.email) {
return jwtPayload;
}
const user = await User.getByEmail(jwtPayload?.email);

1
packages/nocodb/src/types/express.d.ts vendored

@ -10,5 +10,6 @@ declare module 'express-serve-static-core' {
};
ncSiteUrl: string;
clientIp: string;
dashboardUrl: string;
}
}

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

@ -162,6 +162,7 @@ export enum CacheScope {
PROJECT_ALIAS = 'baseAlias',
MODEL_ALIAS = 'modelAlias',
VIEW_ALIAS = 'viewAlias',
SSO_CLIENT = 'ssoClient',
}
export enum CacheGetType {

7
packages/nocodb/tests/unit/init/cleanupMeta.ts

@ -1,8 +1,7 @@
import { Base, Model } from '../../../src/models';
import NcConnectionMgrv2 from '../../../src/utils/common/NcConnectionMgrv2';
import { orderedMetaTables } from '../../../src/utils/globals';
import { Base, Model } from '~/models';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { orderedMetaTables } from '~/utils/globals';
import TestDbMngr from '../TestDbMngr';
import { isPg } from './db';
const dropTablesAllNonExternalProjects = async () => {
const bases = await Base.list({});

4
packages/nocodb/tests/unit/init/index.ts

@ -41,7 +41,9 @@ export default async function (forceReset = false, roles = 'editor') {
const { token, user } = await createUser({ app: server }, { roles });
const extra: any = {};
const extra: {
fk_workspace_id?: string;
} = {};
// create ws for ee
if (process.env.EE === 'true') {

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

@ -13,8 +13,10 @@ import groupByTest from './tests/groupby.test';
import formulaTests from './tests/formula.test';
let workspaceTest = () => {};
let ssoTest = () => {};
if (process.env.EE === 'true') {
workspaceTest = require('./tests/ee/workspace.test').default;
ssoTest = require('./tests/ee/sso.test').default;
}
// import layoutTests from './tests/layout.test';
// import widgetTest from './tests/widget.test';
@ -33,6 +35,7 @@ function restTests() {
groupByTest();
workspaceTest();
formulaTests();
ssoTest();
// Enable for dashboard feature
// widgetTest();

52
pnpm-lock.yaml

@ -233,8 +233,8 @@ importers:
specifier: ^1.1.23
version: 1.1.23
'@iconify-json/carbon':
specifier: ^1.1.28
version: 1.1.28
specifier: ^1.1.29
version: 1.1.29
'@iconify-json/cil':
specifier: ^1.1.8
version: 1.1.8
@ -257,8 +257,8 @@ importers:
specifier: ^1.1.42
version: 1.1.42
'@iconify-json/lucide':
specifier: ^1.1.162
version: 1.1.162
specifier: ^1.1.163
version: 1.1.163
'@iconify-json/material-symbols':
specifier: ^1.1.72
version: 1.1.72
@ -332,8 +332,8 @@ importers:
specifier: ^5.0.4
version: 5.0.4
'@types/validator':
specifier: ^13.11.8
version: 13.11.8
specifier: ^13.11.9
version: 13.11.9
'@types/vue-barcode-reader':
specifier: ^0.0.3
version: 0.0.3
@ -443,14 +443,14 @@ importers:
specifier: ^10.2.10
version: 10.2.10(@nestjs/common@10.2.10)(@nestjs/platform-express@10.2.10)(@nestjs/websockets@10.2.10)(reflect-metadata@0.1.14)(rxjs@7.2.0)
'@nestjs/event-emitter':
specifier: ^2.0.3
version: 2.0.3(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(reflect-metadata@0.1.14)
specifier: ^2.0.4
version: 2.0.4(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)
'@nestjs/jwt':
specifier: ^10.2.0
version: 10.2.0(@nestjs/common@10.2.10)
'@nestjs/mapped-types':
specifier: ^2.0.4
version: 2.0.4(@nestjs/common@10.2.10)(reflect-metadata@0.1.14)
specifier: ^2.0.5
version: 2.0.5(@nestjs/common@10.2.10)(reflect-metadata@0.1.14)
'@nestjs/passport':
specifier: ^10.0.3
version: 10.0.3(@nestjs/common@10.2.10)(passport@0.6.0)
@ -461,8 +461,8 @@ importers:
specifier: ^10.2.10
version: 10.2.10(@nestjs/common@10.2.10)(@nestjs/websockets@10.2.10)(rxjs@7.2.0)
'@nestjs/serve-static':
specifier: ^4.0.0
version: 4.0.0(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(express@4.18.2)
specifier: ^4.0.1
version: 4.0.1(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(express@4.18.2)
'@nestjs/throttler':
specifier: ^4.2.1
version: 4.2.1(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(reflect-metadata@0.1.14)
@ -4576,8 +4576,8 @@ packages:
'@iconify/types': 2.0.0
dev: true
/@iconify-json/carbon@1.1.28:
resolution: {integrity: sha512-tg+h0i+69JrIqUpQva2Mt611KdLMeCyibqS7lIqaMRXJgnalHtdqDcdZAKCSLb/hTbkJHyk0NCpQSYJ3f/v51w==}
/@iconify-json/carbon@1.1.29:
resolution: {integrity: sha512-zfyvX/kPItpBEU0fV0FhMW8Ln8PJX6is/L/GX7z9OOoVWEz1k8IlbK3KoBBH6ODZ8HHKG7HQ9FZ4nNl6RDe0Lw==}
dependencies:
'@iconify/types': 2.0.0
dev: true
@ -4624,8 +4624,8 @@ packages:
'@iconify/types': 2.0.0
dev: true
/@iconify-json/lucide@1.1.162:
resolution: {integrity: sha512-qmyZP9dTKN1J+ZNxzpqery29qysl9507tPqkGhRUjIJT/AhVxPZ1EY29ISg+jqqoCOhMvl+mR56z5LFb6KH37w==}
/@iconify-json/lucide@1.1.163:
resolution: {integrity: sha512-zZWM3FJfsUk6RQOrh3rd9DEek4QLCndiFGHyC6sRTMZQgCQ3muI3q20OQJ5QlvX2Md87OKcxPLgFiY2+Z/wA0w==}
dependencies:
'@iconify/types': 2.0.0
dev: true
@ -5503,17 +5503,15 @@ packages:
transitivePeerDependencies:
- encoding
/@nestjs/event-emitter@2.0.3(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(reflect-metadata@0.1.14):
resolution: {integrity: sha512-Pt7KAERrgK0OjvarSI3wfVhwZ8X1iLq1lXuodyRe+Zx3aLLP7fraFUHirASbFkB6KIQ1Zj+gZ1g8a9eu4GfFhw==}
/@nestjs/event-emitter@2.0.4(@nestjs/common@10.2.10)(@nestjs/core@10.2.10):
resolution: {integrity: sha512-quMiw8yOwoSul0pp3mOonGz8EyXWHSBTqBy8B0TbYYgpnG1Ix2wGUnuTksLWaaBiiOTDhciaZ41Y5fJZsSJE1Q==}
peerDependencies:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0
reflect-metadata: ^0.1.12
dependencies:
'@nestjs/common': 10.2.10(reflect-metadata@0.1.14)(rxjs@7.2.0)
'@nestjs/core': 10.2.10(@nestjs/common@10.2.10)(@nestjs/platform-express@10.2.10)(@nestjs/websockets@10.2.10)(reflect-metadata@0.1.14)(rxjs@7.2.0)
eventemitter2: 6.4.9
reflect-metadata: 0.1.14
dev: false
/@nestjs/graphql@12.0.11(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(graphql@15.3.0)(reflect-metadata@0.1.14):
@ -5591,13 +5589,13 @@ packages:
dev: false
optional: true
/@nestjs/mapped-types@2.0.4(@nestjs/common@10.2.10)(reflect-metadata@0.1.14):
resolution: {integrity: sha512-xl+gUSp0B+ln1VSNoUftlglk8dfpUes3DHGxKZ5knuBxS5g2H/8p9/DSBOYWUfO5f4u9s6ffBPZ71WO+tbe5SA==}
/@nestjs/mapped-types@2.0.5(@nestjs/common@10.2.10)(reflect-metadata@0.1.14):
resolution: {integrity: sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==}
peerDependencies:
'@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0
class-transformer: ^0.4.0 || ^0.5.0
class-validator: ^0.13.0 || ^0.14.0
reflect-metadata: ^0.1.12
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
class-transformer:
optional: true
@ -5666,8 +5664,8 @@ packages:
- chokidar
dev: true
/@nestjs/serve-static@4.0.0(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(express@4.18.2):
resolution: {integrity: sha512-8cTrNV2ngdHIjiLNsXePnw0+KY1ThrZGz/WeyAG5gIvmZNDbnZBOrPoYlKL+MOzlXlQStxR5jKLYmn+nJeoncQ==}
/@nestjs/serve-static@4.0.1(@nestjs/common@10.2.10)(@nestjs/core@10.2.10)(express@4.18.2):
resolution: {integrity: sha512-AoOrVdAe+WmsceuCcA8nWmKUYmaOsg9pqBCbIj7PS4W3XdikJQMtfxgSIoOlyUksZdhTBFjHqKh0Yhpj6pulwQ==}
peerDependencies:
'@fastify/static': ^6.5.0
'@nestjs/common': ^9.0.0 || ^10.0.0
@ -9132,8 +9130,8 @@ packages:
resolution: {integrity: sha512-cputDpIbFgLUaGQn6Vqg3/YsJwxUwHLO13v3i5ouxT4lat0khip9AEWxtERujXV9wxIB1EyF97BSJFt6vpdI8g==}
dev: true
/@types/validator@13.11.8:
resolution: {integrity: sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==}
/@types/validator@13.11.9:
resolution: {integrity: sha512-FCTsikRozryfayPuiI46QzH3fnrOoctTjvOYZkho9BTFLCOZ2rgZJHMOVgCOfttjPJcgOx52EpkY0CMfy87MIw==}
dev: true
/@types/vue-barcode-reader@0.0.3:

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

@ -0,0 +1,162 @@
import BasePage from '../Base';
import { AccountPage } from './index';
import * as assert from 'assert';
import { expect } from '@playwright/test';
export class AccountAuthenticationPage extends BasePage {
private accountPage: AccountPage;
constructor(accountPage: AccountPage) {
super(accountPage.rootPage);
this.accountPage = accountPage;
}
async goto() {
await this.waitForResponse({
uiAction: () => this.rootPage.goto('/#/account/authentication', { waitUntil: 'networkidle' }),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/sso-client',
});
}
get() {
return this.accountPage.get().locator(`[data-test-id="nc-authentication"]`);
}
async verifySAMLProviderCount({ count }: { count: number }) {
await expect.poll(async () => await this.get().locator('.nc-saml-provider').count()).toBe(count);
}
async verifyOIDCProviderCount({ count }: { count: number }) {
await expect.poll(async () => await this.get().locator('.nc-oidc-provider').count()).toBe(count);
}
async getProvider(provider: 'saml' | 'oidc', title: string) {
return this.rootPage.locator(`[data-test-id="nc-${provider}-provider-${title}"]`);
}
async deleteProvider(provider: 'saml' | 'oidc', title: string) {
await this.rootPage.locator(`.nc-${provider}-${title}-more-option`).click();
await this.waitForResponse({
uiAction: () => this.rootPage.locator(`[data-test-id="nc-${provider}-delete"]`).click(),
httpMethodsToMatch: ['DELETE'],
requestUrlPathToMatch: `/api/v2/sso-client/`,
});
}
async toggleProvider(provider: 'saml' | 'oidc', title: string) {
await this.waitForResponse({
uiAction: () => this.get().locator(`.nc-${provider}-${title}-enable .nc-switch`).click(),
httpMethodsToMatch: ['PATCH'],
requestUrlPathToMatch: `/api/v2/sso-client/`,
});
}
async selectScope({ type, locator }: { type: string[] }) {
await this.rootPage.locator('.ant-select-selector').click();
await this.rootPage.locator('.ant-select-selection-search-input[aria-expanded="true"]').waitFor();
for (const t of type) {
await this.rootPage.locator('.rc-virtual-list-holder-inner > div').locator(`text="${t}"`).click();
}
}
async createSAMLProvider(
p: { title: string; url?: string; xml?: string },
setupRedirectUrlCbk?: ({ redirectUrl: string, audience: string }) => Promise<void>
) {
const newSamlBtn = this.get().locator('[data-test-id="nc-new-saml-provider"]');
await newSamlBtn.click();
const samlModal = this.accountPage.rootPage.locator('.nc-saml-modal');
// wait until redirect url is generated
await samlModal.locator('[data-test-id="nc-saml-redirect-url"]:has-text("http://")').waitFor();
if (setupRedirectUrlCbk) {
const redirectUrl = (
await samlModal.locator('[data-test-id="nc-saml-redirect-url"]:has-text("http://")').textContent()
).trim();
const audience = (
await samlModal.locator('[data-test-id="nc-saml-issuer-url"]:has-text("http://")').textContent()
).trim();
await setupRedirectUrlCbk({ redirectUrl, audience });
}
await samlModal.locator('[data-test-id="nc-saml-title"]').fill(p.title);
if (p.url) {
await samlModal.locator('[data-test-id="nc-saml-metadata-url"]').fill(p.url);
}
if (p.xml) {
await samlModal.locator('[data-test-id="nc-saml-xml"]').fill(p.xml);
}
await this.waitForResponse({
uiAction: () => samlModal.locator('[data-test-id="nc-saml-submit"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/sso-client',
});
}
async createOIDCProvider(
p: {
issuer: string;
title: string;
clientId: string;
clientSecret: string;
authUrl: string;
userInfoUrl: string;
tokenUrl: string;
jwkUrl: string;
scopes: Array<string>;
userAttributes: string;
},
setupRedirectUrlCbk?: ({ redirectUrl: string }) => Promise<void>
) {
const newOIDCBtn = this.get().locator('[data-test-id="nc-new-oidc-provider"]');
await newOIDCBtn.click();
const oidcModal = this.accountPage.rootPage.locator('.nc-oidc-modal');
// wait until redirect url is generated
await oidcModal.locator('[data-test-id="nc-openid-redirect-url"]:has-text("http://")').waitFor();
if (setupRedirectUrlCbk) {
const redirectUrl = (
await oidcModal.locator('[data-test-id="nc-openid-redirect-url"]:has-text("http://")').textContent()
).trim();
await setupRedirectUrlCbk({ redirectUrl });
}
await oidcModal.locator('[data-test-id="nc-oidc-title"]').fill(p.title);
await oidcModal.locator('[data-test-id="nc-oidc-issuer"]').fill(p.issuer);
await oidcModal.locator('[data-test-id="nc-oidc-client-id"]').fill(p.clientId);
await oidcModal.locator('[data-test-id="nc-oidc-client-secret"]').fill(p.clientSecret);
await oidcModal.locator('[data-test-id="nc-oidc-auth-url"]').fill(p.authUrl);
await oidcModal.locator('[data-test-id="nc-oidc-token-url"]').fill(p.tokenUrl);
await oidcModal.locator('[data-test-id="nc-oidc-user-info-url"]').fill(p.userInfoUrl);
await oidcModal.locator('[data-test-id="nc-oidc-jwk-url"]').fill(p.jwkUrl);
await this.selectScope({
type: p.scopes,
locator: oidcModal.locator('[data-test-id="nc-oidc-scope"]'),
});
await oidcModal.locator('[data-test-id="nc-oidc-user-attribute"]').fill(p.userAttributes);
await this.waitForResponse({
uiAction: () => oidcModal.locator('[data-test-id="nc-oidc-save-btn"]').click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/api/v2/sso-client',
});
}
}

3
tests/playwright/pages/Account/index.ts

@ -5,6 +5,7 @@ import { AccountTokenPage } from './Token';
import { AccountUsersPage } from './Users';
import { AccountAppStorePage } from './AppStore';
import { AccountLicensePage } from './License';
import { AccountAuthenticationPage } from './Authentication';
export class AccountPage extends BasePage {
readonly settings: AccountSettingsPage;
@ -12,6 +13,7 @@ export class AccountPage extends BasePage {
readonly users: AccountUsersPage;
readonly appStore: AccountAppStorePage;
readonly license: AccountLicensePage;
readonly authentication: AccountAuthenticationPage;
constructor(page: Page) {
super(page);
@ -20,6 +22,7 @@ export class AccountPage extends BasePage {
this.users = new AccountUsersPage(this);
this.appStore = new AccountAppStorePage(this);
this.license = new AccountLicensePage(this);
this.authentication = new AccountAuthenticationPage(this);
}
get() {

16
tests/playwright/pages/Dashboard/Grid/index.ts

@ -302,12 +302,12 @@ export class GridPage extends BasePage {
async verifyTotalRowCount({ count }: { count: number }) {
// wait for 100 ms and try again : 5 times
let i = 0;
await this.get().locator(`.nc-pagination`).waitFor();
await this.get().locator(`.nc-pagination-skeleton`).waitFor({ state: 'hidden' });
let records = await this.get().locator(`[data-testid="grid-pagination"]`).allInnerTexts();
let recordCnt = records[0].split(' ')[0];
while (parseInt(recordCnt) !== count && i < 5) {
await this.get().locator(`.nc-pagination`).waitFor();
await this.get().locator(`.nc-pagination-skeleton`).waitFor({ state: 'hidden' });
records = await this.get().locator(`[data-testid="grid-pagination"]`).allInnerTexts();
recordCnt = records[0].split(' ')[0];
@ -319,6 +319,11 @@ export class GridPage extends BasePage {
}
async verifyPaginationCount({ count }: { count: string }) {
if (await this.get().locator('.nc-pagination').isHidden()) {
expect(1).toBe(+count);
return;
}
await expect(this.get().locator(`.nc-pagination .total`)).toHaveText(count);
}
@ -329,6 +334,8 @@ export class GridPage extends BasePage {
type: 'first-page' | 'last-page' | 'next-page' | 'prev-page';
skipWait?: boolean;
}) {
if (await this.get().locator('.nc-pagination').isHidden()) return;
if (!skipWait) {
await this.get().locator(`.nc-pagination .${type}`).click();
await this.waitLoading();
@ -345,6 +352,11 @@ export class GridPage extends BasePage {
}
async verifyActivePage({ pageNumber }: { pageNumber: string }) {
if (await this.get().locator('.nc-pagination').isHidden()) {
expect(1).toBe(+pageNumber);
return;
}
await expect(this.get().locator(`.nc-pagination .ant-select-selection-item`)).toHaveText(pageNumber);
}

41
tests/playwright/pages/SsoIdpPage/OpenIDLoginPage.ts

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

38
tests/playwright/pages/SsoIdpPage/SAMLLoginPage.ts

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

19
tests/playwright/setup/index.ts

@ -181,7 +181,7 @@ async function localInit({
try {
let response: AxiosResponse<any, any>;
// Login as root user
if (isSuperUser && !isEE()) {
if (isSuperUser && process.env.NC_CLOUD !== 'true') {
// required for configuring license key settings
response = await axios.post('http://localhost:8080/api/v1/auth/user/signin', {
email: `user@nocodb.com`,
@ -209,6 +209,18 @@ async function localInit({
// console.log(process.env.TEST_WORKER_INDEX, process.env.TEST_PARALLEL_INDEX);
// delete sso-clients
if (isEE() && api['ssoClient'] && isSuperUser) {
const clients = await api.ssoClient.list();
for (const client of clients.list) {
try {
await api.ssoClient.delete(client.id);
} catch (e) {
console.log(`Error deleting sso-client: ${client.id}`);
}
}
}
if (isEE() && api['workspace']) {
// Delete associated workspace
// Note that: on worker error, entire thread is reset & worker ID numbering is reset too
@ -398,9 +410,9 @@ const setup = async ({
// ignore error: some roles will not have permission for license reset
// console.error(`Error resetting base: ${process.env.TEST_PARALLEL_INDEX}`, e);
}
await page.addInitScript(
async ({ token }) => {
if (location.search?.match(/code=|short-token=|skip-init-script=/)) return;
try {
let initialLocalStorage = {};
try {
@ -408,6 +420,9 @@ const setup = async ({
} catch (e) {
console.error('Failed to parse local storage', e);
}
if (initialLocalStorage?.token) return;
window.localStorage.setItem(
'nocodb-gui-v2',
JSON.stringify({

Loading…
Cancel
Save