mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #16945 from overleaf/ab-fix-sso-managed-users-enrollment
[web] Fix managed users enrollment clearing out SSO linking status GitOrigin-RevId: b2083b48df1782c426794f16e2cdd767b217256c
This commit is contained in:
parent
2833a8ce61
commit
03aaee84a3
4 changed files with 17 additions and 593 deletions
|
@ -1,84 +0,0 @@
|
|||
const { SSOConfig } = require('../../models/SSOConfig')
|
||||
const UserAuditLogHandler = require('../User/UserAuditLogHandler')
|
||||
const UserUpdater = require('../User/UserUpdater')
|
||||
const SAMLIdentityManager = require('../User/SAMLIdentityManager')
|
||||
const { User } = require('../../models/User')
|
||||
const Errors = require('../Errors/Errors')
|
||||
const GroupUtils = require('./GroupUtils')
|
||||
|
||||
async function checkUserCanEnrollInSubscription(userId, subscription) {
|
||||
const ssoConfig = await SSOConfig.findById(subscription?.ssoConfig).exec()
|
||||
if (!ssoConfig?.enabled) {
|
||||
throw new Errors.SAMLGroupSSODisabledError()
|
||||
}
|
||||
|
||||
const userIsMember = subscription.member_ids.some(
|
||||
memberId => memberId.toString() === userId.toString()
|
||||
)
|
||||
if (!userIsMember) {
|
||||
throw new Errors.SAMLGroupSSOLoginIdentityNotFoundError()
|
||||
}
|
||||
|
||||
const user = await User.findOne({ _id: userId }, { enrollment: 1 }).exec()
|
||||
|
||||
const userIsEnrolled = user.enrollment?.sso?.some(
|
||||
enrollment => enrollment.groupId.toString() === subscription._id.toString()
|
||||
)
|
||||
if (userIsEnrolled) {
|
||||
throw new Errors.SAMLIdentityExistsError()
|
||||
}
|
||||
}
|
||||
|
||||
async function enrollInSubscription(
|
||||
userId,
|
||||
subscription,
|
||||
externalUserId,
|
||||
userIdAttribute,
|
||||
auditLog
|
||||
) {
|
||||
await checkUserCanEnrollInSubscription(userId, subscription)
|
||||
|
||||
const providerId = GroupUtils.getProviderId(subscription._id)
|
||||
|
||||
const userBySamlIdentifier = await SAMLIdentityManager.getUser(
|
||||
providerId,
|
||||
externalUserId,
|
||||
userIdAttribute
|
||||
)
|
||||
|
||||
if (userBySamlIdentifier) {
|
||||
throw new Errors.SAMLIdentityExistsError()
|
||||
}
|
||||
|
||||
const samlIdentifiers = {
|
||||
externalUserId,
|
||||
userIdAttribute,
|
||||
providerId,
|
||||
}
|
||||
|
||||
await UserUpdater.promises.updateUser(userId, {
|
||||
$push: {
|
||||
samlIdentifiers,
|
||||
'enrollment.sso': {
|
||||
groupId: subscription._id,
|
||||
linkedAt: new Date(),
|
||||
primary: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await UserAuditLogHandler.promises.addEntry(
|
||||
userId,
|
||||
'group-sso-link',
|
||||
auditLog.initiatorId,
|
||||
auditLog.ipAddress,
|
||||
samlIdentifiers
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
promises: {
|
||||
checkUserCanEnrollInSubscription,
|
||||
enrollInSubscription,
|
||||
},
|
||||
}
|
|
@ -138,6 +138,23 @@ class Subscription {
|
|||
})
|
||||
}
|
||||
|
||||
linkGroupSSO(user, externalUserId, userIdAttribute, auditLog, callback) {
|
||||
SubscriptionModel.findById(this._id).exec((error, subscription) => {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
Modules.hooks.fire(
|
||||
'linkUserToGroupSSO',
|
||||
user._id,
|
||||
subscription,
|
||||
externalUserId,
|
||||
userIdAttribute,
|
||||
auditLog,
|
||||
callback
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expectDeleted(deleterData, callback) {
|
||||
DeletedSubscriptionModel.find(
|
||||
{ 'subscription._id': this._id },
|
||||
|
|
|
@ -1,300 +0,0 @@
|
|||
import GroupSettingsSSORoot from '../../../../../../modules/group-settings/frontend/js/components/sso/group-settings-sso-root'
|
||||
import { SSOConfigurationProvider } from '../../../../../../modules/group-settings/frontend/js/context/sso-configuration-context'
|
||||
import { singleLineCertificates } from '../../../../../../modules/group-settings/test/data/certificates'
|
||||
|
||||
function GroupSettingsSSOComponent() {
|
||||
return (
|
||||
<div style={{ padding: '25px', width: '600px' }}>
|
||||
<SSOConfigurationProvider>
|
||||
<GroupSettingsSSORoot />
|
||||
</SSOConfigurationProvider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(<GroupSettingsSSOComponent />)
|
||||
|
||||
cy.get('.group-settings-sso').within(() => {
|
||||
cy.contains('Single Sign-On (SSO)')
|
||||
cy.contains('Enable SSO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupSettingsSSOEnable', function () {
|
||||
it('renders without sso configuration', function () {
|
||||
cy.mount(<GroupSettingsSSOComponent />)
|
||||
|
||||
cy.contains('Enable SSO')
|
||||
cy.contains(
|
||||
'Set up single sign-on for your group. This sign in method will be optional for group members unless Managed Users is enabled.'
|
||||
)
|
||||
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 not validated', function () {
|
||||
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
entryPoint: 'entrypoint',
|
||||
certificates: [
|
||||
{
|
||||
value: singleLineCertificates[0],
|
||||
},
|
||||
{ value: singleLineCertificates[1] },
|
||||
],
|
||||
userIdAttribute: 'email',
|
||||
enabled: false,
|
||||
validated: false,
|
||||
},
|
||||
}).as('sso')
|
||||
|
||||
cy.mount(<GroupSettingsSSOComponent />)
|
||||
|
||||
cy.wait('@sso')
|
||||
|
||||
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 validated and not enabled', function () {
|
||||
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
entryPoint: 'entrypoint',
|
||||
certificates: [
|
||||
{ value: singleLineCertificates[0] },
|
||||
{ value: singleLineCertificates[1] },
|
||||
],
|
||||
userIdAttribute: 'email',
|
||||
validated: true,
|
||||
enabled: false,
|
||||
},
|
||||
}).as('sso')
|
||||
|
||||
cy.mount(<GroupSettingsSSOComponent />)
|
||||
|
||||
cy.wait('@sso')
|
||||
|
||||
cy.get('.switch-input').within(() => {
|
||||
cy.get('.invisible-input').should('not.be.checked')
|
||||
cy.get('.invisible-input').should('not.be.disabled')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders with sso configuration validated and enabled', function () {
|
||||
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
entryPoint: 'entrypoint',
|
||||
certificates: [
|
||||
{ value: singleLineCertificates[0] },
|
||||
{ value: singleLineCertificates[1] },
|
||||
],
|
||||
userIdAttribute: 'email',
|
||||
validated: true,
|
||||
enabled: true,
|
||||
},
|
||||
}).as('sso')
|
||||
|
||||
cy.mount(<GroupSettingsSSOComponent />)
|
||||
|
||||
cy.wait('@sso')
|
||||
|
||||
cy.get('.switch-input').within(() => {
|
||||
cy.get('.invisible-input').should('be.checked')
|
||||
cy.get('.invisible-input').should('not.be.disabled')
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the configuration, and checks the draft configuration message', function () {
|
||||
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
entryPoint: 'entrypoint',
|
||||
certificates: [{ value: singleLineCertificates[0] }],
|
||||
userIdAttribute: 'email',
|
||||
validated: true,
|
||||
enabled: false,
|
||||
},
|
||||
}).as('sso')
|
||||
|
||||
cy.intercept('POST', `/manage/groups/${GROUP_ID}/settings/sso`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
entryPoint: 'entrypoint',
|
||||
certificates: [{ value: singleLineCertificates[1] }],
|
||||
userIdAttribute: 'email',
|
||||
validated: false,
|
||||
enabled: false,
|
||||
},
|
||||
}).as('ssoUpdated')
|
||||
|
||||
cy.mount(<GroupSettingsSSOComponent />)
|
||||
|
||||
cy.wait('@sso')
|
||||
|
||||
cy.get('.switch-input').within(() => {
|
||||
cy.get('.invisible-input').should('not.be.checked')
|
||||
cy.get('.invisible-input').should('not.be.disabled')
|
||||
})
|
||||
|
||||
cy.findByRole('button', { name: 'View configuration' }).click()
|
||||
cy.findByRole('button', { name: 'Edit' }).click()
|
||||
cy.findByRole('button', { name: 'Next' }).click()
|
||||
cy.wait('@ssoUpdated')
|
||||
cy.findByText('Your configuration has not been finalized.')
|
||||
})
|
||||
|
||||
describe('sso enable modal', function () {
|
||||
beforeEach(function () {
|
||||
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
entryPoint: 'entrypoint',
|
||||
certificates: [{ value: singleLineCertificates[0] }],
|
||||
userIdAttribute: 'email',
|
||||
enabled: false,
|
||||
},
|
||||
}).as('sso')
|
||||
|
||||
cy.mount(<GroupSettingsSSOComponent />)
|
||||
|
||||
cy.wait('@sso')
|
||||
|
||||
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 and shows success banner', function () {
|
||||
cy.intercept('POST', `/manage/groups/${GROUP_ID}/settings/enableSSO`, {
|
||||
statusCode: 200,
|
||||
}).as('enableSSO')
|
||||
|
||||
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
entryPoint: 'entrypoint',
|
||||
certificates: [{ value: singleLineCertificates[0] }],
|
||||
userIdAttribute: 'email',
|
||||
validated: true,
|
||||
enabled: true,
|
||||
},
|
||||
}).as('sso')
|
||||
|
||||
cy.get('.modal-dialog').within(() => {
|
||||
cy.findByRole('button', { name: 'Enable SSO' }).click()
|
||||
})
|
||||
cy.get('.modal-dialog').should('not.exist')
|
||||
|
||||
cy.get('.switch-input').within(() => {
|
||||
cy.get('.invisible-input').should('be.checked')
|
||||
cy.get('.invisible-input').should('not.be.disabled')
|
||||
})
|
||||
|
||||
cy.findByText('SSO is enabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SSO disable modal', function () {
|
||||
beforeEach(function () {
|
||||
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
entryPoint: 'entrypoint',
|
||||
certificates: [{ value: singleLineCertificates[0] }],
|
||||
userIdAttribute: 'email',
|
||||
validated: true,
|
||||
enabled: true,
|
||||
},
|
||||
}).as('sso')
|
||||
|
||||
cy.mount(<GroupSettingsSSOComponent />)
|
||||
|
||||
cy.wait('@sso')
|
||||
|
||||
cy.get('.switch-input').within(() => {
|
||||
cy.get('.invisible-input').click({ force: true })
|
||||
})
|
||||
})
|
||||
|
||||
it('render disable modal correctly', function () {
|
||||
// disable modal
|
||||
cy.get('.modal-dialog').within(() => {
|
||||
cy.contains('Disable single sign-on')
|
||||
cy.contains(
|
||||
'You’re about to disable single sign-on for all group members.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('close disable 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('disables SSO if Disable SSO button is clicked and shows success banner', function () {
|
||||
cy.intercept('POST', `/manage/groups/${GROUP_ID}/settings/disableSSO`, {
|
||||
statusCode: 200,
|
||||
}).as('disableSSO')
|
||||
|
||||
cy.intercept('GET', `/manage/groups/${GROUP_ID}/settings/sso`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
entryPoint: 'entrypoint',
|
||||
certificates: ['cert'],
|
||||
userIdAttribute: 'email',
|
||||
validated: true,
|
||||
enabled: false,
|
||||
},
|
||||
}).as('sso')
|
||||
|
||||
cy.get('.modal-dialog').within(() => {
|
||||
cy.findByRole('button', { name: 'Disable SSO' }).click()
|
||||
})
|
||||
cy.get('.modal-dialog').should('not.exist')
|
||||
|
||||
cy.get('.switch-input').within(() => {
|
||||
cy.get('.invisible-input').should('not.be.checked')
|
||||
})
|
||||
|
||||
cy.findByText('SSO is disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,209 +0,0 @@
|
|||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const chai = require('chai')
|
||||
const { expect } = chai
|
||||
const { ObjectId } = require('mongodb')
|
||||
const Errors = require('../../../../app/src/Features/Errors/Errors')
|
||||
|
||||
const MODULE_PATH = '../../../../app/src/Features/Subscription/GroupSSOHandler'
|
||||
|
||||
describe('GroupSSOHandler', function () {
|
||||
beforeEach(function () {
|
||||
this.user = { _id: new ObjectId(), enrollment: { sso: [] } }
|
||||
this.subscription = {
|
||||
_id: new ObjectId().toString(),
|
||||
admin_id: new ObjectId(),
|
||||
member_ids: [this.user._id],
|
||||
}
|
||||
this.samlIdentifier = {
|
||||
externalUserId: 'user@external.com',
|
||||
userIdAttribute: 'email',
|
||||
providerId: `ol-group-subscription-id:${this.subscription._id.toString()}`,
|
||||
}
|
||||
this.auditLog = {
|
||||
initiatorId: 'test-initiator-id',
|
||||
ipAddress: '127.0.0.1',
|
||||
}
|
||||
this.ssoConfigData = {
|
||||
entryPoint: 'https://example.com/saml',
|
||||
certificates: ['abc'],
|
||||
userIdAttribute: 'nameId',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
this.SSOConfig = {
|
||||
findById: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(this.ssoConfigData),
|
||||
}),
|
||||
}
|
||||
this.UserAuditLogHandler = {
|
||||
promises: {
|
||||
addEntry: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
this.UserUpdater = {
|
||||
promises: {
|
||||
updateUser: sinon.stub().resolves(),
|
||||
},
|
||||
}
|
||||
this.SAMLIdentityManager = {
|
||||
getUser: sinon.stub().resolves(),
|
||||
}
|
||||
this.User = {
|
||||
findOne: sinon.stub().returns({
|
||||
exec: sinon.stub().resolves(this.user),
|
||||
}),
|
||||
}
|
||||
this.GroupSSOHandler = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
'../../models/SSOConfig': { SSOConfig: this.SSOConfig },
|
||||
'../User/UserAuditLogHandler': this.UserAuditLogHandler,
|
||||
'../User/UserUpdater': this.UserUpdater,
|
||||
'../User/SAMLIdentityManager': this.SAMLIdentityManager,
|
||||
'../../models/User': {
|
||||
User: this.User,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkUserCanEnrollInSubscription', function () {
|
||||
it('should throw an error if the subscription is not found', async function () {
|
||||
this.SSOConfig.findById.returns({
|
||||
exec: sinon.stub().resolves(undefined),
|
||||
})
|
||||
await expect(
|
||||
this.GroupSSOHandler.promises.checkUserCanEnrollInSubscription(
|
||||
this.user._id,
|
||||
this.subscription
|
||||
)
|
||||
).to.be.rejectedWith(Errors.SAMLGroupSSODisabledError)
|
||||
})
|
||||
|
||||
it('should throw an error if SSO is not enabled for the group', async function () {
|
||||
const disabledSSOConfig = { ...this.ssoConfig, enabled: false }
|
||||
this.SSOConfig.findById.returns({
|
||||
exec: sinon.stub().resolves(disabledSSOConfig),
|
||||
})
|
||||
await expect(
|
||||
this.GroupSSOHandler.promises.checkUserCanEnrollInSubscription(
|
||||
this.user._id,
|
||||
this.subscription
|
||||
)
|
||||
).to.be.rejectedWith(Errors.SAMLGroupSSODisabledError)
|
||||
})
|
||||
|
||||
it('should throw an error if the user is not a member of the group', async function () {
|
||||
const testSubscription = {
|
||||
...this.subscription,
|
||||
member_ids: [],
|
||||
}
|
||||
await expect(
|
||||
this.GroupSSOHandler.promises.checkUserCanEnrollInSubscription(
|
||||
this.user._id,
|
||||
testSubscription
|
||||
)
|
||||
).to.be.rejectedWith(Errors.SAMLGroupSSOLoginIdentityNotFoundError)
|
||||
})
|
||||
|
||||
it('should throw an error if the user is already enrolled to the group', async function () {
|
||||
const testUser = {
|
||||
...this.user,
|
||||
enrollment: {
|
||||
sso: [{ groupId: this.subscription._id }],
|
||||
},
|
||||
}
|
||||
this.User.findOne.returns({
|
||||
exec: sinon.stub().resolves(testUser),
|
||||
})
|
||||
await expect(
|
||||
this.GroupSSOHandler.promises.checkUserCanEnrollInSubscription(
|
||||
this.user._id,
|
||||
this.subscription
|
||||
)
|
||||
).to.be.rejectedWith(Errors.SAMLIdentityExistsError)
|
||||
})
|
||||
|
||||
it('should resolve if the user can be enrolled to the group', async function () {
|
||||
await expect(
|
||||
this.GroupSSOHandler.promises.checkUserCanEnrollInSubscription(
|
||||
this.user._id,
|
||||
this.subscription
|
||||
)
|
||||
).to.be.fulfilled
|
||||
})
|
||||
})
|
||||
|
||||
describe('enrollInSubscription', function () {
|
||||
it('should throw an error if the user cannot be enrolled', async function () {
|
||||
const disabledSSOConfig = { ...this.ssoConfig, enabled: false }
|
||||
this.SSOConfig.findById.returns({
|
||||
exec: sinon.stub().resolves(disabledSSOConfig),
|
||||
})
|
||||
await expect(
|
||||
this.GroupSSOHandler.promises.enrollInSubscription(
|
||||
this.user._id,
|
||||
this.subscription,
|
||||
this.samlIdentifier.externalUserId,
|
||||
this.samlIdentifier.userIdAttribute,
|
||||
this.auditLog
|
||||
)
|
||||
).to.be.rejectedWith(Errors.SAMLGroupSSODisabledError)
|
||||
})
|
||||
|
||||
it('should throw an error if an identical SAML identity for the subscription/user already exists', async function () {
|
||||
this.SAMLIdentityManager.getUser.resolves(this.user)
|
||||
await expect(
|
||||
this.GroupSSOHandler.promises.enrollInSubscription(
|
||||
this.user._id,
|
||||
this.subscription,
|
||||
this.samlIdentifier.externalUserId,
|
||||
this.samlIdentifier.userIdAttribute,
|
||||
this.auditLog
|
||||
)
|
||||
).to.be.rejectedWith(Errors.SAMLIdentityExistsError)
|
||||
})
|
||||
|
||||
it("should add an entry the user's SSO enrollment and samlIdentifiers lists", async function () {
|
||||
await this.GroupSSOHandler.promises.enrollInSubscription(
|
||||
this.user._id,
|
||||
this.subscription,
|
||||
this.samlIdentifier.externalUserId,
|
||||
this.samlIdentifier.userIdAttribute,
|
||||
this.auditLog
|
||||
)
|
||||
expect(this.UserUpdater.promises.updateUser).to.have.been.calledWith(
|
||||
this.user._id,
|
||||
{
|
||||
$push: {
|
||||
samlIdentifiers: this.samlIdentifier,
|
||||
'enrollment.sso': {
|
||||
groupId: this.subscription._id,
|
||||
linkedAt: sinon.match.instanceOf(Date),
|
||||
primary: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should add an entry to the user audit log', async function () {
|
||||
await this.GroupSSOHandler.promises.enrollInSubscription(
|
||||
this.user._id,
|
||||
this.subscription,
|
||||
this.samlIdentifier.externalUserId,
|
||||
this.samlIdentifier.userIdAttribute,
|
||||
this.auditLog
|
||||
)
|
||||
expect(
|
||||
this.UserAuditLogHandler.promises.addEntry
|
||||
).to.have.been.calledWith(
|
||||
this.user._id,
|
||||
'group-sso-link',
|
||||
this.auditLog.initiatorId,
|
||||
this.auditLog.ipAddress,
|
||||
this.samlIdentifier
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue