From d94eaa19ccbf3c817e7d5b6b4fb3a3dc3d8ed3e9 Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Mon, 2 Oct 2023 09:26:33 +0200 Subject: [PATCH] Merge pull request #14587 from overleaf/mf-enable-sso-managed-users [web] Enable SSO switch on Group Settings GitOrigin-RevId: 591881eb4e6bad912de026f7a687f3b020712c2d --- .../web/frontend/extracted-translations.json | 17 +++ .../frontend/js/shared/components/switch.tsx | 26 ++++ .../sso/configuration-modal.stories.tsx | 4 +- .../subscription/sso/enable-modal.stories.tsx | 37 +++++ .../web/frontend/stories/switch.stories.tsx | 14 ++ .../frontend/stylesheets/_style_includes.less | 1 + .../stylesheets/components/switch.less | 63 +++++++++ .../web/frontend/stylesheets/main-style.less | 1 + .../stylesheets/modules/managed-users.less | 29 +++- services/web/locales/en.json | 19 ++- .../sso/group-settings-sso.spec.tsx | 130 ++++++++++++++++++ services/web/types/subscription/sso.ts | 9 ++ 12 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 services/web/frontend/js/shared/components/switch.tsx create mode 100644 services/web/frontend/stories/subscription/sso/enable-modal.stories.tsx create mode 100644 services/web/frontend/stories/switch.stories.tsx create mode 100644 services/web/frontend/stylesheets/components/switch.less create mode 100644 services/web/test/frontend/features/group-management/components/sso/group-settings-sso.spec.tsx create mode 100644 services/web/types/subscription/sso.ts 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 features 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” under the <1>Recompile 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>only 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 more", + "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>only way group members can log in to Overleaf. <1>Learn more about how we support SAML 2.0 IdPs.", "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>only 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__ people to __appName__. Good job!", "you_introed_small_number": " You’ve introduced <0>__numberOfPeople__ 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__.", "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 +}