Merge pull request #32941 from overleaf/ii-share-modal-manage-access

[web] Share modal redesign: invited people screen

GitOrigin-RevId: bf7f83dccc245e41eca8087fc27c1b411f025b0d
This commit is contained in:
ilkin-overleaf
2026-04-27 13:47:13 +03:00
committed by Copybot
parent e6861ab6fa
commit 6ccbefc3f8
11 changed files with 393 additions and 19 deletions
@@ -965,7 +965,10 @@
"invite_more_members": "",
"invite_not_accepted": "",
"invite_resend_limit_hit": "",
"invite_resent": "",
"invite_revoked": "",
"invite_users": "",
"invited_people": "",
"invited_to_group": "",
"invited_to_group_have_individual_subcription": "",
"inviting": "",
@@ -1576,6 +1579,7 @@
"resend": "",
"resend_confirmation_code": "",
"resend_group_invite": "",
"resend_invite": "",
"resend_link_sso": "",
"resend_managed_user_invite": "",
"resending_confirmation_code": "",
@@ -45,6 +45,7 @@ export default /** @type {const} */ ([
'lightbulb_2',
'lock',
'lock_open',
'mail',
'mode_comment',
'more_vert',
'neurology',
@@ -52,6 +53,8 @@ export default /** @type {const} */ ([
'notifications',
'open_in_new',
'password',
'person',
'person_edit',
'picture_as_pdf',
'push_pin',
'rate_review',
@@ -75,6 +78,7 @@ export default /** @type {const} */ ([
'upload',
'visibility',
'visibility_off',
'warning',
'web_asset',
'whatshot',
'wifi_off',
@@ -15,6 +15,15 @@ import OLCol from '@/shared/components/ol/ol-col'
import MaterialIcon from '@/shared/components/material-icon'
import { useUserContext } from '@/shared/context/user-context'
import { upgradePlan } from '@/main/account-upgrade'
import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import classnames from 'classnames'
type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded'
@@ -38,6 +47,7 @@ export default function EditMember({
canAddCollaborators,
isReviewerOnFreeProject,
}: EditMemberProps) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const [privileges, setPrivileges] = useState<PermissionsOption>(
member.privileges
)
@@ -51,8 +61,8 @@ export default function EditMember({
setPrivileges(member.privileges)
}, [member.privileges])
const { monitorRequest } = useShareProjectContext()
const { projectId, project, updateProject } = useProjectContext()
const { monitorRequest, setSuccessActionMessage } = useShareProjectContext()
const { projectId, project, updateProject, features } = useProjectContext()
const { members, invites } = project || {}
const user = useUserContext()
@@ -113,10 +123,14 @@ export default function EditMember({
).then(() => {
updateProject({
members:
members?.map(item =>
item._id === member._id ? { ...item, newPrivileges } : item
members?.map(
(item): ProjectMember =>
item._id === member._id
? { ...item, privileges: newPrivileges }
: item
) || [],
})
setSuccessActionMessage(t('access_updated'))
})
}
}
@@ -136,7 +150,195 @@ export default function EditMember({
const confirmRemoval =
privileges !== member.privileges && privilegeChangePending
return (
const getDropdownLabel = () => {
if (hasBeenDowngraded) {
return t('select_access_level')
}
switch (privileges) {
case 'owner':
return t('make_owner')
case 'readAndWrite':
return t('editor')
case 'review':
return t('reviewer')
case 'readOnly':
return t('viewer')
case 'removeAccess':
return t('remove_access')
case 'downgraded':
return t('select_access_level')
default:
return t('remove_access')
}
}
const getPrivilegeSubtitle = (privilege: PermissionsOption) => {
if (!['readAndWrite', 'review'].includes(privilege)) {
return
}
if (
(hasBeenDowngraded && !confirmRemoval) ||
(!canAddCollaborators && !['readAndWrite', 'review'].includes(privileges))
) {
return t('limited_to_n_collaborators_per_project', {
count: features.collaborators,
})
}
}
const isPrivilegeDisabled = (privilege: PermissionsOption) => {
return (
!canAddCollaborators &&
['readAndWrite', 'review'].includes(privilege) &&
((hasBeenDowngraded && !confirmRemoval) ||
!['readAndWrite', 'review'].includes(privileges))
)
}
return isSharingUpdatesEnabled ? (
<ShareProjectModalRow>
<div className="d-inline-flex align-items-center h5 m-0 gap-2">
<MaterialIcon
type={
shouldWarnMember() || member.pendingEditor || member.pendingReviewer
? 'warning'
: 'person'
}
className={
shouldWarnMember() || member.pendingEditor || member.pendingReviewer
? 'text-warning'
: undefined
}
unfilled
/>
<div className="px-2">
{member.email}
{member.pendingEditor && (
<div className="small fw-normal">{t('view_only_downgraded')}</div>
)}
{member.pendingReviewer && (
<div className="small fw-normal">
{t('view_only_reviewer_downgraded')}
</div>
)}
{shouldWarnMember() && (
<div className="small fw-normal">
{t('will_lose_edit_access_on_date', {
date: linkSharingEnforcementDate,
})}
</div>
)}
{isReviewerOnFreeProject && (
<div className="small fw-normal">
<Trans
i18nKey="comment_only_upgrade_to_enable_track_changes"
components={[
// eslint-disable-next-line react/jsx-key
<OLButton
variant="link"
className="btn-inline-link"
onClick={() => upgradePlan('track-changes')}
/>,
]}
/>
</div>
)}
</div>
</div>
<div className="text-end">
<Dropdown
align="end"
onSelect={(eventKey: PermissionsOption) => {
if (eventKey) {
handlePrivilegeChange(eventKey)
}
}}
>
<div className="d-flex align-items-center gap-2">
{hasBeenDowngraded && !confirmRemoval && (
<MaterialIcon type="warning" unfilled className="text-warning" />
)}
<DropdownToggle
variant="ghost"
className="d-flex align-items-center gap-2 no-default-caret"
>
{getDropdownLabel()}
<MaterialIcon type="keyboard_arrow_down" />
</DropdownToggle>
</div>
<DropdownMenu>
<OLDropdownMenuItem
as="button"
eventKey="owner"
leadingIcon={<MaterialIcon type="person_edit" unfilled />}
active={privileges === 'owner'}
trailingIcon={privileges === 'owner' ? 'check' : undefined}
>
{t('make_owner')}
</OLDropdownMenuItem>
<OLDropdownMenuItem
as="button"
eventKey="readAndWrite"
leadingIcon={<MaterialIcon type="edit" unfilled />}
active={privileges === 'readAndWrite'}
trailingIcon={privileges === 'readAndWrite' ? 'check' : undefined}
disabled={isPrivilegeDisabled('readAndWrite')}
description={getPrivilegeSubtitle('readAndWrite')}
>
{t('editor')}
</OLDropdownMenuItem>
{features.trackChangesVisible && (
<OLDropdownMenuItem
as="button"
eventKey="review"
leadingIcon={<MaterialIcon type="mode_comment" unfilled />}
active={privileges === 'review'}
trailingIcon={privileges === 'review' ? 'check' : undefined}
disabled={isPrivilegeDisabled('review')}
description={getPrivilegeSubtitle('review')}
>
{t('reviewer')}
</OLDropdownMenuItem>
)}
<OLDropdownMenuItem
as="button"
eventKey="readOnly"
leadingIcon={<MaterialIcon type="visibility" unfilled />}
active={privileges === 'readOnly'}
trailingIcon={privileges === 'readOnly' ? 'check' : undefined}
>
{t('viewer')}
</OLDropdownMenuItem>
<OLDropdownMenuItem
as="button"
eventKey="removeAccess"
variant="danger"
leadingIcon={<MaterialIcon type="block" unfilled />}
active={privileges === 'removeAccess'}
trailingIcon={privileges === 'removeAccess' ? 'check' : undefined}
>
{t('remove_access')}
</OLDropdownMenuItem>
</DropdownMenu>
</Dropdown>
{confirmRemoval && (
<form
onSubmit={e => {
e.preventDefault()
if (privilegeChangePending) {
commitPrivilegeChange(privileges)
}
}}
>
<ChangePrivilegesActions
handleReset={() => setPrivileges(member.privileges)}
/>
</form>
)}
</div>
</ShareProjectModalRow>
) : (
<form
id="share-project-form"
onSubmit={e => {
@@ -322,12 +524,17 @@ type ChangePrivilegesActionsProps = {
function ChangePrivilegesActions({
handleReset,
}: ChangePrivilegesActionsProps) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const { t } = useTranslation()
return (
<div className="text-center d-flex gap-1 me-3">
<div
className={classnames('text-center d-flex gap-1 me-3', {
'mt-2 justify-content-end': isSharingUpdatesEnabled,
})}
>
<OLButton
type="submit"
type="button"
size="sm"
variant="secondary"
onClick={handleReset}
@@ -9,8 +9,17 @@ import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import OLButton from '@/shared/components/ol/ol-button'
import OLBadge from '@/shared/components/ol/ol-badge'
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
import MaterialIcon from '@/shared/components/material-icon'
import { ProjectMember } from '@/shared/context/types/project-metadata'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export default function Invite({
invite,
@@ -19,8 +28,41 @@ export default function Invite({
invite: ProjectMember
isProjectOwner: boolean
}) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const { t } = useTranslation()
return (
return isSharingUpdatesEnabled ? (
<ShareProjectModalRow>
<div className="d-inline-flex align-items-center h5 m-0 gap-2">
<MaterialIcon type="person" unfilled />
<div className="px-2">{invite.email}</div>
<OLBadge bg="light" text="dark">
{t('pending_invite')}
</OLBadge>
</div>
{isProjectOwner ? (
<Dropdown align="end" onSelect={() => {}}>
<DropdownToggle
variant="ghost"
className="d-flex align-items-center gap-2 no-default-caret"
>
<MemberPrivileges privileges={invite.privileges} />
<MaterialIcon type="keyboard_arrow_down" />
</DropdownToggle>
<DropdownMenu>
<ResendInvite invite={invite} />
<RevokeInvite invite={invite} />
</DropdownMenu>
</Dropdown>
) : (
<div className="h5 m-0 px-4 fw-semibold">
<div className="form-control-plaintext border-0">
<MemberPrivileges privileges={invite.privileges} />
</div>
</div>
)}
</ShareProjectModalRow>
) : (
<OLRow className="project-invite">
<OLCol xs={8}>
<div>{invite.email}</div>
@@ -45,8 +87,10 @@ export default function Invite({
}
function ResendInvite({ invite }: { invite: ProjectMember }) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const { t } = useTranslation()
const { monitorRequest, setError, inFlight } = useShareProjectContext()
const { monitorRequest, setError, inFlight, setSuccessActionMessage } =
useShareProjectContext()
const { projectId } = useProjectContext()
// const buttonRef = useRef(null)
@@ -54,6 +98,7 @@ function ResendInvite({ invite }: { invite: ProjectMember }) {
const handleClick = useCallback(
() =>
monitorRequest(() => resendInvite(projectId, invite))
.then(() => setSuccessActionMessage(t('invite_resent')))
.catch(error => {
if (error?.response?.status === 404) {
setError('invite_expired')
@@ -71,10 +116,19 @@ function ResendInvite({ invite }: { invite: ProjectMember }) {
;(document.activeElement as HTMLElement).blur()
}
}),
[invite, monitorRequest, projectId, setError]
[invite, monitorRequest, projectId, setError, setSuccessActionMessage, t]
)
return (
return isSharingUpdatesEnabled ? (
<OLDropdownMenuItem
as="button"
leadingIcon={<MaterialIcon type="mail" unfilled />}
onClick={handleClick}
disabled={inFlight}
>
{t('resend_invite')}
</OLDropdownMenuItem>
) : (
<OLButton
variant="link"
className="btn-inline-link"
@@ -88,8 +142,9 @@ function ResendInvite({ invite }: { invite: ProjectMember }) {
}
function RevokeInvite({ invite }: { invite: ProjectMember }) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const { t } = useTranslation()
const { monitorRequest } = useShareProjectContext()
const { monitorRequest, setSuccessActionMessage } = useShareProjectContext()
const { projectId, project, updateProject } = useProjectContext()
const { invites, members } = project || {}
@@ -107,10 +162,20 @@ function RevokeInvite({ invite }: { invite: ProjectMember }) {
current_invites_amount: updatedInvites.length,
current_collaborators_amount: members?.length || 0,
})
setSuccessActionMessage(t('invite_revoked'))
})
}
return (
return isSharingUpdatesEnabled ? (
<OLDropdownMenuItem
as="button"
leadingIcon={<MaterialIcon type="block" unfilled />}
variant="danger"
onClick={handleClick}
>
{t('revoke_invite')}
</OLDropdownMenuItem>
) : (
<OLTooltip
id="revoke-invite"
description={t('revoke_invite')}
@@ -1,7 +1,61 @@
type InvitedPeopleProps = object
import { useTranslation } from 'react-i18next'
import OwnerInfo from '@/features/share-project-modal/components/owner-info'
import EditMember from '@/features/share-project-modal/components/edit-member'
import ViewMember from '@/features/share-project-modal/components/view-member'
import Invite from '@/features/share-project-modal/components/invite'
import { useEditorContext } from '@/shared/context/editor-context'
import { ProjectMember } from '@/shared/context/types/project-metadata'
function InvitedPeople(_props: InvitedPeopleProps) {
return null
type InvitedPeopleProps = {
hasExceededCollaboratorLimit: boolean
hasTrackChangesFeature: boolean
canAddCollaborators: boolean
sortedMembers: ProjectMember[]
invites?: ProjectMember[]
}
function InvitedPeople({
hasExceededCollaboratorLimit,
hasTrackChangesFeature,
canAddCollaborators,
sortedMembers,
invites,
}: InvitedPeopleProps) {
const { t } = useTranslation()
const { isProjectOwner } = useEditorContext()
return (
<>
<h3 className="h4 fw-normal mt-3 mb-2 pt-1">{t('invited_people')}</h3>
<OwnerInfo />
{sortedMembers.map(member =>
isProjectOwner ? (
<EditMember
key={member._id}
member={member}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
hasBeenDowngraded={Boolean(
member.pendingEditor || member.pendingReviewer
)}
canAddCollaborators={canAddCollaborators}
isReviewerOnFreeProject={
member.privileges === 'review' && !hasTrackChangesFeature
}
/>
) : (
<ViewMember key={member._id} member={member} />
)
)}
{(invites || []).map(invite => (
<Invite
key={invite._id}
invite={invite}
isProjectOwner={isProjectOwner}
/>
))}
</>
)
}
export default InvitedPeople
@@ -2,13 +2,26 @@ import { useProjectContext } from '@/shared/context/project-context'
import { useTranslation } from 'react-i18next'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
import MaterialIcon from '@/shared/components/material-icon'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export default function OwnerInfo() {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const { t } = useTranslation()
const { project } = useProjectContext()
return (
return isSharingUpdatesEnabled ? (
<ShareProjectModalRow>
<div className="d-inline-flex align-items-center h5 m-0 gap-2">
<MaterialIcon type="person" unfilled />
<div className="px-2">{project?.owner.email}</div>
</div>
<div className="h5 m-0 px-4 fw-semibold">
<div className="form-control-plaintext border-0">{t('owner')}</div>
</div>
</ShareProjectModalRow>
) : (
<OLRow className="project-member">
<OLCol xs={8}>
<div className="project-member-email-icon">
@@ -134,7 +134,13 @@ export default function ShareModalBody({
/>
)}
{isInvitedPeopleScreen || !isProjectOwner ? (
<InvitedPeople />
<InvitedPeople
sortedMembers={sortedMembers}
invites={invites}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
hasTrackChangesFeature={Boolean(features.trackChanges)}
canAddCollaborators={canAddCollaborators}
/>
) : (
<ProjectAccess
setIsInvitedPeopleScreen={setIsInvitedPeopleScreen}
@@ -160,6 +160,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
// update `error` and `inFlight` while sending a request
const monitorRequest = useCallback((request: () => any) => {
setError(undefined)
setSuccessActionMessage(undefined)
setInFlight(true)
const promise = request()
@@ -1,11 +1,27 @@
import MemberPrivileges from './member-privileges'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
import MaterialIcon from '@/shared/components/material-icon'
import { ProjectMember } from '@/shared/context/types/project-metadata'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export default function ViewMember({ member }: { member: ProjectMember }) {
return (
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
return isSharingUpdatesEnabled ? (
<ShareProjectModalRow>
<div className="d-inline-flex align-items-center h5 m-0 gap-2">
<MaterialIcon type="person" unfilled />
<div className="px-2">{member.email}</div>
</div>
<div className="h5 m-0 px-4 fw-semibold">
<div className="form-control-plaintext border-0">
<MemberPrivileges privileges={member.privileges} />
</div>
</div>
</ShareProjectModalRow>
) : (
<OLRow className="project-member">
<OLCol xs={8}>
<div className="project-member-email-icon">
+4
View File
@@ -1261,7 +1261,10 @@
"invite_not_valid": "This is not a valid project invite",
"invite_not_valid_description": "The invite may have expired. Please contact the project owner",
"invite_resend_limit_hit": "The invite resend limit hit",
"invite_resent": "Invite resent.",
"invite_revoked": "Invite revoked.",
"invite_users": "Invite users",
"invited_people": "Invited people",
"invited_to_group": "<0>__inviterName__</0> has invited you to join a group subscription on __appName__",
"invited_to_group_have_individual_subcription": "__inviterName__ has invited you to join a group __appName__ subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?",
"invited_to_group_login": "To accept this invitation you need to log in as __emailAddress__.",
@@ -2094,6 +2097,7 @@
"resend": "Resend",
"resend_confirmation_code": "Resend confirmation code",
"resend_group_invite": "Resend group invite",
"resend_invite": "Resend invite",
"resend_link_sso": "Resend SSO invite",
"resend_managed_user_invite": "Resend managed user invite",
"resending_confirmation_code": "Resending confirmation code",