[WEB-2711]fix: guest mentions and assignees (#6315)

* chore: issue search filter

* * fix: restricting guest user from assignees and mentions

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Vamsi Krishna 2025-01-06 13:06:16 +05:30 committed by GitHub
parent d26fb8ce02
commit 625cbf872b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 153 additions and 105 deletions

View file

@ -277,28 +277,14 @@ class SearchEndpoint(BaseAPIView):
for field in fields: for field in fields:
q |= Q(**{f"{field}__icontains": query}) q |= Q(**{f"{field}__icontains": query})
base_filters = Q(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
project_id=project_id,
role__gt=10,
)
if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id)
.values_list("created_by_id", flat=True)
.first()
)
# Add condition to include `issue_created_by` in the query
filters = Q(member_id=issue_created_by) | base_filters
else:
filters = base_filters
# Query to fetch users
users = ( users = (
ProjectMember.objects.filter(filters) ProjectMember.objects.filter(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
project_id=project_id,
)
.annotate( .annotate(
member__avatar_url=Case( member__avatar_url=Case(
When( When(
@ -318,14 +304,35 @@ class SearchEndpoint(BaseAPIView):
) )
) )
.order_by("-created_at") .order_by("-created_at")
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)[:count]
) )
response_data["user_mention"] = list(users) if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id)
.values_list("created_by_id", flat=True)
.first()
)
users = (
users.filter(Q(role__gt=10) | Q(member_id=issue_created_by))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
else:
users = (
users.filter(Q(role__gt=10))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
response_data["user_mention"] = list(users[:count])
elif query_type == "project": elif query_type == "project":
fields = ["name", "identifier"] fields = ["name", "identifier"]

View file

@ -74,4 +74,5 @@ export type TSearchEntityRequestPayload = {
query_type: TSearchEntities[]; query_type: TSearchEntities[];
query: string; query: string;
team_id?: string; team_id?: string;
issue_id?: string;
}; };

View file

@ -33,31 +33,34 @@ export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
} = useMember(); } = useMember();
const options = const options =
projectMemberIds?.map((userId) => { projectMemberIds
const memberDetails = getProjectMemberDetails(userId); ?.map((userId) => {
if (!projectId) return;
const memberDetails = getProjectMemberDetails(userId, projectId.toString());
return { return {
value: `${memberDetails?.member?.id}`, value: `${memberDetails?.member?.id}`,
query: `${memberDetails?.member?.display_name}`, query: `${memberDetails?.member?.display_name}`,
content: ( content: (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar <Avatar
name={memberDetails?.member?.display_name} name={memberDetails?.member?.display_name}
src={getFileURL(memberDetails?.member?.avatar_url ?? "")} src={getFileURL(memberDetails?.member?.avatar_url ?? "")}
showTooltip={false} showTooltip={false}
/> />
{memberDetails?.member?.display_name} {memberDetails?.member?.display_name}
</div>
{issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && (
<div>
<Check className="h-3 w-3" />
</div> </div>
)} {issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && (
</> <div>
), <Check className="h-3 w-3" />
}; </div>
}) ?? []; )}
</>
),
};
})
.filter((o) => o !== undefined) ?? [];
const handleUpdateIssue = async (formData: Partial<TIssue>) => { const handleUpdateIssue = async (formData: Partial<TIssue>) => {
if (!workspaceSlug || !projectId || !issue) return; if (!workspaceSlug || !projectId || !issue) return;
@ -80,15 +83,18 @@ export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
return ( return (
<> <>
{options.map((option) => ( {options.map(
<Command.Item (option) =>
key={option.value} option && (
onSelect={() => handleIssueAssignees(option.value)} <Command.Item
className="focus:outline-none" key={option.value}
> onSelect={() => handleIssueAssignees(option.value)}
{option.content} className="focus:outline-none"
</Command.Item> >
))} {option.content}
</Command.Item>
)
)}
</> </>
); );
}); });

View file

@ -17,6 +17,7 @@ import { getFileURL } from "@/helpers/file.helper";
// hooks // hooks
import { useUser, useMember } from "@/hooks/store"; import { useUser, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
import { EUserPermissions } from "@/plane-web/constants";
interface Props { interface Props {
className?: string; className?: string;
@ -39,7 +40,7 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const { const {
getUserDetails, getUserDetails,
project: { getProjectMemberIds, fetchProjectMembers }, project: { getProjectMemberIds, fetchProjectMembers, getProjectMemberDetails },
workspace: { workspaceMemberIds }, workspace: { workspaceMemberIds },
} = useMember(); } = useMember();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
@ -78,23 +79,32 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
} }
}; };
const options = memberIds?.map((userId) => { const options = memberIds
const userDetails = getUserDetails(userId); ?.map((userId) => {
const userDetails = getUserDetails(userId);
if (projectId) {
const role = getProjectMemberDetails(userId, projectId)?.role;
const isGuest = role === EUserPermissions.GUEST;
if (isGuest) return;
}
return { return {
value: userId, value: userId,
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
content: ( content: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} /> <Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
<span className="flex-grow truncate">{currentUser?.id === userId ? t("you") : userDetails?.display_name}</span> <span className="flex-grow truncate">
</div> {currentUser?.id === userId ? t("you") : userDetails?.display_name}
), </span>
}; </div>
}); ),
};
})
.filter((o) => !!o);
const filteredOptions = const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); query === "" ? options : options?.filter((o) => o?.query.toLowerCase().includes(query.toLowerCase()));
return createPortal( return createPortal(
<Combobox.Options data-prevent-outside-click static> <Combobox.Options data-prevent-outside-click static>
@ -125,24 +135,27 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll"> <div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? ( {filteredOptions ? (
filteredOptions.length > 0 ? ( filteredOptions.length > 0 ? (
filteredOptions.map((option) => ( filteredOptions.map(
<Combobox.Option (option) =>
key={option.value} option && (
value={option.value} <Combobox.Option
className={({ active, selected }) => key={option.value}
`flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${ value={option.value}
active ? "bg-custom-background-80" : "" className={({ active, selected }) =>
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}` `flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${
} active ? "bg-custom-background-80" : ""
> } ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
{({ selected }) => ( }
<> >
<span className="flex-grow truncate">{option.content}</span> {({ selected }) => (
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />} <>
</> <span className="flex-grow truncate">{option.content}</span>
)} {selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</Combobox.Option> </>
)) )}
</Combobox.Option>
)
)
) : ( ) : (
<p className="px-1.5 py-1 italic text-custom-text-400">{t("no_matching_results")}</p> <p className="px-1.5 py-1 italic text-custom-text-400">{t("no_matching_results")}</p>
) )

View file

@ -19,6 +19,8 @@ type Props = {
export const EditorUserMention: React.FC<Props> = observer((props) => { export const EditorUserMention: React.FC<Props> = observer((props) => {
const { id } = props; const { id } = props;
// router
const { projectId } = useParams();
// states // states
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [referenceElement, setReferenceElement] = useState<HTMLAnchorElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLAnchorElement | null>(null);
@ -44,7 +46,7 @@ export const EditorUserMention: React.FC<Props> = observer((props) => {
}); });
// derived values // derived values
const userDetails = getUserDetails(id); const userDetails = getUserDetails(id);
const roleDetails = getProjectMemberDetails(id)?.role; const roleDetails = projectId ? getProjectMemberDetails(id, projectId.toString())?.role : null;
const profileLink = `/${workspaceSlug}/profile/${id}`; const profileLink = `/${workspaceSlug}/profile/${id}`;
if (!userDetails) { if (!userDetails) {

View file

@ -30,6 +30,7 @@ interface LiteTextEditorWrapperProps
isSubmitting?: boolean; isSubmitting?: boolean;
showToolbarInitially?: boolean; showToolbarInitially?: boolean;
uploadFile: (file: File) => Promise<string>; uploadFile: (file: File) => Promise<string>;
issue_id?: string;
} }
export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => { export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {
@ -38,6 +39,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
workspaceSlug, workspaceSlug,
workspaceId, workspaceId,
projectId, projectId,
issue_id,
accessSpecifier, accessSpecifier,
handleAccessChange, handleAccessChange,
showAccessSpecifier = false, showAccessSpecifier = false,
@ -58,6 +60,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload, ...payload,
project_id: projectId?.toString() ?? "", project_id: projectId?.toString() ?? "",
issue_id: issue_id,
}), }),
}); });
// file size // file size

View file

@ -125,6 +125,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", { await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload, ...payload,
project_id: projectId?.toString() ?? "", project_id: projectId?.toString() ?? "",
issue_id: issueId?.toString(),
}) })
} }
containerClassName={containerClassName} containerClassName={containerClassName}

View file

@ -44,6 +44,7 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
activityComment.activity_type === "COMMENT" ? ( activityComment.activity_type === "COMMENT" ? (
<IssueCommentCard <IssueCommentCard
projectId={projectId} projectId={projectId}
issueId={issueId}
key={activityComment.id} key={activityComment.id}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
commentId={activityComment.id} commentId={activityComment.id}

View file

@ -22,6 +22,7 @@ import { IssueCommentBlock } from "./comment-block";
type TIssueCommentCard = { type TIssueCommentCard = {
projectId: string; projectId: string;
issueId: string;
workspaceSlug: string; workspaceSlug: string;
commentId: string; commentId: string;
activityOperations: TActivityOperations; activityOperations: TActivityOperations;
@ -34,6 +35,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
const { const {
workspaceSlug, workspaceSlug,
projectId, projectId,
issueId,
commentId, commentId,
activityOperations, activityOperations,
ends, ends,
@ -144,6 +146,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
<LiteTextEditor <LiteTextEditor
workspaceId={workspaceId} workspaceId={workspaceId}
projectId={projectId} projectId={projectId}
issue_id={issueId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
ref={editorRef} ref={editorRef}
id={comment.id} id={comment.id}

View file

@ -95,6 +95,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
id={"add_comment_" + issueId} id={"add_comment_" + issueId}
value={"<p></p>"} value={"<p></p>"}
projectId={projectId} projectId={projectId}
issue_id={issueId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
onEnterKeyPress={(e) => { onEnterKeyPress={(e) => {
if (!isEmpty && !isSubmitting) { if (!isEmpty && !isSubmitting) {

View file

@ -36,6 +36,7 @@ export const IssueCommentRoot: FC<TIssueCommentRoot> = observer((props) => {
commentIds.map((commentId, index) => ( commentIds.map((commentId, index) => (
<IssueCommentCard <IssueCommentCard
projectId={projectId} projectId={projectId}
issueId={issueId}
key={commentId} key={commentId}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
commentId={commentId} commentId={commentId}

View file

@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
// hooks // hooks
// components // components
@ -14,6 +15,8 @@ import { useEventTracker, useMember, useUserPermissions } from "@/hooks/store";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const ProjectMemberList: React.FC = observer(() => { export const ProjectMemberList: React.FC = observer(() => {
// router
const { projectId } = useParams();
// states // states
const [inviteModal, setInviteModal] = useState(false); const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -24,7 +27,7 @@ export const ProjectMemberList: React.FC = observer(() => {
} = useMember(); } = useMember();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const searchedMembers = (projectMemberIds ?? []).filter((userId) => { const searchedMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = getProjectMemberDetails(userId); const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member) return false; if (!memberDetails?.member) return false;
@ -33,7 +36,9 @@ export const ProjectMemberList: React.FC = observer(() => {
return displayName?.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase()); return displayName?.includes(searchQuery.toLowerCase()) || fullName.includes(searchQuery.toLowerCase());
}); });
const memberDetails = searchedMembers?.map((memberId) => getProjectMemberDetails(memberId)); const memberDetails = searchedMembers?.map((memberId) =>
projectId ? getProjectMemberDetails(memberId, projectId.toString()) : null
);
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);

View file

@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Ban } from "lucide-react"; import { Ban } from "lucide-react";
// plane ui // plane ui
import { Avatar, CustomSearchSelect } from "@plane/ui"; import { Avatar, CustomSearchSelect } from "@plane/ui";
@ -9,6 +10,7 @@ import { Avatar, CustomSearchSelect } from "@plane/ui";
import { getFileURL } from "@/helpers/file.helper"; import { getFileURL } from "@/helpers/file.helper";
// hooks // hooks
import { useMember } from "@/hooks/store"; import { useMember } from "@/hooks/store";
import { EUserPermissions } from "@/plane-web/constants";
type Props = { type Props = {
value: any; value: any;
@ -18,6 +20,8 @@ type Props = {
export const MemberSelect: React.FC<Props> = observer((props) => { export const MemberSelect: React.FC<Props> = observer((props) => {
const { value, onChange, isDisabled = false } = props; const { value, onChange, isDisabled = false } = props;
// router
const { projectId } = useParams();
// store hooks // store hooks
const { const {
project: { projectMemberIds, getProjectMemberDetails }, project: { projectMemberIds, getProjectMemberDetails },
@ -25,9 +29,11 @@ export const MemberSelect: React.FC<Props> = observer((props) => {
const options = projectMemberIds const options = projectMemberIds
?.map((userId) => { ?.map((userId) => {
const memberDetails = getProjectMemberDetails(userId); const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member) return; if (!memberDetails?.member) return;
const isGuest = memberDetails.role === EUserPermissions.GUEST;
if (isGuest) return;
return { return {
value: `${memberDetails?.member.id}`, value: `${memberDetails?.member.id}`,
@ -47,7 +53,7 @@ export const MemberSelect: React.FC<Props> = observer((props) => {
content: React.JSX.Element; content: React.JSX.Element;
}[] }[]
| undefined; | undefined;
const selectedOption = getProjectMemberDetails(value); const selectedOption = projectId ? getProjectMemberDetails(value, projectId.toString()) : null;
return ( return (
<CustomSearchSelect <CustomSearchSelect

View file

@ -112,7 +112,7 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
Number(getWorkspaceMemberDetails(rowData.member.id)?.role) ?? EUserPermissions.GUEST Number(getWorkspaceMemberDetails(rowData.member.id)?.role) ?? EUserPermissions.GUEST
); );
const isCurrentUserProjectMember = currentUser const isCurrentUserProjectMember = currentUser
? getProjectMemberDetails(currentUser.id)?.role === EUserPermissions.MEMBER ? getProjectMemberDetails(currentUser.id, projectId)?.role === EUserPermissions.MEMBER
: false; : false;
const isRoleNonEditable = const isRoleNonEditable =
isCurrentUser || (isProjectAdminOrGuest && !isWorkspaceMember) || isCurrentUserProjectMember; isCurrentUser || (isProjectAdminOrGuest && !isWorkspaceMember) || isCurrentUserProjectMember;

View file

@ -36,7 +36,7 @@ export interface IProjectMemberStore {
// computed // computed
projectMemberIds: string[] | null; projectMemberIds: string[] | null;
// computed actions // computed actions
getProjectMemberDetails: (userId: string) => IProjectMemberDetails | null; getProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
getProjectMemberIds: (projectId: string) => string[] | null; getProjectMemberIds: (projectId: string) => string[] | null;
// fetch actions // fetch actions
fetchProjectMembers: (workspaceSlug: string, projectId: string) => Promise<IProjectMembership[]>; fetchProjectMembers: (workspaceSlug: string, projectId: string) => Promise<IProjectMembership[]>;
@ -110,12 +110,10 @@ export class ProjectMemberStore implements IProjectMemberStore {
* @description get the details of a project member * @description get the details of a project member
* @param userId * @param userId
*/ */
getProjectMemberDetails = computedFn((userId: string) => { getProjectMemberDetails = computedFn((userId: string, projectId: string) => {
const projectId = this.routerStore.projectId;
if (!projectId) return null;
const projectMember = this.projectMemberMap?.[projectId]?.[userId]; const projectMember = this.projectMemberMap?.[projectId]?.[userId];
if (!projectMember) return null; if (!projectMember) return null;
console.log({ projectMember });
const memberDetails: IProjectMemberDetails = { const memberDetails: IProjectMemberDetails = {
id: projectMember.id, id: projectMember.id,
role: projectMember.role, role: projectMember.role,
@ -183,7 +181,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
* @param data * @param data
*/ */
updateMember = async (workspaceSlug: string, projectId: string, userId: string, data: { role: EUserPermissions }) => { updateMember = async (workspaceSlug: string, projectId: string, userId: string, data: { role: EUserPermissions }) => {
const memberDetails = this.getProjectMemberDetails(userId); const memberDetails = this.getProjectMemberDetails(userId, projectId);
if (!memberDetails) throw new Error("Member not found"); if (!memberDetails) throw new Error("Member not found");
// original data to revert back in case of error // original data to revert back in case of error
const originalProjectMemberData = this.projectMemberMap?.[projectId]?.[userId]; const originalProjectMemberData = this.projectMemberMap?.[projectId]?.[userId];
@ -214,7 +212,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
* @param userId * @param userId
*/ */
removeMemberFromProject = async (workspaceSlug: string, projectId: string, userId: string) => { removeMemberFromProject = async (workspaceSlug: string, projectId: string, userId: string) => {
const memberDetails = this.getProjectMemberDetails(userId); const memberDetails = this.getProjectMemberDetails(userId, projectId);
if (!memberDetails) throw new Error("Member not found"); if (!memberDetails) throw new Error("Member not found");
await this.projectMemberService.deleteProjectMember(workspaceSlug, projectId, memberDetails?.id).then(() => { await this.projectMemberService.deleteProjectMember(workspaceSlug, projectId, memberDetails?.id).then(() => {
runInAction(() => { runInAction(() => {