[web] Share modal shows downgraded editors (#20015)

* add hasBeenDowngraded prop for EditMember

* reduce padding on share modal collab row, add prompt to hasBeenDowngraded Select

* share modal select styling tweaks to allow for inline warning icon

* always show editor limit subtitle when in downgraded state

* add AccessLevelsChanged warning, tweak owner row styling

* conditionally set hasBeenDowngraded prop. make invited member row styling more consistent between warning/enforcement

* add an info state for access level changed notification

* add notification for lost edit access on collaborator share modal, TSify SendInvitesNotice

* fix member privilege alignment in collaborator share modal

* show blue upgrade CTA when some pending editors have been resolved

* automatically show share modal to owners when has pending editors or is over collab limit

* only show lost edit access warning in read-only share modal to pending editors

---------

Co-authored-by: Thomas <thomas-@users.noreply.github.com>
GitOrigin-RevId: e3b88052a48b8f598299ffc55b7c24cb793da151
This commit is contained in:
roo hutton 2024-08-26 14:03:37 +01:00 committed by Copybot
parent 3e06c0086e
commit 2dcf87e3f6
14 changed files with 280 additions and 61 deletions

View file

@ -29,6 +29,7 @@
"accepted_invite": "",
"accepting_invite_as": "",
"access_denied": "",
"access_levels_changed": "",
"account_has_been_link_to_institution_account": "",
"account_has_past_due_invoice_change_plan_warning": "",
"account_managed_by_group_administrator": "",
@ -745,8 +746,10 @@
"library": "",
"license_for_educational_purposes": "",
"limited_offer": "",
"limited_to_n_editors": "",
"limited_to_n_editors_per_project": "",
"limited_to_n_editors_per_project_plural": "",
"limited_to_n_editors_plural": "",
"line": "",
"line_height": "",
"line_width_is_the_width_of_the_line_in_the_current_environment": "",
@ -985,6 +988,7 @@
"percent_is_the_percentage_of_the_line_width": "",
"plan": "",
"plan_tooltip": "",
"please_ask_the_project_owner_to_upgrade_more_editors": "",
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
"please_change_primary_to_remove": "",
"please_check_your_inbox": "",
@ -1228,6 +1232,8 @@
"select_a_project": "",
"select_a_project_figure_modal": "",
"select_a_row_or_a_column_to_delete": "",
"select_access_level": "",
"select_access_levels": "",
"select_all": "",
"select_all_projects": "",
"select_an_output_file": "",
@ -1451,7 +1457,9 @@
"this_field_is_required": "",
"this_grants_access_to_features_2": "",
"this_is_a_labs_experiment": "",
"this_project_already_has_maximum_editors": "",
"this_project_exceeded_compile_timeout_limit_on_free_plan": "",
"this_project_exceeded_editor_limit": "",
"this_project_has_more_than_max_collabs": "",
"this_project_is_public": "",
"this_project_is_public_read_only": "",
@ -1652,6 +1660,7 @@
"view_metrics_commons_subtext": "",
"view_metrics_group_subtext": "",
"view_more": "",
"view_only_downgraded": "",
"view_options": "",
"view_pdf": "",
"view_your_invoices": "",
@ -1722,6 +1731,8 @@
"you_can_only_add_n_people_to_edit_a_project": "",
"you_can_only_add_n_people_to_edit_a_project_plural": "",
"you_can_request_a_maximum_of_limit_fixes_per_day": "",
"you_can_select_or_invite": "",
"you_can_select_or_invite_plural": "",
"you_cant_add_or_change_password_due_to_sso": "",
"you_cant_join_this_group_subscription": "",
"you_dont_have_any_repositories": "",
@ -1756,6 +1767,7 @@
"your_plan_is_limited_to_n_editors": "",
"your_plan_is_limited_to_n_editors_plural": "",
"your_project_exceeded_compile_timeout_limit_on_free_plan": "",
"your_project_exceeded_editor_limit": "",
"your_project_near_compile_timeout_limit": "",
"your_projects": "",
"your_role": "",
@ -1769,6 +1781,7 @@
"youre_joining": "",
"youre_on_free_trial_which_ends_on": "",
"youre_signed_in_as_logout": "",
"youve_lost_edit_access": "",
"youve_unlinked_all_users": "",
"zoom_in": "",
"zoom_out": "",

View file

@ -54,7 +54,7 @@ export default function EditMember({ member }) {
return (
<Form horizontal id="share-project-form" onSubmit={handleSubmit}>
<FormGroup className="project-member">
<FormGroup className="project-member row">
<Col xs={7}>
<FormControl.Static>{member.email}</FormControl.Static>
</Col>

View file

@ -0,0 +1,84 @@
import { useTranslation } from 'react-i18next'
import { Button } from 'react-bootstrap'
import Notification from '@/shared/components/notification'
import { upgradePlan } from '../../../../main/account-upgrade'
import { useProjectContext } from '@/shared/context/project-context'
import { useUserContext } from '@/shared/context/user-context'
import { sendMB } from '@/infrastructure/event-tracking'
import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
type AccessLevelsChangedProps = {
somePendingEditorsResolved: boolean
}
export default function AccessLevelsChanged({
somePendingEditorsResolved,
}: AccessLevelsChangedProps) {
const { t } = useTranslation()
const { features } = useProjectContext()
const user = useUserContext()
return (
<div className="add-collaborators-upgrade">
<Notification
isActionBelowContent
type={somePendingEditorsResolved ? 'info' : 'warning'}
title={
somePendingEditorsResolved
? t('select_access_levels')
: t('access_levels_changed')
}
content={
somePendingEditorsResolved ? (
<p>{t('your_project_exceeded_editor_limit')}</p>
) : (
<p>
{t('this_project_exceeded_editor_limit')}{' '}
{t('you_can_select_or_invite', {
count: features.collaborators,
})}
</p>
)
}
action={
<div className="upgrade-actions">
{user.allowedFreeTrial ? (
<StartFreeTrialButton
buttonProps={{ variant: 'secondary', size: 'small' }}
source="project-sharing"
variant="exceeds"
>
{t('upgrade')}
</StartFreeTrialButton>
) : (
<Button
bsSize="sm"
className="btn-secondary"
onClick={() => {
upgradePlan('project-sharing')
}}
>
{t('upgrade')}
</Button>
)}
<Button
href="https://www.overleaf.com/blog/changes-to-project-sharing"
bsSize="sm"
className="btn-link"
target="_blank"
rel="noreferrer"
onClick={() => {
sendMB('paywall-info-click', {
'paywall-type': 'project-sharing',
content: 'blog',
variant: 'exceeds',
})
}}
>
{t('read_more')}
</Button>
</div>
}
/>
</div>
)
}

View file

@ -13,11 +13,12 @@ import type { ProjectContextMember } from '@/shared/context/types/project-contex
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { linkSharingEnforcementDate } from '../../utils/link-sharing'
type PermissionsOption = PermissionsLevel | 'removeAccess'
type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded'
type EditMemberProps = {
member: ProjectContextMember
hasExceededCollaboratorLimit: boolean
hasBeenDowngraded: boolean
canAddCollaborators: boolean
}
@ -29,6 +30,7 @@ type Privilege = {
export default function EditMember({
member,
hasExceededCollaboratorLimit,
hasBeenDowngraded,
canAddCollaborators,
}: EditMemberProps) {
const [privileges, setPrivileges] = useState<PermissionsOption>(
@ -133,7 +135,7 @@ export default function EditMember({
<div className="email-warning">
{member.email}
{member.pendingEditor && (
<div className="subtitle">Pending editor</div>
<div className="subtitle">{t('view_only_downgraded')}</div>
)}
{shouldWarnMember() && (
<div className="subtitle">
@ -146,7 +148,7 @@ export default function EditMember({
</div>
</Col>
<Col xs={2}>
<Col xs={1}>
{privileges !== member.privileges && privilegeChangePending && (
<ChangePrivilegesActions
handleReset={() => setPrivileges(member.privileges)}
@ -154,7 +156,9 @@ export default function EditMember({
)}
</Col>
<Col xs={3}>
<Col xs={4} className="project-member-select">
{hasBeenDowngraded && <Icon type="warning" fw />}
<SelectPrivilege
value={privileges}
handleChange={value => {
@ -162,6 +166,7 @@ export default function EditMember({
handlePrivilegeChange(value.key)
}
}}
hasBeenDowngraded={hasBeenDowngraded}
canAddCollaborators={canAddCollaborators}
/>
</Col>
@ -182,12 +187,14 @@ EditMember.propTypes = {
type SelectPrivilegeProps = {
value: string
handleChange: (item: Privilege | null | undefined) => void
hasBeenDowngraded: boolean
canAddCollaborators: boolean
}
function SelectPrivilege({
value,
handleChange,
hasBeenDowngraded,
canAddCollaborators,
}: SelectPrivilegeProps) {
const { t } = useTranslation()
@ -203,25 +210,50 @@ function SelectPrivilege({
[t]
)
const downgradedPseudoPrivilege: Privilege = {
key: 'downgraded',
label: t('select_access_level'),
}
function getPrivilegeSubtitle(privilege: PermissionsOption) {
return !canAddCollaborators &&
privilege === 'readAndWrite' &&
value !== 'readAndWrite'
? t('limited_to_n_editors_per_project', { count: features.collaborators })
if (!hasBeenDowngraded) {
return !canAddCollaborators &&
privilege === 'readAndWrite' &&
value !== 'readAndWrite'
? t('limited_to_n_editors_per_project', {
count: features.collaborators,
})
: ''
}
return privilege === 'readAndWrite'
? t('limited_to_n_editors', {
count: features.collaborators,
})
: ''
}
function isPrivilegeDisabled(privilege: PermissionsOption) {
return (
!canAddCollaborators &&
privilege === 'readAndWrite' &&
(hasBeenDowngraded || value !== 'readAndWrite')
)
}
return (
<Select
items={privileges}
itemToKey={item => item.key}
itemToString={item => (item ? item.label : '')}
itemToSubtitle={item => (item ? getPrivilegeSubtitle(item.key) : '')}
itemToDisabled={item =>
item ? getPrivilegeSubtitle(item.key) !== '' : false
}
itemToDisabled={item => (item ? isPrivilegeDisabled(item.key) : false)}
defaultItem={privileges.find(item => item.key === value)}
selected={privileges.find(item => item.key === value)}
selected={
hasBeenDowngraded
? downgradedPseudoPrivilege
: privileges.find(item => item.key === value)
}
name="privileges"
onSelectedItemChanged={handleChange}
selectedIcon

View file

@ -9,13 +9,13 @@ export default function OwnerInfo() {
return (
<Row className="project-member">
<Col xs={9}>
<Col xs={8}>
<div className="project-member-email-icon">
<Icon type="user" fw />
<div className="email-warning">{owner?.email}</div>
</div>
</Col>
<Col xs={3} className="text-left">
<Col xs={4} className="text-right">
{t('owner')}
</Col>
</Row>

View file

@ -1,33 +0,0 @@
import { Col, Row } from 'react-bootstrap'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
export default function SendInvitesNotice() {
const { publicAccessLevel } = useProjectContext()
return (
<Row className="public-access-level public-access-level--notice">
<Col xs={12} className="text-center">
<AccessLevel level={publicAccessLevel} />
</Col>
</Row>
)
}
function AccessLevel({ level }) {
const { t } = useTranslation()
switch (level) {
case 'private':
return t('to_add_more_collaborators')
case 'tokenBased':
return t('to_change_access_permissions')
default:
return null
}
}
AccessLevel.propTypes = {
level: PropTypes.string,
}

View file

@ -0,0 +1,53 @@
import { Col, Row } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import Notification from '@/shared/components/notification'
import { PublicAccessLevel } from '../../../../../../types/public-access-level'
import { useEditorContext } from '@/shared/context/editor-context'
export default function SendInvitesNotice() {
const { publicAccessLevel } = useProjectContext()
const { isPendingEditor } = useEditorContext()
const { t } = useTranslation()
return (
<div>
{isPendingEditor && (
<Notification
isActionBelowContent
type="info"
title={t('youve_lost_edit_access')}
content={
<div>
<p>{t('this_project_already_has_maximum_editors')}</p>
<p>{t('please_ask_the_project_owner_to_upgrade_more_editors')}</p>
</div>
}
/>
)}
<Row className="public-access-level public-access-level--notice">
<Col xs={12} className="text-center">
<AccessLevel level={publicAccessLevel} />
</Col>
</Row>
</div>
)
}
type AccessLevelProps = {
level: PublicAccessLevel | undefined
}
function AccessLevel({ level }: AccessLevelProps) {
const { t } = useTranslation()
switch (level) {
case 'private':
return <span>{t('to_add_more_collaborators')}</span>
case 'tokenBased':
return <span>{t('to_change_access_permissions')}</span>
default:
return <span>''</span>
}
}

View file

@ -2,18 +2,30 @@ import { Row } from 'react-bootstrap'
import AddCollaborators from './add-collaborators'
import AddCollaboratorsUpgrade from './add-collaborators-upgrade'
import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade'
import AccessLevelsChanged from './access-levels-changed'
import PropTypes from 'prop-types'
export default function SendInvites({
canAddCollaborators,
hasExceededCollaboratorLimit,
haveAnyEditorsBeenDowngraded,
somePendingEditorsResolved,
}) {
return (
<Row className="invite-controls">
{hasExceededCollaboratorLimit && <AddCollaboratorsUpgrade />}
{!canAddCollaborators && !hasExceededCollaboratorLimit && (
<CollaboratorsLimitUpgrade />
{hasExceededCollaboratorLimit && !haveAnyEditorsBeenDowngraded && (
<AddCollaboratorsUpgrade />
)}
{haveAnyEditorsBeenDowngraded && (
<AccessLevelsChanged
somePendingEditorsResolved={somePendingEditorsResolved}
/>
)}
{!canAddCollaborators &&
!hasExceededCollaboratorLimit &&
!haveAnyEditorsBeenDowngraded && <CollaboratorsLimitUpgrade />}
<AddCollaborators readOnly={!canAddCollaborators} />
</Row>
)
@ -22,4 +34,6 @@ export default function SendInvites({
SendInvites.propTypes = {
canAddCollaborators: PropTypes.bool,
hasExceededCollaboratorLimit: PropTypes.bool,
haveAnyEditorsBeenDowngraded: PropTypes.bool,
somePendingEditorsResolved: PropTypes.bool,
}

View file

@ -37,6 +37,26 @@ export default function ShareModalBody() {
)
}, [members, invites, features, isProjectOwner])
// determine if some but not all pending editors' permissions have been resolved,
// for moving between warning and info notification states etc.
const somePendingEditorsResolved = useMemo(() => {
return (
members.some(member => member.privileges === 'readAndWrite') &&
members.some(member => member.pendingEditor)
)
}, [members])
const haveAnyEditorsBeenDowngraded = useMemo(() => {
if (!isProjectOwner || !features) {
return false
}
if (features.collaborators === -1) {
return false
}
return members.some(member => member.pendingEditor)
}, [features, isProjectOwner, members])
const hasExceededCollaboratorLimit = useMemo(() => {
if (!isProjectOwner || !features) {
return false
@ -58,6 +78,8 @@ export default function ShareModalBody() {
<SendInvites
canAddCollaborators={canAddCollaborators}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
haveAnyEditorsBeenDowngraded={haveAnyEditorsBeenDowngraded}
somePendingEditorsResolved={somePendingEditorsResolved}
/>
) : (
<SendInvitesNotice />
@ -72,6 +94,7 @@ export default function ShareModalBody() {
key={member._id}
member={member}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
hasBeenDowngraded={member.pendingEditor ?? false}
canAddCollaborators={canAddCollaborators}
/>
) : (

View file

@ -68,7 +68,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
// split test: link-sharing-warning
// show the new share modal if project owner
// is over collaborator limit (once every 24 hours)
// is over collaborator limit or has pending editors (once every 24 hours)
useEffect(() => {
const hasExceededCollaboratorLimit = () => {
if (!isProjectOwner || !project.features) {
@ -80,7 +80,8 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
}
return (
project.members.filter(member => member.privileges === 'readAndWrite')
.length > (project.features.collaborators ?? 1)
.length > (project.features.collaborators ?? 1) ||
project.members.some(member => member.pendingEditor)
)
}

View file

@ -6,13 +6,13 @@ import Icon from '@/shared/components/icon'
export default function ViewMember({ member }) {
return (
<Row className="project-member">
<Col xs={9}>
<Col xs={8}>
<div className="project-member-email-icon">
<Icon type="user" fw />
<div className="email-warning">{member.email}</div>
</div>
</Col>
<Col xs={3} className="text-left">
<Col xs={4} className="text-right">
<MemberPrivileges privileges={member.privileges} />
</Col>
</Row>

View file

@ -101,12 +101,7 @@ export const EditorProvider: FC = ({ children }) => {
const isPendingEditor = useMemo(
() =>
members?.some(
member =>
member._id === userId &&
member.pendingEditor &&
member.privileges === 'readAndWrite'
),
members?.some(member => member._id === userId && member.pendingEditor),
[members, userId]
)

View file

@ -191,4 +191,28 @@
.invite-warning {
margin-bottom: @line-height-computed / 2;
}
.project-member-select {
padding: 0;
.fa-warning {
color: @brand-warning;
}
}
.project-member.form-group,
.project-member.row {
margin: 0 -30px;
}
.project-member .select-wrapper {
display: inline-block;
position: absolute;
right: 0;
.select-trigger {
gap: @spacing-02;
}
}
.project-member.row {
padding-right: 1.28571429em;
}
}

View file

@ -39,6 +39,7 @@
"accepted_invite": "Accepted invite",
"accepting_invite_as": "You are accepting this invite as",
"access_denied": "Access Denied",
"access_levels_changed": "Access levels changed",
"account": "Account",
"account_has_been_link_to_institution_account": "Your __appName__ account on <b>__email__</b> has been linked to your <b>__institutionName__</b> institutional account.",
"account_has_past_due_invoice_change_plan_warning": "Your account currently has a past due invoice. You will not be able to change your plan until this is resolved.",
@ -1085,8 +1086,10 @@
"license": "License",
"license_for_educational_purposes": "This license is for educational purposes (applies to students or faculty using __appName__ for teaching)",
"limited_offer": "Limited offer",
"limited_to_n_editors": "Limited to __count__ editor",
"limited_to_n_editors_per_project": "Limited to __count__ editor per project",
"limited_to_n_editors_per_project_plural": "Limited to __count__ editors per project",
"limited_to_n_editors_plural": "Limited to __count__ editors",
"line_height": "Line Height",
"line_width_is_the_width_of_the_line_in_the_current_environment": "Line width is the width of the line in the current environment. e.g. a full page width in single-column layout or half a page width in a two-column layout.",
"link": "Link",
@ -1455,6 +1458,7 @@
"plans_amper_pricing": "Plans & Pricing",
"plans_and_pricing": "Plans and Pricing",
"plans_and_pricing_lowercase": "plans and pricing",
"please_ask_the_project_owner_to_upgrade_more_editors": "Please ask the project owner to upgrade their plan to allow more editors.",
"please_ask_the_project_owner_to_upgrade_to_track_changes": "Please ask the project owner to upgrade to use track changes",
"please_change_primary_to_remove": "Please change your primary email in order to remove",
"please_check_your_inbox": "Please check your inbox",
@ -1771,6 +1775,8 @@
"select_a_project": "Select a Project",
"select_a_project_figure_modal": "Select a project",
"select_a_row_or_a_column_to_delete": "Select a row or a column to delete",
"select_access_level": "Select access level",
"select_access_levels": "Select access levels",
"select_all": "Select all",
"select_all_projects": "Select all projects",
"select_an_output_file": "Select an Output File",
@ -2064,7 +2070,9 @@
"this_grants_access_to_features_2": "This grants you access to <0>__appName__</0> <0>__featureType__</0> features.",
"this_is_a_labs_experiment": "This is a Labs experiment",
"this_is_your_template": "This is your template from your project",
"this_project_already_has_maximum_editors": "This project already has the maximum number of editors permitted on the owners plan. This means you can view but not edit the project.",
"this_project_exceeded_compile_timeout_limit_on_free_plan": "This project exceeded the compile timeout limit on our free plan.",
"this_project_exceeded_editor_limit": "This project exceeded the editor limit for your plan. All collaborators now have view-only access.",
"this_project_has_more_than_max_collabs": "This project has more than the maximum number of collaborators allowed on the project owners Overleaf plan. This means you could lose edit access from __linkSharingDate__.",
"this_project_is_public": "This project is public and can be edited by anyone with the URL.",
"this_project_is_public_read_only": "This project is public and can be viewed but not edited by anyone with the URL",
@ -2300,6 +2308,7 @@
"view_metrics_commons_subtext": "Monitor and download usage metrics for your Commons subscription",
"view_metrics_group_subtext": "Monitor and download usage metrics for your group subscription",
"view_more": "View more",
"view_only_downgraded": "View only. Upgrade to restore edit access.",
"view_options": "View options",
"view_pdf": "View PDF",
"view_source": "View Source",
@ -2391,6 +2400,8 @@
"you_can_only_add_n_people_to_edit_a_project_plural": "You can only add __count__ people to edit a project with you on your current plan. Upgrade to add more.",
"you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out</0> of the program at any time on this page",
"you_can_request_a_maximum_of_limit_fixes_per_day": "You can request a maximum of __limit__ fixes per day. Please try again tomorrow.",
"you_can_select_or_invite": "You can select or invite __count__ editor on your current plan, or upgrade to get more.",
"you_can_select_or_invite_plural": "You can select or invite __count__ editors on your current plan, or upgrade to get more.",
"you_cant_add_or_change_password_due_to_sso": "You cant add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
"you_cant_join_this_group_subscription": "You cant join this group subscription",
"you_cant_reset_password_due_to_sso": "You cant reset your password because your group or organization uses SSO. <0>Log in with SSO</0>.",
@ -2434,6 +2445,7 @@
"your_plan_is_limited_to_n_editors": "Your plan allows __count__ collaborator with edit access and unlimited viewers.",
"your_plan_is_limited_to_n_editors_plural": "Your plan allows __count__ collaborators with edit access and unlimited viewers.",
"your_project_exceeded_compile_timeout_limit_on_free_plan": "Your project exceeded the compile timeout limit on our free plan.",
"your_project_exceeded_editor_limit": "Your project exceeded the editor limit and access levels were changed. Select a new access level for your collaborators, or upgrade to add more editors.",
"your_project_near_compile_timeout_limit": "Your project is near the compile timeout limit for our free plan.",
"your_projects": "Your Projects",
"your_questions_answered": "Your questions answered",
@ -2450,6 +2462,7 @@
"youre_on_free_trial_which_ends_on": "Youre on a free trial which ends on <0>__date__</0>.",
"youre_signed_in_as_logout": "Youre signed in as <0>__email__</0>. <1>Log out.</1>",
"youre_signed_up": "Youre signed up",
"youve_lost_edit_access": "Youve lost edit access",
"youve_unlinked_all_users": "Youve unlinked all users",
"zh-CN": "Chinese",
"zip_contents_too_large": "Zip contents too large",