Merge pull request #14585 from overleaf/msm-sso-config-modal

[web] SSO Config modal

GitOrigin-RevId: e704afa61fe14390b64ce29a27ccbce7e884b396
This commit is contained in:
Miguel Serrano 2023-09-12 10:30:11 +02:00 committed by Copybot
parent 4b72908940
commit 0f30edf69f
10 changed files with 502 additions and 6 deletions

View file

@ -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')

View file

@ -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

View file

@ -55,6 +55,7 @@ const SubscriptionSchema = new Schema(
type: Date,
},
},
ssoConfig: { type: ObjectId, ref: 'SSOConfig' },
},
{ minimize: false }
)

View file

@ -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": "",

View file

@ -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<T> = {
export type SelectProps<T> = {
// 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 = <T,>({
items,
itemToString = item => (item === null ? '' : String(item)),
label,
name,
defaultText = 'Items',
defaultItem,
itemToSubtitle,
itemToKey,
onSelectedItemChanged,
@ -48,8 +68,37 @@ export const Select = <T,>({
}
},
})
const rootRef = useRef<HTMLDivElement | null>(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 (
<div className="select-wrapper">
<div className="select-wrapper" ref={rootRef}>
<div>
{label ? (
<label {...getLabelProps()}>
@ -59,14 +108,14 @@ export const Select = <T,>({
({t('optional')})
</span>
)}{' '}
{loading && <Icon fw type="spinner" spin />}
{loading && <Icon data-testid="spinner" fw type="spinner" spin />}
</label>
) : null}
<div
className={classNames({ disabled }, 'select-trigger')}
{...getToggleButtonProps({ disabled })}
>
<div>{selectedItem ? itemToString(selectedItem) : defaultText}</div>
<div>{value}</div>
<div>
{isOpen ? (
<Icon type="chevron-up" fw />

View file

@ -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 <GroupSettings />
}
export const GroupSettingsWithManagedUsersDisabledAndSSOFeature = () => {
useMeta({
'ol-managedUsersEnabled': false,
'ol-hasGroupSSOFeature': true,
})
return <GroupSettings />
}
export const GroupSettingsWithManagedUsersEnabledAndNoSSOFeature = () => {
useMeta({
'ol-managedUsersEnabled': true,
'ol-hasGroupSSOFeature': false,
})
return <GroupSettings />
}
export const GroupSettingsWithManagedUsersEnabledAndSSOFeature = () => {
useMeta({
'ol-managedUsersEnabled': true,
'ol-hasGroupSSOFeature': true,
})
return <GroupSettings />
}
export default {
title: 'Subscription / Managed Users',
component: GroupSettings,
}

View file

@ -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 <SSOConfigurationModal {...args} />
}
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 <SSOConfigurationModal {...args} />
}
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 <SSOConfigurationModal {...args} />
}
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 <SSOConfigurationModal {...args} />
}
export default {
title: 'Subscription / SSO',
component: SSOConfigurationModal,
args: {
show: true,
},
argTypes: {
handleHide: { action: 'close modal' },
},
}

View file

@ -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 youre 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. Its impossible to make conflicting changes, and you dont have to wait for your colleagues to send you the latest draft before you can keep working.",
"site_description": "An online LaTeX editor thats 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</0>",
"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</0> 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",

View file

@ -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) {

View file

@ -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<SelectProps<typeof testData[number]>> & {
onSubmit?: (formData: object) => void
}
function render(props: RenderProps) {
const submitHandler = (event: FormEvent<HTMLFormElement>) => {
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(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<form onSubmit={submitHandler}>
<Select
items={testData}
itemToString={x => String(x?.value)}
label={props.label}
name="select_control"
defaultText={props.defaultText}
defaultItem={props.defaultItem}
itemToSubtitle={props.itemToSubtitle}
itemToKey={x => String(x.key)}
onSelectedItemChanged={props.onSelectedItemChanged}
disabled={props.disabled}
optionalLabel={props.optionalLabel}
loading={props.loading}
/>
<button type="submit">submit</button>
</form>
</div>
)
}
describe('<Select />', 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(
() => (
<Select
name="select_control"
items={testData}
defaultItem={testData[0]}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
/>
),
[]
)
function handleSubmit(event: FormEvent<Form>) {
event.preventDefault()
const formData = new FormData(event.target as HTMLFormElement)
// a plain object is more convenient to work later with assertions
onSubmit(Object.fromEntries(formData.entries()))
}
return (
<Form onSubmit={handleSubmit}>
<FormControl componentClass={selectComponent} />
<Button type="submit">submit</Button>
</Form>
)
}
it('populates FormData with the selected item when the form is submitted', function () {
const submitHandler = cy.stub().as('submitHandler')
cy.mount(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<FormWithSelect onSubmit={submitHandler} />
</div>
)
cy.findByText('Demo item 1').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',
})
})
})
})