ProjectListController.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. const _ = require('lodash')
  2. const ProjectHelper = require('./ProjectHelper')
  3. const ProjectGetter = require('./ProjectGetter')
  4. const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
  5. const SessionManager = require('../Authentication/SessionManager')
  6. const Sources = require('../Authorization/Sources')
  7. const UserGetter = require('../User/UserGetter')
  8. const SurveyHandler = require('../Survey/SurveyHandler')
  9. const TagsHandler = require('../Tags/TagsHandler')
  10. const { expressify } = require('../../util/promises')
  11. const logger = require('@overleaf/logger')
  12. const Features = require('../../infrastructure/Features')
  13. const SubscriptionViewModelBuilder = require('../Subscription/SubscriptionViewModelBuilder')
  14. const NotificationsHandler = require('../Notifications/NotificationsHandler')
  15. const Modules = require('../../infrastructure/Modules')
  16. const { OError, V1ConnectionError } = require('../Errors/Errors')
  17. const { User } = require('../../models/User')
  18. const SplitTestHandler = require('../SplitTests/SplitTestHandler')
  19. const UserPrimaryEmailCheckHandler = require('../User/UserPrimaryEmailCheckHandler')
  20. const UserController = require('../User/UserController')
  21. const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => {
  22. if (!affiliation.institution) return false
  23. // institution.confirmed is for the domain being confirmed, not the email
  24. // Do not show SSO UI for unconfirmed domains
  25. if (!affiliation.institution.confirmed) return false
  26. // Could have multiple emails at the same institution, and if any are
  27. // linked to the institution then do not show notification for others
  28. if (
  29. linkedInstitutionIds.indexOf(affiliation.institution.id.toString()) === -1
  30. ) {
  31. if (affiliation.institution.ssoEnabled) return true
  32. if (affiliation.institution.ssoBeta && session.samlBeta) return true
  33. return false
  34. }
  35. return false
  36. }
  37. /** @typedef {import("./types").GetProjectsRequest} GetProjectsRequest */
  38. /** @typedef {import("./types").GetProjectsResponse} GetProjectsResponse */
  39. /** @typedef {import("../../../../types/project/dashboard/api").Project} Project */
  40. /** @typedef {import("../../../../types/project/dashboard/api").Filters} Filters */
  41. /** @typedef {import("../../../../types/project/dashboard/api").Page} Page */
  42. /** @typedef {import("../../../../types/project/dashboard/api").Sort} Sort */
  43. /** @typedef {import("./types").AllUsersProjects} AllUsersProjects */
  44. /** @typedef {import("./types").MongoProject} MongoProject */
  45. /** @typedef {import("../Tags/types").Tag} Tag */
  46. /**
  47. * @param {import("express").Request} req
  48. * @param {import("express").Response} res
  49. * @param {import("express").NextFunction} next
  50. * @returns {Promise<void>}
  51. */
  52. async function projectListReactPage(req, res, next) {
  53. // can have two values:
  54. // - undefined - when there's no "saas" feature or couldn't get subscription data
  55. // - object - the subscription data object
  56. let usersBestSubscription
  57. let survey
  58. const userId = SessionManager.getLoggedInUserId(req.session)
  59. const user = await User.findById(
  60. userId,
  61. 'email emails features lastPrimaryEmailCheck signUpDate'
  62. )
  63. // Handle case of deleted user
  64. if (user == null) {
  65. UserController.logout(req, res, next)
  66. return
  67. }
  68. if (Features.hasFeature('saas')) {
  69. try {
  70. usersBestSubscription =
  71. await SubscriptionViewModelBuilder.promises.getBestSubscription({
  72. _id: userId,
  73. })
  74. } catch (error) {
  75. logger.err(
  76. { err: error, userId },
  77. "Failed to get user's best subscription"
  78. )
  79. }
  80. try {
  81. survey = await SurveyHandler.promises.getSurvey(userId)
  82. } catch (error) {
  83. logger.err({ err: error, userId }, 'Failed to load the active survey')
  84. }
  85. try {
  86. const assignment = await SplitTestHandler.promises.getAssignment(
  87. req,
  88. res,
  89. 'primary-email-check'
  90. )
  91. const primaryEmailCheckActive = assignment.variant === 'active'
  92. if (
  93. user &&
  94. primaryEmailCheckActive &&
  95. UserPrimaryEmailCheckHandler.requiresPrimaryEmailCheck(user)
  96. ) {
  97. return res.redirect('/user/emails/primary-email-check')
  98. }
  99. } catch (error) {
  100. logger.warn(
  101. { err: error },
  102. 'failed to get "primary-email-check" split test assignment'
  103. )
  104. }
  105. }
  106. const tags = await TagsHandler.promises.getAllTags(userId)
  107. let userEmailsData = { list: [], allInReconfirmNotificationPeriods: [] }
  108. try {
  109. const fullEmails = await UserGetter.promises.getUserFullEmails(userId)
  110. if (!Features.hasFeature('affiliations')) {
  111. userEmailsData.list = fullEmails
  112. } else {
  113. try {
  114. const results = await Modules.promises.hooks.fire(
  115. 'allInReconfirmNotificationPeriodsForUser',
  116. fullEmails
  117. )
  118. const allInReconfirmNotificationPeriods = (results && results[0]) || []
  119. userEmailsData = {
  120. list: fullEmails,
  121. allInReconfirmNotificationPeriods,
  122. }
  123. } catch (error) {
  124. userEmailsData = error
  125. }
  126. }
  127. } catch (error) {
  128. if (!(error instanceof V1ConnectionError)) {
  129. logger.error({ err: error, userId }, 'Failed to get user full emails')
  130. }
  131. }
  132. const userEmails = userEmailsData.list || []
  133. const userAffiliations = userEmails
  134. .filter(emailData => !!emailData.affiliation)
  135. .map(emailData => {
  136. const result = emailData.affiliation
  137. result.email = emailData.email
  138. return result
  139. })
  140. const { allInReconfirmNotificationPeriods } = userEmailsData
  141. const notifications =
  142. await NotificationsHandler.promises.getUserNotifications(userId)
  143. for (const notification of notifications) {
  144. notification.html = req.i18n.translate(
  145. notification.templateKey,
  146. notification.messageOpts
  147. )
  148. }
  149. const notificationsInstitution = []
  150. // Institution SSO Notifications
  151. let reconfirmedViaSAML
  152. if (Features.hasFeature('saml')) {
  153. reconfirmedViaSAML = _.get(req.session, ['saml', 'reconfirmed'])
  154. const samlSession = req.session.saml
  155. // Notification: SSO Available
  156. const linkedInstitutionIds = []
  157. userEmails.forEach(email => {
  158. if (email.samlProviderId) {
  159. linkedInstitutionIds.push(email.samlProviderId)
  160. }
  161. })
  162. if (Array.isArray(userAffiliations)) {
  163. userAffiliations.forEach(affiliation => {
  164. if (_ssoAvailable(affiliation, req.session, linkedInstitutionIds)) {
  165. notificationsInstitution.push({
  166. email: affiliation.email,
  167. institutionId: affiliation.institution.id,
  168. institutionName: affiliation.institution.name,
  169. templateKey: 'notification_institution_sso_available',
  170. })
  171. }
  172. })
  173. }
  174. if (samlSession) {
  175. // Notification: After SSO Linked
  176. if (samlSession.linked) {
  177. notificationsInstitution.push({
  178. email: samlSession.institutionEmail,
  179. institutionName: samlSession.linked.universityName,
  180. templateKey: 'notification_institution_sso_linked',
  181. })
  182. }
  183. // Notification: After SSO Linked or Logging in
  184. // The requested email does not match primary email returned from
  185. // the institution
  186. if (
  187. samlSession.requestedEmail &&
  188. samlSession.emailNonCanonical &&
  189. !samlSession.error
  190. ) {
  191. notificationsInstitution.push({
  192. institutionEmail: samlSession.emailNonCanonical,
  193. requestedEmail: samlSession.requestedEmail,
  194. templateKey: 'notification_institution_sso_non_canonical',
  195. })
  196. }
  197. // Notification: Tried to register, but account already existed
  198. // registerIntercept is set before the institution callback.
  199. // institutionEmail is set after institution callback.
  200. // Check for both in case SSO flow was abandoned
  201. if (
  202. samlSession.registerIntercept &&
  203. samlSession.institutionEmail &&
  204. !samlSession.error
  205. ) {
  206. notificationsInstitution.push({
  207. email: samlSession.institutionEmail,
  208. templateKey: 'notification_institution_sso_already_registered',
  209. })
  210. }
  211. // Notification: When there is a session error
  212. if (samlSession.error) {
  213. notificationsInstitution.push({
  214. templateKey: 'notification_institution_sso_error',
  215. error: samlSession.error,
  216. })
  217. }
  218. }
  219. delete req.session.saml
  220. }
  221. res.render('project/list-react', {
  222. title: 'your_projects',
  223. usersBestSubscription,
  224. notifications,
  225. notificationsInstitution,
  226. user,
  227. userEmails,
  228. reconfirmedViaSAML,
  229. allInReconfirmNotificationPeriods,
  230. survey,
  231. tags,
  232. })
  233. }
  234. /**
  235. * Load user's projects with pagination, sorting and filters
  236. *
  237. * @param {GetProjectsRequest} req the request
  238. * @param {GetProjectsResponse} res the response
  239. * @returns {Promise<void>}
  240. */
  241. async function getProjectsJson(req, res) {
  242. const { filters, page, sort } = req.body
  243. const userId = SessionManager.getLoggedInUserId(req.session)
  244. const projectsPage = await _getProjects(userId, filters, sort, page)
  245. res.json(projectsPage)
  246. }
  247. /**
  248. * @param {string} userId
  249. * @param {Filters} filters
  250. * @param {Sort} sort
  251. * @param {Page} page
  252. * @returns {Promise<{totalSize: number, projects: Project[]}>}
  253. * @private
  254. */
  255. async function _getProjects(
  256. userId,
  257. filters = {},
  258. sort = { by: 'lastUpdated', order: 'desc' },
  259. page = { size: 20 }
  260. ) {
  261. const allProjects =
  262. /** @type {AllUsersProjects} **/ await ProjectGetter.promises.findAllUsersProjects(
  263. userId,
  264. 'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens'
  265. )
  266. const tags = /** @type {Tag[]} **/ await TagsHandler.promises.getAllTags(
  267. userId
  268. )
  269. const formattedProjects = _formatProjects(allProjects, userId)
  270. const filteredProjects = _applyFilters(
  271. formattedProjects,
  272. tags,
  273. filters,
  274. userId
  275. )
  276. const pagedProjects = _sortAndPaginate(filteredProjects, sort, page)
  277. await _injectProjectUsers(pagedProjects)
  278. return {
  279. totalSize: filteredProjects.length,
  280. projects: pagedProjects,
  281. }
  282. }
  283. /**
  284. * @param {AllUsersProjects} projects
  285. * @param {string} userId
  286. * @returns {Project[]}
  287. * @private
  288. */
  289. function _formatProjects(projects, userId) {
  290. const { owned, readAndWrite, readOnly, tokenReadAndWrite, tokenReadOnly } =
  291. projects
  292. const formattedProjects = /** @type {Project[]} **/ []
  293. for (const project of owned) {
  294. formattedProjects.push(
  295. _formatProjectInfo(project, 'owner', Sources.OWNER, userId)
  296. )
  297. }
  298. // Invite-access
  299. for (const project of readAndWrite) {
  300. formattedProjects.push(
  301. _formatProjectInfo(project, 'readWrite', Sources.INVITE, userId)
  302. )
  303. }
  304. for (const project of readOnly) {
  305. formattedProjects.push(
  306. _formatProjectInfo(project, 'readOnly', Sources.INVITE, userId)
  307. )
  308. }
  309. // Token-access
  310. // Only add these formattedProjects if they're not already present, this gives us cascading access
  311. // from 'owner' => 'token-read-only'
  312. for (const project of tokenReadAndWrite) {
  313. if (!_.find(formattedProjects, ['id', project._id.toString()])) {
  314. formattedProjects.push(
  315. _formatProjectInfo(project, 'readAndWrite', Sources.TOKEN, userId)
  316. )
  317. }
  318. }
  319. for (const project of tokenReadOnly) {
  320. if (!_.find(formattedProjects, ['id', project._id.toString()])) {
  321. formattedProjects.push(
  322. _formatProjectInfo(project, 'readOnly', Sources.TOKEN, userId)
  323. )
  324. }
  325. }
  326. return formattedProjects
  327. }
  328. /**
  329. * @param {Project[]} projects
  330. * @param {Tag[]} tags
  331. * @param {Filters} filters
  332. * @param {string} userId
  333. * @returns {Project[]}
  334. * @private
  335. */
  336. function _applyFilters(projects, tags, filters, userId) {
  337. if (!_hasActiveFilter(filters)) {
  338. return projects
  339. }
  340. return projects.filter(project => _matchesFilters(project, tags, filters))
  341. }
  342. /**
  343. * @param {Project[]} projects
  344. * @param {Sort} sort
  345. * @param {Page} page
  346. * @returns {Project[]}
  347. * @private
  348. */
  349. function _sortAndPaginate(projects, sort, page) {
  350. if (
  351. (sort.by && !['lastUpdated', 'title', 'owner'].includes(sort.by)) ||
  352. (sort.order && !['asc', 'desc'].includes(sort.order))
  353. ) {
  354. throw new OError('Invalid sorting criteria', { sort })
  355. }
  356. const sortedProjects = _.orderBy(
  357. projects,
  358. [sort.by || 'lastUpdated'],
  359. [sort.order || 'desc']
  360. )
  361. // TODO handle pagination
  362. return sortedProjects
  363. }
  364. /**
  365. * @param {MongoProject} project
  366. * @param {string} accessLevel
  367. * @param {'owner' | 'invite' | 'token'} source
  368. * @param {string} userId
  369. * @returns {object}
  370. * @private
  371. */
  372. function _formatProjectInfo(project, accessLevel, source, userId) {
  373. const archived = ProjectHelper.isArchived(project, userId)
  374. // If a project is simultaneously trashed and archived, we will consider it archived but not trashed.
  375. const trashed = ProjectHelper.isTrashed(project, userId) && !archived
  376. const model = {
  377. id: project._id,
  378. name: project.name,
  379. owner_ref: project.owner_ref,
  380. lastUpdated: project.lastUpdated,
  381. lastUpdatedBy: project.lastUpdatedBy,
  382. accessLevel,
  383. source,
  384. archived,
  385. trashed,
  386. }
  387. if (accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN) {
  388. model.owner_ref = null
  389. model.lastUpdatedBy = null
  390. }
  391. return model
  392. }
  393. /**
  394. * @param {Project[]} projects
  395. * @returns {Promise<void>}
  396. * @private
  397. */
  398. async function _injectProjectUsers(projects) {
  399. const userIds = new Set()
  400. for (const project of projects) {
  401. if (project.owner_ref != null) {
  402. userIds.add(project.owner_ref.toString())
  403. }
  404. if (project.lastUpdatedBy != null) {
  405. userIds.add(project.lastUpdatedBy.toString())
  406. }
  407. }
  408. const users = {}
  409. for (const userId of userIds) {
  410. const {
  411. email,
  412. first_name: firstName,
  413. last_name: lastName,
  414. } = await UserGetter.promises.getUser(userId, {
  415. first_name: 1,
  416. last_name: 1,
  417. email: 1,
  418. })
  419. users[userId] = {
  420. id: userId,
  421. email,
  422. firstName,
  423. lastName,
  424. }
  425. }
  426. for (const project of projects) {
  427. if (project.owner_ref != null) {
  428. project.owner = users[project.owner_ref.toString()]
  429. }
  430. if (project.lastUpdatedBy != null) {
  431. project.lastUpdatedBy = users[project.lastUpdatedBy.toString()] || null
  432. }
  433. delete project.owner_ref
  434. }
  435. }
  436. /**
  437. * @param {any} project
  438. * @param {Tag[]} tags
  439. * @param {Filters} filters
  440. * @private
  441. */
  442. function _matchesFilters(project, tags, filters) {
  443. if (filters.ownedByUser && project.accessLevel !== 'owner') {
  444. return false
  445. }
  446. if (filters.sharedWithUser && project.accessLevel === 'owner') {
  447. return false
  448. }
  449. if (filters.archived && !project.archived) {
  450. return false
  451. }
  452. if (filters.trashed && !project.trashed) {
  453. return false
  454. }
  455. if (
  456. filters.tag &&
  457. !_.find(
  458. tags,
  459. tag =>
  460. filters.tag === tag.name && (tag.project_ids || []).includes(project.id)
  461. )
  462. ) {
  463. return false
  464. }
  465. if (
  466. filters.search?.length &&
  467. project.name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1
  468. ) {
  469. return false
  470. }
  471. return true
  472. }
  473. /**
  474. * @param {Filters} filters
  475. * @returns {boolean}
  476. * @private
  477. */
  478. function _hasActiveFilter(filters) {
  479. return (
  480. filters.ownedByUser ||
  481. filters.sharedWithUser ||
  482. filters.archived ||
  483. filters.trashed ||
  484. filters.tag === null ||
  485. filters.tag?.length ||
  486. filters.search?.length
  487. )
  488. }
  489. module.exports = {
  490. projectListReactPage: expressify(projectListReactPage),
  491. getProjectsJson: expressify(getProjectsJson),
  492. }