Browse Source

Merge branch 'develop' into fix/i18n

pull/6614/head
Reenphy George 1 year ago committed by GitHub
parent
commit
edff143139
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      SECURITY.md
  2. 4
      charts/nocodb/templates/deployment.yaml
  3. 2
      charts/nocodb/templates/pvc.yaml
  4. 4
      charts/nocodb/values.yaml
  5. 9
      packages/nc-gui/assets/nc-icons/owner.svg
  6. 12
      packages/nc-gui/components/account/Profile.vue
  7. 20
      packages/nc-gui/components/account/ResetPassword.vue
  8. 2
      packages/nc-gui/components/account/SignupSettings.vue
  9. 86
      packages/nc-gui/components/account/Token.vue
  10. 81
      packages/nc-gui/components/account/UserList.vue
  11. 25
      packages/nc-gui/components/account/UsersModal.vue
  12. 6
      packages/nc-gui/components/api-client/Headers.vue
  13. 6
      packages/nc-gui/components/api-client/Params.vue
  14. 11
      packages/nc-gui/components/cell/attachment/index.vue
  15. 4
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  16. 134
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  17. 2
      packages/nc-gui/components/dlg/ProjectDelete.vue
  18. 36
      packages/nc-gui/components/dlg/share-and-collaborate/ShareBase.vue
  19. 13
      packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue
  20. 2
      packages/nc-gui/components/erd/TableNode.vue
  21. 4
      packages/nc-gui/components/general/BaseLogo.vue
  22. 7
      packages/nc-gui/components/general/ShareProject.vue
  23. 15
      packages/nc-gui/components/nc/Pagination.vue
  24. 5
      packages/nc-gui/components/nc/Tooltip.vue
  25. 4
      packages/nc-gui/components/project/AllTables.vue
  26. 14
      packages/nc-gui/components/project/View.vue
  27. 137
      packages/nc-gui/components/smartsheet/Kanban.vue
  28. 2
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  29. 3
      packages/nc-gui/components/smartsheet/column/LookupOptions.vue
  30. 4
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  31. 9
      packages/nc-gui/components/smartsheet/details/Erd.vue
  32. 11
      packages/nc-gui/components/smartsheet/details/Fields.vue
  33. 17
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  34. 79
      packages/nc-gui/components/smartsheet/grid/Table.vue
  35. 2
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue
  36. 2
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  37. 7
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  38. 1
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  39. 1
      packages/nc-gui/components/virtual-cell/components/ListItems.vue
  40. 17
      packages/nc-gui/composables/useExpandedFormStore.ts
  41. 6
      packages/nc-gui/composables/useGlobal/actions.ts
  42. 1
      packages/nc-gui/composables/useGlobal/types.ts
  43. 32
      packages/nc-gui/composables/useGridViewColumn.ts
  44. 10
      packages/nc-gui/lang/en.json
  45. 1
      packages/nc-gui/lib/types.ts
  46. 16
      packages/nc-gui/middleware/auth.global.ts
  47. 11
      packages/nc-gui/pages/account/index.vue
  48. 10
      packages/nc-gui/plugins/a.i18n.ts
  49. 10
      packages/nc-gui/plugins/api.ts
  50. 1
      packages/nc-gui/plugins/resizeDirective.ts
  51. 10
      packages/nc-gui/plugins/state.ts
  52. 71
      packages/nc-gui/store/views.ts
  53. 88
      packages/nc-gui/utils/colorsUtils.ts
  54. 3
      packages/noco-docs/docs/020.getting-started/020.environment-variables.md
  55. 1
      packages/nocodb/package.json
  56. 4
      packages/nocodb/src/app.config.ts
  57. 3
      packages/nocodb/src/app.module.ts
  58. 6
      packages/nocodb/src/controllers/api-docs/api-docs.controller.ts
  59. 3
      packages/nocodb/src/controllers/api-tokens.controller.ts
  60. 5
      packages/nocodb/src/controllers/attachments-secure.controller.ts
  61. 5
      packages/nocodb/src/controllers/attachments.controller.ts
  62. 3
      packages/nocodb/src/controllers/audits.controller.ts
  63. 19
      packages/nocodb/src/controllers/auth/auth.controller.ts
  64. 3
      packages/nocodb/src/controllers/base-users.controller.ts
  65. 3
      packages/nocodb/src/controllers/bases.controller.ts
  66. 3
      packages/nocodb/src/controllers/bulk-data-alias.controller.ts
  67. 3
      packages/nocodb/src/controllers/caches.controller.ts
  68. 3
      packages/nocodb/src/controllers/columns.controller.ts
  69. 4
      packages/nocodb/src/controllers/data-alias-export.controller.ts
  70. 3
      packages/nocodb/src/controllers/data-alias-nested.controller.ts
  71. 3
      packages/nocodb/src/controllers/data-alias.controller.ts
  72. 5
      packages/nocodb/src/controllers/data-table.controller.ts
  73. 3
      packages/nocodb/src/controllers/datas.controller.ts
  74. 3
      packages/nocodb/src/controllers/filters.controller.ts
  75. 3
      packages/nocodb/src/controllers/form-columns.controller.ts
  76. 3
      packages/nocodb/src/controllers/forms.controller.ts
  77. 3
      packages/nocodb/src/controllers/galleries.controller.ts
  78. 3
      packages/nocodb/src/controllers/grid-columns.controller.ts
  79. 3
      packages/nocodb/src/controllers/grids.controller.ts
  80. 3
      packages/nocodb/src/controllers/hooks.controller.ts
  81. 0
      packages/nocodb/src/controllers/imports/helpers/job.ts
  82. 0
      packages/nocodb/src/controllers/imports/import.controller.ts
  83. 3
      packages/nocodb/src/controllers/kanbans.controller.ts
  84. 3
      packages/nocodb/src/controllers/maps.controller.ts
  85. 3
      packages/nocodb/src/controllers/meta-diffs.controller.ts
  86. 3
      packages/nocodb/src/controllers/model-visibilities.controller.ts
  87. 3
      packages/nocodb/src/controllers/notifications.controller.ts
  88. 3
      packages/nocodb/src/controllers/old-datas/old-datas.controller.ts
  89. 3
      packages/nocodb/src/controllers/org-lcense.controller.ts
  90. 3
      packages/nocodb/src/controllers/org-tokens.controller.ts
  91. 3
      packages/nocodb/src/controllers/org-users.controller.ts
  92. 3
      packages/nocodb/src/controllers/plugins.controller.ts
  93. 11
      packages/nocodb/src/controllers/public-datas-export.controller.ts
  94. 3
      packages/nocodb/src/controllers/public-datas.controller.ts
  95. 4
      packages/nocodb/src/controllers/public-metas.controller.ts
  96. 3
      packages/nocodb/src/controllers/shared-bases.controller.ts
  97. 3
      packages/nocodb/src/controllers/sorts.controller.ts
  98. 3
      packages/nocodb/src/controllers/sources.controller.ts
  99. 3
      packages/nocodb/src/controllers/sql-views.controller.ts
  100. 3
      packages/nocodb/src/controllers/sync.controller.ts
  101. Some files were not shown because too many files have changed in this diff Show More

2
SECURITY.md

@ -3,5 +3,5 @@
### Reporting a Vulnerability
Please report (suspected) security vulnerabilities to security@nocodb.com
- You will receive a response from us within 3 working days.
- You will receive a response from us within 7 working days.
- If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.

4
charts/nocodb/templates/deployment.yaml

@ -33,9 +33,11 @@ spec:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- if .Values.storage.enabled }}
volumeMounts:
- name: {{ include "nocodb.fullname" . }}
mountPath: /usr/app/data
{{- end }}
envFrom:
- configMapRef:
name: {{ include "nocodb.fullname" . }}
@ -67,7 +69,9 @@ spec:
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.storage.enabled }}
volumes:
- name: {{ include "nocodb.fullname" . }}
persistentVolumeClaim:
claimName: {{ include "nocodb.fullname" . }}
{{- end }}

2
charts/nocodb/templates/pvc.yaml

@ -1,3 +1,4 @@
{{ if .Values.storage.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
@ -12,3 +13,4 @@ spec:
accessModes:
{{- default (toYaml .Values.storage.accessModes) "- ReadWriteMany" | nindent 4 }}
volumeMode: Filesystem
{{ end }}

4
charts/nocodb/values.yaml

@ -86,6 +86,10 @@ extraSecretEnvs:
NC_DB: "mysql2://mysql:3306?u=nocodb&p=secretPass&d=nocodb"
storage:
# If disabled, another persistent storage should be configured for attachments to work.
# We recommend setting NC_S3_BUCKET_NAME and other NC_S3* environment variables.
# Refer documentation for more details.
enabled: true
size: 3Gi
storageClassName: ""

9
packages/nc-gui/assets/nc-icons/owner.svg

@ -1,9 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_18_1042" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
<rect width="16" height="16" fill="#D9D9D9"/>
<mask id="mask0_18_1022" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
<rect width="16" height="16" fill="currentColor"/>
</mask>
<g mask="url(#mask0_18_1042)">
<path d="M6.66665 8.00002C5.93331 8.00002 5.30554 7.73891 4.78331 7.21669C4.26109 6.69447 3.99998 6.06669 3.99998 5.33335C3.99998 4.60002 4.26109 3.97224 4.78331 3.45002C5.30554 2.9278 5.93331 2.66669 6.66665 2.66669C7.39998 2.66669 8.02776 2.9278 8.54998 3.45002C9.0722 3.97224 9.33331 4.60002 9.33331 5.33335C9.33331 6.06669 9.0722 6.69447 8.54998 7.21669C8.02776 7.73891 7.39998 8.00002 6.66665 8.00002ZM1.33331 12V11.4667C1.33331 11.1 1.42776 10.7556 1.61665 10.4334C1.80554 10.1111 2.06665 9.86669 2.39998 9.70002C2.96665 9.41113 3.60554 9.16669 4.31665 8.96669C4.71274 8.85529 4.93609 8.67502 5.79165 8.67502H6.02498C6.09165 8.67502 6.15831 8.68613 6.22498 8.70835C5.99998 9.00002 5.33331 9.66669 4.99998 10C4.99998 10 4.8376 10.2057 4.54165 10.3C3.91387 10.5 3.39998 10.7 2.99998 10.9C2.89998 10.9556 2.81942 11.0334 2.75831 11.1334C2.6972 11.2334 2.66665 11.3445 2.66665 11.4667V12H4.99998C5.33331 12.3334 5.33331 12.3334 5.66665 12.6917C5.90596 12.949 6.19998 13.1334 6.33331 13.3334H2.66665C2.29998 13.3334 1.98609 13.2028 1.72498 12.9417C1.46387 12.6806 1.33331 12.3667 1.33331 12ZM6.66665 6.66669C7.03331 6.66669 7.3472 6.53613 7.60831 6.27502C7.86942 6.01391 7.99998 5.70002 7.99998 5.33335C7.99998 4.96669 7.86942 4.6528 7.60831 4.39169C7.3472 4.13058 7.03331 4.00002 6.66665 4.00002C6.29998 4.00002 5.98609 4.13058 5.72498 4.39169C5.46387 4.6528 5.33331 4.96669 5.33331 5.33335C5.33331 5.70002 5.46387 6.01391 5.72498 6.27502C5.98609 6.53613 6.29998 6.66669 6.66665 6.66669Z" fill="currentColor" stroke="none"/>
<path d="M9.99998 12.6667C10.6555 12.6667 11.2639 12.5167 11.825 12.2167C12.3861 11.9167 12.8333 11.5111 13.1666 11C12.8333 10.4889 12.3861 10.0833 11.825 9.78333C11.2639 9.48333 10.6555 9.33333 9.99998 9.33333C9.34442 9.33333 8.73609 9.48333 8.17498 9.78333C7.61387 10.0833 7.16665 10.4889 6.83331 11C7.16665 11.5111 7.61387 11.9167 8.17498 12.2167C8.73609 12.5167 9.34442 12.6667 9.99998 12.6667ZM9.99998 14C8.93331 14 7.98054 13.7194 7.14165 13.1583C6.30276 12.5972 5.69998 11.8778 5.33331 11C5.69998 10.1222 6.30276 9.40278 7.14165 8.84167C7.98054 8.28056 8.93331 8 9.99998 8C11.0666 8 12.0194 8.28056 12.8583 8.84167C13.6972 9.40278 14.3 10.1222 14.6666 11C14.3 11.8778 13.6972 12.5972 12.8583 13.1583C12.0194 13.7194 11.0666 14 9.99998 14ZM9.99998 12C9.7222 12 9.48609 11.9028 9.29165 11.7083C9.0972 11.5139 8.99998 11.2778 8.99998 11C8.99998 10.7222 9.0972 10.4861 9.29165 10.2917C9.48609 10.0972 9.7222 10 9.99998 10C10.2778 10 10.5139 10.0972 10.7083 10.2917C10.9028 10.4861 11 10.7222 11 11C11 11.2778 10.9028 11.5139 10.7083 11.7083C10.5139 11.9028 10.2778 12 9.99998 12Z" fill="currentColor" stroke="none"/>
<g mask="url(#mask0_18_1022)">
<path d="M7.99999 9.20002C7.33332 9.20002 6.76665 8.96668 6.29999 8.50002C5.83332 8.03335 5.59999 7.46668 5.59999 6.80002C5.59999 6.13335 5.83332 5.56668 6.29999 5.10002C6.76665 4.63335 7.33332 4.40002 7.99999 4.40002C8.66665 4.40002 9.23332 4.63335 9.69999 5.10002C10.1667 5.56668 10.4 6.13335 10.4 6.80002C10.4 7.46668 10.1667 8.03335 9.69999 8.50002C9.23332 8.96668 8.66665 9.20002 7.99999 9.20002ZM7.99999 8.00002C8.33332 8.00002 8.61665 7.88335 8.84999 7.65002C9.08332 7.41668 9.19999 7.13335 9.19999 6.80002C9.19999 6.46668 9.08332 6.18335 8.84999 5.95002C8.61665 5.71668 8.33332 5.60002 7.99999 5.60002C7.66665 5.60002 7.38332 5.71668 7.14999 5.95002C6.91665 6.18335 6.79999 6.46668 6.79999 6.80002C6.79999 7.13335 6.91665 7.41668 7.14999 7.65002C7.38332 7.88335 7.66665 8.00002 7.99999 8.00002ZM7.99999 2.88335L3.99999 4.41668V7.41668C3.99999 8.00557 4.08332 8.57779 4.24999 9.13335C4.41665 9.6889 4.64443 10.2056 4.93332 10.6833C5.4111 10.4611 5.90832 10.2917 6.42499 10.175C6.94165 10.0583 7.46665 10 7.99999 10C8.53332 10 9.05832 10.0583 9.57499 10.175C10.0917 10.2917 10.5889 10.4611 11.0667 10.6833C11.3555 10.2056 11.5833 9.6889 11.75 9.13335C11.9167 8.57779 12 8.00557 12 7.41668V4.41668L7.99999 2.88335ZM7.99999 11.2C7.59999 11.2 7.20554 11.2389 6.81665 11.3167C6.42777 11.3945 6.04999 11.5111 5.68332 11.6667C6.00554 12.0111 6.3611 12.3111 6.74999 12.5667C7.13888 12.8222 7.55554 13.0167 7.99999 13.15C8.44443 13.0167 8.8611 12.8222 9.24999 12.5667C9.63888 12.3111 9.99443 12.0111 10.3167 11.6667C9.94999 11.5111 9.57221 11.3945 9.18332 11.3167C8.79443 11.2389 8.39999 11.2 7.99999 11.2ZM7.99999 14.3333C7.93332 14.3333 7.86665 14.3306 7.79999 14.325C7.73332 14.3195 7.67221 14.3056 7.61665 14.2833C6.1611 13.8056 4.99443 12.9222 4.11665 11.6333C3.23888 10.3445 2.79999 8.93891 2.79999 7.41668V4.41668C2.79999 4.16113 2.86943 3.93335 3.00832 3.73335C3.14721 3.53335 3.33332 3.38891 3.56665 3.30002L7.56665 1.76668C7.7111 1.71113 7.85554 1.68335 7.99999 1.68335C8.14443 1.68335 8.28888 1.71113 8.43332 1.76668L12.4333 3.30002C12.6667 3.38891 12.8528 3.53335 12.9917 3.73335C13.1305 3.93335 13.2 4.16113 13.2 4.41668V7.41668C13.2 8.93891 12.7611 10.3445 11.8833 11.6333C11.0055 12.9222 9.83888 13.8056 8.38332 14.2833C8.32777 14.3056 8.26665 14.3195 8.19999 14.325C8.13332 14.3306 8.06665 14.3333 7.99999 14.3333Z" fill="currentColor" stroke="none"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

12
packages/nc-gui/components/account/Profile.vue

@ -63,10 +63,10 @@ const onValidate = async (_: any, valid: boolean) => {
<template>
<div class="flex flex-col items-center">
<div class="flex flex-col w-150">
<div class="flex font-bold text-xl">{{ $t('labels.profile') }}</div>
<div class="flex font-bold text-xl" data-rec="true">{{ $t('labels.profile') }}</div>
<div class="mt-5 flex flex-col border-1 rounded-2xl border-gray-200 p-6 gap-y-2">
<div class="flex font-medium text-base">{{ $t('labels.accountDetails') }}</div>
<div class="flex text-gray-500">{{ $t('labels.controlAppearance') }}</div>
<div class="flex font-medium text-base" data-rec="true">{{ $t('labels.accountDetails') }}</div>
<div class="flex text-gray-500" data-rec="true">{{ $t('labels.controlAppearance') }}</div>
<div class="flex flex-row mt-4">
<div class="flex h-20 mt-1.5">
<GeneralUserIcon size="xlarge" :email="user?.email" />
@ -81,7 +81,7 @@ const onValidate = async (_: any, valid: boolean) => {
@finish="onSubmit"
@validate="onValidate"
>
<div class="text-gray-800 mb-1.5">{{ $t('general.name') }}</div>
<div class="text-gray-800 mb-1.5" data-rec="true">{{ $t('general.name') }}</div>
<a-form-item name="title" :rules="formRules.title">
<a-input
v-model:value="form.title"
@ -90,7 +90,7 @@ const onValidate = async (_: any, valid: boolean) => {
data-testid="nc-account-settings-rename-input"
/>
</a-form-item>
<div class="text-gray-800 mb-1.5">{{ $t('labels.accountEmailID') }}</div>
<div class="text-gray-800 mb-1.5" data-rec="true">{{ $t('labels.accountEmailID') }}</div>
<a-input
v-model:value="email"
class="w-full !rounded-md !py-1.5"
@ -98,7 +98,7 @@ const onValidate = async (_: any, valid: boolean) => {
disabled
data-testid="nc-account-settings-email-input"
/>
<div class="flex flex-row w-full justify-end mt-8">
<div class="flex flex-row w-full justify-end mt-8" data-rec="true">
<NcButton
type="primary"
html-type="submit"

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

@ -72,14 +72,19 @@ const resetError = () => {
>
<Transition name="layout">
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center">
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center" data-rec="true">
<MaterialSymbolsWarning />
{{ error }}
</div>
</div>
</Transition>
<a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword">
<a-form-item
:label="$t('placeholder.password.current')"
data-rec="true"
name="currentPassword"
:rules="formRules.currentPassword"
>
<a-input-password
v-model:value="form.currentPassword"
data-testid="nc-user-settings-form__current-password"
@ -90,7 +95,7 @@ const resetError = () => {
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.new')" name="password" :rules="formRules.password">
<a-form-item :label="$t('placeholder.password.new')" data-rec="true" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
data-testid="nc-user-settings-form__new-password"
@ -101,7 +106,12 @@ const resetError = () => {
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.confirm')" name="passwordRepeat" :rules="formRules.passwordRepeat">
<a-form-item
:label="$t('placeholder.password.confirm')"
data-rec="true"
name="passwordRepeat"
:rules="formRules.passwordRepeat"
>
<a-input-password
v-model:value="form.passwordRepeat"
data-testid="nc-user-settings-form__new-password-repeat"
@ -120,7 +130,7 @@ const resetError = () => {
type="primary"
html-type="submit"
>
<div class="flex justify-center items-center gap-2">
<div class="flex justify-center items-center gap-2" data-rec="true">
<component :is="iconMap.passwordChange" />
{{ $t('activity.changePwd') }}
</div>

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

@ -42,7 +42,9 @@ loadSettings()
@change="saveSettings"
/>
</a-form-item>
<span data-rec="true">
{{ $t('labels.inviteOnlySignup') }}
</span>
</div>
</div>
</template>

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

@ -160,10 +160,10 @@ const handleCancel = () => {
</script>
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<div class="max-w-[810px] mx-auto p-4" data-testid="nc-token-list">
<div class="py-2 flex gap-4 items-center justify-between">
<h6 class="text-2xl my-4 text-left font-bold">{{ $t('title.apiTokens') }}</h6>
<div class="h-full pt-2">
<div class="max-w-202 mx-auto px-4 h-full" data-testid="nc-token-list">
<div class="py-2 flex gap-4 items-baseline justify-between">
<h6 class="text-2xl text-left font-bold" data-rec="true">{{ $t('title.apiTokens') }}</h6>
<NcTooltip :disabled="!(isEeUI && tokens.length)">
<template #title>{{ $t('labels.tokenLimit') }}</template>
<NcButton
@ -175,28 +175,39 @@ const handleCancel = () => {
tooltip="bottom"
@click="showNewTokenModal = true"
>
<span class="hidden md:block">
<span class="hidden md:block" data-rec="true">
{{ $t('title.addNewToken') }}
</span>
<span class="flex items-center justify-center md:hidden">
<span class="flex items-center justify-center md:hidden" data-rec="true">
<component :is="iconMap.plus" />
</span>
</NcButton>
</NcTooltip>
</div>
<span>{{ $t('msg.apiTokenCreate') }}</span>
<div class="w-full mt-5 rounded-md h-136 overflow-y-scroll">
<div>
<div class="flex w-full pl-5 bg-gray-50 border-1">
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9">{{ $t('title.tokenName') }}</span>
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9 text-start">{{ $t('title.creator') }}</span>
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-3/9 text-start">{{ $t('labels.token') }}</span>
<span class="py-3.5 pl-19 text-gray-500 font-medium text-3.5 w-2/9 text-start">{{ $t('labels.actions') }}</span>
<span data-rec="true">{{ $t('msg.apiTokenCreate') }}</span>
<div class="mt-5 h-[calc(100%-13rem)]">
<div class="h-full w-full !overflow-hidden rounded-md">
<div class="flex w-full pl-5 bg-gray-50 border-1 rounded-t-md">
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9" data-rec="true">{{ $t('title.tokenName') }}</span>
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9 text-start" data-rec="true">{{
$t('title.creator')
}}</span>
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-3/9 text-start" data-rec="true">{{
$t('labels.token')
}}</span>
<span class="py-3.5 pl-19 text-gray-500 font-medium text-3.5 w-2/9 text-start" data-rec="true">{{
$t('labels.actions')
}}</span>
</div>
<main>
<div class="nc-scrollbar-md !overflow-y-auto flex flex-col h-[calc(100%-5rem)]">
<div v-if="showNewTokenModal">
<div class="flex gap-5 px-3 py-3.5 text-gray-500 font-medium text-3.5 w-full nc-token-generate">
<div class="flex flex-col w-full">
<div
class="flex gap-5 px-3 py-2.5 text-gray-500 font-medium text-3.5 w-full nc-token-generate border-b-1 border-l-1 border-r-1"
:class="{
'rounded-b-md': !tokens.length,
}"
>
<div class="flex w-full">
<a-input
:ref="selectInputOnMount"
v-model:value="selectedTokenData.description"
@ -207,7 +218,9 @@ const handleCancel = () => {
data-testid="nc-token-input"
@press-enter="generateToken"
/>
<span v-if="!isValidTokenName" class="text-red-500 text-xs font-light mt-1.5 ml-1">{{ errorMessage }} </span>
<span v-if="!isValidTokenName" class="text-red-500 text-xs font-light mt-1.5 ml-1" data-rec="true"
>{{ errorMessage }}
</span>
</div>
<div class="flex gap-2 justify-start">
<NcButton v-if="!isLoading" type="secondary" size="small" @click="handleCancel">
@ -224,17 +237,19 @@ const handleCancel = () => {
</NcButton>
</div>
</div>
<NcDivider />
</div>
<div v-if="!tokens.length" class="h-118 justify-center flex items-center">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('title.noLabels')" />
<div
v-if="!tokens.length && !showNewTokenModal"
class="border-l-1 border-r-1 border-b-1 rounded-b-md justify-center flex items-center"
>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noToken')" />
</div>
<div
v-for="el of tokens"
:key="el.id"
data-testid="nc-token-list"
class="flex border-1 pl-5 py-3 justify-between token"
class="flex pl-5 py-3 justify-between token items-center border-l-1 border-r-1 border-b-1"
>
<span class="text-black font-bold text-3.5 text-start w-2/9">
<GeneralTruncateText placement="top" length="20">
@ -250,39 +265,42 @@ const handleCancel = () => {
<GeneralTruncateText v-if="el.token === selectedToken.id && selectedToken.isShow" placement="top" length="29">
{{ el.token }}
</GeneralTruncateText>
<span v-else>**************************************</span>
<span v-else>************************************</span>
</span>
<!-- ACTIONS -->
<span class="text-gray-500 font-medium text-3.5 w-2/9">
<div class="flex justify-end items-center gap-3 pr-5">
<div class="flex justify-end items-center gap-3 pr-5 text-gray-500 font-medium text-3.5 w-2/9">
<NcTooltip placement="top">
<template #title>{{ $t('labels.showOrHide') }}</template>
<component
:is="iconMap.eye"
class="nc-toggle-token-visibility hover::cursor-pointer"
class="nc-toggle-token-visibility hover::cursor-pointer w-h-4 mb-[1.8px]"
@click="hideOrShowToken(el.token as string)"
/>
</NcTooltip>
<NcTooltip placement="top" class="h-4">
<template #title>{{ $t('general.copy') }}</template>
<component :is="iconMap.copy" class="hover::cursor-pointer" @click="copyToken(el.token)" />
<component
:is="iconMap.copy"
class="hover::cursor-pointer w-4 h-4 text-gray-600 mt-0.25"
@click="copyToken(el.token)"
/>
</NcTooltip>
<NcTooltip placement="top" class="mb-0.5">
<template #title>{{ $t('general.delete') }}</template>
<component
:is="iconMap.delete"
data-testid="nc-token-row-action-icon"
class="nc-delete-icon hover::cursor-pointer"
class="nc-delete-icon hover::cursor-pointer w-4 h-4"
@click="triggerDeleteModal(el.token as string, el.description as string)"
/>
</NcTooltip>
</div>
</span>
</div>
</main>
</div>
</div>
<div v-if="pagination.total > 10" class="flex items-center justify-center mt-15">
</div>
<div v-if="pagination.total > 10" class="flex items-center justify-center mt-5">
<a-pagination
v-model:current="currentPage"
:total="pagination.total"
@ -313,3 +331,9 @@ const handleCancel = () => {
</GeneralDeleteModal>
</div>
</template>
<style>
.token:last-child {
@apply border-b-1 rounded-b-md;
}
</style>

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

@ -153,9 +153,9 @@ const openDeleteModal = (user: UserType) => {
</script>
<template>
<div data-testid="nc-super-user-list">
<div class="max-w-195 mx-auto">
<div class="text-2xl my-4 text-left font-weight-bold">{{ $t('title.userManagement') }}</div>
<div data-testid="nc-super-user-list" class="h-full">
<div class="max-w-195 mx-auto h-full">
<div class="text-2xl text-left font-weight-bold mb-4" data-rec="true">{{ $t('title.userManagement') }}</div>
<div class="py-2 flex gap-4 items-center justify-between">
<a-input v-model:value="searchText" class="!max-w-90 !rounded-md" placeholder="Search members" @change="loadUsers()">
<template #prefix>
@ -165,42 +165,50 @@ const openDeleteModal = (user: UserType) => {
<div class="flex gap-3 items-center justify-center">
<component :is="iconMap.reload" class="cursor-pointer" @click="loadUsers(currentPage, currentLimit)" />
<NcButton data-testid="nc-super-user-invite" size="small" type="primary" @click="openInviteModal">
<div class="flex items-center gap-1">
<div class="flex items-center gap-1" data-rec="true">
<component :is="iconMap.plus" />
{{ $t('activity.inviteUser') }}
</div>
</NcButton>
</div>
</div>
<div class="w-full mt-5 border-1 rounded-md h-[613px] max-w-250">
<div class="flex w-full bg-gray-50 border-b-1">
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-1/3 text-start pl-10">{{ $t('labels.email') }}</span>
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-1/3 text-start pl-20">{{ $t('objects.role') }}</span>
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-1/3 text-end pl-42">{{ $t('labels.action') }}</span>
<div class="w-full rounded-md max-w-250 h-[calc(100%-12rem)] rounded-md overflow-hidden mt-5">
<div class="flex w-full bg-gray-50 border-1 rounded-t-md">
<div class="py-3.5 text-gray-500 font-medium text-3.5 w-2/3 text-start pl-6" data-rec="true">
{{ $t('labels.email') }}
</div>
<div class="py-3.5 text-gray-500 font-medium text-3.5 w-1/3 text-start" data-rec="true">{{ $t('objects.role') }}</div>
<div class="flex py-3.5 text-gray-500 font-medium text-3.5 w-28 justify-end mr-4" data-rec="true">
{{ $t('labels.action') }}
</div>
</div>
<div v-if="isLoading" class="flex items-center justify-center text-center h-[513px]">
<GeneralLoader size="xlarge" />
</div>
<!-- if users are empty -->
<div v-else-if="!users.length" class="flex items-center justify-center text-center h-128.25">
<div v-else-if="!users.length" class="flex items-center justify-center text-center h-full">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</div>
<section v-else class="tbody">
<section v-else class="tbody h-[calc(100%-4rem)] nc-scrollbar-md border-t-0 !overflow-auto">
<div
v-for="el of users"
:key="el.id"
data-testid="nc-token-list"
class="flex py-3 justify-around px-5 border-b-1"
class="user flex py-3 justify-around px-1 border-b-1 border-l-1 border-r-1"
:class="{
'py-4': el.roles?.includes('super'),
}"
>
<span class="text-3.5 text-start w-1/3 pl-5">
<div class="text-3.5 text-start w-2/3 pl-5 flex items-center">
<GeneralTruncateText length="29">
{{ el.email }}
</span>
<span class="text-3.5 text-start w-1/3 pl-18">
<div v-if="el?.roles?.includes('super')" class="font-weight-bold">{{ $t('labels.superAdmin') }}</div>
<a-select
</GeneralTruncateText>
</div>
<div class="text-3.5 text-start w-1/3">
<div v-if="el?.roles?.includes('super')" class="font-weight-bold" data-rec="true">
{{ $t('labels.superAdmin') }}
</div>
<NcSelect
v-else
v-model:value="el.roles"
class="w-55 nc-user-roles"
@ -212,8 +220,8 @@ const openDeleteModal = (user: UserType) => {
:value="OrgUserRoles.CREATOR"
:label="$t(`objects.roleType.orgLevelCreator`)"
>
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
<div data-rec="true">{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal" data-rec="true">
{{ $t('msg.info.roles.orgCreator') }}
</span>
</a-select-option>
@ -223,14 +231,14 @@ const openDeleteModal = (user: UserType) => {
:value="OrgUserRoles.VIEWER"
:label="$t(`objects.roleType.orgLevelViewer`)"
>
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
<div data-rec="true">{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal" data-rec="true">
{{ $t('msg.info.roles.orgViewer') }}
</span>
</a-select-option>
</a-select>
</span>
<span class="w-1/3 pl-43">
</NcSelect>
</div>
<span class="w-26 flex items-center justify-end mr-4">
<div
class="flex items-center gap-2"
:class="{
@ -238,28 +246,31 @@ const openDeleteModal = (user: UserType) => {
}"
>
<NcDropdown :trigger="['click']">
<NcButton size="xsmall" type="ghost">
<MdiDotsVertical
class="border-1 !text-gray-600 h-5.5 w-5.5 rounded outline-0 p-0.5 nc-workspace-menu transform transition-transform !text-gray-400 cursor-pointer hover:(!text-gray-500 bg-gray-100)"
class="text-gray-600 h-5.5 w-5.5 rounded outline-0 p-0.5 nc-workspace-menu transform transition-transform !text-gray-400 cursor-pointer hover:(!text-gray-500 bg-gray-100)"
/>
</NcButton>
<template #overlay>
<NcMenu>
<template v-if="!el.roles?.includes('super')">
<!-- Resend invite Email -->
<NcMenuItem @click="resendInvite(el)">
<component :is="iconMap.email" class="flex text-gray-500" />
<div>{{ $t('activity.resendInvite') }}</div>
<component :is="iconMap.email" class="flex text-gray-600" />
<div data-rec="true">{{ $t('activity.resendInvite') }}</div>
</NcMenuItem>
<NcMenuItem @click="copyInviteUrl(el)">
<component :is="iconMap.copy" class="flex text-gray-500" />
<div>{{ $t('activity.copyInviteURL') }}</div>
<component :is="iconMap.copy" class="flex text-gray-600" />
<div data-rec="true">{{ $t('activity.copyInviteURL') }}</div>
</NcMenuItem>
<NcMenuItem @click="copyPasswordResetUrl(el)">
<component :is="iconMap.copy" class="flex text-gray-500" />
<component :is="iconMap.copy" class="flex text-gray-600" />
<div>{{ $t('activity.copyPasswordResetURL') }}</div>
</NcMenuItem>
</template>
<NcDivider v-if="!el.roles?.includes('super')" />
<NcMenuItem class="!text-red-500 !hover:bg-red-50" @click="openDeleteModal(el)">
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="openDeleteModal(el)">
<MaterialSymbolsDeleteOutlineRounded />
{{ $t('general.remove') }} {{ $t('objects.user') }}
</NcMenuItem>
@ -271,7 +282,7 @@ const openDeleteModal = (user: UserType) => {
</div>
</section>
</div>
<div v-if="pagination.total > 10" class="flex items-center justify-center mt-7">
<div v-if="pagination.total > 10" class="flex items-center justify-center mt-4">
<a-pagination
v-model:current="currentPage"
:total="pagination.total"
@ -285,7 +296,7 @@ const openDeleteModal = (user: UserType) => {
<div class="flex flex-row items-center py-2.25 px-2.5 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralIcon icon="account" class="nc-view-icon"></GeneralIcon>
<div
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-1.75"
class="text-ellipsis overflow-hidden select-none w-full pl-1.75"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ deleteModalInfo?.email }}
@ -301,7 +312,7 @@ const openDeleteModal = (user: UserType) => {
</template>
<style scoped>
.tbody div:nth-child(10) {
border-bottom: none;
.user:last-child {
@apply rounded-b-md;
}
</style>

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

@ -80,7 +80,7 @@ const copyUrl = async () => {
await copy(inviteUrl.value)
// Copied shareable source url to clipboard!
message.success(t('msg.success.shareableURLCopied'))
message.success(t('msg.toast.inviteUrlCopy'))
} catch (e: any) {
message.error(e.message)
}
@ -110,7 +110,7 @@ const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
>
<div class="flex flex-col">
<div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full">
<a-typography-title class="select-none" :level="4"> {{ $t('activity.inviteUser') }}</a-typography-title>
<a-typography-title class="select-none" :level="4" data-rec="true"> {{ $t('activity.inviteUser') }}</a-typography-title>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')">
<template #icon>
@ -124,13 +124,13 @@ const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
<div class="flex flex-col mt-1 pb-5">
<div class="flex flex-row items-center pl-1.5 pb-1 h-[1.1rem]">
<component :is="iconMap.account" />
<div class="text-xs ml-0.5 mt-0.5">{{ $t('activity.copyInviteToken') }}</div>
<div class="text-xs ml-0.5 mt-0.5" data-rec="true">{{ $t('activity.copyInviteURL') }}</div>
</div>
<a-alert class="!mt-2" type="success" show-icon>
<template #message>
<div class="flex flex-row justify-between items-center py-1">
<div class="flex pl-2 text-green-700 text-xs">
<div class="flex pl-2 text-green-700 text-xs" data-rec="true">
{{ inviteUrl }}
</div>
@ -143,7 +143,7 @@ const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</template>
</a-alert>
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2">
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2" data-rec="true">
{{ $t('msg.info.userInviteNoSMTP') }}
{{ usersData.invitationToken && usersData.emails }}
</div>
@ -153,7 +153,7 @@ const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
<div class="flex flex-row justify-center items-center space-x-0.5">
<MaterialSymbolsSendOutline class="flex mx-auto text-gray-600 h-[0.8rem]" />
<div class="text-xs text-gray-600">{{ $t('activity.inviteMore') }}</div>
<div class="text-xs text-gray-600" data-rec="true">{{ $t('activity.inviteMore') }}</div>
</div>
</a-button>
</div>
@ -177,7 +177,7 @@ const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
name="emails"
:rules="[{ required: true, message: $t('msg.plsInputEmail') }]"
>
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('datatype.Email') }}:</div>
<div class="ml-1 mb-1 text-xs text-gray-500" data-rec="true">{{ $t('datatype.Email') }}:</div>
<a-input
:ref="emailInput"
@ -192,15 +192,14 @@ const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
<div class="flex flex-col w-2/4">
<a-form-item name="role" :rules="[{ required: true, message: $t('msg.roleRequired') }]">
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('labels.selectUserRole') }}</div>
<a-select v-model:value="usersData.role" class="nc-user-roles" dropdown-class-name="nc-dropdown-user-role">
<a-select-option
class="nc-role-option"
:value="OrgUserRoles.CREATOR"
:label="$t(`objects.roleType.orgLevelCreator`)"
>
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
<div data-rec="true">{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal" data-rec="true">
{{ $t('msg.info.roles.orgCreator') }}
</span>
</a-select-option>
@ -210,8 +209,8 @@ const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
:value="OrgUserRoles.VIEWER"
:label="$t(`objects.roleType.orgLevelViewer`)"
>
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
<div data-rec="true">{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal" data-rec="true">
{{ $t('msg.info.roles.orgViewer') }}
</span>
</a-select-option>
@ -224,7 +223,7 @@ const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
<a-button type="primary" class="!rounded-md" html-type="submit">
<div class="flex flex-row justify-center items-center space-x-1.5">
<MaterialSymbolsSendOutline class="flex h-[0.8rem]" />
<div>{{ $t('activity.invite') }}</div>
<div data-rec="true">{{ $t('activity.invite') }}</div>
</div>
</a-button>
</div>

6
packages/nc-gui/components/api-client/Headers.vue

@ -66,10 +66,10 @@ const filterOption = (input: string, option: Option) => option.value.toUpperCase
<tr>
<th></th>
<th>
<div class="text-left font-normal ml-2">{{ $t('labels.headerName') }}</div>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('labels.headerName') }}</div>
</th>
<th>
<div class="text-left font-normal ml-2">{{ $t('placeholder.value') }}</div>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('placeholder.value') }}</div>
</th>
<th class="w-8"></th>
</tr>
@ -124,7 +124,7 @@ const filterOption = (input: string, option: Option) => option.value.toUpperCase
<td :colspan="12" class="">
<NcButton size="small" type="secondary" @click="addHeaderRow">
<div class="flex flex-row items-center gap-x-1">
<div>{{ $t('labels.addHeader') }}</div>
<div data-rec="true">{{ $t('labels.addHeader') }}</div>
<component :is="iconMap.plus" class="flex mx-auto" />
</div>
</NcButton>

6
packages/nc-gui/components/api-client/Params.vue

@ -24,11 +24,11 @@ const deleteParamRow = (i: number) => {
<thead class="h-8">
<tr>
<th>
<div class="text-left font-normal ml-2">{{ $t('title.parameterName') }}</div>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('title.parameterName') }}</div>
</th>
<th>
<div class="text-left font-normal ml-2">{{ $t('placeholder.value') }}</div>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('placeholder.value') }}</div>
</th>
<th class="w-8">
@ -69,7 +69,7 @@ const deleteParamRow = (i: number) => {
<td :colspan="12" class="">
<NcButton size="small" type="secondary" @click="addParamRow">
<div class="flex flex-row items-center gap-x-1">
<div>{{ $t('activity.addParameter') }}</div>
<div data-rec="true">{{ $t('activity.addParameter') }}</div>
<component :is="iconMap.plus" class="flex mx-auto" />
</div>
</NcButton>

11
packages/nc-gui/components/cell/attachment/index.vue

@ -8,6 +8,7 @@ import {
DropZoneRef,
IsExpandedFormOpenInj,
IsGalleryInj,
IsKanbanInj,
RowHeightInj,
iconMap,
inject,
@ -46,6 +47,8 @@ const isLockedMode = inject(IsLockedInj, ref(false))
const isGallery = inject(IsGalleryInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const { isSharedForm } = useSmartsheetStoreOrThrow()!
@ -185,6 +188,7 @@ const onExpand = () => {
v-model="isOverDropZone"
inline
:target="currentCellRef"
data-rec="true"
class="nc-attachment-cell-dropzone text-white text-lg ring ring-accent ring-opacity-100 bg-gray-700/75 flex items-center justify-center gap-2 backdrop-blur-xl"
>
<MaterialSymbolsFileCopyOutline class="text-accent" />
@ -202,7 +206,9 @@ const onExpand = () => {
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<NcTooltip placement="bottom">
<template #title>{{ $t('activity.attachmentDrop') }} </template>
<template #title
><span data-rec="true">{{ $t('activity.attachmentDrop') }} </span></template
>
<div v-if="active || !visibleItems.length || (isForm && visibleItems.length)" class="flex items-center gap-1">
<MaterialSymbolsAttachFile
@ -210,6 +216,7 @@ const onExpand = () => {
/>
<div
v-if="!visibleItems.length"
data-rec="true"
class="group-hover:text-primary text-gray-500 dark:text-gray-200 dark:group-hover:!text-white text-xs"
>
{{ $t('activity.addFiles') }}
@ -223,7 +230,7 @@ const onExpand = () => {
<template v-if="visibleItems.length">
<div
ref="sortableRef"
:class="{ 'justify-center': !isExpandedForm && !isGallery }"
:class="{ 'justify-center': !isExpandedForm && !isGallery && !isKanban }"
class="flex cursor-pointer w-full items-center flex-wrap gap-2 py-1.5 scrollbar-thin-dull overflow-hidden mt-0 items-start"
:style="{
maxHeight: isForm || isExpandedForm ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,

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

@ -657,7 +657,7 @@ const projectDelete = () => {
class="source-context flex items-center gap-2 text-gray-800 nc-sidebar-node-title"
@contextmenu="setMenuContext('source', source)"
>
<GeneralBaseLogo :source-type="source.type" class="min-w-4 !xs:(min-w-4.25 w-4.25 text-sm)" />
<GeneralBaseLogo class="min-w-4 !xs:(min-w-4.25 w-4.25 text-sm)" />
{{ $t('general.default') }}
</div>
<div
@ -665,7 +665,7 @@ const projectDelete = () => {
class="source-context flex flex-grow items-center gap-1.75 text-gray-800 min-w-1/20 max-w-full"
@contextmenu="setMenuContext('source', source)"
>
<GeneralBaseLogo :source-type="source.type" class="min-w-4 !xs:(min-w-4.25 w-4.25 text-sm)" />
<GeneralBaseLogo class="min-w-4 !xs:(min-w-4.25 w-4.25 text-sm)" />
<div
:data-testid="`nc-sidebar-base-${source.alias}`"
class="nc-sidebar-node-title flex capitalize text-ellipsis overflow-hidden select-none"

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

@ -315,7 +315,7 @@ const isEditBaseModalOpen = computed({
<div class="ds-table-col ds-table-enabled cursor-pointer" @dblclick="forceAwaken">{{ $t('general.visibility') }}</div>
<div class="ds-table-col ds-table-name">{{ $t('general.name') }}</div>
<div class="ds-table-col ds-table-type">{{ $t('general.type') }}</div>
<div class="ds-table-col ds-table-actions pl-2">{{ $t('labels.actions') }}</div>
<div class="ds-table-col ds-table-actions -ml-13">{{ $t('labels.actions') }}</div>
<div class="ds-table-col ds-table-crud"></div>
</div>
</div>
@ -351,58 +351,82 @@ const isEditBaseModalOpen = computed({
<div class="ds-table-col ds-table-actions">
<div class="flex items-center gap-2">
<a-button
v-if="!sources[0].is_meta && !sources[0].is_local"
<NcTooltip v-if="!sources[0].is_meta && !sources[0].is_local">
<template #title>
{{ $t('tooltip.metaSync') }}
</template>
<NcButton
class="nc-action-btn cursor-pointer outline-0"
type="text"
data-testid="nc-data-sources-view-meta-sync"
size="small"
@click="baseAction(sources[0].id, DataSourcesSubTab.Metadata)"
>
<div class="flex items-center gap-2 text-gray-600">
<GeneralIcon icon="sync" class="group-hover:text-accent" />
{{ $t('tooltip.metaSync') }}
</div>
</a-button>
<a-button
</NcButton>
</NcTooltip>
<NcTooltip>
<template #title>
{{ $t('title.relations') }}
</template>
<NcButton
size="small"
class="nc-action-btn cursor-pointer outline-0"
type="text"
data-testid="nc-data-sources-view-erd"
@click="baseAction(sources[0].id, DataSourcesSubTab.ERD)"
>
<div class="flex items-center gap-2 text-gray-600">
<GeneralIcon icon="erd" class="group-hover:text-accent" />
{{ $t('title.relations') }}
</div>
</a-button>
<a-button
</NcButton>
</NcTooltip>
<NcTooltip>
<template #title>
{{ $t('labels.uiAcl') }}
</template>
<NcButton
size="small"
class="nc-action-btn cursor-pointer outline-0"
type="text"
data-testid="nc-data-sources-view-ui-acl"
@click="baseAction(sources[0].id, DataSourcesSubTab.UIAcl)"
>
<div class="flex items-center gap-2 text-gray-600">
<GeneralIcon icon="acl" class="group-hover:text-accent" />
{{ $t('labels.uiAcl') }}
</div>
</a-button>
<a-button
</NcButton>
</NcTooltip>
<NcTooltip>
<template #title>
{{ $t('title.audit') }}
</template>
<NcButton
size="small"
class="nc-action-btn cursor-pointer outline-0"
type="text"
data-testid="nc-data-sources-view-audit"
@click="baseAction(sources[0].id, DataSourcesSubTab.Audit)"
>
<div class="flex items-center gap-2 text-gray-600">
<GeneralIcon icon="book" class="group-hover:text-accent" />
{{ $t('title.audit') }}
</div>
</a-button>
</NcButton>
</NcTooltip>
</div>
</div>
<div class="ds-table-col ds-table-crud">
<a-button
<NcButton
v-if="!sources[0].is_meta && !sources[0].is_local"
size="small"
class="nc-action-btn cursor-pointer outline-0 !w-8 !px-1 !rounded-lg"
type="text"
@click="baseAction(sources[0].id, DataSourcesSubTab.Edit)"
>
<GeneralIcon icon="edit" class="text-gray-600" />
</a-button>
</NcButton>
</div>
</div>
</template>
@ -419,12 +443,12 @@ const isEditBaseModalOpen = computed({
</a-tooltip>
</div>
</div>
<div class="ds-table-col ds-table-name font-medium">
<div class="ds-table-col ds-table-name font-medium w-full">
<GeneralIcon v-if="sources.length > 2" icon="dragVertical" small class="ds-table-handle" />
<div v-if="source.is_meta || source.is_local">-</div>
<div v-else class="flex items-center gap-1">
<span v-else class="truncate">
{{ source.is_meta || source.is_local ? $t('general.base') : source.alias }}
</div>
</span>
</div>
<div class="ds-table-col ds-table-type">
@ -437,56 +461,86 @@ const isEditBaseModalOpen = computed({
<div class="ds-table-col ds-table-actions">
<div class="flex items-center gap-2">
<a-button
<NcTooltip>
<template #title>
{{ $t('title.relations') }}
</template>
<NcButton
size="small"
class="nc-action-btn cursor-pointer outline-0"
type="text"
data-testid="nc-data-sources-view-erd"
@click="baseAction(source.id, DataSourcesSubTab.ERD)"
>
<div class="flex items-center gap-2 text-gray-600">
<GeneralIcon icon="erd" class="group-hover:text-accent" />
{{ $t('title.relations') }}
</div>
</a-button>
<a-button
</NcButton>
</NcTooltip>
<NcTooltip>
<template #title>
{{ $t('labels.uiAcl') }}
</template>
<NcButton
size="small"
type="text"
class="nc-action-btn cursor-pointer outline-0"
data-testid="nc-data-sources-view-ui-acl"
@click="baseAction(source.id, DataSourcesSubTab.UIAcl)"
>
<div class="flex items-center gap-2 text-gray-600">
<GeneralIcon icon="acl" class="group-hover:text-accent" />
{{ $t('labels.uiAcl') }}
</div>
</a-button>
<a-button
</NcButton>
</NcTooltip>
<NcTooltip>
<template #title>
{{ $t('tooltip.metaSync') }}
</template>
<NcButton
v-if="!source.is_meta && !source.is_local"
size="small"
type="text"
data-testid="nc-data-sources-view-meta-sync"
class="nc-action-btn cursor-pointer outline-0"
@click="baseAction(source.id, DataSourcesSubTab.Metadata)"
>
<div class="flex items-center gap-2 text-gray-600">
<GeneralIcon icon="sync" class="group-hover:text-accent" />
{{ $t('tooltip.metaSync') }}
</div>
</a-button>
</NcButton>
</NcTooltip>
</div>
</div>
<div class="ds-table-col ds-table-crud justify-end gap-x-1">
<a-button
<NcTooltip>
<template #title>
{{ $t('general.edit') }}
</template>
<NcButton
v-if="!source.is_meta && !source.is_local"
class="nc-action-btn cursor-pointer outline-0 !w-8 !px-1 !rounded-lg mt-0.5"
size="small"
class="nc-action-btn cursor-pointer outline-0 !w-8 !px-1 !rounded-lg"
type="text"
@click="baseAction(source.id, DataSourcesSubTab.Edit)"
>
<GeneralIcon icon="edit" class="text-gray-600 -mt-0.5" />
</a-button>
<a-button
<GeneralIcon icon="edit" class="text-gray-600" />
</NcButton>
</NcTooltip>
<NcTooltip>
<template #title>
{{ $t('general.delete') }}
</template>
<NcButton
v-if="!source.is_meta && !source.is_local"
class="nc-action-btn cursor-pointer outline-0 !w-8 !px-1 !rounded-lg mt-0.5"
size="small"
class="nc-action-btn cursor-pointer outline-0 !w-8 !px-1 !rounded-lg"
type="text"
@click="openDeleteBase(source)"
>
<GeneralIcon icon="delete" class="text-red-500 -mt-0.5" />
</a-button>
<GeneralIcon icon="delete" class="text-red-500" />
</NcButton>
</NcTooltip>
</div>
</div>
</template>
@ -550,7 +604,7 @@ const isEditBaseModalOpen = computed({
<style>
.ds-table-head {
@apply flex items-center border-0 text-gray-400;
@apply flex items-center border-0 text-gray-500;
}
.ds-table-body {
@ -570,15 +624,15 @@ const isEditBaseModalOpen = computed({
}
.ds-table-name {
@apply col-span-6 items-center capitalize;
@apply col-span-9 items-center capitalize;
}
.ds-table-type {
@apply col-span-3 items-center;
@apply col-span-2 items-center;
}
.ds-table-actions {
@apply col-span-7;
@apply col-span-5 flex w-full justify-end;
}
.ds-table-crud {

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

@ -52,7 +52,7 @@ const onDelete = async () => {
<GeneralDeleteModal v-model:visible="visible" :entity-name="$t('objects.project')" :on-delete="onDelete">
<template #entity-preview>
<div v-if="base" class="flex flex-row items-center py-2 px-2.25 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralProjectIcon :type="base.type" class="nc-view-icon px-1.5"></GeneralProjectIcon>
<GeneralProjectIcon :type="base.type" class="nc-view-icon px-1.5 w-10" />
<div
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-1.75"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"

36
packages/nc-gui/components/dlg/share-and-collaborate/ShareBase.vue

@ -1,5 +1,15 @@
<script setup lang="ts">
import { extractSdkResponseErrorMsg, message, onMounted, storeToRefs, useBase, useDashboard, useNuxtApp } from '#imports'
import {
extractSdkResponseErrorMsg,
message,
onMounted,
storeToRefs,
useBase,
useDashboard,
useGlobal,
useNuxtApp,
useWorkspace,
} from '#imports'
interface ShareBase {
uuid?: string
@ -20,9 +30,23 @@ const sharedBase = ref<null | ShareBase>(null)
const { base } = storeToRefs(useBase())
const url = computed(() =>
sharedBase.value && sharedBase.value.uuid ? `${dashboardUrl.value}#/base/${sharedBase.value.uuid}` : '',
)
const { getBaseUrl, appInfo } = useGlobal()
const workspaceStore = useWorkspace()
const url = computed(() => {
if (!sharedBase.value || !sharedBase.value.uuid) return ''
// get base url for workspace
const baseUrl = getBaseUrl(workspaceStore.activeWorkspaceId)
let dashboardUrl1 = dashboardUrl.value
if (baseUrl) {
dashboardUrl1 = `${baseUrl}${appInfo.value?.dashboardPath}`
}
return encodeURI(`${dashboardUrl1}#/base/${sharedBase.value.uuid}`)
})
const loadBase = async () => {
try {
@ -50,6 +74,8 @@ const createShareBase = async (role = ShareBaseRole.Viewer) => {
sharedBase.value = res ?? {}
sharedBase.value!.role = role
base.value.uuid = res.uuid
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
@ -63,6 +89,8 @@ const disableSharedBase = async () => {
await $api.base.sharedBaseDisable(base.value.id)
sharedBase.value = null
base.value.uuid = undefined
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}

13
packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue

@ -6,6 +6,7 @@ import { useMetas } from '#imports'
const { view: _view, $api } = useSmartsheetStoreOrThrow()
const { $e } = useNuxtApp()
const { getBaseUrl, appInfo } = useGlobal()
const { dashboardUrl } = useDashboard()
@ -13,6 +14,8 @@ const viewStore = useViewsStore()
const { metas } = useMetas()
const workspaceStore = useWorkspace()
const isUpdating = ref({
public: false,
password: false,
@ -162,7 +165,15 @@ function sharedViewUrl() {
viewType = 'view'
}
return encodeURI(`${dashboardUrl?.value}#/nc/${viewType}/${activeView.value.uuid}`)
// get base url for workspace
const baseUrl = getBaseUrl(workspaceStore.activeWorkspaceId)
let dashboardUrl1 = dashboardUrl.value
if (baseUrl) {
dashboardUrl1 = `${baseUrl}${appInfo.value?.dashboardPath}`
}
return encodeURI(`${dashboardUrl1}#/nc/${viewType}/${activeView.value.uuid}`)
}
const toggleViewShare = async () => {

2
packages/nc-gui/components/erd/TableNode.vue

@ -32,7 +32,7 @@ const hasColumns = computed(() => data.pkAndFkColumns.length || data.nonPkColumn
const nonPkColumns = computed(() =>
data.nonPkColumns
// Removed MM system column from the table node
.filter((col) => !(col.system && isLinksOrLTAR(col) && /nc_.*___nc_m2m_.*/.test(col.title!))),
.filter((col) => !(col.system && isLinksOrLTAR(col) && /.*_nc_m2m_.*/.test(col.title!))),
)
watch(

4
packages/nc-gui/components/general/BaseLogo.vue

@ -6,10 +6,10 @@ import SimpleIconsMicrosoftsqlserver from '~icons/simple-icons/microsoftsqlserve
import LogosSnowflakeIcon from '~icons/logos/snowflake-icon'
import MdiDatabaseOutline from '~icons/mdi/database-outline'
const { baseType } = defineProps<{ baseType?: string }>()
const { sourceType } = defineProps<{ sourceType?: string }>()
const baseIcon = computed(() => {
switch (baseType) {
switch (sourceType) {
case ClientType.MYSQL:
return LogosMysqlIcon
case ClientType.PG:

7
packages/nc-gui/components/general/ShareProject.vue

@ -8,7 +8,7 @@ interface Props {
const { disabled, isViewToolbar } = defineProps<Props>()
const { isMobileMode } = useGlobal()
const { isMobileMode, getMainUrl } = useGlobal()
const { visibility, showShareModal } = storeToRefs(useShare())
@ -38,7 +38,10 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
})
const copySharedBase = async () => {
navigateTo(`/copy-shared-base?base=${route.params.baseId}`)
const baseUrl = getMainUrl()
navigateTo(`${baseUrl || ''}#/copy-shared-base?base=${route.params.baseId}`, {
external: true,
})
}
</script>

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

@ -70,8 +70,11 @@ const pagesList = computed(() => {
>
<GeneralIcon icon="arrowLeft" />
</NcButton>
<div class="text-gray-600">
<div v-if="!isMobileMode" class="text-gray-600">
<a-select v-model:value="current" class="!mr-[2px]" virtual>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-500 nc-select-expand-btn" />
</template>
<a-select-option v-for="p of pagesList" :key="`p-${p}`" @click="changePage({ set: p })">{{ p }}</a-select-option>
</a-select>
<span class="mx-1"> {{ mode !== 'full' ? '/' : 'of' }} </span>
@ -104,3 +107,13 @@ const pagesList = computed(() => {
</NcButton>
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-select-selector) {
@apply !border-gray-200 !rounded-lg;
}
:deep(.ant-select-dropdown) {
@apply !rounded-lg !overflow-hidden;
}
</style>

5
packages/nc-gui/components/nc/Tooltip.vue

@ -12,6 +12,7 @@ interface Props {
disabled?: boolean
placement?: TooltipPlacement | undefined
hideOnClick?: boolean
overlayClassName?: string
}
const props = defineProps<Props>()
@ -36,6 +37,8 @@ const attrs = useAttrs()
const isKeyPressed = ref(false)
const overlayClassName = computed(() => props.overlayClassName)
onKeyStroke(
(e) => e.key === modifierKey.value,
(e) => {
@ -100,7 +103,7 @@ const onClick = () => {
<template>
<a-tooltip
v-model:visible="showTooltip"
:overlay-class-name="`nc-tooltip ${showTooltip ? 'visible' : 'hidden'}`"
:overlay-class-name="`nc-tooltip ${showTooltip ? 'visible' : 'hidden'} ${overlayClassName}`"
:overlay-style="tooltipStyle"
arrow-point-at-center
:trigger="[]"

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

@ -147,8 +147,8 @@ const onCreateBaseClick = () => {
</div>
<div class="w-1/5 text-gray-600" data-testid="proj-view-list__item-type">
<div v-if="table.source_id === defaultBase?.id" class="ml-0.75">-</div>
<div v-else>
<GeneralBaseLogo :source-type="sources.get(table.source_id!)?.type" class="w-4 mr-1" />
<div v-else class="capitalize flex flex-row items-center gap-x-0.5">
<GeneralBaseLogo class="w-4 mr-1" />
{{ sources.get(table.source_id!)?.alias }}
</div>
</div>

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

@ -3,7 +3,7 @@ import { useTitle } from '@vueuse/core'
import NcLayout from '~icons/nc-icons/layout'
const { openedProject } = storeToRefs(useBases())
const { activeTables } = storeToRefs(useTablesStore())
const { activeWorkspace } = storeToRefs(useWorkspace())
const { activeWorkspace, workspaceUserCount } = storeToRefs(useWorkspace())
const { navigateToProjectPage } = useBase()
@ -113,6 +113,16 @@ watch(
<div class="tab-title" data-testid="proj-view-tab__access-settings">
<GeneralIcon icon="users" class="!h-3.5 !w-3.5" />
<div>{{ $t('labels.members') }}</div>
<div
v-if="workspaceUserCount"
class="tab-info"
:class="{
'bg-primary-selected': projectPageTab === 'data-source',
'bg-gray-50': projectPageTab !== 'data-source',
}"
>
{{ workspaceUserCount }}
</div>
</div>
</template>
<ProjectAccessSettings />
@ -130,7 +140,7 @@ watch(
'bg-gray-50': projectPageTab !== 'data-source',
}"
>
{{ base.sources.length - 1 }}
{{ base.sources.length }}
</div>
</div>
</template>

137
packages/nc-gui/components/smartsheet/Kanban.vue

@ -1,6 +1,6 @@
<script lang="ts" setup>
import Draggable from 'vuedraggable'
import { UITypes, ViewTypes, isVirtualCol } from 'nocodb-sdk'
import { ViewTypes, isVirtualCol } from 'nocodb-sdk'
import {
ActiveViewInj,
FieldsInj,
@ -16,7 +16,6 @@ import {
iconMap,
inject,
isImage,
isLTAR,
onBeforeUnmount,
provide,
useAttachment,
@ -56,6 +55,8 @@ const expandedFormRow = ref<RowType>()
const expandedFormRowState = ref<Record<string, any>>()
provide(RowHeightInj, ref(1 as const))
const deleteStackVModel = ref(false)
const stackToBeDeleted = ref('')
@ -107,7 +108,9 @@ const hasEditPermission = computed(() => isUIAllowed('dataEdit'))
const fields = inject(FieldsInj, ref([]))
const fieldsWithoutCover = computed(() => fields.value.filter((f) => f.id !== kanbanMetaData.value?.fk_cover_image_col_id))
const fieldsWithoutDisplay = computed(() => fields.value.filter((f) => !isPrimary(f)))
const displayField = computed(() => meta.value?.columns?.find((c) => c.pv && fields.value.includes(c)) ?? null)
const coverImageColumn: any = computed(() =>
meta.value?.columnsById
@ -209,6 +212,8 @@ const expandedFormOnRowIdDlg = computed({
})
const expandFormClick = async (e: MouseEvent, row: RowType) => {
const target = e.target as HTMLElement
if (target.closest('.arrow') || target.closest('.slick-dots')) return
if (e.target as HTMLElement) {
expandForm(row)
}
@ -380,6 +385,11 @@ watch(
immediate: true,
},
)
const getRowId = (row: RowType) => {
const pk = extractPkFromRow(row.row, meta.value!.columns!)
return pk ? `row-${pk}` : ''
}
</script>
<template>
@ -425,7 +435,7 @@ watch(
<!-- Non Collapsed Stacks -->
<a-card
v-if="!stack.collapsed"
:key="stack.id"
:key="`${stack.id}-${stackIdx}`"
class="mx-4 !bg-gray-100 flex flex-col w-80 h-full !rounded-xl overflow-y-hidden"
:class="{
'not-draggable': stack.title === null || isLocked || isPublic || !hasEditPermission,
@ -516,27 +526,27 @@ watch(
@end="(e) => e.target.classList.remove('grabbing')"
@change="onMove($event, stack.title)"
>
<template #item="{ element: record }">
<template #item="{ element: record, index }">
<div class="nc-kanban-item py-2 pl-3 pr-2">
<LazySmartsheetRow :row="record">
<a-card
hoverable
:key="`${getRowId(record)}-${index}`"
class="!rounded-lg h-full border-gray-200 border-1 group overflow-hidden break-all max-w-[450px] shadow-sm hover:shadow-md cursor-pointer"
:body-style="{ padding: '0px' }"
:data-stack="stack.title"
class="!rounded-xl h-full overflow-hidden break-all max-w-[450px]"
:data-testid="`nc-gallery-card-${record.row.id}`"
:class="{
'not-draggable': isLocked || !hasEditPermission || isPublic,
'!cursor-default': isLocked || !hasEditPermission || isPublic,
}"
:body-style="{ padding: '10px' }"
@click="expandFormClick($event, record)"
@contextmenu="showContextMenu($event, record)"
>
<template v-if="kanbanMetaData?.fk_cover_image_col_id" #cover>
<template v-if="!reloadAttachments && attachments(record).length">
<a-carousel
v-if="!reloadAttachments && attachments(record).length"
autoplay
class="gallery-carousel"
arrows
:key="attachments(record).reduce((acc, curr) => acc + curr?.path, '')"
class="gallery-carousel !border-b-1 !border-gray-200"
>
<template #customPaging>
<a>
@ -547,64 +557,92 @@ watch(
</template>
<template #prevArrow>
<div style="z-index: 1"></div>
<div class="z-10 arrow">
<MdiChevronLeft
class="text-gray-700 w-6 h-6 absolute left-1.5 bottom-[-90px] !opacity-0 !group-hover:opacity-100 !bg-white border-1 border-gray-200 rounded-md transition"
/>
</div>
</template>
<template #nextArrow>
<div style="z-index: 1"></div>
<div class="z-10 arrow">
<MdiChevronRight
class="text-gray-700 w-6 h-6 absolute right-1.5 bottom-[-90px] !opacity-0 !group-hover:opacity-100 !bg-white border-1 border-gray-200 rounded-md transition"
/>
</div>
</template>
<template v-for="(attachment, index) in attachments(record)">
<template v-for="attachment in attachments(record)">
<LazyCellAttachmentImage
v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)"
:key="`carousel-${record.row.id}-${index}`"
:key="attachment.path"
class="h-52 object-cover"
:srcs="getPossibleAttachmentSrc(attachment)"
/>
</template>
</a-carousel>
<component :is="iconMap.imagePlaceholder" v-else class="w-full h-48 my-4 text-cool-gray-200" />
</template>
<div
v-for="col in fieldsWithoutCover"
:key="`record-${record.row.id}-${col.id}`"
class="flex flex-col rounded-lg w-full"
v-else
class="h-52 w-full !flex flex-row !border-b-1 !border-gray-200 items-center justify-center"
>
<div v-if="!isRowEmpty(record, col) || isLTAR(col.uidt, col.colOptions)">
<!-- Smartsheet Header (Virtual) Cell -->
<div class="flex flex-row w-full justify-start pt-2">
<div class="w-full text-gray-400">
<img class="object-contain w-[48px] h-[48px]" src="~assets/icons/FileIconImageBox.png" />
</div>
</template>
<h2 v-if="displayField" class="text-base mt-3 mx-3 font-bold">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(displayField)"
v-model="record.row[displayField.title]"
class="!text-gray-600"
:column="displayField"
:row="record"
/>
<LazySmartsheetCell
v-else
v-model="record.row[displayField.title]"
class="!text-gray-600"
:column="displayField"
:edit-enabled="false"
:read-only="true"
/>
</h2>
<div v-for="col in fieldsWithoutDisplay" :key="`record-${record.row.id}-${col.id}`">
<div class="flex flex-col first:mt-3 ml-2 !pr-3.5 !mb-[0.75rem] rounded-lg w-full">
<div class="flex flex-row w-full justify-start scale-75">
<div class="w-full pb-1 text-gray-300">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)"
:column="col"
:hide-menu="true"
:hide-icon="true"
/>
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="true" />
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="true" :hide-icon="true" />
</div>
</div>
<!-- Smartsheet (Virtual) Cell -->
<div
class="flex flex-row w-full items-center justify-start"
:class="{ '!ml-[-12px] pl-3': col.uidt === UITypes.SingleSelect }"
v-if="!isRowEmpty(record, col)"
class="flex flex-row w-full text-gray-700 px-1 mt-[-0.25rem] items-center justify-start"
>
<LazySmartsheetVirtualCell
v-if="col.title && isVirtualCol(col)"
v-if="isVirtualCol(col)"
v-model="record.row[col.title]"
class="text-sm pt-1 pl-5"
:column="col"
:row="record"
/>
<LazySmartsheetCell
v-else-if="col.title"
v-else
v-model="record.row[col.title]"
class="text-sm pt-1 pl-7.25"
:column="col"
:edit-enabled="false"
:read-only="true"
/>
</div>
<div v-else class="flex flex-row w-full h-[1.375rem] pl-1 items-center justify-start">-</div>
</div>
</div>
</a-card>
@ -755,4 +793,37 @@ watch(
transform-origin: left top 0px;
transition: left 0.2s ease-in-out 0s;
}
:deep(.slick-dots li button) {
@apply !bg-black;
}
.ant-carousel.gallery-carousel :deep(.slick-dots) {
@apply !w-auto absolute h-auto bottom-[-15px] absolute h-auto;
height: auto;
}
.ant-carousel.gallery-carousel :deep(.slick-dots li div > div) {
@apply rounded-full border-0 cursor-pointer block opacity-100 p-0 outline-none transition-all duration-500 text-transparent h-2 w-2 bg-[#d9d9d9];
font-size: 0;
}
.ant-carousel.gallery-carousel :deep(.slick-dots li.slick-active div > div) {
@apply bg-brand-500 opacity-100;
}
.ant-carousel.gallery-carousel :deep(.slick-dots li) {
@apply !w-auto;
}
.ant-carousel.gallery-carousel :deep(.slick-prev) {
@apply left-0;
}
.ant-carousel.gallery-carousel :deep(.slick-next) {
@apply right-0;
}
:deep(.slick-slide) {
@apply !pointer-events-none;
}
</style>

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

@ -51,8 +51,6 @@ const isForm = inject(IsFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
emit('navigate', dir)

3
packages/nc-gui/components/smartsheet/column/LookupOptions.vue

@ -86,7 +86,7 @@ const cellIcon = (column: ColumnType) =>
<template>
<div class="p-6 w-full flex flex-col border-2 mb-2 mt-4">
<div class="w-full flex flex-row space-x-2">
<div v-if="refTables.length" class="w-full flex flex-row space-x-2">
<a-form-item class="flex w-1/2 pb-2" :label="$t('labels.links')" v-bind="validateInfos.fk_relation_column_id">
<a-select
v-model:value="vModel.fk_relation_column_id"
@ -121,6 +121,7 @@ const cellIcon = (column: ColumnType) =>
</a-select>
</a-form-item>
</div>
<div v-else>{{ $t('msg.linkColumnClearNotSupportedYet') }}</div>
</div>
</template>

4
packages/nc-gui/components/smartsheet/column/SelectOptions.vue

@ -3,7 +3,7 @@ import Draggable from 'vuedraggable'
import { UITypes } from 'nocodb-sdk'
import InfiniteLoading from 'v3-infinite-loading'
import { IsKanbanInj, enumColor, iconMap, onMounted, useColumnCreateStoreOrThrow, useVModel, watch } from '#imports'
import { IsKanbanInj, enumColor, iconMap, onMounted, useColumnCreateStoreOrThrow, useVModel } from '#imports'
interface Option {
color: string
@ -21,7 +21,7 @@ const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit)
const { setAdditionalValidations, validateInfos, isMysql, isPg } = useColumnCreateStoreOrThrow()
const { setAdditionalValidations, validateInfos } = useColumnCreateStoreOrThrow()
// const { base } = storeToRefs(useBase())

9
packages/nc-gui/components/smartsheet/details/Erd.vue

@ -23,6 +23,13 @@ const indicator = h(LoadingOutlined, {
<a-spin size="large" :indicator="indicator" />
</div>
<LazyErdView v-else :table="activeTable" :source-id="activeTable?.source_id" :show-all-columns="false" />
<Suspense v-else>
<LazyErdView :table="activeTable" :source-id="activeTable?.source_id" :show-all-columns="false" />
<template #fallback>
<div class="h-full w-full flex flex-col justify-center items-center mt-28">
<a-spin size="large" :indicator="indicator" />
</div>
</template>
</Suspense>
</div>
</template>

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

@ -43,6 +43,8 @@ const moveOps = ref<moveOp[]>([])
const visibilityOps = ref<fieldsVisibilityOps[]>([])
const fieldsListWrapperDomRef = ref<HTMLElement>()
const {
fields: viewFields,
toggleFieldVisibility,
@ -192,6 +194,13 @@ const addField = (field?: TableExplorerColumn, before = false) => {
setFieldMoveHook(field, before)
}
changeField({})
// Scroll to the bottom of the list for new field add
setTimeout(() => {
if (!field && !before && fieldsListWrapperDomRef.value) {
fieldsListWrapperDomRef.value.scrollTop = fieldsListWrapperDomRef.value.scrollHeight
}
}, 100)
}
const displayColumn = computed(() => {
@ -626,7 +635,7 @@ onMounted(async () => {
</div>
</div>
<div class="flex flex-row rounded-lg border-1 border-gray-200">
<div class="nc-scrollbar-md !overflow-auto w-full flex-grow-1 nc-fields-height">
<div ref="fieldsListWrapperDomRef" class="nc-scrollbar-md !overflow-auto w-full flex-grow-1 nc-fields-height">
<Draggable v-model="fields" item-key="id" @change="onMove($event)">
<template #item="{ element: field }">
<div

17
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { TableType, ViewType } from 'nocodb-sdk'
import { isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { ViewTypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue'
import MdiChevronDown from '~icons/mdi/chevron-down'
@ -47,6 +47,8 @@ const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev'])
const { activeView } = storeToRefs(useViewsStore())
const key = ref(0)
const wrapper = ref()
@ -86,6 +88,8 @@ const { isUIAllowed } = useRoles()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
// override cell click hook to avoid unexpected behavior at form fields
provide(CellClickHookInj, undefined)
@ -184,7 +188,16 @@ const save = async () => {
await syncLTARRefs(data)
reloadTrigger?.trigger()
} else {
await _save()
let kanbanClbk
if (activeView.value?.type === ViewTypes.KANBAN) {
kanbanClbk = (row: any, isNewRow: boolean) => {
addOrEditStackRow(row, isNewRow)
}
}
await _save(undefined, undefined, {
kanbanClbk,
})
reloadTrigger?.trigger()
}
isUnsavedFormExist.value = false

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

@ -1,4 +1,5 @@
<script lang="ts" setup>
import axios from 'axios'
import { nextTick } from '@vue/runtime-core'
import type { ColumnReqType, ColumnType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, ViewTypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
@ -122,7 +123,7 @@ const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj, createEventHook())
useViewColumns(view, meta, () => reloadViewDataHook.trigger())
const { isViewColumnsLoading } = useViewColumns(view, meta, () => reloadViewDataHook.trigger())
const { isMobileMode } = useGlobal()
@ -383,11 +384,19 @@ const gridWrapperClass = computed<string>(() => {
return classes.join(' ')
})
const dummyDataForLoading = computed(() => {
const dummyColumnDataForLoading = computed(() => {
let length = fields.value?.length ?? 40
length = length || 40
return Array.from({ length: length + 1 }).map(() => ({}))
})
const dummyRowDataForLoading = computed(() => {
return Array.from({ length: 40 }).map(() => ({}))
})
const showSkeleton = computed(() => disableSkeleton !== true && (isViewDataLoading.value || isPaginationLoading.value))
const showSkeleton = computed(
() => disableSkeleton !== true && (isViewDataLoading.value || isPaginationLoading.value || isViewColumnsLoading.value),
)
// #Grid
@ -884,7 +893,7 @@ const saveOrUpdateRecords = async (args: { metaValue?: TableType; viewMetaValue?
}
// #Grid Resize
const { updateGridViewColumn, resizingColWidth, resizingCol } = useGridViewColumnOrThrow()
const { updateGridViewColumn, gridViewCols, resizingColOldWith } = useGridViewColumnOrThrow()
const onresize = (colID: string | undefined, event: any) => {
if (!colID) return
@ -893,8 +902,12 @@ const onresize = (colID: string | undefined, event: any) => {
const onXcResizing = (cn: string | undefined, event: any) => {
if (!cn) return
resizingCol.value = cn
resizingColWidth.value = event.detail
gridViewCols.value[cn].width = `${event.detail}`
}
const onXcStartResizing = (cn: string | undefined, event: any) => {
if (!cn) return
resizingColOldWith.value = event.detail
}
const loadColumn = (title: string, tp: string, colOptions?: any) => {
@ -1059,6 +1072,22 @@ onBeforeUnmount(async () => {
reloadViewDataHook?.on(reloadViewDataHandler)
openNewRecordFormHook?.on(openNewRecordHandler)
// TODO: Use CSS animations
const showLoaderAfterDelay = ref(false)
watch([isViewDataLoading, showSkeleton, isPaginationLoading], () => {
if (!isViewDataLoading.value && !showSkeleton.value && !isPaginationLoading.value) {
showLoaderAfterDelay.value = false
return
}
showLoaderAfterDelay.value = false
setTimeout(() => {
showLoaderAfterDelay.value = true
}, 500)
})
// #Watchers
// reset context menu target on hide
@ -1099,8 +1128,10 @@ watch(
try {
await loadData?.()
} catch (e) {
if (!axios.isCancel(e)) {
console.log(e)
message.error(t('msg.errorLoadingData'))
}
} finally {
isViewDataLoading.value = false
}
@ -1163,7 +1194,7 @@ const loaderText = computed(() => {
<div class="flex flex-col" :class="`${headerOnly !== true ? 'h-full w-full' : ''}`">
<div ref="gridWrapper" class="nc-grid-wrapper min-h-0 flex-1 relative" :class="gridWrapperClass">
<div
v-show="showSkeleton && !isPaginationLoading"
v-show="showSkeleton && !isPaginationLoading && showLoaderAfterDelay"
class="flex items-center justify-center absolute l-0 t-0 w-full h-full z-10 pb-10"
>
<div class="flex flex-col justify-center gap-2">
@ -1187,11 +1218,11 @@ const loaderText = computed(() => {
@contextmenu="showContextMenu"
>
<thead v-show="hideHeader !== true" ref="tableHeadEl">
<tr v-if="showSkeleton && isPaginationLoading">
<tr v-if="isViewColumnsLoading">
<td
v-for="(col, colIndex) of dummyDataForLoading"
v-for="(col, colIndex) of dummyColumnDataForLoading"
:key="colIndex"
class="!bg-gray-50 h-full"
class="!bg-gray-50 h-full border-b-1 border-r-1"
:class="{ 'min-w-50': colIndex !== 0, 'min-w-21.25': colIndex === 0 }"
>
<a-skeleton
@ -1203,7 +1234,7 @@ const loaderText = computed(() => {
/>
</td>
</tr>
<tr v-show="!isPaginationLoading" class="nc-grid-header">
<tr v-show="!isViewColumnsLoading" class="nc-grid-header">
<th class="w-[85px] min-w-[85px]" data-testid="grid-id-column" @dblclick="() => {}">
<div class="w-full h-full flex pl-5 pr-1 items-center" data-testid="nc-check-all">
<template v-if="!readOnly">
@ -1228,9 +1259,14 @@ const loaderText = computed(() => {
v-xc-ver-resize
:data-col="col.id"
:data-title="col.title"
:style="{
'min-width': gridViewCols[col.id]?.width || '200px',
'max-width': gridViewCols[col.id]?.width || '200px',
'width': gridViewCols[col.id]?.width || '200px',
}"
@xcstartresizing="onXcStartResizing(col.id, $event)"
@xcresize="onresize(col.id, $event)"
@xcresizing="onXcResizing(col.title, $event)"
@xcresized="resizingCol = null"
@xcresizing="onXcResizing(col.id, $event)"
>
<div class="w-full h-full flex items-center">
<LazySmartsheetHeaderVirtualCell
@ -1352,10 +1388,11 @@ const loaderText = computed(() => {
</thead>
<tbody v-if="headerOnly !== true" ref="tableBodyEl">
<template v-if="showSkeleton">
<tr v-for="(row, rowIndex) of dummyDataForLoading" :key="rowIndex">
<tr v-for="(row, rowIndex) of dummyRowDataForLoading" :key="rowIndex">
<td
v-for="(col, colIndex) of dummyDataForLoading"
v-for="(col, colIndex) of dummyColumnDataForLoading"
:key="colIndex"
class="border-b-1 border-r-1"
:class="{ 'min-w-50': colIndex !== 0, 'min-w-21.25': colIndex === 0 }"
></td>
</tr>
@ -1456,6 +1493,11 @@ const loaderText = computed(() => {
hasEditPermission &&
isCellSelected(rowIndex, colIndex),
}"
:style="{
'min-width': gridViewCols[columnObj.id]?.width || '200px',
'max-width': gridViewCols[columnObj.id]?.width || '200px',
'width': gridViewCols[columnObj.id]?.width || '200px',
}"
:data-testid="`cell-${columnObj.title}-${rowIndex}`"
:data-key="`data-key-${rowIndex}-${columnObj.id}`"
:data-col="columnObj.id"
@ -1949,4 +1991,11 @@ tbody tr:hover {
.nc-fill-handle:focus {
@apply w-[8px] h-[8px] mt-[-5px] ml-[-5px];
}
:deep(.ant-skeleton-input) {
@apply rounded text-gray-100 !bg-gray-100 !bg-opacity-65;
animation: slow-show-1 5s ease 5s forwards;
}
</style>
<style lang="scss"></style>

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

@ -3,6 +3,7 @@ import {
ActiveViewInj,
IsLockedInj,
computed,
iconMap,
inject,
ref,
useGlobal,
@ -10,7 +11,6 @@ import {
useSmartsheetStoreOrThrow,
useViewFilters,
watch,
iconMap,
} from '#imports'
const isLocked = inject(IsLockedInj, ref(false))

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

@ -346,7 +346,7 @@ useMenuCloseOnEsc(open)
<div class="pt-0.25 w-full bg-gray-50"></div>
</div>
<div class="flex flex-col py-1 nc-scrollbar-md max-h-[47.5vh] pr-5">
<div class="flex flex-col my-1.5 nc-scrollbar-md max-h-[47.5vh] pr-5">
<div class="nc-fields-list">
<div
v-if="!fields?.filter((el) => el.title.toLowerCase().includes(filterQuery.toLowerCase())).length"

7
packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue

@ -1,6 +1,4 @@
<script setup lang="ts">
import { ViewTypes } from 'nocodb-sdk'
const { isMobileMode } = useGlobal()
const { openedViewsTab, activeView } = storeToRefs(useViewsStore())
@ -16,9 +14,8 @@ const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
<div
class="ml-0.25 flex flex-row font-medium items-center border-gray-50 transition-all duration-100"
:class="{
'min-w-36/100 max-w-36/100': !isMobileMode && activeView?.type !== ViewTypes.KANBAN && isLeftSidebarOpen,
'min-w-39/100 max-w-39/100': !isMobileMode && activeView?.type !== ViewTypes.KANBAN && !isLeftSidebarOpen,
'min-w-1/4 max-w-1/4': !isMobileMode && activeView?.type === ViewTypes.KANBAN,
'min-w-36/100 max-w-36/100': !isMobileMode && isLeftSidebarOpen,
'min-w-39/100 max-w-39/100': !isMobileMode && !isLeftSidebarOpen,
'w-2/3 text-base ml-1.5': isMobileMode,
'!max-w-3/4': isSharedBase && !isMobileMode,
}"

1
packages/nc-gui/components/virtual-cell/components/ListChildItems.vue

@ -345,6 +345,7 @@ const linkOrUnLink = (rowRef: Record<string, string>, id: string) => {
new: true,
},
}"
:row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])"
use-meta-fields
/>
</Suspense>

1
packages/nc-gui/components/virtual-cell/components/ListItems.vue

@ -335,6 +335,7 @@ onKeyStroke('Escape', () => {
new: true,
},
}"
:row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])"
:state="newRowState"
use-meta-fields
/>

17
packages/nc-gui/composables/useExpandedFormStore.ts

@ -153,7 +153,16 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
$e('a:row-expand:comment')
}
const save = async (ltarState: Record<string, any> = {}, undo = false) => {
const save = async (
ltarState: Record<string, any> = {},
undo = false,
// TODO: Hack. Remove this when kanban injection store issue is resolved
{
kanbanClbk,
}: {
kanbanClbk?: (row: Row, isNewRow: boolean) => void
} = {},
) => {
let data
try {
const isNewRow = row.value.rowMeta?.new ?? false
@ -266,13 +275,13 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
}
}
if (activeView.value?.type === ViewTypes.KANBAN) {
const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
addOrEditStackRow(row.value, isNewRow)
if (activeView.value?.type === ViewTypes.KANBAN && kanbanClbk) {
kanbanClbk(row.value, isNewRow)
}
changedColumns.value = new Set()
} catch (e: any) {
console.error(e)
message.error(`${t('msg.error.rowUpdateFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
}
$e('a:row-expand:add')

6
packages/nc-gui/composables/useGlobal/actions.ts

@ -146,5 +146,9 @@ export function useGlobalActions(state: State): Actions {
return undefined
}
return { signIn, signOut, refreshToken, loadAppInfo, setIsMobileMode, navigateToProject, getBaseUrl, ncNavigateTo }
const getMainUrl = () => {
return undefined
}
return { signIn, signOut, refreshToken, loadAppInfo, setIsMobileMode, navigateToProject, getBaseUrl, ncNavigateTo, getMainUrl }
}

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

@ -78,6 +78,7 @@ export interface Actions {
viewId?: string
}) => void
getBaseUrl: (workspaceId: string) => string | undefined
getMainUrl: (workspaceId: string) => string | undefined
}
export type ReadonlyState = Readonly<Pick<State, 'token' | 'user'>> & Omit<State, 'token' | 'user'>

32
packages/nc-gui/composables/useGridViewColumn.ts

@ -1,11 +1,9 @@
import type { ColumnType, GridColumnReqType, GridColumnType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { IsPublicInj, computed, inject, ref, useMetas, useNuxtApp, useRoles, useStyleTag, useUndoRedo, watch } from '#imports'
import { IsPublicInj, computed, inject, ref, useMetas, useNuxtApp, useRoles, useUndoRedo, watch } from '#imports'
const [useProvideGridViewColumn, useGridViewColumn] = useInjectionState(
(view: Ref<(ViewType & { columns?: GridColumnType[] }) | undefined>, statePublic = false) => {
const { css, load: loadCss, unload: unloadCss } = useStyleTag('')
const { isUIAllowed } = useRoles()
const { $api } = useNuxtApp()
@ -15,30 +13,11 @@ const [useProvideGridViewColumn, useGridViewColumn] = useInjectionState(
const { addUndo, defineViewScope } = useUndoRedo()
const gridViewCols = ref<Record<string, GridColumnType>>({})
const resizingCol = ref<string | null>('')
const resizingColWidth = ref('200px')
const resizingColOldWith = ref('200px')
const isPublic = inject(IsPublicInj, ref(statePublic))
const columns = computed<ColumnType[]>(() => metas.value?.[view.value?.fk_model_id as string]?.columns || [])
watch(
[gridViewCols, resizingCol, resizingColWidth],
() => {
let style = ''
for (const c of columns?.value || []) {
const val = gridViewCols?.value?.[c?.id as string]?.width || '200px'
if (val && c.title !== resizingCol?.value) {
style += `[data-col="${c.id}"]{min-width:${val};max-width:${val};width: ${val};}`
} else {
style += `[data-col="${c.id}"]{min-width:${resizingColWidth?.value};max-width:${resizingColWidth?.value};width: ${resizingColWidth?.value};}`
}
}
css.value = style
},
{ deep: true, immediate: true },
)
const loadGridViewColumns = async () => {
if (!view.value?.id && !isPublic.value) return
@ -52,8 +31,6 @@ const [useProvideGridViewColumn, useGridViewColumn] = useInjectionState(
}),
{},
)
loadCss()
}
/** when columns changes(create/delete) reload grid columns
@ -70,7 +47,8 @@ const [useProvideGridViewColumn, useGridViewColumn] = useInjectionState(
if (!undo) {
const oldProps = Object.keys(props).reduce<Partial<GridColumnReqType>>((o: any, k) => {
if (gridViewCols.value[id][k as keyof GridColumnType]) {
o[k] = gridViewCols.value[id][k as keyof GridColumnType]
if (k === 'width') o[k] = `${resizingColOldWith.value}px`
else o[k] = gridViewCols.value[id][k as keyof GridColumnType]
}
return o
}, {})
@ -105,7 +83,7 @@ const [useProvideGridViewColumn, useGridViewColumn] = useInjectionState(
}
}
return { loadGridViewColumns, updateGridViewColumn, resizingCol, resizingColWidth, gridViewCols, loadCss, unloadCss }
return { loadGridViewColumns, updateGridViewColumn, gridViewCols, resizingColOldWith }
},
'useGridViewColumn',
)

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

@ -403,6 +403,7 @@
}
},
"labels": {
"noToken": "No Token",
"tokenLimit": "Only one token per user is allowed",
"duplicateAttachment": "File with name {filename} already attached",
"toAddress": "To Address",
@ -698,8 +699,8 @@
"newUser": "New User",
"editUser": "Edit user",
"deleteUser": "Remove user from base",
"resendInvite": "Resend invite E-mail",
"copyInviteURL": "Copy invite URL",
"resendInvite": "Resend Invite E-mail",
"copyInviteURL": "Copy Invite URL",
"copyPasswordResetURL": "Copy password reset URL",
"newRole": "New role",
"reloadRoles": "Reload roles",
@ -903,8 +904,9 @@
"newFormWillBeLoaded": "New form will be loaded after {seconds} seconds",
"optimizedQueryDisabled": "Optimized query is disabled",
"optimizedQueryEnabled": "Optimized query is enabled",
"lookupNonBtWarning": "Lookup column is not supported for non-Belongs to relation",
"invalidTime": "Invalid Time",
"linkColumnClearNotSupportedYet": "Link column clear is not supported yet",
"linkColumnClearNotSupportedYet": "You don't have any supported links for Lookup",
"recordCouldNotBeFound": "Record could not be found",
"invalidPhoneNumber": "Invalid phone number",
"pageSizeChanged": "Page size changed",
@ -1248,7 +1250,7 @@
"deleteProject": "Base deleted successfully",
"authToken": "Auth token copied to clipboard",
"projInfo": "Copied base info to clipboard",
"inviteUrlCopy": "Copied invite URL to clipboard",
"inviteUrlCopy": "Copied Invite URL to clipboard",
"createView": "View created successfully",
"formEmailSMTP": "Please activate SMTP plugin in App store for enabling email notification",
"collabView": "Successfully Switched to collaborative view",

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

@ -125,6 +125,7 @@ type NcProject = BaseType & {
temp_title?: string
edit?: boolean
starred?: boolean
uuid?: string
}
interface UndoRedoAction {

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

@ -1,6 +1,7 @@
import type { Api } from 'nocodb-sdk'
import type { Actions } from '~/composables/useGlobal/types'
import { defineNuxtRouteMiddleware, extractSdkResponseErrorMsg, message, navigateTo, useApi, useGlobal, useRoles } from '#imports'
import { defineNuxtRouteMiddleware, message, navigateTo, useApi, useGlobal, useRoles } from '#imports'
import { extractSdkResponseErrorMsg } from '~/utils'
/**
* Global auth middleware
@ -46,7 +47,9 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
}
/** if user isn't signed in and google auth is enabled, try to check if sign-in data is present */
if (!state.signedIn.value && state.appInfo.value.googleAuthEnabled) await tryGoogleAuth(api, state.signIn)
if (!state.signedIn.value && state.appInfo.value.googleAuthEnabled) {
await tryGoogleAuth(api, state.signIn)
}
/** if public allow all visitors */
if (to.meta.public) return
@ -54,13 +57,18 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
/** if shared base allow without validating */
if (to.params.typeOrId === 'base') return
/** if auth is required or unspecified (same as required) and user is not signed in, redirect to signin page */
/** if auth is required or unspecified (same `as required) and user is not signed in, redirect to signin page */
if ((to.meta.requiresAuth || typeof to.meta.requiresAuth === 'undefined') && !state.signedIn.value) {
/** If this is the first usern navigate to signup page directly */
if (state.appInfo.value.firstUser) {
const query = to.fullPath !== '/' && to.fullPath.match(/^\/(?!\?)/) ? { continueAfterSignIn: to.fullPath } : {}
if (query.continueAfterSignIn) {
localStorage.setItem('continueAfterSignIn', query.continueAfterSignIn)
}
return navigateTo({
path: '/signup',
query: to.fullPath !== '/' && to.fullPath.match(/^\/(?!\?)/) ? { continueAfterSignIn: to.fullPath } : {},
query,
})
}

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

@ -145,7 +145,7 @@ const logout = async () => {
<!-- Sub Tabs -->
<div class="flex flex-col w-full ml-65">
<div class="flex flex-row p-3 items-center">
<div class="flex flex-row p-3 items-center h-14">
<div class="flex-1" />
<LazyGeneralReleaseInfo />
@ -185,12 +185,19 @@ const logout = async () => {
</NcDropdown>
</template>
</div>
<div class="flex flex-col container mx-auto mt-2">
<div
class="flex flex-col container mx-auto"
:style="{
height: 'calc(100vh - 3.5rem)',
}"
>
<div class="mt-2 h-full">
<NuxtPage />
</div>
</div>
</div>
</div>
</div>
</NuxtLayout>
</template>

10
packages/nc-gui/plugins/a.i18n.ts

@ -1,6 +1,6 @@
import { createI18n } from 'vue-i18n'
import { isClient } from '@vueuse/core'
import { LanguageAlias, applyLanguageDirection, defineNuxtPlugin, isRtlLang, nextTick } from '#imports'
import { LanguageAlias, applyLanguageDirection, defineNuxtPlugin, isEeUI, isRtlLang, nextTick } from '#imports'
import type { Language, NocoI18n } from '#imports'
let globalI18n: NocoI18n
@ -43,10 +43,16 @@ export async function loadLocaleMessages(
return nextTick()
}
export default defineNuxtPlugin(async (nuxtApp) => {
const i18nPlugin = defineNuxtPlugin(async (nuxtApp) => {
globalI18n = await createI18nPlugin()
nuxtApp.vueApp.i18n = globalI18n
nuxtApp.vueApp.use(globalI18n)
})
const defaultExport = isEeUI ? defineNuxtPlugin(async () => {}) : i18nPlugin
export { i18nPlugin }
export default defaultExport

10
packages/nc-gui/plugins/api.ts

@ -1,6 +1,12 @@
import { defineNuxtPlugin, useApi } from '#imports'
import { defineNuxtPlugin, isEeUI, useApi } from '#imports'
export default defineNuxtPlugin((nuxtApp) => {
const apiPlugin = defineNuxtPlugin((nuxtApp) => {
/** injects a global api instance */
nuxtApp.provide('api', useApi().api)
})
const defaultExport = isEeUI ? defineNuxtPlugin(async () => {}) : apiPlugin
export { apiPlugin }
export default defaultExport

1
packages/nc-gui/plugins/resizeDirective.ts

@ -36,6 +36,7 @@ export default defineNuxtPlugin((nuxtApp) => {
startWidth = parseInt(document.defaultView?.getComputedStyle(el)?.width || '0', 10)
document.documentElement.addEventListener('mousemove', doDrag, false)
document.documentElement.addEventListener('mouseup', stopDrag, false)
emit('xcstartresizing', startWidth)
}
;(el as any).initDrag = initDrag

10
packages/nc-gui/plugins/state.ts

@ -1,4 +1,4 @@
import { Language, LanguageAlias, defineNuxtPlugin, useApi, useGlobal } from '#imports'
import { Language, LanguageAlias, defineNuxtPlugin, isEeUI, useApi, useGlobal } from '#imports'
import { loadLocaleMessages, setI18nLanguage } from '~/plugins/a.i18n'
/**
@ -13,7 +13,7 @@ import { loadLocaleMessages, setI18nLanguage } from '~/plugins/a.i18n'
* console.log($state.lang.value) // 'en'
* ```
*/
export default defineNuxtPlugin(async () => {
const statePlugin = defineNuxtPlugin(async () => {
const state = useGlobal()
const { api } = useApi({ useGlobalInstance: true })
@ -35,3 +35,9 @@ export default defineNuxtPlugin(async () => {
console.error(e)
}
})
const defaultExport = isEeUI ? defineNuxtPlugin(async () => {}) : statePlugin
export { statePlugin }
export default defaultExport

71
packages/nc-gui/store/views.ts

@ -1,18 +1,35 @@
import type { ViewType } from 'nocodb-sdk'
import type { ViewType, ViewTypes } from 'nocodb-sdk'
import { acceptHMRUpdate, defineStore } from 'pinia'
import type { ViewPageType } from '~/lib'
export const useViewsStore = defineStore('viewsStore', () => {
const { $api } = useNuxtApp()
interface RecentView {
viewName: string
viewId: string | undefined
viewType: ViewTypes
tableID: string
isDefault: boolean
baseName: string
workspaceId: string
baseId: string
}
const router = useRouter()
const recentViews = computed(() => [])
const allRecentViews = ref<any>([])
// Store recent views from all Workspaces
const allRecentViews = ref<RecentView[]>([])
const route = router.currentRoute
const bases = useBases()
const tablesStore = useTablesStore()
const { activeWorkspaceId } = storeToRefs(useWorkspace())
const recentViews = computed<RecentView[]>(() =>
allRecentViews.value.filter((f) => f.workspaceId === activeWorkspaceId.value).splice(0, 10),
)
const viewsByTable = ref<Map<string, ViewType[]>>(new Map())
const views = computed({
get: () => (tablesStore.activeTableId ? viewsByTable.value.get(tablesStore.activeTableId) : []) ?? [],
@ -122,10 +139,28 @@ export const useViewsStore = defineStore('viewsStore', () => {
})
}
const changeView = async (..._args: any) => {}
const removeFromRecentViews = (..._args: any) => {}
const changeView = async ({ viewId, tableId, baseId }: { viewId: string | null; tableId: string; baseId: string }) => {
const routeName = 'index-typeOrId-baseId-index-index-viewId-viewTitle'
await router.push({ name: routeName, params: { viewTitle: viewId || '', viewId: tableId, baseId } })
}
const removeFromRecentViews = ({
viewId,
tableId,
baseId,
}: {
viewId?: string | undefined
tableId: string
baseId?: string
}) => {
if (baseId && !viewId && !tableId) {
allRecentViews.value = allRecentViews.value.filter((f) => f.baseId !== baseId)
} else if (baseId && tableId && !viewId) {
allRecentViews.value = allRecentViews.value.filter((f) => f.baseId !== baseId || f.tableID !== tableId)
} else if (tableId && viewId) {
allRecentViews.value = allRecentViews.value.filter((f) => f.viewId !== viewId || f.tableID !== tableId)
}
}
watch(
() => tablesStore.activeTableId,
async (newId, oldId) => {
@ -232,6 +267,28 @@ export const useViewsStore = defineStore('viewsStore', () => {
isPaginationLoading.value = true
})
watch(activeView, (view) => {
if (!view) return
if (!view.base_id) return
const tableName = tablesStore.baseTables.get(view.base_id)?.find((t) => t.id === view.fk_model_id)?.title
const baseName = bases.basesList.find((p) => p.id === view.base_id)?.title
allRecentViews.value = [
{
viewId: view.id,
baseId: view.base_id as string,
tableID: view.fk_model_id,
isDefault: !!view.is_default,
viewName: view.is_default ? (tableName as string) : view.title,
viewType: view.type,
workspaceId: activeWorkspaceId.value,
baseName: baseName as string,
},
...allRecentViews.value.filter((f) => f.viewId !== view.id || f.tableID !== view.fk_model_id),
]
})
return {
isLockedView,
isViewsLoading,

88
packages/nc-gui/utils/colorsUtils.ts

@ -113,85 +113,85 @@ export const baseThemeColors = [
const designSystem = {
light: [
'#EBF0FF',
'#D6E0FF',
'#ADC2FF',
'#85A3FF',
'#5C85FF',
// '#EBF0FF',
// '#D6E0FF',
// '#ADC2FF',
// '#85A3FF',
// '#5C85FF',
'#3366FF',
'#2952CC',
'#1F3D99',
'#142966',
'#0A1433',
'#FCFCFC',
'#F9F9FA',
'#F4F4F5',
'#E7E7E9',
'#D5D5D9',
'#9AA2AF',
'#6A7184',
// '#FCFCFC',
// '#F9F9FA',
// '#F4F4F5',
// '#E7E7E9',
// '#D5D5D9',
// '#9AA2AF',
// '#6A7184',
'#4A5268',
'#374151',
'#1F293A',
'#101015',
'#FFF2F1',
'#FFDBD9',
'#FFB7B2',
'#FF928C',
'#FF6E65',
'#FF4A3F',
// '#FFF2F1',
// '#FFDBD9',
// '#FFB7B2',
// '#FF928C',
// '#FF6E65',
// '#FF4A3F',
'#E8463C',
'#CB3F36',
'#B23830',
'#7D2721',
'#FFEEFB',
'#FED8F4',
'#FEB0E8',
'#FD89DD',
'#FD61D1',
// '#FFEEFB',
// '#FED8F4',
// '#FEB0E8',
// '#FD89DD',
// '#FD61D1',
'#FC3AC6',
'#CA2E9E',
'#972377',
'#65174F',
'#320C28',
'#FFF5EF',
'#FEE6D6',
'#FDCDAD',
'#FCB483',
'#FB9B5A',
// '#FFF5EF',
// '#FEE6D6',
// '#FDCDAD',
// '#FCB483',
// '#FB9B5A',
'#FA8231',
'#E1752C',
'#C86827',
'#964E1D',
'#4B270F',
'#F3ECFA',
'#E5D4F5',
'#CBA8EB',
'#B17DE1',
'#9751D7',
// '#F3ECFA',
// '#E5D4F5',
// '#CBA8EB',
// '#B17DE1',
// '#9751D7',
'#7D26CD',
'#641EA4',
'#4B177B',
'#320F52',
'#190829',
'#EDF9FF',
'#D7F2FF',
'#AFE5FF',
'#86D9FF',
'#5ECCFF',
// '#EDF9FF',
// '#D7F2FF',
// '#AFE5FF',
// '#86D9FF',
// '#5ECCFF',
'#36BFFF',
'#2B99CC',
'#207399',
'#164C66',
'#0B2633',
'#fffbf2',
'#fff0d1',
'#fee5b0',
'#fdd889',
'#fdcb61',
'#fcbe3a',
// '#fffbf2',
// '#fff0d1',
// '#fee5b0',
// '#fdd889',
// '#fdcb61',
// '#fcbe3a',
'#ca982e',
'#977223',
'#654c17',

3
packages/noco-docs/docs/020.getting-started/020.environment-variables.md

@ -62,5 +62,6 @@ For production usecases, it is **recommended** to configure
| NC_MINIMAL_DBS | Create a new SQLite file for each project. All the db files are stored in `nc_minimal_dbs` folder in current working directory. (This option restricts project creation on external sources) | |
| NC_DISABLE_AUDIT | Disable Audit Log | `false` |
| NC_AUTOMATION_LOG_LEVEL | Possible Values: `OFF`, `ERROR`, `ALL`. See [Webhooks](/developer-resources/webhooks#call-log) for details. | `OFF` |
| NC_SECURE_ATTACHMENTS | Allow accessing attachments only through presigned urls. To enable secure set value as `true` any other value treated as false. (⚠ this will make existing links inaccessible ⚠) | `false` |
| NC_SECURE_ATTACHMENTS | Allow accessing attachments only through presigned urls. To enable set value as `true` any other value treated as false. (⚠ this will make existing links inaccessible ⚠) | `false` |
| NC_ATTACHMENT_EXPIRE_SECONDS | How many seconds before expiring presigned attachment urls. (Attachments will expire in at least set seconds and at most 10mins after set time) | 7200 (2 hours) |
| NC_ALLOW_LOCAL_HOOKS | To enable set value as `true` any other value treated as false. (⚠ this will allow webhooks to call local links which can raise security issues ⚠) | `false` |

1
packages/nocodb/package.json

@ -154,6 +154,7 @@
"pg": "^8.10.0",
"redlock": "^5.0.0-beta.2",
"reflect-metadata": "^0.1.13",
"request-filtering-agent": "^1.1.2",
"request-ip": "^2.1.3",
"rmdir": "^1.2.0",
"rxjs": "^7.2.0",

4
packages/nocodb/src/app.config.ts

@ -2,9 +2,7 @@ import type { AppConfig } from './interface/config';
const config: AppConfig = {
throttler: {
ttl: 60,
max_apis: 10000,
calc_execution_time: true,
calc_execution_time: false,
},
basicAuth: {
username: process.env.NC_HTTP_BASIC_USER ?? 'defaultusername',

3
packages/nocodb/src/app.module.ts

@ -10,7 +10,6 @@ import { GuiMiddleware } from '~/middlewares/gui/gui.middleware';
import { DatasModule } from '~/modules/datas/datas.module';
import { EventEmitterModule } from '~/modules/event-emitter/event-emitter.module';
import { AuthService } from '~/services/auth.service';
import { TestModule } from '~/modules/test/test.module';
import { GlobalModule } from '~/modules/global/global.module';
import { LocalStrategy } from '~/strategies/local.strategy';
import { AuthTokenStrategy } from '~/strategies/authtoken.strategy/authtoken.strategy';
@ -31,7 +30,6 @@ export const ceModuleConfig = {
GlobalModule,
UsersModule,
AuthModule,
...(process.env['PLAYWRIGHT_TEST'] === 'true' ? [TestModule] : []),
MetasModule,
DatasModule,
EventEmitterModule,
@ -41,7 +39,6 @@ export const ceModuleConfig = {
load: [() => appConfig],
isGlobal: true,
}),
TestModule,
],
providers: [
AuthService,

6
packages/nocodb/src/controllers/api-docs/api-docs.controller.ts

@ -11,6 +11,8 @@ import getRedocHtml from './template/redocHtml';
import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { ApiDocsService } from '~/services/api-docs/api-docs.service';
import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
export class ApiDocsController {
@ -20,7 +22,7 @@ export class ApiDocsController {
'/api/v1/db/meta/projects/:baseId/swagger.json',
'/api/v1/meta/bases/:baseId/swagger.json',
])
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
@Acl('swaggerJson')
async swaggerJson(@Param('baseId') baseId: string, @Request() req) {
const swagger = await this.apiDocsService.swaggerJson({
@ -35,10 +37,12 @@ export class ApiDocsController {
'/api/v1/meta/bases/:baseId/swagger',
'/api/v1/db/meta/projects/:baseId/swagger',
])
@UseGuards(PublicApiLimiterGuard)
swaggerHtml(@Param('baseId') baseId: string, @Response() res) {
res.send(getSwaggerHtml({ ncSiteUrl: process.env.NC_PUBLIC_URL || '' }));
}
@UseGuards(PublicApiLimiterGuard)
@Get([
'/api/v1/db/meta/projects/:baseId/redoc',
'/api/v1/meta/bases/:baseId/redoc',

3
packages/nocodb/src/controllers/api-tokens.controller.ts

@ -13,9 +13,10 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { ApiTokensService } from '~/services/api-tokens.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class ApiTokensController {
constructor(private readonly apiTokensService: ApiTokensService) {}

5
packages/nocodb/src/controllers/attachments-secure.controller.ts

@ -19,12 +19,13 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { AttachmentsService } from '~/services/attachments.service';
import { PresignedUrl } from '~/models';
import { UploadAllowedInterceptor } from '~/interceptors/is-upload-allowed/is-upload-allowed.interceptor';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
export class AttachmentsSecureController {
constructor(private readonly attachmentsService: AttachmentsService) {}
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
@Post(['/api/v1/db/storage/upload', '/api/v1/storage/upload'])
@HttpCode(200)
@UseInterceptors(UploadAllowedInterceptor, AnyFilesInterceptor())
@ -42,7 +43,7 @@ export class AttachmentsSecureController {
@Post(['/api/v1/db/storage/upload-by-url', '/api/v1/storage/upload-by-url'])
@HttpCode(200)
@UseInterceptors(UploadAllowedInterceptor)
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
async uploadViaURL(@Body() body: any, @Request() req) {
const path = `${moment().format('YYYY/MM/DD')}/${hash(req.user.id)}`;

5
packages/nocodb/src/controllers/attachments.controller.ts

@ -18,12 +18,13 @@ import { UploadAllowedInterceptor } from '~/interceptors/is-upload-allowed/is-up
import { GlobalGuard } from '~/guards/global/global.guard';
import { AttachmentsService } from '~/services/attachments.service';
import { PresignedUrl } from '~/models';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
export class AttachmentsController {
constructor(private readonly attachmentsService: AttachmentsService) {}
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
@Post(['/api/v1/db/storage/upload', '/api/v1/storage/upload'])
@HttpCode(200)
@UseInterceptors(UploadAllowedInterceptor, AnyFilesInterceptor())
@ -43,7 +44,7 @@ export class AttachmentsController {
@Post(['/api/v1/db/storage/upload-by-url', '/api/v1/storage/upload-by-url'])
@HttpCode(200)
@UseInterceptors(UploadAllowedInterceptor)
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
async uploadViaURL(@Body() body: any, @Query('path') path: string) {
const attachments = await this.attachmentsService.uploadViaURL({
urls: body,

3
packages/nocodb/src/controllers/audits.controller.ts

@ -14,9 +14,10 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { AuditsService } from '~/services/audits.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class AuditsController {
constructor(private readonly auditsService: AuditsService) {}

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

@ -23,6 +23,8 @@ 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';
@Controller()
export class AuthController {
@ -37,6 +39,7 @@ export class AuthController {
'/api/v1/db/auth/user/signup',
'/api/v1/auth/user/signup',
])
@UseGuards(PublicApiLimiterGuard)
@HttpCode(200)
async signup(@Request() req: any, @Response() res: any): Promise<any> {
if (this.config.get('auth', { infer: true }).disableEmailAuth) {
@ -56,6 +59,7 @@ export class AuthController {
'/api/v1/db/auth/token/refresh',
'/api/v1/auth/token/refresh',
])
@UseGuards(PublicApiLimiterGuard)
@HttpCode(200)
async refreshToken(@Request() req: any, @Response() res: any): Promise<any> {
res.json(
@ -72,7 +76,7 @@ export class AuthController {
'/api/v1/db/auth/user/signin',
'/api/v1/auth/user/signin',
])
@UseGuards(AuthGuard('local'))
@UseGuards(PublicApiLimiterGuard, AuthGuard('local'))
@HttpCode(200)
async signin(@Request() req, @Response() res) {
if (this.config.get('auth', { infer: true }).disableEmailAuth) {
@ -99,20 +103,20 @@ export class AuthController {
@Post(`/auth/google/genTokenByCode`)
@HttpCode(200)
@UseGuards(AuthGuard('google'))
@UseGuards(PublicApiLimiterGuard, AuthGuard('google'))
async googleSignin(@Request() req, @Response() res) {
await this.setRefreshToken({ req, res });
res.json(await this.usersService.login(req.user));
}
@Get('/auth/google')
@UseGuards(AuthGuard('google'))
@UseGuards(PublicApiLimiterGuard, AuthGuard('google'))
googleAuthenticate() {
// google strategy will take care the request
}
@Get(['/auth/user/me', '/api/v1/db/auth/user/me', '/api/v1/auth/user/me'])
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
async me(@Request() req) {
const user = {
...req.user,
@ -128,7 +132,7 @@ export class AuthController {
'/api/v1/db/auth/password/change',
'/api/v1/auth/password/change',
])
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
@Acl('passwordChange', {
scope: 'org',
})
@ -152,6 +156,7 @@ export class AuthController {
'/api/v1/db/auth/password/forgot',
'/api/v1/auth/password/forgot',
])
@UseGuards(PublicApiLimiterGuard)
@HttpCode(200)
async passwordForgot(@Request() req: any): Promise<any> {
await this.usersService.passwordForgot({
@ -168,6 +173,7 @@ export class AuthController {
'/api/v1/db/auth/token/validate/:tokenId',
'/api/v1/auth/token/validate/:tokenId',
])
@UseGuards(PublicApiLimiterGuard)
@HttpCode(200)
async tokenValidate(@Param('tokenId') tokenId: string): Promise<any> {
await this.usersService.tokenValidate({
@ -181,6 +187,7 @@ export class AuthController {
'/api/v1/db/auth/password/reset/:tokenId',
'/api/v1/auth/password/reset/:tokenId',
])
@UseGuards(PublicApiLimiterGuard)
@HttpCode(200)
async passwordReset(
@Request() req: any,
@ -200,6 +207,7 @@ export class AuthController {
'/api/v1/db/auth/email/validate/:tokenId',
'/api/v1/auth/email/validate/:tokenId',
])
@UseGuards(PublicApiLimiterGuard)
@HttpCode(200)
async emailVerification(
@Request() req: any,
@ -217,6 +225,7 @@ export class AuthController {
'/api/v1/db/auth/password/reset/:tokenId',
'/auth/password/reset/:tokenId',
])
@UseGuards(PublicApiLimiterGuard)
async renderPasswordReset(
@Request() req: any,
@Response() res: any,

3
packages/nocodb/src/controllers/base-users.controller.ts

@ -15,8 +15,9 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { BaseUsersService } from '~/services/base-users/base-users.service';
import { NcError } from '~/helpers/catchError';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
@Controller()
export class BaseUsersController {
constructor(protected readonly baseUsersService: BaseUsersService) {}

3
packages/nocodb/src/controllers/bases.controller.ts

@ -21,8 +21,9 @@ import { packageVersion } from '~/utils/packageVersion';
import { BasesService } from '~/services/bases.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { Filter } from '~/models';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
@Controller()
export class BasesController {
constructor(protected readonly projectsService: BasesService) {}

3
packages/nocodb/src/controllers/bulk-data-alias.controller.ts

@ -13,9 +13,10 @@ import {
import { GlobalGuard } from '~/guards/global/global.guard';
import { BulkDataAliasService } from '~/services/bulk-data-alias.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(DataApiLimiterGuard, GlobalGuard)
export class BulkDataAliasController {
constructor(private bulkDataAliasService: BulkDataAliasService) {}

3
packages/nocodb/src/controllers/caches.controller.ts

@ -3,9 +3,10 @@ import { OrgUserRoles } from 'nocodb-sdk';
import { CachesService } from '~/services/caches.service';
import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class CachesController {
constructor(private readonly cachesService: CachesService) {}

3
packages/nocodb/src/controllers/columns.controller.ts

@ -15,9 +15,10 @@ import type { Column } from '~/models';
import { GlobalGuard } from '~/guards/global/global.guard';
import { ColumnsService } from '~/services/columns.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class ColumnsController {
constructor(private readonly columnsService: ColumnsService) {}

4
packages/nocodb/src/controllers/data-alias-export.controller.ts

@ -5,9 +5,10 @@ import { DatasService } from '~/services/datas.service';
import { extractCsvData, extractXlsxData } from '~/modules/datas/helpers';
import { View } from '~/models';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(DataApiLimiterGuard, GlobalGuard)
export class DataAliasExportController {
constructor(private datasService: DatasService) {}
@ -39,6 +40,7 @@ export class DataAliasExportController {
});
res.end(buf);
}
@Get([
'/api/v1/db/data/:orgs/:baseName/:tableName/views/:viewName/export/csv',
'/api/v1/db/data/:orgs/:baseName/:tableName/export/csv',

3
packages/nocodb/src/controllers/data-alias-nested.controller.ts

@ -11,9 +11,10 @@ import {
import { DataAliasNestedService } from '~/services/data-alias-nested.service';
import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(DataApiLimiterGuard, GlobalGuard)
export class DataAliasNestedController {
constructor(private dataAliasNestedService: DataAliasNestedService) {}

3
packages/nocodb/src/controllers/data-alias.controller.ts

@ -16,9 +16,10 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { parseHrtimeToMilliSeconds } from '~/helpers';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { DatasService } from '~/services/datas.service';
import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(DataApiLimiterGuard, GlobalGuard)
export class DataAliasController {
constructor(private readonly datasService: DatasService) {}

5
packages/nocodb/src/controllers/data-table.controller.ts

@ -12,13 +12,14 @@ import {
Response,
UseGuards,
} from '@nestjs/common';
import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { DataTableService } from '~/services/data-table.service';
import { parseHrtimeToMilliSeconds } from '~/helpers';
import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
import { GlobalGuard } from '~/guards/global/global.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(DataApiLimiterGuard, GlobalGuard)
export class DataTableController {
constructor(private readonly dataTableService: DataTableService) {}

3
packages/nocodb/src/controllers/datas.controller.ts

@ -13,9 +13,10 @@ import {
import { GlobalGuard } from '~/guards/global/global.guard';
import { DatasService } from '~/services/datas.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(DataApiLimiterGuard, GlobalGuard)
export class DatasController {
constructor(private readonly datasService: DatasService) {}

3
packages/nocodb/src/controllers/filters.controller.ts

@ -15,9 +15,10 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { FiltersService } from '~/services/filters.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class FiltersController {
constructor(private readonly filtersService: FiltersService) {}

3
packages/nocodb/src/controllers/form-columns.controller.ts

@ -2,11 +2,12 @@ import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common';
import { GlobalGuard } from '~/guards/global/global.guard';
import { FormColumnsService } from '~/services/form-columns.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
class FormColumnUpdateReqType {}
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class FormColumnsController {
constructor(private readonly formColumnsService: FormColumnsService) {}

3
packages/nocodb/src/controllers/forms.controller.ts

@ -13,9 +13,10 @@ import { ViewCreateReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { FormsService } from '~/services/forms.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class FormsController {
constructor(private readonly formsService: FormsService) {}

3
packages/nocodb/src/controllers/galleries.controller.ts

@ -13,9 +13,10 @@ import { GalleryUpdateReqType, ViewCreateReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { GalleriesService } from '~/services/galleries.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class GalleriesController {
constructor(private readonly galleriesService: GalleriesService) {}

3
packages/nocodb/src/controllers/grid-columns.controller.ts

@ -3,9 +3,10 @@ import { GridColumnReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { GridColumnsService } from '~/services/grid-columns.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class GridColumnsController {
constructor(private readonly gridColumnsService: GridColumnsService) {}

3
packages/nocodb/src/controllers/grids.controller.ts

@ -12,9 +12,10 @@ import { ViewCreateReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { GridsService } from '~/services/grids.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class GridsController {
constructor(private readonly gridsService: GridsService) {}

3
packages/nocodb/src/controllers/hooks.controller.ts

@ -16,9 +16,10 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { HooksService } from '~/services/hooks.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class HooksController {
constructor(private readonly hooksService: HooksService) {}

0
packages/nocodb/src/controllers/imports/helpers/job.ts

0
packages/nocodb/src/controllers/imports/import.controller.ts

3
packages/nocodb/src/controllers/kanbans.controller.ts

@ -13,9 +13,10 @@ import { ViewCreateReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { KanbansService } from '~/services/kanbans.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class KanbansController {
constructor(private readonly kanbansService: KanbansService) {}

3
packages/nocodb/src/controllers/maps.controller.ts

@ -13,9 +13,10 @@ import { MapUpdateReqType, ViewCreateReqType } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { MapsService } from '~/services/maps.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class MapsController {
constructor(private readonly mapsService: MapsService) {}

3
packages/nocodb/src/controllers/meta-diffs.controller.ts

@ -2,9 +2,10 @@ import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { GlobalGuard } from '~/guards/global/global.guard';
import { MetaDiffsService } from '~/services/meta-diffs.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class MetaDiffsController {
constructor(private readonly metaDiffsService: MetaDiffsService) {}

3
packages/nocodb/src/controllers/model-visibilities.controller.ts

@ -11,9 +11,10 @@ import {
import { GlobalGuard } from '~/guards/global/global.guard';
import { ModelVisibilitiesService } from '~/services/model-visibilities.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class ModelVisibilitiesController {
constructor(
private readonly modelVisibilitiesService: ModelVisibilitiesService,

3
packages/nocodb/src/controllers/notifications.controller.ts

@ -13,9 +13,10 @@ import {
import { NotificationsService } from '~/services/notifications.service';
import { GlobalGuard } from '~/guards/global/global.guard';
import { extractProps } from '~/helpers/extractProps';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}

3
packages/nocodb/src/controllers/old-datas/old-datas.controller.ts

@ -14,9 +14,10 @@ import {
import { OldDatasService } from './old-datas.service';
import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(DataApiLimiterGuard, GlobalGuard)
export class OldDatasController {
constructor(private readonly oldDatasService: OldDatasService) {}

3
packages/nocodb/src/controllers/org-lcense.controller.ts

@ -10,9 +10,10 @@ import { OrgUserRoles } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard';
import { OrgLcenseService } from '~/services/org-lcense.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class OrgLcenseController {
constructor(private readonly orgLcenseService: OrgLcenseService) {}

3
packages/nocodb/src/controllers/org-tokens.controller.ts

@ -15,8 +15,9 @@ import { getConditionalHandler } from '~/helpers/getHandler';
import { OrgTokensEeService } from '~/services/org-tokens-ee.service';
import { OrgTokensService } from '~/services/org-tokens.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@UseGuards(AuthGuard('jwt'))
@UseGuards(MetaApiLimiterGuard, AuthGuard('jwt'))
@Controller()
export class OrgTokensController {
constructor(

3
packages/nocodb/src/controllers/org-users.controller.ts

@ -16,9 +16,10 @@ import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { OrgUsersService } from '~/services/org-users.service';
import { User } from '~/models';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class OrgUsersController {
constructor(private readonly orgUsersService: OrgUsersService) {}

3
packages/nocodb/src/controllers/plugins.controller.ts

@ -12,6 +12,7 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { PluginsService } from '~/services/plugins.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
// todo: move to a interceptor
// const blockInCloudMw = (_req, res, next) => {
@ -21,7 +22,7 @@ import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
// };
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class PluginsController {
constructor(private readonly pluginsService: PluginsService) {}

11
packages/nocodb/src/controllers/public-datas-export.controller.ts

@ -1,4 +1,11 @@
import { Controller, Get, Param, Request, Response } from '@nestjs/common';
import {
Controller,
Get,
Param,
Request,
Response,
UseGuards,
} from '@nestjs/common';
import { ErrorMessages, isSystemColumn, ViewTypes } from 'nocodb-sdk';
import * as XLSX from 'xlsx';
import { nocoExecute } from 'nc-help';
@ -9,7 +16,9 @@ import { serializeCellValue } from '~/modules/datas/helpers';
import { PublicDatasExportService } from '~/services/public-datas-export.service';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import { Column, Model, Source, View } from '~/models';
import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard';
@UseGuards(PublicApiLimiterGuard)
@Controller()
export class PublicDatasExportController {
constructor(

3
packages/nocodb/src/controllers/public-datas.controller.ts

@ -5,11 +5,14 @@ import {
Param,
Post,
Request,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { PublicDatasService } from '~/services/public-datas.service';
import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard';
@UseGuards(PublicApiLimiterGuard)
@Controller()
export class PublicDatasController {
constructor(private readonly publicDatasService: PublicDatasService) {}

4
packages/nocodb/src/controllers/public-metas.controller.ts

@ -1,6 +1,8 @@
import { Controller, Get, Param, Request } from '@nestjs/common';
import { Controller, Get, Param, Request, UseGuards } from '@nestjs/common';
import { PublicMetasService } from '~/services/public-metas.service';
import { PublicApiLimiterGuard } from '~/guards/public-api-limiter.guard';
@UseGuards(PublicApiLimiterGuard)
@Controller()
export class PublicMetasController {
constructor(private readonly publicMetasService: PublicMetasService) {}

3
packages/nocodb/src/controllers/shared-bases.controller.ts

@ -13,9 +13,10 @@ import {
import { GlobalGuard } from '~/guards/global/global.guard';
import { SharedBasesService } from '~/services/shared-bases.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class SharedBasesController {
constructor(private readonly sharedBasesService: SharedBasesService) {}

3
packages/nocodb/src/controllers/sorts.controller.ts

@ -15,9 +15,10 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { SortsService } from '~/services/sorts.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class SortsController {
constructor(private readonly sortsService: SortsService) {}

3
packages/nocodb/src/controllers/sources.controller.ts

@ -4,9 +4,10 @@ import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { SourcesService } from '~/services/sources.service';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class SourcesController {
constructor(private readonly sourcesService: SourcesService) {}

3
packages/nocodb/src/controllers/sql-views.controller.ts

@ -9,9 +9,10 @@ import {
import { SqlViewsService } from '~/services/sql-views.service';
import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class SqlViewsController {
constructor(private readonly sqlViewsService: SqlViewsService) {}

3
packages/nocodb/src/controllers/sync.controller.ts

@ -13,9 +13,10 @@ import {
import { GlobalGuard } from '~/guards/global/global.guard';
import { SyncService } from '~/services/sync.service';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
@Controller()
@UseGuards(GlobalGuard)
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class SyncController {
constructor(private readonly syncService: SyncService) {}

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

Loading…
Cancel
Save