Merge pull request #2251 from overleaf/jel-dashboard-institution-notifications

Institution SSO dashboard notifications

GitOrigin-RevId: c80117c2732fafdc9d09eed69db06a26ad28a663
This commit is contained in:
Jessica Lawshe
2019-10-21 11:02:01 -05:00
committed by sharelatex
parent 9e651d77b2
commit 28a4be296f
8 changed files with 947 additions and 691 deletions
@@ -412,7 +412,7 @@ const ProjectController = {
user(cb) {
User.findById(
userId,
'featureSwitches overleaf awareOfV2 features lastLoginIp',
'emails featureSwitches overleaf awareOfV2 features lastLoginIp',
cb
)
},
@@ -428,27 +428,92 @@ const ProjectController = {
logger.warn({ err }, 'error getting data for project list page')
return next(err)
}
const { notifications, user, userAffiliations } = results
const v1Tags =
(results.v1Projects != null ? results.v1Projects.tags : undefined) ||
[]
const tags = results.tags.concat(v1Tags)
const notifications = results.notifications
for (const notification of notifications) {
notification.html = req.i18n.translate(
notification.templateKey,
notification.messageOpts
)
}
// Institution SSO Notifications
if (Features.hasFeature('saml') || req.session.samlBeta) {
const samlSession = req.session.saml
// Notification: SSO Available
// Could have multiple emails at the same institution, and if any are
// linked to the institution then do not show notification for others
const linkedInstitutionIds = []
const linkedInstitutionEmails = []
user.emails.forEach(email => {
if (email.samlProviderId) {
linkedInstitutionEmails.push(email.email)
linkedInstitutionIds.push(email.samlProviderId)
}
})
if (Array.isArray(userAffiliations)) {
userAffiliations.forEach(affiliation => {
if (
affiliation.institution &&
affiliation.institution.ssoEnabled &&
linkedInstitutionEmails.indexOf(affiliation.email) === -1 &&
linkedInstitutionIds.indexOf(
affiliation.institution.id.toString()
) === -1
) {
notifications.push({
email: affiliation.email,
institutionId: affiliation.institution.id,
institutionName: affiliation.institution.name,
templateKey: 'notification_institution_sso_available'
})
}
})
}
if (samlSession) {
// Notification: After SSO Linked
if (samlSession.linked) {
notifications.push({
email: samlSession.institutionEmail,
institutionName: samlSession.linked.universityName,
templateKey: 'notification_institution_sso_linked'
})
}
// Notification: After SSO Linked or Logging in
// The requested email does not match primary email returned from
// the institution
if (samlSession.emailNonCanonical) {
notifications.push({
institutionEmail: samlSession.emailNonCanonical,
requestedEmail: samlSession.requestedEmail,
templateKey: 'notification_institution_sso_non_canonical'
})
}
// Notification: Tried to register, but account already existed
if (samlSession.registerIntercept) {
notifications.push({
email: samlSession.institutionEmail,
templateKey: 'notification_institution_sso_already_registered'
})
}
}
delete req.session.saml
}
const portalTemplates = ProjectController._buildPortalTemplatesList(
results.userAffiliations
userAffiliations
)
const projects = ProjectController._buildProjectList(
results.projects,
userId,
results.v1Projects != null ? results.v1Projects.projects : undefined
)
const { user } = results
const { userAffiliations } = results
const warnings = ProjectController._buildWarningsList(
results.v1Projects
)
@@ -115,7 +115,16 @@ const UserPagesController = {
delete req.session.ssoError
}
// Institution SSO
const institutionLinked = _.get(req.session, ['saml', 'linked'])
let institutionLinked = _.get(req.session, ['saml', 'linked'])
if (institutionLinked) {
// copy object if exists because _.get does not
institutionLinked = Object.assign(
{
hasEntitlement: _.get(req.session, ['saml', 'hasEntitlement'])
},
institutionLinked
)
}
const institutionNotLinked = _.get(req.session, ['saml', 'notLinked'])
const institutionEmailNonCanonical = _.get(req.session, [
'saml',
+4 -1
View File
@@ -89,8 +89,11 @@ block content
include ./list/project-list
.project-list-empty.row(ng-if="projects.length === 0")
.col-md-offset-2.col-md-8.col-md-offset-2.col-xs-8
.project-list-empty-col.col-md-offset-2.col-md-8.col-md-offset-2.col-xs-8.col-xs-offset-2
include ./list/empty-project-list
.row.row-spaced
.col-sm-12
include ./list/notifications
include ./list/modals
@@ -1,77 +1,106 @@
span(ng-controller="NotificationsController").userNotifications
ul.list-unstyled.notifications-list(
.user-notifications(ng-controller="NotificationsController")
ul.list-unstyled(
ng-if="notifications.length > 0",
ng-cloak
)
li.notification_entry(
li.notification-entry(
ng-repeat="notification in notifications",
)
.row(ng-hide="notification.hide")
.col-xs-12
div(ng-switch="notification.templateKey")
.alert.alert-info(ng-switch-when="notification_project_invite", ng-controller="ProjectInviteNotificationController")
div.notification_inner
.notification_body(ng-show="!notification.accepted")
| !{translate("notification_project_invite_message", { userName: "{{ userName }}", projectName: "{{ projectName }}" })}
a.pull-right.btn.btn-sm.btn-info(href, ng-click="accept()", ng-disabled="notification.inflight")
span(ng-show="!notification.inflight") #{translate("join_project")}
span(ng-show="notification.inflight")
i.fa.fa-fw.fa-spinner.fa-spin(aria-hidden="true")
|  
| #{translate("joining")}...
.notification_body(ng-show="notification.accepted")
| !{translate("notification_project_invite_accepted_message", { projectName: "{{ projectName }}" })}
a.pull-right.btn.btn-sm.btn-info(href="/project/{{ notification.messageOpts.projectId }}") #{translate("open_project")}
span().notification_close
button(ng-click="dismiss(notification)").close.pull-right
span(aria-hidden="true") ×
span.sr-only #{translate("close")}
.alert.alert-info(ng-switch-when="notification_ip_matched_affiliation")
div.notification_inner
.notification_body
| It looks like you're at
strong {{ notification.messageOpts.university_name }}! <br/>
| Did you know that {{notification.messageOpts.university_name}} is providing
strong free Overleaf Professional accounts
| to everyone at {{notification.messageOpts.university_name}}? <br/>
| Add an institutional email address to claim your account.
a.pull-right.btn.btn-sm.btn-info(href="/user/settings")
| Add Affiliation
span().notification_close
button(ng-click="dismiss(notification)").close.pull-right
span(aria-hidden="true") &times;
span.sr-only #{translate("close")}
.alert.alert-info(ng-switch-default)
div.notification_inner
span(ng-bind-html="notification.html").notification_body
span().notification_close
button(ng-click="dismiss(notification)").close.pull-right
span(aria-hidden="true") &times;
span.sr-only #{translate("close")}
div(ng-switch="notification.templateKey" ng-hide="notification.hide")
.alert.alert-info(ng-switch-when="notification_project_invite", ng-controller="ProjectInviteNotificationController")
.notification-body(ng-show="!notification.accepted")
| !{translate("notification_project_invite_message", { userName: "{{ userName }}", projectName: "{{ projectName }}" })}
a.pull-right.btn.btn-sm.btn-info(href, ng-click="accept()", ng-disabled="notification.inflight")
span(ng-show="!notification.inflight") #{translate("join_project")}
span(ng-show="notification.inflight")
i.fa.fa-fw.fa-spinner.fa-spin(aria-hidden="true")
| &nbsp;
| #{translate("joining")}...
.notification-body(ng-show="notification.accepted")
| !{translate("notification_project_invite_accepted_message", { projectName: "{{ projectName }}" })}
a.pull-right.btn.btn-sm.btn-info(href="/project/{{ notification.messageOpts.projectId }}") #{translate("open_project")}
.notification-close
button(ng-click="dismiss(notification)").close.pull-right
span(aria-hidden="true") &times;
span.sr-only #{translate("close")}
.alert.alert-info(ng-switch-when="notification_ip_matched_affiliation")
.notification-body
| It looks like you're at
strong {{ notification.messageOpts.university_name }}! <br/>
| Did you know that {{notification.messageOpts.university_name}} is providing
strong free Overleaf Professional accounts
| to everyone at {{notification.messageOpts.university_name}}? <br/>
| Add an institutional email address to claim your account.
a.pull-right.btn.btn-sm.btn-info(href="/user/settings")
| Add Affiliation
.notification-close
button(ng-click="dismiss(notification)").close.pull-right
span(aria-hidden="true") &times;
span.sr-only #{translate("close")}
.alert.alert-info(ng-switch-when="notification_institution_sso_available")
.notification-body
p !{translate("can_link_institution_email_acct_to_institution_acct", {appName: settings.appName, email: "{{notification.email}}", institutionName: "{{notification.institutionName}}"})}
div !{translate("doing_this_allow_log_in_through_institution", {appName: settings.appName})}
.notification-action
a.btn.btn-info(href="{{samlInitPath}}?university_id={{notification.institutionId}}&auto=project&email={{notification.email}}")
| #{translate('link_account')}
.notification-close
button(ng-click="dismiss(notification)").close.pull-right
span(aria-hidden="true") &times;
span.sr-only #{translate("close")}
.alert.alert-info(ng-switch-when="notification_institution_sso_linked")
.notification-body
div !{translate("account_has_been_link_to_institution_account", {appName: settings.appName, email: "{{notification.email}}", institutionName: "{{notification.institutionName}}"})}
.notification-close
button(ng-click="dismiss(notification)").close.pull-right
span(aria-hidden="true") &times;
span.sr-only #{translate("close")}
.alert.alert-warning(ng-switch-when="notification_institution_sso_non_canonical")
.notification-body
div
i.fa.fa-fw.fa-exclamation-triangle(aria-hidden="true")
| !{translate("tried_to_log_in_with_email", {email: "{{notification.requestedEmail}}"})} !{translate("in_order_to_match_institutional_metadata_associated", {email: "{{notification.institutionEmail}}"})}
.notification-close
button(ng-click="dismiss(notification)").close.pull-right
span(aria-hidden="true") &times;
span.sr-only #{translate("close")}
.alert.alert-info(ng-switch-when="notification_institution_sso_already_registered")
.notification-body
| !{translate("tried_to_register_with_email", {appName: settings.appName, email: "{{notification.email}}"})}
| #{translate("we_logged_you_in")}
.notification-action
a.btn.btn-info(href="/")
| #{translate("find_out_more")}
.notification-close
button(ng-click="dismiss(notification)").close.pull-right
span(aria-hidden="true") &times;
span.sr-only #{translate("close")}
.alert.alert-info(ng-switch-default)
span(ng-bind-html="notification.html").notification-body
.notification-close
button(ng-click="dismiss(notification)").close.pull-right
span(aria-hidden="true") &times;
span.sr-only #{translate("close")}
ul.list-unstyled.notifications-list(
ul.list-unstyled(
ng-controller="EmailNotificationController",
ng-cloak
)
li.notification_entry(
li.notification-entry(
ng-repeat="userEmail in userEmails",
ng-if="showConfirmEmail(userEmail)"
)
.row
.col-xs-12
.alert.alert-warning
.notification_inner(
ng-if="!userEmail.confirmationInflight"
)
| #{translate("please_confirm_email", {emailAddress: "{{ userEmail.email }}"})}
|
a(
href
ng-click="resendConfirmationEmail(userEmail)"
) (#{translate('resend_confirmation_email')})
.notification_inner(
ng-if="userEmail.confirmationInflight"
)
i.fa.fa-spinner.fa-spin(aria-hidden="true")
|
| #{translate('resending_confirmation_email')}&hellip;
.alert.alert-warning(ng-if="!userEmail.confirmationInflight")
.notification-body
| #{translate("please_confirm_email", {emailAddress: "{{ userEmail.email }}"})}
|
a(
href
ng-click="resendConfirmationEmail(userEmail)"
) (#{translate('resend_confirmation_email')})
.alert.alert-warning(ng-if="userEmail.confirmationInflight")
.notification-body
i.fa.fa-spinner.fa-spin(aria-hidden="true")
|
| #{translate('resending_confirmation_email')}&hellip;
@@ -212,7 +212,7 @@ form.row(
span(ng-if="ui.errorMessage") &nbsp;{{ui.errorMessage}}
if institutionLinked
tr.affiliations-table-info-row(ng-if="!hideInstitutionNotifications.info")
td(colspan="3").text-center(aria-live="assertive")
td.text-center(aria-live="assertive" colspan="3")
button.close(
type="button"
data-dismiss="modal"
@@ -225,7 +225,7 @@ form.row(
.small !{translate("this_grants_access_to_features", {featureType: translate("professional")})}
if institutionEmailNonCanonical
tr.affiliations-table-warning-row(ng-if="!hideInstitutionNotifications.warning")
td(colspan="3").text-center(aria-live="assertive")
td.text-center(aria-live="assertive" colspan="3")
button.close(
type="button"
data-dismiss="modal"
@@ -239,7 +239,7 @@ form.row(
| !{translate("in_order_to_match_institutional_metadata", {email: institutionEmailNonCanonical})}
if institutionNotLinked
tr.affiliations-table-error-row(ng-if="!hideInstitutionNotifications.error")
td(colspan="3").text-center(aria-live="assertive")
td.text-center(aria-live="assertive" colspan="3")
button.close(
type="button"
data-dismiss="modal"
@@ -17,6 +17,8 @@ define(['base'], function(App) {
notification.hide = false
}
$scope.samlInitPath = ExposedSettings.samlInitPath
return ($scope.dismiss = notification =>
$http({
url: `/notifications/${notification._id}`,
File diff suppressed because it is too large Load Diff
@@ -17,8 +17,10 @@ describe('ProjectController', function() {
this.user = {
_id: ObjectId('123456123456123456123456'),
email: 'test@overleaf.com',
first_name: 'bjkdsjfk',
features: {}
features: {},
emails: [{ email: 'test@overleaf.com' }]
}
this.settings = {
apis: {
@@ -107,13 +109,27 @@ describe('ProjectController', function() {
fire: sinon.stub()
}
}
this.Features = { hasFeature: sinon.stub() }
this.Features = {
hasFeature: sinon
.stub()
.withArgs('saml')
.returns(false)
}
this.BrandVariationsHandler = {
getBrandVariationById: sinon
.stub()
.callsArgWith(1, null, this.brandVariationDetails)
}
this.getUserAffiliations = sinon.stub().callsArgWith(1, null, [])
this.getUserAffiliations = sinon.stub().callsArgWith(1, null, [
{
email: 'test@overleaf.com',
institution: {
id: 1,
name: 'Overleaf',
ssoEnabled: true
}
}
])
this.ProjectController = SandboxedModule.require(MODULE_PATH, {
globals: {
@@ -622,6 +638,99 @@ describe('ProjectController', function() {
this.ProjectController.projectListPage(this.req, this.res)
})
})
describe('When Institution SSO is released', function() {
beforeEach(function(done) {
this.institutionEmail = 'test@overleaf.com'
this.institutionName = 'Overleaf'
this.Features.hasFeature.withArgs('saml').returns(true)
done()
})
it('should show institution SSO available notification', function() {
this.res.render = (pageName, opts) => {
expect(opts.notifications).to.deep.include({
email: 'test@overleaf.com',
institutionId: 1,
institutionName: 'Overleaf',
templateKey: 'notification_institution_sso_available'
})
}
this.ProjectController.projectListPage(this.req, this.res)
})
it('should show a linked notification', function() {
this.req.session.saml = {
institutionEmail: this.institutionEmail,
linked: {
hasEntitlement: false,
universityName: this.institutionName
}
}
this.res.render = (pageName, opts) => {
expect(opts.notifications).to.deep.include({
email: this.institutionEmail,
institutionName: this.institutionName,
templateKey: 'notification_institution_sso_linked'
})
}
this.ProjectController.projectListPage(this.req, this.res)
})
it('should show a linked another email notification', function() {
// when they request to link an email but the institution returns
// a different email
this.res.render = (pageName, opts) => {
expect(opts.notifications).to.deep.include({
institutionEmail: this.institutionEmail,
requestedEmail: 'requested@overleaf.com',
templateKey: 'notification_institution_sso_non_canonical'
})
}
this.req.session.saml = {
emailNonCanonical: this.institutionEmail,
institutionEmail: this.institutionEmail,
requestedEmail: 'requested@overleaf.com',
linked: {
hasEntitlement: false,
universityName: this.institutionName
}
}
this.ProjectController.projectListPage(this.req, this.res)
})
it('should show a notification when intent was to register via SSO but account existed', function() {
this.res.render = (pageName, opts) => {
expect(opts.notifications).to.deep.include({
email: this.institutionEmail,
templateKey: 'notification_institution_sso_already_registered'
})
}
this.req.session.saml = {
institutionEmail: this.institutionEmail,
linked: {
hasEntitlement: false,
universityName: 'Overleaf'
},
registerIntercept: true
}
this.ProjectController.projectListPage(this.req, this.res)
})
})
describe('When Institution SSO is not released', function() {
beforeEach(function(done) {
this.Features.hasFeature.withArgs('saml').returns(false)
done()
})
it('should not show institution sso available notification', function() {
this.res.render = (pageName, opts) => {
expect(opts.notifications).to.deep.not.include({
email: 'test@overleaf.com',
institutionId: 1,
institutionName: 'Overleaf',
templateKey: 'notification_institution_sso_available'
})
}
this.ProjectController.projectListPage(this.req, this.res)
})
})
})
describe('projectListPage with duplicate projects', function() {