diff --git a/hosting/proxy/nginx.prod.conf b/hosting/proxy/nginx.prod.conf index dce1a71918..001a08a9a6 100644 --- a/hosting/proxy/nginx.prod.conf +++ b/hosting/proxy/nginx.prod.conf @@ -55,7 +55,7 @@ http { set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com"; set $csp_object "object-src 'none'"; set $csp_base_uri "base-uri 'self'"; - set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com"; + set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com"; set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com"; set $csp_frame "frame-src 'self' https:"; set $csp_img "img-src http: https: data: blob:"; @@ -82,6 +82,12 @@ http { set $couchdb ${COUCHDB_UPSTREAM_URL}; set $watchtower ${WATCHTOWER_UPSTREAM_URL}; + location /health { + access_log off; + add_header 'Content-Type' 'application/json'; + return 200 '{ "status": "OK" }'; + } + location /app { proxy_pass $apps; } diff --git a/lerna.json b/lerna.json index 664712d568..e56d307592 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.6.8-alpha.11", + "version": "2.6.16-alpha.2", "npmClient": "yarn", "packages": [ "packages/backend-core", diff --git a/packages/backend-core/src/constants/db.ts b/packages/backend-core/src/constants/db.ts index aa40f13775..be49b9f261 100644 --- a/packages/backend-core/src/constants/db.ts +++ b/packages/backend-core/src/constants/db.ts @@ -21,7 +21,7 @@ export enum ViewName { AUTOMATION_LOGS = "automation_logs", ACCOUNT_BY_EMAIL = "account_by_email", PLATFORM_USERS_LOWERCASE = "platform_users_lowercase", - USER_BY_GROUP = "by_group_user", + USER_BY_GROUP = "user_by_group", APP_BACKUP_BY_TRIGGER = "by_trigger", } diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 155f09e6d9..9163dfeba6 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -69,10 +69,10 @@ function findVersion() { try { const packageJsonFile = findFileInAncestors("package.json", process.cwd()) const content = readFileSync(packageJsonFile!, "utf-8") - const version = JSON.parse(content).version - return version + return JSON.parse(content).version } catch { - throw new Error("Cannot find a valid version in its package.json") + // throwing an error here is confusing/causes backend-core to be hard to import + return undefined } } @@ -95,7 +95,7 @@ const environment = { GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, SALT_ROUNDS: process.env.SALT_ROUNDS, REDIS_URL: process.env.REDIS_URL || "localhost:6379", - REDIS_PASSWORD: process.env.REDIS_PASSWORD || "budibase", + REDIS_PASSWORD: process.env.REDIS_PASSWORD, REDIS_CLUSTERED: process.env.REDIS_CLUSTERED, MOCK_REDIS: process.env.MOCK_REDIS, MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, diff --git a/packages/builder/package.json b/packages/builder/package.json index ccd3b94c01..d39fb69dd0 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -69,6 +69,7 @@ "@codemirror/state": "^6.2.0", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.11.2", + "@budibase/types": "0.0.1", "@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1", diff --git a/packages/builder/src/components/backend/DataTable/Table.svelte b/packages/builder/src/components/backend/DataTable/Table.svelte index 460a02a9b1..642f10ad35 100644 --- a/packages/builder/src/components/backend/DataTable/Table.svelte +++ b/packages/builder/src/components/backend/DataTable/Table.svelte @@ -23,6 +23,7 @@ export let disableSorting = false export let customPlaceholder = false export let allowClickRows + export let allowEditing = true const dispatch = createEventDispatcher() @@ -110,6 +111,7 @@ {rowCount} {disableSorting} {customPlaceholder} + allowEditRows={allowEditing} showAutoColumns={!hideAutocolumns} {allowClickRows} on:clickrelationship={e => selectRelationship(e.detail)} diff --git a/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte b/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte index 056bc33994..d239cabd59 100644 --- a/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte @@ -58,6 +58,7 @@ {loading} {type} rowCount={10} + allowEditing={false} bind:hideAutocolumns > diff --git a/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte b/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte index 94b9020203..f1337e88d4 100644 --- a/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte +++ b/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte @@ -6,7 +6,8 @@ import NavItem from "components/common/NavItem.svelte" import { goto, isActive } from "@roxi/routify" - const alphabetical = (a, b) => a.name?.toLowerCase() > b.name?.toLowerCase() + const alphabetical = (a, b) => + a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1 export let sourceId diff --git a/packages/builder/src/components/common/NavItem.svelte b/packages/builder/src/components/common/NavItem.svelte index 1fd43e2c70..73a8d3d647 100644 --- a/packages/builder/src/components/common/NavItem.svelte +++ b/packages/builder/src/components/common/NavItem.svelte @@ -17,6 +17,7 @@ export let highlighted = false export let rightAlignIcon = false export let id + export let showTooltip = false const scrollApi = getContext("scroll") const dispatch = createEventDispatcher() @@ -84,7 +85,7 @@ {/if} -
{text}
+
{text}
{#if withActions}
diff --git a/packages/builder/src/components/portal/licensing/AccountLockedModal.svelte b/packages/builder/src/components/portal/licensing/AccountLockedModal.svelte new file mode 100644 index 0000000000..a33d982627 --- /dev/null +++ b/packages/builder/src/components/portal/licensing/AccountLockedModal.svelte @@ -0,0 +1,31 @@ + + + + + Due to the free plan user limit being exceeded, your account has been + de-activated. Upgrade your plan to re-activate your account. + + diff --git a/packages/builder/src/components/portal/licensing/licensingBanners.js b/packages/builder/src/components/portal/licensing/licensingBanners.js index dafa8cfaed..ea4a1bb946 100644 --- a/packages/builder/src/components/portal/licensing/licensingBanners.js +++ b/packages/builder/src/components/portal/licensing/licensingBanners.js @@ -3,7 +3,6 @@ import { temporalStore } from "builderStore" import { admin, auth, licensing } from "stores/portal" import { get } from "svelte/store" import { BANNER_TYPES } from "@budibase/bbui" -import { capitalise } from "helpers" const oneDayInSeconds = 86400 @@ -146,23 +145,19 @@ const buildUsersAboveLimitBanner = EXPIRY_KEY => { const userLicensing = get(licensing) return { key: EXPIRY_KEY, - type: BANNER_TYPES.WARNING, + type: BANNER_TYPES.NEGATIVE, onChange: () => { defaultCacheFn(EXPIRY_KEY) }, criteria: () => { - return userLicensing.warnUserLimit + return userLicensing.errUserLimit }, - message: `${capitalise( - userLicensing.license.plan.type - )} plan changes - Users will be limited to ${ - userLicensing.userLimit - } users in ${userLicensing.userLimitDays}`, + message: "Your Budibase account is de-activated. Upgrade your plan", ...{ - extraButtonText: "Find out more", + extraButtonText: "View plans", extraButtonAction: () => { defaultCacheFn(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER) - window.location.href = "/builder/portal/users/users" + window.location.href = "https://budibase.com/pricing/" }, }, showCloseButton: true, diff --git a/packages/builder/src/components/start/AppRow.svelte b/packages/builder/src/components/start/AppRow.svelte index 34d083a096..194f897fdc 100644 --- a/packages/builder/src/components/start/AppRow.svelte +++ b/packages/builder/src/components/start/AppRow.svelte @@ -6,6 +6,8 @@ export let app + export let lockedAction + const handleDefaultClick = () => { if (window.innerWidth < 640) { goToOverview() @@ -29,7 +31,7 @@ } -
+
@@ -58,8 +60,11 @@
- - + +
diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte index be8237f616..65963b828d 100644 --- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte @@ -28,13 +28,16 @@ let inviting = false let searchFocus = false + // Initially filter entities without app access + // Show all when false + let filterByAppAccess = true + let appInvites = [] let filteredInvites = [] let filteredUsers = [] let filteredGroups = [] let selectedGroup let userOnboardResponse = null - let userLimitReachedModal $: queryIsEmail = emailValidator(query) === true @@ -52,15 +55,32 @@ } const filterInvites = async query => { - appInvites = await getInvites() - if (!query || query == "") { - filteredInvites = appInvites + if (!prodAppId) { return } - filteredInvites = appInvites.filter(invite => invite.email.includes(query)) + + appInvites = await getInvites() + + //On Focus behaviour + if (!filterByAppAccess && !query) { + filteredInvites = + appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites] + return + } + + filteredInvites = appInvites.filter(invite => { + const inviteInfo = invite.info?.apps + if (!query && inviteInfo && prodAppId) { + return Object.keys(inviteInfo).includes(prodAppId) + } + return invite.email.includes(query) + }) } - $: filterInvites(query) + $: filterByAppAccess, prodAppId, filterInvites(query) + $: if (searchFocus === true) { + filterByAppAccess = false + } const usersFetch = fetchData({ API, @@ -79,9 +99,9 @@ } await usersFetch.update({ query: { - appId: query ? null : prodAppId, + appId: query || !filterByAppAccess ? null : prodAppId, email: query, - paginated: query ? null : false, + paginated: query || !filterByAppAccess ? null : false, }, }) await usersFetch.refresh() @@ -107,7 +127,12 @@ } const debouncedUpdateFetch = Utils.debounce(searchUsers, 250) - $: debouncedUpdateFetch(query, $store.builderSidePanel, loaded) + $: debouncedUpdateFetch( + query, + $store.builderSidePanel, + loaded, + filterByAppAccess + ) const updateAppUser = async (user, role) => { if (!prodAppId) { @@ -182,9 +207,10 @@ } const searchGroups = (userGroups, query) => { - let filterGroups = query?.length - ? userGroups - : getAppGroups(userGroups, prodAppId) + let filterGroups = + query?.length || !filterByAppAccess + ? userGroups + : getAppGroups(userGroups, prodAppId) return filterGroups .filter(group => { if (!query?.length) { @@ -214,7 +240,7 @@ } // Adds the 'role' attribute and sets it to the current app. - $: enrichedGroups = getEnrichedGroups($groups) + $: enrichedGroups = getEnrichedGroups($groups, filterByAppAccess) $: filteredGroups = searchGroups(enrichedGroups, query) $: groupUsers = buildGroupUsers(filteredGroups, filteredUsers) $: allUsers = [...filteredUsers, ...groupUsers] @@ -226,7 +252,7 @@ specific roles for the app. */ const buildGroupUsers = (userGroups, filteredUsers) => { - if (query) { + if (query || !filterByAppAccess) { return [] } // Must exclude users who have explicit privileges @@ -321,12 +347,12 @@ [prodAppId]: role, }, }) - await filterInvites() + await filterInvites(query) } const onUninviteAppUser = async invite => { await uninviteAppUser(invite) - await filterInvites() + await filterInvites(query) } // Purge only the app from the invite or recind the invite if only 1 app remains? @@ -351,7 +377,6 @@ onMount(() => { rendered = true - searchFocus = true }) function handleKeyDown(evt) { @@ -417,7 +442,6 @@ autocomplete="off" disabled={inviting} value={query} - autofocus on:input={e => { query = e.target.value.trim() }} @@ -428,16 +452,20 @@ { + if (!filterByAppAccess) { + filterByAppAccess = true + } if (!query) { return } query = null userOnboardResponse = null + filterByAppAccess = true }} > - +
diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/screens/_components/ScreenListPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/screens/_components/ScreenListPanel.svelte index a6fd9089b1..06f9f86eb6 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/screens/_components/ScreenListPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/screens/_components/ScreenListPanel.svelte @@ -59,6 +59,7 @@ text={screen.routing.route} on:click={() => store.actions.screens.select(screen._id)} rightAlignIcon + showTooltip > diff --git a/packages/builder/src/pages/builder/apps/index.svelte b/packages/builder/src/pages/builder/apps/index.svelte index 23f4df5bb5..4b77671345 100644 --- a/packages/builder/src/pages/builder/apps/index.svelte +++ b/packages/builder/src/pages/builder/apps/index.svelte @@ -133,7 +133,7 @@ - {#if $licensing.usageMetrics?.dayPasses >= 100} + {#if $licensing.usageMetrics?.dayPasses >= 100 || $licensing.errUserLimit}
spaceman diff --git a/packages/builder/src/pages/builder/portal/apps/index.svelte b/packages/builder/src/pages/builder/portal/apps/index.svelte index 783cac49d7..ce1c249087 100644 --- a/packages/builder/src/pages/builder/portal/apps/index.svelte +++ b/packages/builder/src/pages/builder/portal/apps/index.svelte @@ -14,6 +14,7 @@ import Spinner from "components/common/Spinner.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" + import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte" import { store, automationStore } from "builderStore" import { API } from "api" @@ -28,6 +29,7 @@ let template let creationModal let appLimitModal + let accountLockedModal let creatingApp = false let searchTerm = "" let creatingFromTemplate = false @@ -48,6 +50,11 @@ : true) ) $: automationErrors = getAutomationErrors(enrichedApps) + $: isOwner = $auth.accountPortalAccess && $admin.cloud + + const usersLimitLockAction = $licensing?.errUserLimit + ? () => accountLockedModal.show() + : null const enrichApps = (apps, user, sortBy) => { const enrichedApps = apps.map(app => ({ @@ -189,6 +196,9 @@ creatingFromTemplate = true createAppFromTemplateUrl(initInfo.init_template) } + if (usersLimitLockAction) { + usersLimitLockAction() + } } catch (error) { notifications.error("Error getting init info") } @@ -230,20 +240,30 @@
- {#if $apps?.length > 0} {/if} {#if !$apps?.length} - {/if} @@ -267,7 +287,7 @@
{#each filteredApps as app (app.appId)} - + {/each}
@@ -294,6 +314,11 @@ + + isOwner ? $licensing.goToUpgradePage() : $licensing.goToPricingPage()} +/> diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/AddUserModal.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/AddUserModal.svelte index 6908639f6f..a4e0ebd4bb 100644 --- a/packages/builder/src/pages/builder/portal/users/users/_components/AddUserModal.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/_components/AddUserModal.svelte @@ -30,8 +30,8 @@ $: hasError = userData.find(x => x.error != null) $: userCount = $licensing.userCount + userData.length - $: willReach = licensing.willReachUserLimit(userCount) - $: willExceed = licensing.willExceedUserLimit(userCount) + $: reached = licensing.usersLimitReached(userCount) + $: exceeded = licensing.usersLimitExceeded(userCount) function removeInput(idx) { userData = userData.filter((e, i) => i !== idx) @@ -87,7 +87,7 @@ confirmDisabled={disabled} cancelText="Cancel" showCloseIcon={false} - disabled={hasError || !userData.length || willExceed} + disabled={hasError || !userData.length || exceeded} > @@ -118,7 +118,7 @@
{/each} - {#if willReach} + {#if reached}
diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/ImportUsersModal.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/ImportUsersModal.svelte index b35c6c998f..338cc1e7c1 100644 --- a/packages/builder/src/pages/builder/portal/users/users/_components/ImportUsersModal.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/_components/ImportUsersModal.svelte @@ -25,10 +25,10 @@ $: invalidEmails = [] $: userCount = $licensing.userCount + userEmails.length - $: willExceed = licensing.willExceedUserLimit(userCount) + $: exceed = licensing.usersLimitExceeded(userCount) $: importDisabled = - !userEmails.length || !validEmails(userEmails) || !usersRole || willExceed + !userEmails.length || !validEmails(userEmails) || !usersRole || exceed const validEmails = userEmails => { if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) { @@ -93,7 +93,7 @@
- {#if willExceed} + {#if exceed}
{capitalise($licensing.license.plan.type)} plan is limited to {$licensing.userLimit} diff --git a/packages/builder/src/pages/builder/portal/users/users/index.svelte b/packages/builder/src/pages/builder/portal/users/users/index.svelte index 3c4ed41839..f23d84f8bc 100644 --- a/packages/builder/src/pages/builder/portal/users/users/index.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte @@ -268,8 +268,6 @@ notifications.error("Error fetching user group data") } }) - - let staticUserLimit = $licensing.license.quotas.usage.static.users.value @@ -278,7 +276,7 @@ Add users and control who gets access to your published apps - {#if $licensing.warnUserLimit} + {#if $licensing.errUserLimit} { @@ -290,13 +288,9 @@ }} buttonText={isOwner ? "Upgrade" : "View plans"} cta - header={`Users will soon be limited to ${staticUserLimit}`} - message={`Our free plan is going to be limited to ${staticUserLimit} users in ${$licensing.userLimitDays}. - - This means any users exceeding the limit will be de-activated. - - De-activated users will not able to access the builder or any published apps until you upgrade to one of our paid plans. - `} + header="Account de-activated" + message="Due to the free plan user limit being exceeded, your account has been de-activated. + Upgrade your plan to re-activate your account." /> {/if}
diff --git a/packages/builder/src/stores/portal/licensing.js b/packages/builder/src/stores/portal/licensing.js index e6056f1993..307e640f33 100644 --- a/packages/builder/src/stores/portal/licensing.js +++ b/packages/builder/src/stores/portal/licensing.js @@ -4,7 +4,7 @@ import { auth, admin } from "stores/portal" import { Constants } from "@budibase/frontend-core" import { StripeStatus } from "components/portal/licensing/constants" import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags" -import dayjs from "dayjs" +import { PlanModel } from "@budibase/types" const UNLIMITED = -1 @@ -12,6 +12,7 @@ export const createLicensingStore = () => { const DEFAULT = { // navigation goToUpgradePage: () => {}, + goToPricingPage: () => {}, // the top level license license: undefined, isFreePlan: true, @@ -37,29 +38,37 @@ export const createLicensingStore = () => { // user limits userCount: undefined, userLimit: undefined, - userLimitDays: undefined, userLimitReached: false, - warnUserLimit: false, + errUserLimit: false, } const oneDayInMilliseconds = 86400000 const store = writable(DEFAULT) - function willReachUserLimit(userCount, userLimit) { + function usersLimitReached(userCount, userLimit) { if (userLimit === UNLIMITED) { return false } return userCount >= userLimit } - function willExceedUserLimit(userCount, userLimit) { + function usersLimitExceeded(userCount, userLimit) { if (userLimit === UNLIMITED) { return false } return userCount > userLimit } + async function isCloud() { + let adminStore = get(admin) + if (!adminStore.loaded) { + await admin.init() + adminStore = get(admin) + } + return adminStore.cloud + } + const actions = { init: async () => { actions.setNavigation() @@ -71,10 +80,14 @@ export const createLicensingStore = () => { const goToUpgradePage = () => { window.location.href = upgradeUrl } + const goToPricingPage = () => { + window.open("https://budibase.com/pricing/", "_blank") + } store.update(state => { return { ...state, goToUpgradePage, + goToPricingPage, } }) }, @@ -128,15 +141,15 @@ export const createLicensingStore = () => { quotaUsage, } }) - actions.setUsageMetrics() + await actions.setUsageMetrics() }, - willReachUserLimit: userCount => { - return willReachUserLimit(userCount, get(store).userLimit) + usersLimitReached: userCount => { + return usersLimitReached(userCount, get(store).userLimit) }, - willExceedUserLimit(userCount) { - return willExceedUserLimit(userCount, get(store).userLimit) + usersLimitExceeded(userCount) { + return usersLimitExceeded(userCount, get(store).userLimit) }, - setUsageMetrics: () => { + setUsageMetrics: async () => { if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) { const usage = get(store).quotaUsage const license = get(auth).user.license @@ -198,11 +211,13 @@ export const createLicensingStore = () => { const userQuota = license.quotas.usage.static.users const userLimit = userQuota?.value const userCount = usage.usageQuota.users - const userLimitReached = willReachUserLimit(userCount, userLimit) - const userLimitExceeded = willExceedUserLimit(userCount, userLimit) - const days = dayjs(userQuota?.startDate).diff(dayjs(), "day") - const userLimitDays = days > 1 ? `${days} days` : "1 day" - const warnUserLimit = userQuota?.startDate && userLimitExceeded + const userLimitReached = usersLimitReached(userCount, userLimit) + const userLimitExceeded = usersLimitExceeded(userCount, userLimit) + const isCloudAccount = await isCloud() + const errUserLimit = + isCloudAccount && + license.plan.model === PlanModel.PER_USER && + userLimitExceeded store.update(state => { return { @@ -217,9 +232,8 @@ export const createLicensingStore = () => { // user limits userCount, userLimit, - userLimitDays, userLimitReached, - warnUserLimit, + errUserLimit, } }) } diff --git a/packages/cli/package.json b/packages/cli/package.json index 7f8b4b13b1..d748f71555 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,9 +2,9 @@ "name": "@budibase/cli", "version": "0.0.1", "description": "Budibase CLI, for developers, self hosting and migrations.", - "main": "dist/index.js", + "main": "dist/src/index.js", "bin": { - "budi": "dist/index.js" + "budi": "dist/src/index.js" }, "author": "Budibase", "license": "GPL-3.0", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8082d3f0a0..fc285aebd7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,17 +2,17 @@ process.env.DISABLE_PINO_LOGGER = "1" import "./prebuilds" import "./environment" -import { env } from "@budibase/backend-core" import { getCommands } from "./options" import { Command } from "commander" import { getHelpDescription } from "./utils" +import { version } from "../package.json" // add hosting config async function init() { const program = new Command() .addHelpCommand("help", getHelpDescription("Help with Budibase commands.")) .helpOption(false) - .version(env.VERSION) + .version(version) // add commands for (let command of getCommands()) { command.configure(program) diff --git a/packages/cli/src/prebuilds.ts b/packages/cli/src/prebuilds.ts index bfdae90cf3..17e084160a 100644 --- a/packages/cli/src/prebuilds.ts +++ b/packages/cli/src/prebuilds.ts @@ -13,7 +13,7 @@ if (!process.argv[0].includes("node")) { } function checkForBinaries() { - const readDir = join(__filename, "..", "..", PREBUILDS, ARCH) + const readDir = join(__filename, "..", "..", "..", PREBUILDS, ARCH) if (fs.existsSync(PREBUILD_DIR) || !fs.existsSync(readDir)) { return } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 0fa0bd8f5a..ab306debca 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -5,6 +5,7 @@ "declaration": true, "sourceMap": true, "baseUrl": ".", + "resolveJsonModule": true, "paths": { "@budibase/types": ["../types/src"], "@budibase/backend-core": ["../backend-core/src"], @@ -16,6 +17,6 @@ "swc": true }, "references": [{ "path": "../types" }, { "path": "../backend-core" }], - "include": ["src/**/*"], + "include": ["src/**/*", "package.json"], "exclude": ["node_modules", "dist"] } diff --git a/packages/frontend-core/src/api/groups.js b/packages/frontend-core/src/api/groups.js index cbc5bfd72a..72cbe30718 100644 --- a/packages/frontend-core/src/api/groups.js +++ b/packages/frontend-core/src/api/groups.js @@ -55,10 +55,13 @@ export const buildGroupsEndpoints = API => { /** * Gets a group users by the group id */ - getGroupUsers: async ({ id, bookmark }) => { + getGroupUsers: async ({ id, bookmark, emailSearch }) => { let url = `/api/global/groups/${id}/users?` if (bookmark) { - url += `bookmark=${bookmark}` + url += `bookmark=${bookmark}&` + } + if (emailSearch) { + url += `emailSearch=${emailSearch}&` } return await API.get({ diff --git a/packages/frontend-core/src/fetch/GroupUserFetch.js b/packages/frontend-core/src/fetch/GroupUserFetch.js index b0ca9a5388..bd2cf264c5 100644 --- a/packages/frontend-core/src/fetch/GroupUserFetch.js +++ b/packages/frontend-core/src/fetch/GroupUserFetch.js @@ -31,6 +31,7 @@ export default class GroupUserFetch extends DataFetch { try { const res = await this.API.getGroupUsers({ id: query.groupId, + emailSearch: query.emailSearch, bookmark: cursor, }) diff --git a/packages/pro b/packages/pro index 14345384f7..64a2025727 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 14345384f7a6755d1e2de327104741e0f208f55d +Subproject commit 64a2025727c25d5813832c92eb360de3947b7aa6 diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index cc1450060c..e3cb419236 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -118,8 +118,11 @@ export async function patch(ctx: UserCtx) { combinedRow[key] = inputs[key] } + // need to copy the table so it can be differenced on way out + const tableClone = cloneDeep(dbTable) + // this returns the table and row incase they have been updated - let { table, row } = inputProcessing(ctx.user, dbTable, combinedRow) + let { table, row } = inputProcessing(ctx.user, tableClone, combinedRow) const validateResult = await utils.validate({ row, table, @@ -163,7 +166,12 @@ export async function save(ctx: UserCtx) { // this returns the table and row incase they have been updated const dbTable = await db.get(inputs.tableId) - let { table, row } = inputProcessing(ctx.user, dbTable, inputs) + + // need to copy the table so it can be differenced on way out + const tableClone = cloneDeep(dbTable) + + let { table, row } = inputProcessing(ctx.user, tableClone, inputs) + const validateResult = await utils.validate({ row, table, diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts index bc967a90f4..cbbda7b930 100644 --- a/packages/server/src/api/controllers/table/index.ts +++ b/packages/server/src/api/controllers/table/index.ts @@ -97,6 +97,7 @@ export async function bulkImport(ctx: UserCtx) { // right now we don't trigger anything for bulk import because it // can only be done in the builder, but in the future we may need to // think about events for bulk items + ctx.status = 200 ctx.body = { message: `Bulk rows created.` } } diff --git a/packages/server/src/api/controllers/table/internal.ts b/packages/server/src/api/controllers/table/internal.ts index 628932bba1..d2a4de575e 100644 --- a/packages/server/src/api/controllers/table/internal.ts +++ b/packages/server/src/api/controllers/table/internal.ts @@ -184,8 +184,13 @@ export async function destroy(ctx: any) { } export async function bulkImport(ctx: any) { + const db = context.getAppDB() const table = await sdk.tables.getTable(ctx.params.tableId) const { rows } = ctx.request.body await handleDataImport(ctx.user, table, rows) + + // Ensure auto id and other table updates are persisted + await db.put(table) + return table } diff --git a/packages/server/src/api/controllers/table/utils.ts b/packages/server/src/api/controllers/table/utils.ts index 1a3eda683b..f088dbaa8e 100644 --- a/packages/server/src/api/controllers/table/utils.ts +++ b/packages/server/src/api/controllers/table/utils.ts @@ -129,17 +129,17 @@ export function importToRows( // the real schema of the table passed in, not the clone used for // incrementing auto IDs for (const [fieldName, schema] of Object.entries(originalTable.schema)) { + const rowVal = Array.isArray(row[fieldName]) + ? row[fieldName] + : [row[fieldName]] if ( (schema.type === FieldTypes.OPTIONS || schema.type === FieldTypes.ARRAY) && - row[fieldName] && - (!schema.constraints!.inclusion || - schema.constraints!.inclusion.indexOf(row[fieldName]) === -1) + row[fieldName] ) { - schema.constraints!.inclusion = [ - ...schema.constraints!.inclusion!, - row[fieldName], - ] + let merged = [...schema.constraints!.inclusion!, ...rowVal] + let superSet = new Set(merged) + schema.constraints!.inclusion = Array.from(superSet) schema.constraints!.inclusion.sort() } } diff --git a/packages/server/src/api/routes/public/index.ts b/packages/server/src/api/routes/public/index.ts index bed798f75b..6852778e68 100644 --- a/packages/server/src/api/routes/public/index.ts +++ b/packages/server/src/api/routes/public/index.ts @@ -42,13 +42,17 @@ if (!env.isTest()) { host: REDIS_OPTS.host, port: REDIS_OPTS.port, }, - password: - REDIS_OPTS.opts.password || REDIS_OPTS.opts.redisOptions.password, + } + + if (REDIS_OPTS.opts?.password || REDIS_OPTS.opts.redisOptions?.password) { + // @ts-ignore + options.password = + REDIS_OPTS.opts.password || REDIS_OPTS.opts.redisOptions.password } if (!env.REDIS_CLUSTERED) { - // Can't set direct redis db in clustered env // @ts-ignore + // Can't set direct redis db in clustered env options.database = 1 } } diff --git a/packages/server/src/api/routes/tests/misc.spec.js b/packages/server/src/api/routes/tests/misc.spec.js index 6dd82df496..21ebea637f 100644 --- a/packages/server/src/api/routes/tests/misc.spec.js +++ b/packages/server/src/api/routes/tests/misc.spec.js @@ -73,18 +73,97 @@ describe("run misc tests", () => { type: "string", }, }, + e: { + name: "Auto ID", + type: "number", + subtype: "autoID", + icon: "ri-magic-line", + autocolumn: true, + constraints: { + type: "number", + presence: false, + numericality: { + greaterThanOrEqualTo: "", + lessThanOrEqualTo: "", + }, + }, + }, + f: { + type: "array", + constraints: { + type: "array", + presence: { + "allowEmpty": true + }, + inclusion: [ + "One", + "Two", + "Three", + ] + }, + name: "Sample Tags", + sortable: false + }, + g: { + type: "options", + constraints: { + type: "string", + presence: false, + inclusion: [ + "Alpha", + "Beta", + "Gamma" + ] + }, + name: "Sample Opts" + } }, }) - + + // Shift specific row tests to the row spec await tableUtils.handleDataImport( { userId: "test" }, table, - [{ a: '1', b: '2', c: '3', d: '4'}] + [ + { a: '1', b: '2', c: '3', d: '4', f: "['One']", g: "Alpha" }, + { a: '5', b: '6', c: '7', d: '8', f: "[]", g: undefined}, + { a: '9', b: '10', c: '11', d: '12', f: "['Two','Four']", g: ""}, + { a: '13', b: '14', c: '15', d: '16', g: "Omega"} + ] ) + + // 4 rows imported, the auto ID starts at 1 + // We expect the handleDataImport function to update the lastID + expect(table.schema.e.lastID).toEqual(4); + + // Array/Multi - should have added a new value to the inclusion. + expect(table.schema.f.constraints.inclusion).toEqual(['Four','One','Three','Two']); + + // Options - should have a new value in the inclusion + expect(table.schema.g.constraints.inclusion).toEqual(['Alpha','Beta','Gamma','Omega']); + const rows = await config.getRows() - expect(rows[0].a).toEqual("1") - expect(rows[0].b).toEqual("2") - expect(rows[0].c).toEqual("3") + expect(rows.length).toEqual(4); + + const rowOne = rows.find(row => row.e === 1) + expect(rowOne.a).toEqual("1") + expect(rowOne.f).toEqual(['One']) + expect(rowOne.g).toEqual('Alpha') + + const rowTwo = rows.find(row => row.e === 2) + expect(rowTwo.a).toEqual("5") + expect(rowTwo.f).toEqual([]) + expect(rowTwo.g).toEqual(undefined) + + const rowThree = rows.find(row => row.e === 3) + expect(rowThree.a).toEqual("9") + expect(rowThree.f).toEqual(['Two','Four']) + expect(rowThree.g).toEqual(null) + + const rowFour = rows.find(row => row.e === 4) + expect(rowFour.a).toEqual("13") + expect(rowFour.f).toEqual(undefined) + expect(rowFour.g).toEqual('Omega') }) }) }) diff --git a/packages/server/src/api/routes/tests/row.spec.js b/packages/server/src/api/routes/tests/row.spec.js index 4b835a1fb5..a8041dac3b 100644 --- a/packages/server/src/api/routes/tests/row.spec.js +++ b/packages/server/src/api/routes/tests/row.spec.js @@ -34,9 +34,9 @@ describe("/rows", () => { row = basicRow(table._id) }) - const loadRow = async (id, status = 200) => + const loadRow = async (id, tbl_Id, status = 200) => await request - .get(`/api/${table._id}/rows/${id}`) + .get(`/api/${tbl_Id}/rows/${id}`) .set(config.defaultHeaders()) .expect("Content-Type", /json/) .expect(status) @@ -79,6 +79,60 @@ describe("/rows", () => { await assertQueryUsage(queryUsage + 1) }) + it("Increment row autoId per create row request", async () => { + const rowUsage = await getRowUsage() + const queryUsage = await getQueryUsage() + + const newTable = await config.createTable({ + name: "TestTableAuto", + type: "table", + key: "name", + schema: { + ...table.schema, + "Row ID": { + name: "Row ID", + type: "number", + subtype: "autoID", + icon: "ri-magic-line", + autocolumn: true, + constraints: { + type: "number", + presence: false, + numericality: { + greaterThanOrEqualTo: "", + lessThanOrEqualTo: "", + }, + }, + }, + } + }) + + const ids = [1,2,3] + + // Performing several create row requests should increment the autoID fields accordingly + const createRow = async (id) => { + const res = await request + .post(`/api/${newTable._id}/rows`) + .send({ + name: "row_" + id + }) + .set(config.defaultHeaders()) + .expect('Content-Type', /json/) + .expect(200) + expect(res.res.statusMessage).toEqual(`${newTable.name} saved successfully`) + expect(res.body.name).toEqual("row_" + id) + expect(res.body._rev).toBeDefined() + expect(res.body["Row ID"]).toEqual(id) + } + + for (let i=0; i { const existing = await config.createRow() const rowUsage = await getRowUsage() @@ -182,8 +236,32 @@ describe("/rows", () => { type: "string", presence: false, datetime: { earliest: "", latest: "" }, - }, + } } + const arrayField = { + type: "array", + constraints: { + type: "array", + presence: false, + inclusion: [ + "One", + "Two", + "Three", + ] + }, + name: "Sample Tags", + sortable: false + } + const optsField = { + fieldName: "Sample Opts", + name: "Sample Opts", + type: "options", + constraints: { + type: "string", + presence: false, + inclusion: [ "Alpha", "Beta", "Gamma" ] + }, + }, table = await config.createTable({ name: "TestTable2", @@ -212,7 +290,15 @@ describe("/rows", () => { attachmentNull: attachment, attachmentUndefined: attachment, attachmentEmpty: attachment, - attachmentEmptyArrayStr: attachment + attachmentEmptyArrayStr: attachment, + arrayFieldEmptyArrayStr: arrayField, + arrayFieldArrayStrKnown: arrayField, + arrayFieldNull: arrayField, + arrayFieldUndefined: arrayField, + optsFieldEmptyStr: optsField, + optsFieldUndefined: optsField, + optsFieldNull: optsField, + optsFieldStrKnown: optsField }, }) @@ -241,11 +327,20 @@ describe("/rows", () => { attachmentUndefined: undefined, attachmentEmpty: "", attachmentEmptyArrayStr: "[]", + arrayFieldEmptyArrayStr: "[]", + arrayFieldUndefined: undefined, + arrayFieldNull: null, + arrayFieldArrayStrKnown: "['One']", + optsFieldEmptyStr: "", + optsFieldUndefined: undefined, + optsFieldNull: null, + optsFieldStrKnown: 'Alpha' } - const id = (await config.createRow(row))._id + const createdRow = await config.createRow(row); + const id = createdRow._id - const saved = (await loadRow(id)).body + const saved = (await loadRow(id, table._id)).body expect(saved.stringUndefined).toBe(undefined) expect(saved.stringNull).toBe("") @@ -270,7 +365,15 @@ describe("/rows", () => { expect(saved.attachmentNull).toEqual([]) expect(saved.attachmentUndefined).toBe(undefined) expect(saved.attachmentEmpty).toEqual([]) - expect(saved.attachmentEmptyArrayStr).toEqual([]) + expect(saved.attachmentEmptyArrayStr).toEqual([]) + expect(saved.arrayFieldEmptyArrayStr).toEqual([]) + expect(saved.arrayFieldNull).toEqual([]) + expect(saved.arrayFieldUndefined).toEqual(undefined) + expect(saved.optsFieldEmptyStr).toEqual(null) + expect(saved.optsFieldUndefined).toEqual(undefined) + expect(saved.optsFieldNull).toEqual(null) + expect(saved.arrayFieldArrayStrKnown).toEqual(['One']) + expect(saved.optsFieldStrKnown).toEqual('Alpha') }) }) @@ -299,7 +402,7 @@ describe("/rows", () => { expect(res.body.name).toEqual("Updated Name") expect(res.body.description).toEqual(existing.description) - const savedRow = await loadRow(res.body._id) + const savedRow = await loadRow(res.body._id, table._id) expect(savedRow.body.description).toEqual(existing.description) expect(savedRow.body.name).toEqual("Updated Name") @@ -401,7 +504,7 @@ describe("/rows", () => { .expect(200) expect(res.body.length).toEqual(2) - await loadRow(row1._id, 404) + await loadRow(row1._id, table._id, 404) await assertRowUsage(rowUsage - 2) await assertQueryUsage(queryUsage + 1) }) diff --git a/packages/server/src/api/routes/tests/table.spec.js b/packages/server/src/api/routes/tests/table.spec.js index d28f2232ee..9c6980c1d7 100644 --- a/packages/server/src/api/routes/tests/table.spec.js +++ b/packages/server/src/api/routes/tests/table.spec.js @@ -167,7 +167,10 @@ describe("/tables", () => { expect(events.table.created).not.toHaveBeenCalled() expect(events.rows.imported).toBeCalledTimes(1) - expect(events.rows.imported).toBeCalledWith(table, 1) + expect(events.rows.imported).toBeCalledWith(expect.objectContaining({ + name: "TestTable", + _id: table._id + }), 1) }) }) diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts index a5bb352eeb..44cab4d18b 100644 --- a/packages/server/src/utilities/rowProcessor/index.ts +++ b/packages/server/src/utilities/rowProcessor/index.ts @@ -137,8 +137,7 @@ export function inputProcessing( opts?: AutoColumnProcessingOpts ) { let clonedRow = cloneDeep(row) - // need to copy the table so it can be differenced on way out - const copiedTable = cloneDeep(table) + const dontCleanseKeys = ["type", "_id", "_rev", "tableId"] for (let [key, value] of Object.entries(clonedRow)) { const field = table.schema[key] @@ -175,7 +174,7 @@ export function inputProcessing( } // handle auto columns - this returns an object like {table, row} - return processAutoColumn(user, copiedTable, clonedRow, opts) + return processAutoColumn(user, table, clonedRow, opts) } /** diff --git a/packages/server/src/utilities/rowProcessor/map.ts b/packages/server/src/utilities/rowProcessor/map.ts index 808b16178d..cf6823856c 100644 --- a/packages/server/src/utilities/rowProcessor/map.ts +++ b/packages/server/src/utilities/rowProcessor/map.ts @@ -2,6 +2,22 @@ import { FieldTypes } from "../../constants" import { logging } from "@budibase/backend-core" +const parseArrayString = value => { + if (typeof value === "string") { + if (value === "") { + return [] + } + let result + try { + result = JSON.parse(value.replace(/'/g, '"')) + return result + } catch (e) { + logging.logAlert("Could not parse row value", e) + } + } + return value +} + /** * A map of how we convert various properties in rows to each other based on the row type. */ @@ -26,9 +42,9 @@ export const TYPE_TRANSFORM_MAP: any = { [undefined]: undefined, }, [FieldTypes.ARRAY]: { - "": [], [null]: [], [undefined]: undefined, + parse: parseArrayString, }, [FieldTypes.STRING]: { "": "", @@ -70,21 +86,7 @@ export const TYPE_TRANSFORM_MAP: any = { [FieldTypes.ATTACHMENT]: { [null]: [], [undefined]: undefined, - parse: attachments => { - if (typeof attachments === "string") { - if (attachments === "") { - return [] - } - let result - try { - result = JSON.parse(attachments) - } catch (e) { - logging.logAlert("Could not parse attachments", e) - } - return result - } - return attachments - }, + parse: parseArrayString, }, [FieldTypes.BOOLEAN]: { "": null, diff --git a/packages/worker/src/api/routes/global/tests/groups.spec.ts b/packages/worker/src/api/routes/global/tests/groups.spec.ts index 5b7fb9db9e..afeaae952c 100644 --- a/packages/worker/src/api/routes/global/tests/groups.spec.ts +++ b/packages/worker/src/api/routes/global/tests/groups.spec.ts @@ -1,5 +1,9 @@ import { events } from "@budibase/backend-core" +import { generator } from "@budibase/backend-core/tests" import { structures, TestConfiguration, mocks } from "../../../../tests" +import { UserGroup } from "@budibase/types" + +mocks.licenses.useGroups() describe("/api/global/groups", () => { const config = new TestConfiguration() @@ -113,4 +117,118 @@ describe("/api/global/groups", () => { }) }) }) + + describe("find users", () => { + describe("without users", () => { + let group: UserGroup + beforeAll(async () => { + group = structures.groups.UserGroup() + await config.api.groups.saveGroup(group) + }) + + it("should return empty", async () => { + const result = await config.api.groups.searchUsers(group._id!) + expect(result.body).toEqual({ + users: [], + bookmark: undefined, + hasNextPage: false, + }) + }) + }) + + describe("existing users", () => { + let groupId: string + let users: { _id: string; email: string }[] = [] + + beforeAll(async () => { + groupId = ( + await config.api.groups.saveGroup(structures.groups.UserGroup()) + ).body._id + + await Promise.all( + Array.from({ length: 30 }).map(async (_, i) => { + const email = `user${i}@${generator.domain()}` + const user = await config.api.users.saveUser({ + ...structures.users.user(), + email, + }) + users.push({ _id: user.body._id, email }) + }) + ) + users = users.sort((a, b) => a._id.localeCompare(b._id)) + await config.api.groups.updateGroupUsers(groupId, { + add: users.map(u => u._id), + remove: [], + }) + }) + + describe("pagination", () => { + it("should return first page", async () => { + const result = await config.api.groups.searchUsers(groupId) + expect(result.body).toEqual({ + users: users.slice(0, 10), + bookmark: users[10]._id, + hasNextPage: true, + }) + }) + + it("given a bookmark, should return skip items", async () => { + const result = await config.api.groups.searchUsers(groupId, { + bookmark: users[7]._id, + }) + expect(result.body).toEqual({ + users: users.slice(7, 17), + bookmark: users[17]._id, + hasNextPage: true, + }) + }) + + it("bookmarking the last page, should return last page info", async () => { + const result = await config.api.groups.searchUsers(groupId, { + bookmark: users[20]._id, + }) + expect(result.body).toEqual({ + users: users.slice(20), + bookmark: undefined, + hasNextPage: false, + }) + }) + }) + + describe("search by email", () => { + it('should be able to search "starting" by email', async () => { + const result = await config.api.groups.searchUsers(groupId, { + emailSearch: `user1`, + }) + + const matchedUsers = users + .filter(u => u.email.startsWith("user1")) + .sort((a, b) => a.email.localeCompare(b.email)) + + expect(result.body).toEqual({ + users: matchedUsers.slice(0, 10), + bookmark: matchedUsers[10].email, + hasNextPage: true, + }) + }) + + it("should be able to bookmark when searching by email", async () => { + const matchedUsers = users + .filter(u => u.email.startsWith("user1")) + .sort((a, b) => a.email.localeCompare(b.email)) + + const result = await config.api.groups.searchUsers(groupId, { + emailSearch: `user1`, + bookmark: matchedUsers[4].email, + }) + + expect(result.body).toEqual({ + users: matchedUsers.slice(4), + bookmark: undefined, + hasNextPage: false, + }) + }) + }) + }) + }) }) diff --git a/packages/worker/src/tests/api/groups.ts b/packages/worker/src/tests/api/groups.ts index 5524d2a811..91f7c92c7d 100644 --- a/packages/worker/src/tests/api/groups.ts +++ b/packages/worker/src/tests/api/groups.ts @@ -23,4 +23,34 @@ export class GroupsAPI extends TestAPI { .expect("Content-Type", /json/) .expect(200) } + + searchUsers = ( + id: string, + params?: { bookmark?: string; emailSearch?: string } + ) => { + let url = `/api/global/groups/${id}/users?` + if (params?.bookmark) { + url += `bookmark=${params.bookmark}&` + } + if (params?.emailSearch) { + url += `emailSearch=${params.emailSearch}&` + } + return this.request + .get(url) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + } + + updateGroupUsers = ( + id: string, + body: { add: string[]; remove: string[] } + ) => { + return this.request + .post(`/api/global/groups/${id}/users`) + .send(body) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + } } diff --git a/packages/worker/src/utilities/redis.ts b/packages/worker/src/utilities/redis.ts index d77e44cd9f..6b82df3b57 100644 --- a/packages/worker/src/utilities/redis.ts +++ b/packages/worker/src/utilities/redis.ts @@ -7,7 +7,7 @@ function getExpirySecondsForDB(db: string) { // a hour return 3600 case redis.utils.Databases.INVITATIONS: - // a day + // a week return 604800 } }