diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 3461db96c3..e6e7e00c26 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -261,7 +261,10 @@
"dictionary": "",
"did_you_know_institution_providing_professional": "",
"did_you_know_that_overleaf_offers": "",
+ "disable_single_sign_on": "",
+ "disable_sso": "",
"disable_stop_on_first_error": "",
+ "disabling": "",
"discount_of": "",
"dismiss": "",
"dismiss_error_popup": "",
@@ -319,9 +322,12 @@
"emails_and_affiliations_explanation": "",
"emails_and_affiliations_title": "",
"enable_managed_users": "",
+ "enable_single_sign_on": "",
+ "enable_sso": "",
"enable_stop_on_first_error_under_recompile_dropdown_menu": "",
"enabled_managed_users_set_up_sso": "",
"enabling": "",
+ "enabling_sso_will_make_this_the_only_sign_in_option": "",
"end_of_document": "",
"enter_image_url": "",
"entry_point": "",
@@ -845,6 +851,7 @@
"project_url": "",
"projects": "",
"projects_list": "",
+ "provide_details_of_your_sso_configuration": "",
"public": "",
"publish": "",
"publish_as_template": "",
@@ -1040,7 +1047,12 @@
"sso_config_prop_help_last_name": "",
"sso_config_prop_help_user_entry_point": "",
"sso_config_prop_help_user_id": "",
+ "sso_configuration": "",
"sso_explanation": "",
+ "sso_is_disabled_explanation_1": "",
+ "sso_is_disabled_explanation_2": "",
+ "sso_is_enabled_explanation_1": "",
+ "sso_is_enabled_explanation_2": "",
"sso_link_error": "",
"start_a_free_trial": "",
"start_by_adding_your_email": "",
@@ -1274,7 +1286,9 @@
"we_logged_you_in": "",
"wed_love_you_to_stay": "",
"welcome_to_sl": "",
+ "what_does_this_mean": "",
"what_does_this_mean_for_you": "",
+ "what_happens_when_sso_is_enabled": "",
"when_you_tick_the_include_caption_box": "",
"wide": "",
"with_premium_subscription_you_also_get": "",
@@ -1305,6 +1319,7 @@
"you_have_been_invited_to_transfer_management_of_your_account": "",
"you_have_been_invited_to_transfer_management_of_your_account_to": "",
"you_may_be_able_to_prevent_a_compile_timeout": "",
+ "you_need_to_configure_your_sso_settings": "",
"you_will_be_able_to_reassign_subscription": "",
"youll_get_best_results_in_visual_but_can_be_used_in_source": "",
"your_affiliation_is_confirmed": "",
@@ -1327,6 +1342,8 @@
"your_projects": "",
"your_subscription": "",
"your_subscription_has_expired": "",
+ "youre_about_to_disable_single_sign_on": "",
+ "youre_about_to_enable_single_sign_on": "",
"youre_on_free_trial_which_ends_on": "",
"zoom_in": "",
"zoom_out": "",
diff --git a/services/web/frontend/js/shared/components/switch.tsx b/services/web/frontend/js/shared/components/switch.tsx
new file mode 100644
index 0000000000..1b21600dc8
--- /dev/null
+++ b/services/web/frontend/js/shared/components/switch.tsx
@@ -0,0 +1,26 @@
+import classNames from 'classnames'
+
+type SwitchProps = {
+ onChange: () => void
+ checked: boolean
+ disabled?: boolean
+}
+
+function Switch({ onChange, checked, disabled = false }: SwitchProps) {
+ return (
+
+ )
+}
+
+export default Switch
diff --git a/services/web/frontend/stories/subscription/sso/configuration-modal.stories.tsx b/services/web/frontend/stories/subscription/sso/configuration-modal.stories.tsx
index 61b3a270c0..52eabb5124 100644
--- a/services/web/frontend/stories/subscription/sso/configuration-modal.stories.tsx
+++ b/services/web/frontend/stories/subscription/sso/configuration-modal.stories.tsx
@@ -31,7 +31,7 @@ export const ConfigurationModalLoadingError = (
return
}
-export const ConfigurationModal = (args: SSOConfigurationModalProps) => {
+export const ConfigurationModalFilled = (args: SSOConfigurationModalProps) => {
useMeta({ 'ol-groupId': '123' })
useFetchMock(fetchMock => {
fetchMock.get('express:/manage/groups/:id/settings/sso', config, {
@@ -77,7 +77,7 @@ export const ConfigurationModalSaveError = (
}
export default {
- title: 'Subscription / SSO',
+ title: 'Subscription / SSO / Configuration Modal',
component: SSOConfigurationModal,
args: {
show: true,
diff --git a/services/web/frontend/stories/subscription/sso/enable-modal.stories.tsx b/services/web/frontend/stories/subscription/sso/enable-modal.stories.tsx
new file mode 100644
index 0000000000..ad5d82f139
--- /dev/null
+++ b/services/web/frontend/stories/subscription/sso/enable-modal.stories.tsx
@@ -0,0 +1,37 @@
+import SSOEnableModal, {
+ type SSOEnableModalProps,
+} from '../../../../modules/managed-users/frontend/js/components/modals/sso-enable-modal'
+import useFetchMock from '../../hooks/use-fetch-mock'
+import { useMeta } from '../../hooks/use-meta'
+
+export const EnableSSOModalDefault = (args: SSOEnableModalProps) => {
+ useMeta({ 'ol-groupId': '123' })
+ useFetchMock(fetchMock => {
+ fetchMock.post('express:/manage/groups/:id/settings/enableSSO', 200, {
+ delay: 500,
+ })
+ })
+ return
+}
+
+export const EnableSSOModalError = (args: SSOEnableModalProps) => {
+ useMeta({ 'ol-groupId': '123' })
+ useFetchMock(fetchMock => {
+ fetchMock.post('express:/manage/groups/:id/settings/enableSSO', 500, {
+ delay: 500,
+ })
+ })
+ return
+}
+
+export default {
+ title: 'Subscription / SSO / Enable Modal',
+ component: SSOEnableModal,
+ args: {
+ show: true,
+ },
+ argTypes: {
+ handleHide: { action: 'close modal' },
+ onEnableSSO: { action: 'callback' },
+ },
+}
diff --git a/services/web/frontend/stories/switch.stories.tsx b/services/web/frontend/stories/switch.stories.tsx
new file mode 100644
index 0000000000..5997f88ede
--- /dev/null
+++ b/services/web/frontend/stories/switch.stories.tsx
@@ -0,0 +1,14 @@
+import Switch from '../js/shared/components/switch'
+
+export const Unchecked = () => {
+ return {}} checked={false} />
+}
+
+export const Checked = () => {
+ return {}} checked />
+}
+
+export default {
+ title: 'Shared / Components / Switch',
+ component: Switch,
+}
diff --git a/services/web/frontend/stylesheets/_style_includes.less b/services/web/frontend/stylesheets/_style_includes.less
index 5182f84761..142c659ffa 100644
--- a/services/web/frontend/stylesheets/_style_includes.less
+++ b/services/web/frontend/stylesheets/_style_includes.less
@@ -46,6 +46,7 @@
@import 'components/alerts.less';
@import 'components/progress-bars.less';
@import 'components/select.less';
+@import 'components/switch.less';
@import 'components/switcher.less';
// @import "components/media.less";
@import 'components/list-group.less';
diff --git a/services/web/frontend/stylesheets/components/switch.less b/services/web/frontend/stylesheets/components/switch.less
new file mode 100644
index 0000000000..74f5b96430
--- /dev/null
+++ b/services/web/frontend/stylesheets/components/switch.less
@@ -0,0 +1,63 @@
+@switch-circle-diameter: 16px;
+@switch-inner-padding: 2px;
+@switch-width: 34px;
+@switch-height: @switch-circle-diameter + @switch-inner-padding +
+ @switch-inner-padding;
+@switch-circle-translate-x: @switch-width - @switch-circle-diameter -
+ @switch-inner-padding - @switch-inner-padding;
+@switch-circle-wrapper-border-radius: @switch-height / 2;
+@switch-transition: 0.4s;
+
+.switch-input {
+ position: relative;
+ display: inline-block;
+ width: @switch-width;
+ height: @switch-height;
+
+ input.invisible-input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+
+ // span.switch -> circle "wrapper"
+ & + span.switch {
+ background-color: @ol-blue-gray-4;
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ transition: @switch-transition;
+ border-radius: @switch-circle-wrapper-border-radius;
+ }
+
+ // span.switch::before is the circle itself
+ & + span.switch::before {
+ position: absolute;
+ content: '';
+ height: @switch-circle-diameter;
+ width: @switch-circle-diameter;
+ left: @switch-inner-padding;
+ bottom: @switch-inner-padding;
+ background-color: @white;
+ transition: @switch-transition;
+ border-radius: 50%;
+ }
+
+ &:checked + span.switch {
+ background-color: @ol-green;
+ }
+
+ // when input is checked, move circle to the right
+ &:checked + span.switch::before {
+ transform: translateX(@switch-circle-translate-x);
+ }
+ }
+
+ &.disabled {
+ input.invisible-input + span.switch {
+ background-color: @gray-light;
+ }
+ }
+}
diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less
index 84f055be6b..f5c7a5af5a 100644
--- a/services/web/frontend/stylesheets/main-style.less
+++ b/services/web/frontend/stylesheets/main-style.less
@@ -73,6 +73,7 @@
@import 'components/split-menu.less';
@import 'components/list-group.less';
@import 'components/select.less';
+@import 'components/switch.less';
@import 'components/switcher.less';
// Components w/ JavaScript
diff --git a/services/web/frontend/stylesheets/modules/managed-users.less b/services/web/frontend/stylesheets/modules/managed-users.less
index 7ba835a065..fcba224566 100644
--- a/services/web/frontend/stylesheets/modules/managed-users.less
+++ b/services/web/frontend/stylesheets/modules/managed-users.less
@@ -1,10 +1,21 @@
-.group-settings-title {
- font-family: Lato, sans-serif;
+h2.group-settings-title {
+ margin-bottom: 5px;
font-size: @font-size-large;
+}
+
+h3.group-settings-title {
+ margin-bottom: 0;
+ font-size: @font-size-base;
+}
+
+h2.group-settings-title,
+h3.group-settings-title {
+ font-family: Lato, sans-serif;
line-height: 28px;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
+ margin-top: 0;
}
.enrollment-invite {
@@ -82,3 +93,17 @@
vertical-align: text-bottom;
}
}
+
+.group-settings-sso {
+ border-top: 1px solid @gray-lighter;
+ padding-top: 25px;
+ margin-top: 25px;
+
+ .group-settings-sso-enable,
+ .group-settings-sso-configure {
+ margin-top: @margin-md;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 710d8910ec..9d04c94510 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -400,7 +400,10 @@
"did_you_know_institution_providing_professional": "Did you know that __institutionName__ is providing <0>free __appName__ Professional features0> to everyone at __institutionName__?",
"did_you_know_that_overleaf_offers": "Did you know that __appName__ offers group and organization-wide subscription options? Request information or a quote.",
"direct_link": "Direct Link",
+ "disable_single_sign_on": "Disable single sign-on",
+ "disable_sso": "Disable SSO",
"disable_stop_on_first_error": "Disable “Stop on first error”",
+ "disabling": "Disabling",
"disconnected": "Disconnected",
"discount_of": "Discount of __amount__",
"dismiss": "Dismiss",
@@ -494,9 +497,12 @@
"empty_zip_file": "Zip doesn’t contain any file",
"en": "English",
"enable_managed_users": "Enable Managed Users",
+ "enable_single_sign_on": "Enable single sign-on",
+ "enable_sso": "Enable SSO",
"enable_stop_on_first_error_under_recompile_dropdown_menu": "Enable <0>“Stop on first error”0> under the <1>Recompile1> drop-down menu to help you find and fix errors right away.",
"enabled_managed_users_set_up_sso": "You need to enable Managed Users to set up SSO.",
"enabling": "Enabling",
+ "enabling_sso_will_make_this_the_only_sign_in_option": "Enabling SSO will make this the <0>only0> sign-in option for members.",
"end_of_document": "End of document",
"enter_image_url": "Enter image URL",
"enter_institution_email_to_log_in": "Enter your institutional email to log in through your institution.",
@@ -1343,6 +1349,7 @@
"project_url": "Affected project URL",
"projects": "Projects",
"projects_list": "Projects list",
+ "provide_details_of_your_sso_configuration": "Provide details of your SSO configuration",
"pt": "Portuguese",
"public": "Public",
"publish": "Publish",
@@ -1623,9 +1630,14 @@
"sso_config_prop_help_last_name": "Property in SAML assertion to use for last name",
"sso_config_prop_help_user_entry_point": "URL for SAML SSO redirect flow",
"sso_config_prop_help_user_id": "Property in SAML assertion to use for unique id",
- "sso_explanation": "SAML 2.0 - based single sign-on gives your team members access to Overleaf through ADFS, Azure, Okta, OneLogin, or your custom Identity Provider. <0>Learn more0>",
+ "sso_configuration": "SSO configuration",
+ "sso_explanation": "You can enforce single sign-on for members of this group. When SSO is enabled it will be the <0>only0> way group members can log in to Overleaf. <1>Learn more about how we support SAML 2.0 IdPs.1>",
"sso_integration": "SSO integration",
"sso_integration_info": "Overleaf offers a standard SAML-based Single Sign On integration.",
+ "sso_is_disabled_explanation_1": "Group members won’t be able to log in via SSO",
+ "sso_is_disabled_explanation_2": "All members of the group will need a username and password to log in to __appName__",
+ "sso_is_enabled_explanation_1": "Group members will <0>only0> be able to sign in via SSO",
+ "sso_is_enabled_explanation_2": "If there are any problems with the configuration, only you (as the group administrator) will be able to disable SSO.",
"sso_link_error": "Error linking account",
"sso_not_linked": "You have not linked your account to __provider__. Please log in to your account another way and link your __provider__ account via your account settings.",
"sso_user_denied_access": "Cannot log in because __appName__ was not granted access to your __provider__ account. Please try again.",
@@ -1955,7 +1967,9 @@
"website_status": "Website status",
"wed_love_you_to_stay": "We’d love you to stay",
"welcome_to_sl": "Welcome to __appName__!",
+ "what_does_this_mean": "What does this mean?",
"what_does_this_mean_for_you": "This means:",
+ "what_happens_when_sso_is_enabled": "What happens when SSO is enabled?",
"when_you_tick_the_include_caption_box": "When you tick the box “Include caption” the image will be inserted into your document with a placeholder caption. To edit it, you simply select the placeholder text and type to replace it with your own.",
"wide": "Wide",
"will_need_to_log_out_from_and_in_with": "You will need to log out from your __email1__ account and then log in with __email2__.",
@@ -2006,6 +2020,7 @@
"you_introed_high_number": " You’ve introduced <0>__numberOfPeople__0> people to __appName__. Good job!",
"you_introed_small_number": " You’ve introduced <0>__numberOfPeople__0> person to __appName__. Good job, but can you get some more?",
"you_may_be_able_to_prevent_a_compile_timeout": "You may be able to prevent a compile timeout using the following tips.",
+ "you_need_to_configure_your_sso_settings": "You need to configure your SSO settings before enabling SSO",
"you_not_introed_anyone_to_sl": "You’ve not introduced anyone to __appName__ yet. Get sharing!",
"you_plus_1": "You + 1",
"you_plus_10": "You + 10",
@@ -2036,6 +2051,8 @@
"your_sessions": "Your Sessions",
"your_subscription": "Your Subscription",
"your_subscription_has_expired": "Your subscription has expired.",
+ "youre_about_to_disable_single_sign_on": "You’re about to disable single sign-on for all group members.",
+ "youre_about_to_enable_single_sign_on": "You’re about to enable single sign-on (SSO). Before you do this, you should ensure you’re confident the SSO configuration is correct and all your group members have managed user accounts.",
"youre_on_free_trial_which_ends_on": "You’re on a free trial which ends on <0>__date__0>.",
"zh-CN": "Chinese",
"zip_contents_too_large": "Zip contents too large",
diff --git a/services/web/test/frontend/features/group-management/components/sso/group-settings-sso.spec.tsx b/services/web/test/frontend/features/group-management/components/sso/group-settings-sso.spec.tsx
new file mode 100644
index 0000000000..155db5ae78
--- /dev/null
+++ b/services/web/test/frontend/features/group-management/components/sso/group-settings-sso.spec.tsx
@@ -0,0 +1,130 @@
+import GroupSettingsSSO from '../../../../../../modules/managed-users/frontend/js/components/sso/group-settings-sso'
+
+function GroupSettingsSSOComponent() {
+ return (
+
+
+
+ )
+}
+
+const GROUP_ID = '123abc'
+
+describe('GroupSettingsSSO', function () {
+ beforeEach(function () {
+ cy.window().then(win => {
+ win.metaAttributesCache = new Map()
+ win.metaAttributesCache.set('ol-groupId', GROUP_ID)
+ })
+ })
+
+ it('renders sso settings in group management', function () {
+ cy.mount()
+
+ cy.get('.group-settings-sso').within(() => {
+ cy.contains('Single Sign-On (SSO)')
+ cy.contains('Enable SSO')
+ cy.contains('SSO configuration')
+ cy.findByRole('button', { name: 'Configure SSO' })
+ })
+ })
+
+ describe('GroupSettingsSSOEnable', function () {
+ it('renders without sso configuration', function () {
+ cy.mount()
+
+ cy.get('.group-settings-sso-enable').within(() => {
+ cy.contains('Enable SSO')
+ cy.contains(
+ 'Enabling SSO will make this the only sign-in option for members.'
+ )
+ cy.get('.switch-input').within(() => {
+ cy.get('.invisible-input').should('not.be.checked')
+ cy.get('.invisible-input').should('be.disabled')
+ })
+ })
+ })
+
+ it('renders with sso configuration', function () {
+ cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
+ statusCode: 200,
+ body: {
+ entryPoint: 'entrypoint',
+ certificate: 'cert',
+ signatureAlgorithm: 'sha1',
+ userIdAttribute: 'email',
+ enabled: true,
+ },
+ }).as('sso')
+
+ cy.mount()
+
+ cy.wait('@sso')
+
+ cy.get('.group-settings-sso-enable').within(() => {
+ cy.get('.switch-input').within(() => {
+ cy.get('.invisible-input').should('be.checked')
+ cy.get('.invisible-input').should('not.be.disabled')
+ })
+ })
+ })
+
+ describe('sso enable modal', function () {
+ beforeEach(function () {
+ cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
+ statusCode: 200,
+ body: {
+ entryPoint: 'entrypoint',
+ certificate: 'cert',
+ signatureAlgorithm: 'sha1',
+ userIdAttribute: 'email',
+ enabled: false,
+ },
+ }).as('sso')
+
+ cy.mount()
+
+ cy.wait('@sso')
+
+ cy.get('.group-settings-sso-enable').within(() => {
+ cy.get('.switch-input').within(() => {
+ cy.get('.invisible-input').click({ force: true })
+ })
+ })
+ })
+
+ it('render enable modal correctly', function () {
+ // enable modal
+ cy.get('.modal-dialog').within(() => {
+ cy.contains('Enable single sign-on')
+ cy.contains('What happens when SSO is enabled?')
+ })
+ })
+
+ it('close enable modal if Cancel button is clicked', function () {
+ cy.get('.modal-dialog').within(() => {
+ cy.findByRole('button', { name: 'Cancel' }).click()
+ })
+
+ cy.get('.modal-dialog').should('not.exist')
+ })
+
+ it('enables SSO if Enable SSO button is clicked', function () {
+ cy.intercept('POST', `/manage/groups/${GROUP_ID}/settings/enableSSO`, {
+ statusCode: 200,
+ }).as('enableSSO')
+
+ cy.get('.modal-dialog').within(() => {
+ cy.findByRole('button', { name: 'Enable SSO' }).click()
+ })
+ cy.get('.modal-dialog').should('not.exist')
+ cy.get('.group-settings-sso-enable').within(() => {
+ cy.get('.switch-input').within(() => {
+ cy.get('.invisible-input').should('be.checked')
+ cy.get('.invisible-input').should('not.be.disabled')
+ })
+ })
+ })
+ })
+ })
+})
diff --git a/services/web/types/subscription/sso.ts b/services/web/types/subscription/sso.ts
new file mode 100644
index 0000000000..78a2a29b0c
--- /dev/null
+++ b/services/web/types/subscription/sso.ts
@@ -0,0 +1,9 @@
+export type SSOConfig = {
+ entryPoint?: string
+ certificate?: string
+ signatureAlgorithm: 'sha1' | 'sha256' | 'sha512'
+ userIdAttribute?: string
+ userFirstNameAttribute?: string
+ userLastNameAttribute?: string
+ enabled?: boolean
+}