diff --git a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
index ef8d48291d..b3fced21fb 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
+++ b/services/web/app/src/Features/Subscription/SubscriptionGroupController.mjs
@@ -8,6 +8,8 @@ import SessionManager from '../Authentication/SessionManager.js'
import UserAuditLogHandler from '../User/UserAuditLogHandler.js'
import { expressify } from '@overleaf/promise-utils'
import Modules from '../../infrastructure/Modules.js'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.js'
+import ErrorController from '../Errors/ErrorController.js'
/**
* @import { Subscription } from "../../../../types/subscription/dashboard/subscription"
@@ -111,7 +113,33 @@ async function _removeUserFromGroup(
res.sendStatus(200)
}
+async function requestConfirmation(req, res) {
+ const { variant } = await SplitTestHandler.promises.getAssignment(
+ req,
+ res,
+ 'flexible-group-licensing'
+ )
+
+ if (variant !== 'enabled') {
+ return ErrorController.notFound(req, res)
+ }
+
+ try {
+ const userId = SessionManager.getLoggedInUserId(req.session)
+ const subscription =
+ await SubscriptionLocator.promises.getUsersSubscription(userId)
+
+ res.render('subscriptions/request-confirmation-react', {
+ groupName: subscription.teamName,
+ })
+ } catch (error) {
+ logger.err({ error }, 'error trying to request seats to subscription')
+ return res.render('/user/subscription')
+ }
+}
+
export default {
removeUserFromGroup: expressify(removeUserFromGroup),
removeSelfFromGroup: expressify(removeSelfFromGroup),
+ requestConfirmation: expressify(requestConfirmation),
}
diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
index 4a4e4516fd..2305dc29de 100644
--- a/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
+++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.mjs
@@ -80,6 +80,13 @@ export default {
SubscriptionGroupController.removeSelfFromGroup
)
+ webRouter.get(
+ '/user/subscription/group/request-confirmation',
+ AuthenticationController.requireLogin(),
+ RateLimiterMiddleware.rateLimit(subscriptionRateLimiter),
+ SubscriptionGroupController.requestConfirmation
+ )
+
// Team invites
webRouter.get(
'/subscription/invites/:token/',
diff --git a/services/web/app/views/subscriptions/request-confirmation-react.pug b/services/web/app/views/subscriptions/request-confirmation-react.pug
new file mode 100644
index 0000000000..6687d9aa60
--- /dev/null
+++ b/services/web/app/views/subscriptions/request-confirmation-react.pug
@@ -0,0 +1,13 @@
+extends ../layout-marketing
+
+block vars
+ - bootstrap5PageStatus = 'enabled' // Enforce BS5 version
+
+block entrypointVar
+ - entrypoint = 'pages/user/subscription/group-management/request-confirmation'
+
+block append meta
+ meta(name="ol-groupName", data-type="string", content=groupName)
+
+block content
+ main.content.content-alt#subscription-manage-group-root
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index ce069bf772..e72e1ea9d2 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -603,6 +603,7 @@
"go_to_overleaf": "",
"go_to_pdf_location_in_code": "",
"go_to_settings": "",
+ "go_to_subscriptions": "",
"group_admin": "",
"group_invitations": "",
"group_invite_has_been_sent_to_email": "",
@@ -1019,6 +1020,7 @@
"other": "",
"other_logs_and_files": "",
"other_output_files": "",
+ "our_team_will_get_back_to_you_shortly": "",
"our_values": "",
"out_of_sync": "",
"out_of_sync_detail": "",
@@ -1793,6 +1795,7 @@
"we_are_unable_to_opt_you_into_this_experiment": "",
"we_cant_find_any_sections_or_subsections_in_this_file": "",
"we_do_not_share_personal_information": "",
+ "we_got_your_request": "",
"we_logged_you_in": "",
"we_sent_new_code": "",
"webinars": "",
diff --git a/services/web/frontend/js/features/group-management/components/request-confirmation.tsx b/services/web/frontend/js/features/group-management/components/request-confirmation.tsx
new file mode 100644
index 0000000000..8323a27a4c
--- /dev/null
+++ b/services/web/frontend/js/features/group-management/components/request-confirmation.tsx
@@ -0,0 +1,50 @@
+import { useTranslation } from 'react-i18next'
+import { Card, CardBody, Row, Col } from 'react-bootstrap-5'
+import Button from '@/features/ui/components/bootstrap-5/button'
+import MaterialIcon from '@/shared/components/material-icon'
+import getMeta from '@/utils/meta'
+import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
+
+function RequestConfirmation() {
+ const { t } = useTranslation()
+ const groupName = getMeta('ol-groupName')
+
+ return (
+
+
+
+
+
+
{groupName || t('group_subscription')}
+
+
+
+
+
+
+
+
{t('we_got_your_request')}
+
+ {t('our_team_will_get_back_to_you_shortly')}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default RequestConfirmation
diff --git a/services/web/frontend/js/pages/user/subscription/group-management/request-confirmation.tsx b/services/web/frontend/js/pages/user/subscription/group-management/request-confirmation.tsx
new file mode 100644
index 0000000000..5a5eba47bc
--- /dev/null
+++ b/services/web/frontend/js/pages/user/subscription/group-management/request-confirmation.tsx
@@ -0,0 +1,8 @@
+import '../base'
+import ReactDOM from 'react-dom'
+import RequestConfirmation from '@/features/group-management/components/request-confirmation'
+
+const element = document.getElementById('subscription-manage-group-root')
+if (element) {
+ ReactDOM.render(, element)
+}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/card.scss b/services/web/frontend/stylesheets/bootstrap-5/components/card.scss
index 250370ceda..fe6209f872 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/components/card.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/components/card.scss
@@ -183,3 +183,21 @@
padding: var(--spacing-10) var(--spacing-09);
}
}
+
+.card-icon {
+ display: flex;
+ width: max-content;
+ margin: 0 auto;
+ padding: var(--spacing-08);
+ border-radius: 50%;
+ background-color: var(--bg-light-secondary);
+ color: var(--content-secondary);
+
+ .material-symbols {
+ font-size: 2rem;
+ }
+}
+
+.card-description-secondary {
+ color: var(--content-secondary);
+}
diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss
index 573c914b55..076f04772a 100644
--- a/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss
+++ b/services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss
@@ -158,3 +158,21 @@
}
}
}
+
+.group-heading {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-04);
+ margin-bottom: var(--spacing-06);
+
+ h2 {
+ @include heading-lg;
+
+ margin: 0;
+ word-break: break-all;
+ }
+
+ .btn-ghost {
+ --bs-btn-bg: transparent;
+ }
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 13a0ed19c9..8cd9b4691c 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -860,6 +860,7 @@
"go_to_pdf_location_in_code": "Go to PDF location in code (Tip: double click on the PDF for best results)",
"go_to_previous_page": "Go to previous page",
"go_to_settings": "Go to settings",
+ "go_to_subscriptions": "Go to Subscriptions",
"great_for_getting_started": "Great for getting started",
"great_for_small_teams_and_departments": "Great for small teams and departments",
"group": "Group",
@@ -1455,6 +1456,7 @@
"other_output_files": "Download other output files",
"other_sessions": "Other Sessions",
"other_ways_to_log_in": "Other ways to log in",
+ "our_team_will_get_back_to_you_shortly": "Our team will get back to you shortly.",
"our_values": "Our values",
"out_of_sync": "Out of sync",
"out_of_sync_detail": "Sorry, this file has gone out of sync and we need to do a full refresh.<0 /><1>Please see this help guide for more information1>",
@@ -2438,6 +2440,7 @@
"we_cant_confirm_this_email": "We can’t confirm this email",
"we_cant_find_any_sections_or_subsections_in_this_file": "We can’t find any sections or subsections in this file",
"we_do_not_share_personal_information": "See our <0>Privacy Notice0> for details of how we treat your personal data",
+ "we_got_your_request": "We’ve got your request",
"we_logged_you_in": "We have logged you in.",
"we_may_also_contact_you_from_time_to_time_by_email_with_a_survey": "<0>We may also contact you0> from time to time by email with a survey, or to see if you would like to participate in other user research initiatives",
"we_sent_new_code": "We’ve sent a new code. If it doesn’t arrive, make sure to check your spam and any promotions folders.",
diff --git a/services/web/test/frontend/features/group-management/components/request-confirmation.spec.tsx b/services/web/test/frontend/features/group-management/components/request-confirmation.spec.tsx
new file mode 100644
index 0000000000..320372453b
--- /dev/null
+++ b/services/web/test/frontend/features/group-management/components/request-confirmation.spec.tsx
@@ -0,0 +1,40 @@
+import '../../../helpers/bootstrap-5'
+import RequestConfirmation from '@/features/group-management/components/request-confirmation'
+
+describe('request confirmation page', function () {
+ beforeEach(function () {
+ cy.window().then(win => {
+ win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
+ })
+ cy.mount()
+ })
+
+ it('renders the back button', function () {
+ cy.findByTestId('group-heading').within(() => {
+ cy.findByRole('button', { name: /back to subscription/i }).should(
+ 'have.attr',
+ 'href',
+ '/user/subscription'
+ )
+ })
+ })
+
+ it('shows the group name', function () {
+ cy.findByTestId('group-heading').within(() => {
+ cy.findByRole('heading', { name: 'My Awesome Team' })
+ })
+ })
+
+ it('indicates the message was received', function () {
+ cy.findByRole('heading', { name: /we’ve got your request/i })
+ cy.findByText(/our team will get back to you shortly/i)
+ })
+
+ it('renders the link to subscriptions', function () {
+ cy.findByRole('button', { name: /go to subscriptions/i }).should(
+ 'have.attr',
+ 'href',
+ '/user/subscription'
+ )
+ })
+})
diff --git a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
index d113e0950e..78d13d3a61 100644
--- a/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
+++ b/services/web/test/unit/src/Subscription/SubscriptionGroupControllerTests.mjs
@@ -1,5 +1,6 @@
import esmock from 'esmock'
import sinon from 'sinon'
+import { expect } from 'chai'
const modulePath =
'../../../../app/src/Features/Subscription/SubscriptionGroupController'
@@ -24,6 +25,7 @@ describe('SubscriptionGroupController', function () {
this.subscription = {
_id: this.subscriptionId,
+ teamName: 'Cool group',
}
this.SubscriptionGroupHandler = {
@@ -35,6 +37,7 @@ describe('SubscriptionGroupController', function () {
this.SubscriptionLocator = {
promises: {
getSubscription: sinon.stub().resolves(this.subscription),
+ getUsersSubscription: sinon.stub().resolves(this.subscription),
},
}
@@ -61,6 +64,13 @@ describe('SubscriptionGroupController', function () {
},
}
+ this.SplitTestHandler = {
+ promises: {
+ getAssignment: sinon.stub().resolves({ variant: 'default' }),
+ },
+ getAssignment: sinon.stub().yields(null, { variant: 'default' }),
+ }
+
this.Controller = await esmock.strict(modulePath, {
'../../../../app/src/Features/Subscription/SubscriptionGroupHandler':
this.SubscriptionGroupHandler,
@@ -71,6 +81,12 @@ describe('SubscriptionGroupController', function () {
'../../../../app/src/Features/User/UserAuditLogHandler':
this.UserAuditLogHandler,
'../../../../app/src/infrastructure/Modules': this.Modules,
+ '../../../../app/src/Features/SplitTests/SplitTestHandler':
+ this.SplitTestHandler,
+ '../../../../app/src/Features/Errors/ErrorController':
+ (this.ErrorController = {
+ notFound: sinon.stub(),
+ }),
})
})
@@ -253,4 +269,19 @@ describe('SubscriptionGroupController', function () {
this.Controller.removeSelfFromGroup(this.req, res, done)
})
})
+
+ describe('add seats', function () {
+ it('render the request confirmation view', async function () {
+ this.SplitTestHandler.promises.getAssignment.resolves({
+ variant: 'enabled',
+ })
+ await this.Controller.requestConfirmation(this.req, {
+ render: (viewPath, viewParams) => {
+ expect(viewPath).to.equal('subscriptions/request-confirmation-react')
+ expect(viewParams.groupName).to.equal('Cool group')
+ },
+ })
+ expect(this.ErrorController.notFound).to.not.have.been.called
+ })
+ })
})