diff --git a/services/web/app/src/infrastructure/mongodb.js b/services/web/app/src/infrastructure/mongodb.js index 1a9bacbb3f..f48c125cd3 100644 --- a/services/web/app/src/infrastructure/mongodb.js +++ b/services/web/app/src/infrastructure/mongodb.js @@ -70,6 +70,7 @@ async function setupDb() { db.samlLogs = internalDb.collection('samlLogs') db.spellingPreferences = internalDb.collection('spellingPreferences') db.splittests = internalDb.collection('splittests') + db.ssoConfigs = internalDb.collection('ssoConfigs') db.subscriptions = internalDb.collection('subscriptions') db.surveys = internalDb.collection('surveys') db.systemmessages = internalDb.collection('systemmessages') diff --git a/services/web/app/src/models/SSOConfig.js b/services/web/app/src/models/SSOConfig.js new file mode 100644 index 0000000000..8daab2d7b7 --- /dev/null +++ b/services/web/app/src/models/SSOConfig.js @@ -0,0 +1,22 @@ +const mongoose = require('../infrastructure/Mongoose') +const { Schema } = mongoose + +const SSOConfigSchema = new Schema( + { + entryPoint: { type: String, required: true }, + certificate: { type: String, required: true }, + signatureAlgorithm: { type: String, required: true }, + userIdAttribute: { type: String, required: true }, + userFirstNameAttribute: { type: String }, + userLastNameAttribute: { type: String }, + enabled: { type: Boolean, default: false }, + }, + + { + collection: 'ssoConfigs', + minimize: false, + } +) + +exports.SSOConfig = mongoose.model('SSOConfig', SSOConfigSchema) +exports.SSOConfigSchema = SSOConfigSchema diff --git a/services/web/app/src/models/Subscription.js b/services/web/app/src/models/Subscription.js index c6d85343ef..77e3be8799 100644 --- a/services/web/app/src/models/Subscription.js +++ b/services/web/app/src/models/Subscription.js @@ -55,6 +55,7 @@ const SubscriptionSchema = new Schema( type: Date, }, }, + ssoConfig: { type: ObjectId, ref: 'SSOConfig' }, }, { minimize: false } ) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 3fb02ad4b3..73f9b80c5f 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -127,6 +127,7 @@ "category_operators": "", "category_relations": "", "center": "", + "certificate": "", "change": "", "change_currency": "", "change_or_cancel-cancel": "", @@ -178,6 +179,7 @@ "compile_terminated_by_user": "", "compiler": "", "compiling": "", + "configure_sso": "", "confirm": "", "confirm_affiliation": "", "confirm_affiliation_to_relink_dropbox": "", @@ -293,6 +295,7 @@ "edit_dictionary_empty": "", "edit_dictionary_remove": "", "edit_figure": "", + "edit_sso_configuration": "", "edit_tag": "", "editing": "", "editing_captions": "", @@ -311,6 +314,7 @@ "enabling": "", "end_of_document": "", "enter_image_url": "", + "entry_point": "", "error": "", "error_performing_request": "", "example_project": "", @@ -995,6 +999,7 @@ "showing_x_out_of_n_projects": "", "showing_x_results": "", "showing_x_results_of_total": "", + "signature_algorithm": "", "single_sign_on_sso": "", "something_went_wrong_loading_pdf_viewer": "", "something_went_wrong_processing_the_request": "", @@ -1007,6 +1012,11 @@ "sort_by_x": "", "source": "", "spell_check": "", + "sso_config_prop_help_certificate": "", + "sso_config_prop_help_first_name": "", + "sso_config_prop_help_last_name": "", + "sso_config_prop_help_user_entry_point": "", + "sso_config_prop_help_user_id": "", "sso_explanation": "", "sso_link_error": "", "start_by_adding_your_email": "", @@ -1204,6 +1214,9 @@ "used_when_referring_to_the_figure_elsewhere_in_the_document": "", "user_deletion_error": "", "user_deletion_password_reset_tip": "", + "user_first_name_attribute": "", + "user_id_attribute": "", + "user_last_name_attribute": "", "user_sessions": "", "validation_issue_entry_description": "", "vat": "", diff --git a/services/web/frontend/js/shared/components/select.tsx b/services/web/frontend/js/shared/components/select.tsx index e5af26d054..eb7c453099 100644 --- a/services/web/frontend/js/shared/components/select.tsx +++ b/services/web/frontend/js/shared/components/select.tsx @@ -1,20 +1,38 @@ /* eslint-disable jsx-a11y/label-has-for */ /* eslint-disable jsx-a11y/label-has-associated-control */ +import { useRef, useEffect } from 'react' import classNames from 'classnames' import { useSelect } from 'downshift' import Icon from './icon' import { useTranslation } from 'react-i18next' -type SelectProps = { +export type SelectProps = { + // The items rendered as dropdown options. items: T[] - itemToString: (item: T | null) => string + // Stringifies an item of type T. The resulting string is rendered as a dropdown option. + itemToString: (item: T | null | undefined) => string + // Caption for the dropdown. label?: string + // Attribute used to identify the component inside a Form. This name is used to + // retrieve FormData when the form is submitted. The value of the FormData entry + // is the string returned by `itemToString(selectedItem)`. + name?: string + // Hint text displayed in the initial render. defaultText?: string - itemToSubtitle?: (item: T | null) => string + // Initial selected item, displayed in the initial render. When both `defaultText` + // and `defaultItem` are set the latter is ignored. + defaultItem?: T + // Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option. + itemToSubtitle?: (item: T | null | undefined) => string + // Stringifies an item. The resulting string is rendered as a React `key` for each item. itemToKey: (item: T) => string + // Callback invoked after the selected item is updated. onSelectedItemChanged?: (item: T | null | undefined) => void + // When `true` item selection is disabled. disabled?: boolean + // When `true` displays an "Optional" subtext after the `label` caption. optionalLabel?: boolean + // When `true` displays a spinner next to the `label` caption. loading?: boolean } @@ -22,7 +40,9 @@ export const Select = ({ items, itemToString = item => (item === null ? '' : String(item)), label, + name, defaultText = 'Items', + defaultItem, itemToSubtitle, itemToKey, onSelectedItemChanged, @@ -48,8 +68,37 @@ export const Select = ({ } }, }) + + const rootRef = useRef(null) + useEffect(() => { + if (!name || !rootRef.current) return + + const parentForm: HTMLFormElement | null | undefined = + rootRef.current?.closest('form') + if (!parentForm) return + + function handleFormDataEvent(event: FormDataEvent) { + const data = event.formData + const key = name as string // can't be undefined due to early exit in the effect + if (selectedItem || defaultItem) { + data.append(key, itemToString(selectedItem || defaultItem)) + } + } + + parentForm.addEventListener('formdata', handleFormDataEvent) + return () => { + parentForm.removeEventListener('formdata', handleFormDataEvent) + } + }, [name, itemToString, selectedItem, defaultItem]) + + let value: string | undefined + if (selectedItem || defaultItem) { + value = itemToString(selectedItem || defaultItem) + } else { + value = defaultText + } return ( -
+
{label ? ( ) : null}
-
{selectedItem ? itemToString(selectedItem) : defaultText}
+
{value}
{isOpen ? ( diff --git a/services/web/frontend/stories/subscription/managed-users/manage-group-settings.stories.tsx b/services/web/frontend/stories/subscription/managed-users/manage-group-settings.stories.tsx new file mode 100644 index 0000000000..14203c5a3f --- /dev/null +++ b/services/web/frontend/stories/subscription/managed-users/manage-group-settings.stories.tsx @@ -0,0 +1,39 @@ +import GroupSettings from '../../../../modules/managed-users/frontend/js/components/group-settings' +import { useMeta } from '../../hooks/use-meta' + +export const GroupSettingsWithManagedUsersDisabledAndNoSSOFeature = () => { + useMeta({ + 'ol-managedUsersEnabled': false, + 'ol-hasGroupSSOFeature': false, + }) + return +} + +export const GroupSettingsWithManagedUsersDisabledAndSSOFeature = () => { + useMeta({ + 'ol-managedUsersEnabled': false, + 'ol-hasGroupSSOFeature': true, + }) + return +} + +export const GroupSettingsWithManagedUsersEnabledAndNoSSOFeature = () => { + useMeta({ + 'ol-managedUsersEnabled': true, + 'ol-hasGroupSSOFeature': false, + }) + return +} + +export const GroupSettingsWithManagedUsersEnabledAndSSOFeature = () => { + useMeta({ + 'ol-managedUsersEnabled': true, + 'ol-hasGroupSSOFeature': true, + }) + return +} + +export default { + title: 'Subscription / Managed Users', + component: GroupSettings, +} diff --git a/services/web/frontend/stories/subscription/sso/configuration-modal.stories.tsx b/services/web/frontend/stories/subscription/sso/configuration-modal.stories.tsx new file mode 100644 index 0000000000..61b3a270c0 --- /dev/null +++ b/services/web/frontend/stories/subscription/sso/configuration-modal.stories.tsx @@ -0,0 +1,88 @@ +import useFetchMock from '../../hooks/use-fetch-mock' +import { useMeta } from '../../hooks/use-meta' +import SSOConfigurationModal, { + SSOConfigurationModalProps, +} from '../../../../modules/managed-users/frontend/js/components/modals/sso-configuration-modal' + +const config = { + entryPoint: 'http://idp.example.com/entry_point', + certificate: + 'MIIDXTCCAkWgAwIBAgIJAOvOeQ4xFTzsMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMTE1MTQxMjU5WhcNMjYxMTE1MTQxMjU5WjBFMQswCQYDVQQGEwJHQjETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCT6MBe5G9VoLU8MfztOEbUhnwLp17ak8eFUqxqeXkkqtWB0b/cmIBU3xoQoO3dIF8PBzfqehqfYVhrNt/TFgcmDfmJnPJRL1RJWMW3VmiP5odJ3LwlkKbZpkeT3wZ8HEJIR1+zbpxiBNkbd2GbdR1iumcsHzMYX1A2CBj+ZMV5VijC+K4P0e9c05VsDEUtLmfeAasJAiumQoVVgAe/BpiXjICGGewa6EPFI7mKkifIRKOGxdRESwZZjxP30bI31oDN0cgKqIgSJtJ9nfCn9jgBMBkQHu42WMuaWD4jrGd7+vYdX+oIfArs9aKgAH5kUGhGdew2R9SpBefrhbNxG8QIDAQABo1AwTjAdBgNVHQ4EFgQU+aSojSyyLChP/IpZcafvSdhj7KkwHwYDVR0jBBgwFoAU+aSojSyyLChP/IpZcafvSdhj7KkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABl3+OOVLBWMKs6PjA8lPuloWDNzSr3v76oUcHqAb+cfbucjXrOVsS9RJ0X9yxvCQyfM9FfY43DbspnN3izYhdvbJD8kKLNf0LA5st+ZxLfy0ACyL2iyAwICaqndqxAjQYplFAHmpUiu1DiHckyBPekokDJd+ze95urHMOsaGS5RWPoKJVE0bkaAeZCmEu0NNpXRSBiuxXSTeSAJfv6kyE/rkdhzUKyUl/cGQFrsVYfAFQVA+W6CKOh74ErSEzSHQQYndl7nD33snD/YqdU1ROxV6aJzLKCg+sdj+wRXSP2u/UHnM4jW9TGJfhO42jzL6WVuEvr9q4l7zWzUQKKKhtQ==', + signatureAlgorithm: 'sha256', + userIdAttribute: 'email', +} + +export const ConfigurationModalLoadingError = ( + args: SSOConfigurationModalProps +) => { + useMeta({ 'ol-groupId': '123' }) + useFetchMock(fetchMock => { + fetchMock.get( + 'express:/manage/groups/:id/settings/sso', + { status: 500 }, + { + delay: 1000, + } + ) + fetchMock.post('express:/manage/groups/:id/settings/sso', 200, { + delay: 500, + }) + }) + return +} + +export const ConfigurationModal = (args: SSOConfigurationModalProps) => { + useMeta({ 'ol-groupId': '123' }) + useFetchMock(fetchMock => { + fetchMock.get('express:/manage/groups/:id/settings/sso', config, { + delay: 500, + }) + fetchMock.post('express:/manage/groups/:id/settings/sso', 200, { + delay: 500, + }) + }) + return +} + +export const ConfigurationModalEmpty = (args: SSOConfigurationModalProps) => { + useMeta({ 'ol-groupId': '123' }) + useFetchMock(fetchMock => { + fetchMock.get( + 'express:/manage/groups/:id/settings/sso', + {}, + { + delay: 500, + } + ) + fetchMock.post('express:/manage/groups/:id/settings/sso', 200, { + delay: 500, + }) + }) + return +} + +export const ConfigurationModalSaveError = ( + args: SSOConfigurationModalProps +) => { + useMeta({ 'ol-groupId': '123' }) + useFetchMock(fetchMock => { + fetchMock.get('express:/manage/groups/:id/settings/sso', config, { + delay: 500, + }) + fetchMock.post('express:/manage/groups/:id/settings/sso', 500, { + delay: 1000, + }) + }) + return +} + +export default { + title: 'Subscription / SSO', + component: SSOConfigurationModal, + args: { + show: true, + }, + argTypes: { + handleHide: { action: 'close modal' }, + }, +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 612b39e563..bed2f30f99 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -215,6 +215,7 @@ "category_operators": "Operators", "category_relations": "Relations", "center": "Center", + "certificate": "Certificate", "change": "Change", "change_currency": "Change currency", "change_or_cancel-cancel": "cancel", @@ -286,6 +287,7 @@ "compiler": "Compiler", "compiling": "Compiling", "complete": "Complete", + "configure_sso": "Configure SSO", "confirm": "Confirm", "confirm_affiliation": "Confirm Affiliation", "confirm_affiliation_to_relink_dropbox": "Please confirm you are still at the institution and on their license, or upgrade your account in order to relink your Dropbox account.", @@ -449,6 +451,7 @@ "edit_dictionary_empty": "Your custom dictionary is empty.", "edit_dictionary_remove": "Remove from dictionary", "edit_figure": "Edit figure", + "edit_sso_configuration": "Edit SSO Configuration", "edit_tag": "Edit Tag", "editing": "Editing", "editing_captions": "Editing captions", @@ -489,6 +492,7 @@ "enter_your_email_address": "Enter your email address", "enter_your_email_address_below_and_we_will_send_you_a_link_to_reset_your_password": "Enter your email address below, and we will send you a link to reset your password", "enter_your_new_password": "Enter your new password", + "entry_point": "Entry point", "error": "Error", "error_performing_request": "An error has occurred while performing your request.", "es": "Spanish", @@ -1565,6 +1569,7 @@ "showing_x_results": "Showing __x__ results", "showing_x_results_of_total": "Showing __x__ results of __total__", "sign_up_now": "Sign Up Now", + "signature_algorithm": "Signature Algorithm", "single_sign_on_sso": "Single Sign-On (SSO)", "single_version_easy_collab_blurb": "__appName__ makes sure that you’re always up to date with your collaborators and what they are doing. There is only a single master version of each document which everyone has access to. It’s impossible to make conflicting changes, and you don’t have to wait for your colleagues to send you the latest draft before you can keep working.", "site_description": "An online LaTeX editor that’s easy to use. No installation, real-time collaboration, version control, hundreds of LaTeX templates, and more.", @@ -1591,6 +1596,11 @@ "spell_check": "Spell check", "spread_the_word_and_fill_bar": "Spread the word and fill this bar up", "sso_account_already_linked": "Account already linked to another __appName__ user", + "sso_config_prop_help_certificate": "Base64 encoded certificate without whitespace", + "sso_config_prop_help_first_name": "Property in SAML assertion to use for first name", + "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_integration": "SSO integration", "sso_integration_info": "Overleaf offers a standard SAML-based Single Sign On integration.", @@ -1871,7 +1881,10 @@ "user_already_added": "User already added", "user_deletion_error": "Sorry, something went wrong deleting your account. Please try again in a minute.", "user_deletion_password_reset_tip": "If you cannot remember your password, or if you are using Single-Sign-On with another provider to sign in (such as Twitter or Google), please <0>reset your password and try again.", + "user_first_name_attribute": "User First Name Attribute", + "user_id_attribute": "User ID Attribute", "user_is_not_part_of_group": "User is not part of group", + "user_last_name_attribute": "User Last Name Attribute", "user_management": "User management", "user_management_info": "Group plan admins have access to an admin panel where users can be added and removed easily. For site-wide plans, users are automatically upgraded when they register or add their email address to Overleaf (domain-based enrollment or SSO).", "user_not_found": "User not found", diff --git a/services/web/test/acceptance/src/helpers/Subscription.js b/services/web/test/acceptance/src/helpers/Subscription.js index 1493964209..030e29e664 100644 --- a/services/web/test/acceptance/src/helpers/Subscription.js +++ b/services/web/test/acceptance/src/helpers/Subscription.js @@ -21,6 +21,7 @@ class Subscription { this.teamInvites = options.teamInvites || [] this.planCode = options.planCode this.recurlySubscription_id = options.recurlySubscription_id + this.features = options.features } ensureExists(callback) { diff --git a/services/web/test/frontend/components/shared/select.spec.tsx b/services/web/test/frontend/components/shared/select.spec.tsx new file mode 100644 index 0000000000..15fa53590d --- /dev/null +++ b/services/web/test/frontend/components/shared/select.spec.tsx @@ -0,0 +1,269 @@ +import { useCallback, FormEvent } from 'react' +import { Button, Form, FormControl } from 'react-bootstrap' +import { + Select, + SelectProps, +} from '../../../../frontend/js/shared/components/select' + +const testData = [1, 2, 3].map(index => ({ + key: index, + value: `Demo item ${index}`, + sub: `Subtitle ${index}`, +})) + +type RenderProps = Partial> & { + onSubmit?: (formData: object) => void +} + +function render(props: RenderProps) { + const submitHandler = (event: FormEvent) => { + event.preventDefault() + if (props.onSubmit) { + const formData = new FormData(event.target as HTMLFormElement) + // a plain object is more convenient to work later with assertions + props.onSubmit(Object.fromEntries(formData.entries())) + } + } + cy.mount( +
+
+ ', function () { + describe('initial rendering', function () { + it('renders default text', function () { + render({ defaultText: 'Choose an item' }) + cy.findByTestId('spinner').should('not.exist') + cy.findByText('Choose an item') + }) + + it('renders default item', function () { + render({ defaultItem: testData[2] }) + cy.findByText('Demo item 3') + }) + + it('default item takes precedence over default text', function () { + render({ defaultText: 'Choose an item', defaultItem: testData[2] }) + cy.findByText('Demo item 3') + }) + + it('renders label', function () { + render({ + defaultText: 'Choose an item', + label: 'test label', + optionalLabel: false, + }) + cy.findByText('test label') + cy.findByText('(Optional)').should('not.exist') + }) + + it('renders optional label', function () { + render({ + defaultText: 'Choose an item', + label: 'test label', + optionalLabel: true, + }) + cy.findByText('test label') + cy.findByText('(Optional)') + }) + + it('renders a spinner while loading when there is a label', function () { + render({ + defaultText: 'Choose an item', + label: 'test label', + loading: true, + }) + cy.findByTestId('spinner') + }) + + it('does not render a spinner while loading if there is no label', function () { + render({ + defaultText: 'Choose an item', + loading: true, + }) + cy.findByTestId('spinner').should('not.exist') + }) + }) + + describe('items rendering', function () { + it('renders all items', function () { + render({ defaultText: 'Choose an item' }) + cy.findByText('Choose an item').click() + + cy.findByText('Demo item 1') + cy.findByText('Demo item 2') + cy.findByText('Demo item 3') + }) + + it('renders subtitles', function () { + render({ + defaultText: 'Choose an item', + itemToSubtitle: x => String(x?.sub), + }) + cy.findByText('Choose an item').click() + + cy.findByText('Subtitle 1') + cy.findByText('Subtitle 2') + cy.findByText('Subtitle 3') + }) + }) + + describe('item selection', function () { + it('cannot select an item when disabled', function () { + render({ defaultText: 'Choose an item', disabled: true }) + cy.findByText('Choose an item').click() + + cy.findByText('Demo item 1').should('not.exist') + cy.findByText('Demo item 2').should('not.exist') + cy.findByText('Demo item 3').should('not.exist') + cy.findByText('Choose an item') + }) + + it('renders only the selected item after selection', function () { + render({ defaultText: 'Choose an item' }) + cy.findByText('Choose an item').click() + + cy.findByText('Demo item 1') + cy.findByText('Demo item 2') + cy.findByText('Demo item 3').click() + + cy.findByText('Choose an item').should('not.exist') + cy.findByText('Demo item 1').should('not.exist') + cy.findByText('Demo item 2').should('not.exist') + cy.findByText('Demo item 3') + }) + + it('invokes callback after selection', function () { + const selectionHandler = cy.stub().as('selectionHandler') + + render({ + defaultText: 'Choose an item', + onSelectedItemChanged: selectionHandler, + }) + cy.findByText('Choose an item').click() + cy.findByText('Demo item 2').click() + + cy.get('@selectionHandler').should( + 'have.been.calledOnceWith', + testData[1] + ) + }) + }) + + describe('when the form is submitted', function () { + it('populates FormData with the default selected item', function () { + const submitHandler = cy.stub().as('submitHandler') + render({ defaultItem: testData[1], onSubmit: submitHandler }) + + cy.findByText('submit').click() + cy.get('@submitHandler').should('have.been.calledOnceWith', { + select_control: 'Demo item 2', + }) + }) + + it('populates FormData with the selected item', function () { + const submitHandler = cy.stub().as('submitHandler') + render({ defaultItem: testData[1], onSubmit: submitHandler }) + + cy.findByText('Demo item 2').click() // open dropdown + cy.findByText('Demo item 3').click() // choose a different item + + cy.findByText('submit').click() + cy.get('@submitHandler').should('have.been.calledOnceWith', { + select_control: 'Demo item 3', + }) + }) + + it('does not populate FormData when no item is selected', function () { + const submitHandler = cy.stub().as('submitHandler') + render({ defaultText: 'Choose an item', onSubmit: submitHandler }) + + cy.findByText('submit').click() + cy.get('@submitHandler').should('have.been.calledOnceWith', {}) + }) + }) + + describe('with react-bootstrap forms', function () { + type FormWithSelectProps = { + onSubmit: (formData: object) => void + } + + const FormWithSelect = ({ onSubmit }: FormWithSelectProps) => { + const selectComponent = useCallback( + () => ( +