diff --git a/services/web/app/src/Features/Newsletter/MailChimpProvider.js b/services/web/app/src/Features/Newsletter/MailChimpProvider.js new file mode 100644 index 0000000000..4c56a8ec60 --- /dev/null +++ b/services/web/app/src/Features/Newsletter/MailChimpProvider.js @@ -0,0 +1,260 @@ +const logger = require('@overleaf/logger') +const Settings = require('@overleaf/settings') +const crypto = require('crypto') +const Mailchimp = require('mailchimp-api-v3') +const OError = require('@overleaf/o-error') +const { callbackify } = require('util') + +function mailchimpIsConfigured() { + return Settings.mailchimp != null && Settings.mailchimp.api_key != null +} + +function make(listName, listId) { + let provider + if (!mailchimpIsConfigured() || !listId) { + logger.debug({ listName }, 'Using newsletter provider: none') + provider = makeNullProvider(listName) + } else { + logger.debug({ listName }, 'Using newsletter provider: mailchimp') + provider = makeMailchimpProvider(listName, listId) + } + return { + subscribed: callbackify(provider.subscribed), + subscribe: callbackify(provider.subscribe), + unsubscribe: callbackify(provider.unsubscribe), + changeEmail: callbackify(provider.changeEmail), + promises: provider, + } +} + +module.exports = { + make, +} + +class NonFatalEmailUpdateError extends OError { + constructor(message, oldEmail, newEmail) { + super(message, { oldEmail, newEmail }) + } +} + +function makeMailchimpProvider(listName, listId) { + const mailchimp = new Mailchimp(Settings.mailchimp.api_key) + const MAILCHIMP_LIST_ID = listId + + return { + subscribed, + subscribe, + unsubscribe, + changeEmail, + } + + async function subscribed(user) { + try { + const path = getSubscriberPath(user.email) + const result = await mailchimp.get(path) + return result?.status === 'subscribed' + } catch (err) { + if (err.status === 404) { + return false + } + throw OError.tag(err, 'error getting newsletter subscriptions status', { + userId: user._id, + listName, + }) + } + } + + async function subscribe(user) { + try { + const path = getSubscriberPath(user.email) + await mailchimp.put(path, { + email_address: user.email, + status: 'subscribed', + status_if_new: 'subscribed', + merge_fields: getMergeFields(user), + }) + logger.debug( + { user, listName }, + 'finished subscribing user to newsletter' + ) + } catch (err) { + throw OError.tag(err, 'error subscribing user to newsletter', { + userId: user._id, + listName, + }) + } + } + + async function unsubscribe(user, options = {}) { + try { + const path = getSubscriberPath(user.email) + if (options.delete) { + await mailchimp.delete(path) + } else { + await mailchimp.patch(path, { + status: 'unsubscribed', + merge_fields: getMergeFields(user), + }) + } + logger.debug( + { user, options, listName }, + 'finished unsubscribing user from newsletter' + ) + } catch (err) { + if (err.status === 404 || err.status === 405) { + // silently ignore users who were never subscribed (404) or previously deleted (405) + return + } + + if (err.message.includes('looks fake or invalid')) { + logger.debug( + { err, user, options, listName }, + 'Mailchimp declined to unsubscribe user because it finds the email looks fake' + ) + return + } + + throw OError.tag(err, 'error unsubscribing user from newsletter', { + userId: user._id, + listName, + }) + } + } + + async function changeEmail(user, newEmail) { + const oldEmail = user.email + + try { + await updateEmailInMailchimp(user, newEmail) + } catch (updateError) { + // if we failed to update the user, delete their old email address so that + // we don't leave it stuck in mailchimp + logger.debug( + { oldEmail, newEmail, updateError, listName }, + 'unable to change email in newsletter, removing old mail' + ) + + try { + await unsubscribe(user, { delete: true }) + } catch (unsubscribeError) { + // something went wrong removing the user's address + throw OError.tag( + unsubscribeError, + 'error unsubscribing old email in response to email change failure', + { oldEmail, newEmail, updateError, listName } + ) + } + + if (!(updateError instanceof NonFatalEmailUpdateError)) { + throw updateError + } + } + } + + async function updateEmailInMailchimp(user, newEmail) { + const oldEmail = user.email + + // mailchimp doesn't give us error codes, so we have to parse the message :'( + const errors = { + 'merge fields were invalid': 'user has never subscribed', + 'could not be validated': + 'user has previously unsubscribed or new email already exist on list', + 'is already a list member': 'new email is already on mailing list', + 'looks fake or invalid': 'mail looks fake to mailchimp', + } + + try { + const path = getSubscriberPath(oldEmail) + await mailchimp.patch(path, { + email_address: newEmail, + merge_fields: getMergeFields(user), + }) + logger.debug( + { newEmail, listName }, + 'finished changing email in the newsletter' + ) + } catch (err) { + // silently ignore users who were never subscribed + if (err.status === 404) { + return + } + + // look through expected mailchimp errors and log if we find one + Object.keys(errors).forEach(key => { + if (err.message.includes(key)) { + const message = `unable to change email in newsletter, ${errors[key]}` + + logger.debug({ oldEmail, newEmail, listName }, message) + + throw new NonFatalEmailUpdateError( + message, + oldEmail, + newEmail + ).withCause(err) + } + }) + + // if we didn't find an expected error, generate something to throw + throw OError.tag(err, 'error changing email in newsletter', { + oldEmail, + newEmail, + listName, + }) + } + } + + function getSubscriberPath(email) { + const emailHash = hashEmail(email) + return `/lists/${MAILCHIMP_LIST_ID}/members/${emailHash}` + } + + function hashEmail(email) { + return crypto.createHash('md5').update(email.toLowerCase()).digest('hex') + } + + function getMergeFields(user) { + return { + FNAME: user.first_name, + LNAME: user.last_name, + MONGO_ID: user._id.toString(), + } + } +} + +function makeNullProvider(listName) { + return { + subscribed, + subscribe, + unsubscribe, + changeEmail, + } + + async function subscribed(user) { + logger.debug( + { user, listName }, + 'Not checking user because no newsletter provider is configured' + ) + return false + } + + async function subscribe(user) { + logger.debug( + { user, listName }, + 'Not subscribing user to newsletter because no newsletter provider is configured' + ) + } + + async function unsubscribe(user) { + logger.debug( + { user, listName }, + 'Not unsubscribing user from newsletter because no newsletter provider is configured' + ) + } + + async function changeEmail(user, newEmail) { + logger.debug( + { userId: user._id, newEmail, listName }, + 'Not changing email in newsletter for user because no newsletter provider is configured' + ) + } +} diff --git a/services/web/app/src/Features/Newsletter/NewsletterManager.js b/services/web/app/src/Features/Newsletter/NewsletterManager.js index 18ac40dd5f..ebae9f288f 100644 --- a/services/web/app/src/Features/Newsletter/NewsletterManager.js +++ b/services/web/app/src/Features/Newsletter/NewsletterManager.js @@ -1,248 +1,9 @@ -const { callbackify } = require('util') -const logger = require('@overleaf/logger') const Settings = require('@overleaf/settings') -const crypto = require('crypto') -const Mailchimp = require('mailchimp-api-v3') -const OError = require('@overleaf/o-error') +const MailchimpProvider = require('./MailChimpProvider') -const provider = getProvider() +const provider = MailchimpProvider.make( + 'newsletter', + Settings.mailchimp ? Settings.mailchimp.list_id : null +) -module.exports = { - subscribed: callbackify(provider.subscribed), - subscribe: callbackify(provider.subscribe), - unsubscribe: callbackify(provider.unsubscribe), - changeEmail: callbackify(provider.changeEmail), - promises: provider, -} - -class NonFatalEmailUpdateError extends OError { - constructor(message, oldEmail, newEmail) { - super(message, { oldEmail, newEmail }) - } -} - -function getProvider() { - if (mailchimpIsConfigured()) { - logger.debug('Using newsletter provider: mailchimp') - return makeMailchimpProvider() - } else { - logger.debug('Using newsletter provider: none') - return makeNullProvider() - } -} - -function mailchimpIsConfigured() { - return Settings.mailchimp != null && Settings.mailchimp.api_key != null -} - -function makeMailchimpProvider() { - const mailchimp = new Mailchimp(Settings.mailchimp.api_key) - const MAILCHIMP_LIST_ID = Settings.mailchimp.list_id - - return { - subscribed, - subscribe, - unsubscribe, - changeEmail, - } - - async function subscribed(user) { - try { - const path = getSubscriberPath(user.email) - const result = await mailchimp.get(path) - return result?.status === 'subscribed' - } catch (err) { - if (err.status === 404) { - return false - } - throw OError.tag(err, 'error getting newsletter subscriptions status', { - userId: user._id, - }) - } - } - - async function subscribe(user) { - try { - const path = getSubscriberPath(user.email) - await mailchimp.put(path, { - email_address: user.email, - status: 'subscribed', - status_if_new: 'subscribed', - merge_fields: getMergeFields(user), - }) - logger.debug({ user }, 'finished subscribing user to newsletter') - } catch (err) { - throw OError.tag(err, 'error subscribing user to newsletter', { - userId: user._id, - }) - } - } - - async function unsubscribe(user, options = {}) { - try { - const path = getSubscriberPath(user.email) - if (options.delete) { - await mailchimp.delete(path) - } else { - await mailchimp.patch(path, { - status: 'unsubscribed', - merge_fields: getMergeFields(user), - }) - } - logger.debug( - { user, options }, - 'finished unsubscribing user from newsletter' - ) - } catch (err) { - if (err.status === 404 || err.status === 405) { - // silently ignore users who were never subscribed (404) or previously deleted (405) - return - } - - if (err.message.includes('looks fake or invalid')) { - logger.debug( - { err, user, options }, - 'Mailchimp declined to unsubscribe user because it finds the email looks fake' - ) - return - } - - throw OError.tag(err, 'error unsubscribing user from newsletter', { - userId: user._id, - }) - } - } - - async function changeEmail(user, newEmail) { - const oldEmail = user.email - - try { - await updateEmailInMailchimp(user, newEmail) - } catch (updateError) { - // if we failed to update the user, delete their old email address so that - // we don't leave it stuck in mailchimp - logger.debug( - { oldEmail, newEmail, updateError }, - 'unable to change email in newsletter, removing old mail' - ) - - try { - await unsubscribe(user, { delete: true }) - } catch (unsubscribeError) { - // something went wrong removing the user's address - throw OError.tag( - unsubscribeError, - 'error unsubscribing old email in response to email change failure', - { oldEmail, newEmail, updateError } - ) - } - - if (!(updateError instanceof NonFatalEmailUpdateError)) { - throw updateError - } - } - } - - async function updateEmailInMailchimp(user, newEmail) { - const oldEmail = user.email - - // mailchimp doesn't give us error codes, so we have to parse the message :'( - const errors = { - 'merge fields were invalid': 'user has never subscribed', - 'could not be validated': - 'user has previously unsubscribed or new email already exist on list', - 'is already a list member': 'new email is already on mailing list', - 'looks fake or invalid': 'mail looks fake to mailchimp', - } - - try { - const path = getSubscriberPath(oldEmail) - await mailchimp.patch(path, { - email_address: newEmail, - merge_fields: getMergeFields(user), - }) - logger.debug('finished changing email in the newsletter') - } catch (err) { - // silently ignore users who were never subscribed - if (err.status === 404) { - return - } - - // look through expected mailchimp errors and log if we find one - Object.keys(errors).forEach(key => { - if (err.message.includes(key)) { - const message = `unable to change email in newsletter, ${errors[key]}` - - logger.debug({ oldEmail, newEmail }, message) - - throw new NonFatalEmailUpdateError( - message, - oldEmail, - newEmail - ).withCause(err) - } - }) - - // if we didn't find an expected error, generate something to throw - throw OError.tag(err, 'error changing email in newsletter', { - oldEmail, - newEmail, - }) - } - } - - function getSubscriberPath(email) { - const emailHash = hashEmail(email) - return `/lists/${MAILCHIMP_LIST_ID}/members/${emailHash}` - } - - function hashEmail(email) { - return crypto.createHash('md5').update(email.toLowerCase()).digest('hex') - } - - function getMergeFields(user) { - return { - FNAME: user.first_name, - LNAME: user.last_name, - MONGO_ID: user._id.toString(), - } - } -} - -function makeNullProvider() { - return { - subscribed, - subscribe, - unsubscribe, - changeEmail, - } - - async function subscribed(user) { - logger.debug( - { user }, - 'Not checking user because no newsletter provider is configured' - ) - return false - } - - async function subscribe(user) { - logger.debug( - { user }, - 'Not subscribing user to newsletter because no newsletter provider is configured' - ) - } - - async function unsubscribe(user) { - logger.debug( - { user }, - 'Not unsubscribing user from newsletter because no newsletter provider is configured' - ) - } - - async function changeEmail(oldEmail, newEmail) { - logger.debug( - { oldEmail, newEmail }, - 'Not changing email in newsletter for user because no newsletter provider is configured' - ) - } -} +module.exports = provider diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 70625fa739..8a7e635ebf 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -765,7 +765,7 @@ const ProjectController = { ) User.findById( userId, - 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace', + 'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram labsProgramGalileo', (err, user) => { // Handle case of deleted user if (user == null) { @@ -1197,6 +1197,8 @@ const ProjectController = { refProviders: _.mapValues(user.refProviders, Boolean), alphaProgram: user.alphaProgram, betaProgram: user.betaProgram, + labsProgram: user.labsProgram, + labsProgramGalileo: user.labsProgramGalileo, isAdmin: hasAdminAccess(user), }, userSettings: { diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index 8495376413..bd7e8d8e81 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -290,8 +290,16 @@ const UserController = { OError.tag(err, 'error unsubscribing to newsletter') return next(err) } - return res.json({ - message: req.i18n.translate('thanks_settings_updated'), + // TODO: figure out why things go wrong if we import at the top + const Modules = require('../../infrastructure/Modules') + Modules.hooks.fire('newsletterUnsubscribed', user, err => { + if (err) { + OError.tag(err, 'error firing "newsletterUnsubscribed" hook') + return next(err) + } + return res.json({ + message: req.i18n.translate('thanks_settings_updated'), + }) }) }) }) diff --git a/services/web/app/src/Features/User/UserPagesController.js b/services/web/app/src/Features/User/UserPagesController.js index 39fe4e494f..c6feb07b42 100644 --- a/services/web/app/src/Features/User/UserPagesController.js +++ b/services/web/app/src/Features/User/UserPagesController.js @@ -73,6 +73,7 @@ async function settingsPage(req, res) { last_name: user.last_name, alphaProgram: user.alphaProgram, betaProgram: user.betaProgram, + labsProgram: user.labsProgram, features: { dropbox: user.features.dropbox, github: user.features.github, diff --git a/services/web/app/src/Features/User/UserUpdater.js b/services/web/app/src/Features/User/UserUpdater.js index ee77bd9c08..03da274ff5 100644 --- a/services/web/app/src/Features/User/UserUpdater.js +++ b/services/web/app/src/Features/User/UserUpdater.js @@ -217,6 +217,16 @@ async function setDefaultEmailAddress( 'Failed to change email in newsletter subscription' ) } + try { + // TODO: figure out why things go wrong if we import at the top + const Modules = require('../../infrastructure/Modules') + await Modules.promises.hooks.fire('userEmailChanged', user, email) + } catch (err) { + logger.error( + { err, oldEmail, newEmail: email }, + 'Failed to fire "userEmailChanged" hook' + ) + } try { await RecurlyWrapper.promises.updateAccountEmailAddress(user._id, email) diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index 1d6b8089bc..27db546836 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/services/web/app/src/infrastructure/ExpressLocals.js @@ -411,6 +411,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) { Settings.analytics.ga.tokenV4, cookieDomain: Settings.cookieDomain, templateLinks: Settings.templateLinks, + labsEnabled: Settings.labs && Settings.labs.enable, } next() }) diff --git a/services/web/app/src/infrastructure/Modules.js b/services/web/app/src/infrastructure/Modules.js index 0157a4b852..cb2c8e99fa 100644 --- a/services/web/app/src/infrastructure/Modules.js +++ b/services/web/app/src/infrastructure/Modules.js @@ -21,7 +21,7 @@ function loadModules() { require(settingsCheckModule) } - for (const moduleName of Settings.moduleImportSequence) { + for (const moduleName of Settings.moduleImportSequence || []) { const loadedModule = require(Path.join( MODULE_BASE_PATH, moduleName, diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js index 200c19a2aa..89f6293000 100644 --- a/services/web/app/src/models/User.js +++ b/services/web/app/src/models/User.js @@ -163,6 +163,8 @@ const UserSchema = new Schema({ }, alphaProgram: { type: Boolean, default: false }, // experimental features betaProgram: { type: Boolean, default: false }, + labsProgram: { type: Boolean, default: false }, + labsProgramGalileo: { type: Boolean, default: false }, overleaf: { id: { type: Number }, accessToken: { type: String }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 2acd136e31..653c6fc247 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -409,6 +409,11 @@ "or": "", "other_logs_and_files": "", "other_output_files": "", + "overleaf_labs": "", + "labs_program_benefits": "", + "labs_program_already_participating": "", + "labs_program_not_participating": "", + "manage_labs_program_membership": "", "owner": "", "page_current": "", "pagination_navigation": "", diff --git a/services/web/frontend/js/features/settings/components/labs-program-section.tsx b/services/web/frontend/js/features/settings/components/labs-program-section.tsx new file mode 100644 index 0000000000..ccb8ea2794 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/labs-program-section.tsx @@ -0,0 +1,28 @@ +import { useTranslation, Trans } from 'react-i18next' +import { useUserContext } from '../../../shared/context/user-context' + +function LabsProgramSection() { + const { t } = useTranslation() + const { labsProgram } = useUserContext() + + return ( + <> +

{t('overleaf_labs')}

+ {labsProgram ? null : ( +

+ + + +

+ )} +

+ {labsProgram + ? t('labs_program_already_participating') + : t('labs_program_not_participating')} +

+ {t('manage_labs_program_membership')} + + ) +} + +export default LabsProgramSection diff --git a/services/web/frontend/js/features/settings/components/root.tsx b/services/web/frontend/js/features/settings/components/root.tsx index db92f2bfc1..9f421a0813 100644 --- a/services/web/frontend/js/features/settings/components/root.tsx +++ b/services/web/frontend/js/features/settings/components/root.tsx @@ -6,6 +6,7 @@ import AccountInfoSection from './account-info-section' import PasswordSection from './password-section' import LinkingSection from './linking-section' import BetaProgramSection from './beta-program-section' +import LabsProgramSection from './labs-program-section' import SessionsSection from './sessions-section' import NewsletterSection from './newsletter-section' import LeaveSection from './leave-section' @@ -38,7 +39,9 @@ function SettingsPageRoot() { function SettingsPageContent() { const { t } = useTranslation() - const { isOverleaf } = getMeta('ol-ExposedSettings') as ExposedSettings + const { isOverleaf, labsEnabled } = getMeta( + 'ol-ExposedSettings' + ) as ExposedSettings return ( @@ -67,6 +70,12 @@ function SettingsPageContent() {
) : null} + {labsEnabled ? ( + <> + +
+ + ) : null} {isOverleaf ? ( <> diff --git a/services/web/frontend/js/shared/context/user-context.js b/services/web/frontend/js/shared/context/user-context.js index fe322cfa3a..db0d83d1ba 100644 --- a/services/web/frontend/js/shared/context/user-context.js +++ b/services/web/frontend/js/shared/context/user-context.js @@ -15,6 +15,7 @@ UserContext.Provider.propTypes = { last_name: PropTypes.string, alphaProgram: PropTypes.boolean, betaProgram: PropTypes.boolean, + labsProgram: PropTypes.boolean, features: PropTypes.shape({ dropbox: PropTypes.boolean, github: PropTypes.boolean, diff --git a/services/web/frontend/stylesheets/components/beta-badges.less b/services/web/frontend/stylesheets/components/beta-badges.less index 00890febc7..41b3444d0f 100644 --- a/services/web/frontend/stylesheets/components/beta-badges.less +++ b/services/web/frontend/stylesheets/components/beta-badges.less @@ -46,7 +46,6 @@ content: 'β'; } } - .alpha-badge { background-color: @ol-green; border-radius: @border-radius-base; @@ -56,6 +55,13 @@ } } +.labs-badge { + background-color: @orange; + border-radius: @border-radius-base; + padding: 2px; + color: white; +} + .split-test-badge-tooltip .tooltip-inner { white-space: pre-wrap; } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 8fef1d053b..b7952e9ca8 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1876,5 +1876,20 @@ "reverse_x_sort_order" : "Reverse __x__ sort order", "create_first_project": "Create First Project", "you_dont_have_any_repositories": "You don’t have any repositories", - "tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters" + "tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters", + "overleaf_labs": "Overleaf Labs", + "labs_program_already_participating": "You are enrolled in Labs", + "labs_program_not_participating": "You are not enrolled in Labs", + "labs_program_benefits": "__appName__ is always looking for new ways to help users work more quickly and effectively. By joining Overleaf Labs, you can participate in experiments that explore innovative ideas in the space of collaborative writing and publishing.", + "you_can_opt_in_and_out_of_overleaf_labs_at_any_time_on_this_page": "You can <0>opt in and out of Overleaf Labs at any time on this page", + "you_can_opt_in_and_out_of_galileo_at_any_time_on_this_page": "You can <0>opt in and out of Galileo at any time on this page", + "you_can_opt_in_to_individual_experiments": "You will be asked to opt in and out of individual experiments; each experiment may have unique partners, requirements, terms and conditions, etc. that must be opted in to for that specific experiment", + "labs_program_badge_description": "While using __appName__, you will see Labs features marked with this badge:", + "note_experiments_under_development": "<0>Please note that experiments in this program are still being tested and actively developed. This means that they might <0>change, be <0>removed or <0>become part of a paid plan", + "galileo_program_description": "Galileo is an AI that helps you write your documents", + "galileo_is_part_of_overleaf_labs": "Galileo is an experiment in <0>Overleaf Labs", + "thank_you_for_being_part_of_our_labs_program": "Thank you for being part of our Labs program, where you can have <0>early access to experimental features and help us explore innovative ideas that help you work more quickly and effectively", + "manage_labs_program_membership": "Manage Labs Program Membership", + "current_experiments": "Current Experiments", + "overleaf_labs": "Overleaf Labs" } diff --git a/services/web/migrations/20220830140459_create_index_user_labsProgram_labsProgramGalileo.js b/services/web/migrations/20220830140459_create_index_user_labsProgram_labsProgramGalileo.js new file mode 100644 index 0000000000..715690ad07 --- /dev/null +++ b/services/web/migrations/20220830140459_create_index_user_labsProgram_labsProgramGalileo.js @@ -0,0 +1,26 @@ +/* eslint-disable no-unused-vars */ + +const Helpers = require('./lib/helpers') + +exports.tags = ['saas'] + +const indexes = [ + { + key: { labsProgram: 1 }, + name: 'labsProgram_1', + }, + { + key: { labsProgramGalileo: 1 }, + name: 'labsProgramGalileo_1', + }, +] + +exports.migrate = async client => { + const { db } = client + await Helpers.addIndexesToCollection(db.users, indexes) +} + +exports.rollback = async client => { + const { db } = client + await Helpers.dropIndexesFromCollection(db.users, indexes) +} diff --git a/services/web/public/img/other-brands/logo_pwc.png b/services/web/public/img/other-brands/logo_pwc.png new file mode 100644 index 0000000000..1ba8b3f432 Binary files /dev/null and b/services/web/public/img/other-brands/logo_pwc.png differ diff --git a/services/web/types/exposed-settings.ts b/services/web/types/exposed-settings.ts index 128af9c536..547e82484e 100644 --- a/services/web/types/exposed-settings.ts +++ b/services/web/types/exposed-settings.ts @@ -36,4 +36,5 @@ export type ExposedSettings = { textExtensions: string[] validRootDocExtensions: string[] templateLinks: TemplateLink[] + labsEnabled: boolean }