diff --git a/services/web/app/src/Features/Subscription/SubscriptionLocator.js b/services/web/app/src/Features/Subscription/SubscriptionLocator.js index 1ad304acde..c1b8fde703 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionLocator.js +++ b/services/web/app/src/Features/Subscription/SubscriptionLocator.js @@ -99,6 +99,14 @@ const SubscriptionLocator = { Subscription.find({ invited_emails: email }, callback) }, + getGroupsWithTeamInvitesEmail(email, callback) { + Subscription.find( + { teamInvites: { $elemMatch: { email } } }, + { teamInvites: 1 }, + callback + ) + }, + getGroupWithV1Id(v1TeamId, callback) { Subscription.findOne({ 'overleaf.id': v1TeamId }, callback) }, @@ -151,6 +159,9 @@ SubscriptionLocator.promises = { getGroupsWithEmailInvite: promisify( SubscriptionLocator.getGroupsWithEmailInvite ), + getGroupsWithTeamInvitesEmail: promisify( + SubscriptionLocator.getGroupsWithTeamInvitesEmail + ), getGroupWithV1Id: promisify(SubscriptionLocator.getGroupWithV1Id), getUserDeletedSubscriptions: promisify( SubscriptionLocator.getUserDeletedSubscriptions diff --git a/services/web/app/src/Features/Subscription/SubscriptionRouter.js b/services/web/app/src/Features/Subscription/SubscriptionRouter.js index 26a6c0978e..bc1674072d 100644 --- a/services/web/app/src/Features/Subscription/SubscriptionRouter.js +++ b/services/web/app/src/Features/Subscription/SubscriptionRouter.js @@ -64,6 +64,12 @@ module.exports = { PermissionsController.useCapabilities(), TeamInvitesController.viewInvite ) + webRouter.get( + '/subscription/invites/', + AuthenticationController.requireLogin(), + PermissionsController.useCapabilities(), + TeamInvitesController.viewInvites + ) webRouter.put( '/subscription/invites/:token/', AuthenticationController.requireLogin(), diff --git a/services/web/app/src/Features/Subscription/TeamInvitesController.js b/services/web/app/src/Features/Subscription/TeamInvitesController.js index 9ed4d279a1..8d8abba4a3 100644 --- a/services/web/app/src/Features/Subscription/TeamInvitesController.js +++ b/services/web/app/src/Features/Subscription/TeamInvitesController.js @@ -171,6 +171,21 @@ async function viewInvite(req, res, next) { } } +async function viewInvites(req, res, next) { + const userId = SessionManager.getLoggedInUserId(req.session) + const userEmail = await UserGetter.promises.getUserEmail(userId) + const groupSubscriptions = + await SubscriptionLocator.promises.getGroupsWithTeamInvitesEmail(userEmail) + + const teamInvites = groupSubscriptions.map(groupSubscription => + groupSubscription.teamInvites.find(invite => invite.email === userEmail) + ) + + return res.render('subscriptions/team/group-invites', { + teamInvites, + }) +} + async function acceptInvite(req, res, next) { const { token } = req.params const userId = SessionManager.getLoggedInUserId(req.session) @@ -255,6 +270,7 @@ async function resendInvite(req, res, next) { module.exports = { createInvite: expressify(createInvite), viewInvite: expressify(viewInvite), + viewInvites: expressify(viewInvites), acceptInvite: expressify(acceptInvite), revokeInvite, resendInvite: expressify(resendInvite), diff --git a/services/web/app/views/subscriptions/team/group-invites.pug b/services/web/app/views/subscriptions/team/group-invites.pug new file mode 100644 index 0000000000..c68bc39e6e --- /dev/null +++ b/services/web/app/views/subscriptions/team/group-invites.pug @@ -0,0 +1,10 @@ +extends ../../layout-marketing + +block entrypointVar + - entrypoint = 'pages/user/subscription/group-invites' + +block append meta + meta(name="ol-teamInvites" data-type="json" content=teamInvites) + +block content + main.content.content-alt.team-invite#group-invites-root diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 6a573305c3..5cc765f69f 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -454,6 +454,7 @@ "go_to_pdf_location_in_code": "", "go_to_settings": "", "group_admin": "", + "group_invitations": "", "group_invite_has_been_sent_to_email": "", "group_libraries": "", "group_managed_by_group_administrator": "", @@ -580,6 +581,7 @@ "is_email_affiliated": "", "join_now": "", "join_project": "", + "join_team_explanation": "", "joining": "", "keep_current_plan": "", "keep_personal_projects_separate": "", @@ -1372,6 +1374,7 @@ "view_hub": "", "view_hub_subtext": "", "view_in_template_gallery": "", + "view_invitation": "", "view_logs": "", "view_metrics": "", "view_metrics_commons_subtext": "", diff --git a/services/web/frontend/js/features/subscription/components/group-invites/group-invites-item-footer.tsx b/services/web/frontend/js/features/subscription/components/group-invites/group-invites-item-footer.tsx new file mode 100644 index 0000000000..94fa3d339b --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/group-invites/group-invites-item-footer.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next' +import type { TeamInvite } from '../../../../../../types/team-invite' + +type GroupInvitesItemFooterProps = { + teamInvite: TeamInvite +} + +export default function GroupInvitesItemFooter({ + teamInvite, +}: GroupInvitesItemFooterProps) { + const { t } = useTranslation() + + return ( +
+

+ {t('join_team_explanation')} +

+
+ + {t('view_invitation')} + +
+
+ ) +} diff --git a/services/web/frontend/js/features/subscription/components/group-invites/group-invites-item.tsx b/services/web/frontend/js/features/subscription/components/group-invites/group-invites-item.tsx new file mode 100644 index 0000000000..c9291589d1 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/group-invites/group-invites-item.tsx @@ -0,0 +1,31 @@ +import { Col, Row } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import GroupInvitesItemFooter from './group-invites-item-footer' +import type { TeamInvite } from '../../../../../../types/team-invite' + +type GroupInvitesItemProps = { + teamInvite: TeamInvite +} + +export default function GroupInvitesItem({ + teamInvite, +}: GroupInvitesItemProps) { + const { t } = useTranslation() + + return ( + + +
+
+

+ {t('invited_to_group', { + inviterName: teamInvite.inviterName, + })} +

+
+ +
+ +
+ ) +} diff --git a/services/web/frontend/js/features/subscription/components/group-invites/group-invites-root.tsx b/services/web/frontend/js/features/subscription/components/group-invites/group-invites-root.tsx new file mode 100644 index 0000000000..64a733df31 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/group-invites/group-invites-root.tsx @@ -0,0 +1,14 @@ +import useWaitForI18n from '../../../../shared/hooks/use-wait-for-i18n' +import GroupInvites from './group-invites' + +function GroupInvitesRoot() { + const { isReady } = useWaitForI18n() + + if (!isReady) { + return null + } + + return +} + +export default GroupInvitesRoot diff --git a/services/web/frontend/js/features/subscription/components/group-invites/group-invites.tsx b/services/web/frontend/js/features/subscription/components/group-invites/group-invites.tsx new file mode 100644 index 0000000000..2ef7ec6996 --- /dev/null +++ b/services/web/frontend/js/features/subscription/components/group-invites/group-invites.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react' +import { Col, Row } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { useLocation } from '@/shared/hooks/use-location' +import GroupInvitesItem from './group-invites-item' +import type { TeamInvite } from '../../../../../../types/team-invite' + +function GroupInvites() { + const { t } = useTranslation() + const teamInvites: TeamInvite[] = getMeta('ol-teamInvites') + const location = useLocation() + + useEffect(() => { + if (teamInvites.length === 0) { + location.assign('/project') + } + }, [teamInvites, location]) + + return ( +
+ + +

{t('group_invitations')}

+ +
+ {teamInvites.map(teamInvite => ( + + ))} +
+ ) +} + +export default GroupInvites diff --git a/services/web/frontend/js/pages/user/subscription/group-invites.tsx b/services/web/frontend/js/pages/user/subscription/group-invites.tsx new file mode 100644 index 0000000000..35e559c110 --- /dev/null +++ b/services/web/frontend/js/pages/user/subscription/group-invites.tsx @@ -0,0 +1,8 @@ +import './base' +import ReactDOM from 'react-dom' +import GroupInvitesRoot from '@/features/subscription/components/group-invites/group-invites-root' + +const element = document.getElementById('group-invites-root') +if (element) { + ReactDOM.render(, element) +} diff --git a/services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx b/services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx new file mode 100644 index 0000000000..1021f770e9 --- /dev/null +++ b/services/web/frontend/stories/subscription/group-invites/group-invites.stories.tsx @@ -0,0 +1,44 @@ +import GroupInvites from '@/features/subscription/components/group-invites/group-invites' +import type { TeamInvite } from '../../../../types/team-invite' +import { useMeta } from '../../hooks/use-meta' +import { ScopeDecorator } from '../../decorators/scope' + +export const GroupInvitesDefault = () => { + const teamInvites: TeamInvite[] = [ + { + email: 'email1@exammple.com', + token: 'token123', + inviterName: 'inviter1@example.com', + sentAt: new Date(), + _id: '123abc', + }, + { + email: 'email2@exammple.com', + token: 'token456', + inviterName: 'inviter2@example.com', + sentAt: new Date(), + _id: '456bcd', + }, + ] + + useMeta({ 'ol-teamInvites': teamInvites }) + + return ( +
+ +
+ ) +} + +export default { + title: 'Subscription / Group Invites', + component: GroupInvites, + args: { + show: true, + }, + argTypes: { + handleHide: { action: 'close modal' }, + onDisableSSO: { action: 'callback' }, + }, + decorators: [ScopeDecorator], +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 731b230c9c..7ccd38d259 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -718,6 +718,7 @@ "group_admins_get_access_to": "Group admins get access to", "group_admins_get_access_to_info": "Special features available only on group plans.", "group_full": "This group is already full", + "group_invitations": "Group Invitations", "group_invite_has_been_sent_to_email": "Group invite has been sent to <0>__email__", "group_libraries": "Group Libraries", "group_managed_by_group_administrator": "User accounts in this group are managed by the group administrator.", @@ -2021,6 +2022,7 @@ "view_hub": "View Admin Hub", "view_hub_subtext": "Access and download subscription statistics and a list of users", "view_in_template_gallery": "View it in the template gallery", + "view_invitation": "View Invitation", "view_logs": "View logs", "view_metrics": "View metrics", "view_metrics_commons_subtext": "Monitor and download usage metrics for your Commons subscription", diff --git a/services/web/types/admin/subscription.ts b/services/web/types/admin/subscription.ts index 4d39d92c5a..701a238b9b 100644 --- a/services/web/types/admin/subscription.ts +++ b/services/web/types/admin/subscription.ts @@ -1,5 +1,5 @@ import { GroupPolicy } from '../subscription/dashboard/subscription' -import { TeamInvite } from './team-invite' +import { TeamInvite } from '../team-invite' export type Subscription = { _id: string diff --git a/services/web/types/admin/team-invite.ts b/services/web/types/admin/team-invite.ts deleted file mode 100644 index 3b6184026f..0000000000 --- a/services/web/types/admin/team-invite.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type TeamInvite = { - email: string -} diff --git a/services/web/types/team-invite.ts b/services/web/types/team-invite.ts new file mode 100644 index 0000000000..e1ff5468ab --- /dev/null +++ b/services/web/types/team-invite.ts @@ -0,0 +1,7 @@ +export type TeamInvite = { + email: string + token: string + inviterName: string + sentAt: Date + _id: string +}