[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
15 changed files with 153 additions and 105 deletions
+34 -27
View File
@@ -277,28 +277,14 @@ class SearchEndpoint(BaseAPIView):
for field in fields:
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 = (
ProjectMember.objects.filter(filters)
ProjectMember.objects.filter(
q,
is_active=True,
workspace__slug=slug,
member__is_bot=False,
project_id=project_id,
)
.annotate(
member__avatar_url=Case(
When(
@@ -318,14 +304,35 @@ class SearchEndpoint(BaseAPIView):
)
)
.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":
fields = ["name", "identifier"]
+1
View File
@@ -74,4 +74,5 @@ export type TSearchEntityRequestPayload = {
query_type: TSearchEntities[];
query: string;
team_id?: string;
issue_id?: string;
};
@@ -33,31 +33,34 @@ export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
} = useMember();
const options =
projectMemberIds?.map((userId) => {
const memberDetails = getProjectMemberDetails(userId);
projectMemberIds
?.map((userId) => {
if (!projectId) return;
const memberDetails = getProjectMemberDetails(userId, projectId.toString());
return {
value: `${memberDetails?.member?.id}`,
query: `${memberDetails?.member?.display_name}`,
content: (
<>
<div className="flex items-center gap-2">
<Avatar
name={memberDetails?.member?.display_name}
src={getFileURL(memberDetails?.member?.avatar_url ?? "")}
showTooltip={false}
/>
{memberDetails?.member?.display_name}
</div>
{issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && (
<div>
<Check className="h-3 w-3" />
return {
value: `${memberDetails?.member?.id}`,
query: `${memberDetails?.member?.display_name}`,
content: (
<>
<div className="flex items-center gap-2">
<Avatar
name={memberDetails?.member?.display_name}
src={getFileURL(memberDetails?.member?.avatar_url ?? "")}
showTooltip={false}
/>
{memberDetails?.member?.display_name}
</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>) => {
if (!workspaceSlug || !projectId || !issue) return;
@@ -80,15 +83,18 @@ export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
return (
<>
{options.map((option) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
))}
{options.map(
(option) =>
option && (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
)
)}
</>
);
});
@@ -17,6 +17,7 @@ import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useUser, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { EUserPermissions } from "@/plane-web/constants";
interface Props {
className?: string;
@@ -39,7 +40,7 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
const { workspaceSlug } = useParams();
const {
getUserDetails,
project: { getProjectMemberIds, fetchProjectMembers },
project: { getProjectMemberIds, fetchProjectMembers, getProjectMemberDetails },
workspace: { workspaceMemberIds },
} = useMember();
const { data: currentUser } = useUser();
@@ -78,23 +79,32 @@ export const MemberOptions: React.FC<Props> = observer((props: Props) => {
}
};
const options = memberIds?.map((userId) => {
const userDetails = getUserDetails(userId);
const options = memberIds
?.map((userId) => {
const userDetails = getUserDetails(userId);
if (projectId) {
const role = getProjectMemberDetails(userId, projectId)?.role;
const isGuest = role === EUserPermissions.GUEST;
if (isGuest) return;
}
return {
value: userId,
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
content: (
<div className="flex items-center gap-2">
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
<span className="flex-grow truncate">{currentUser?.id === userId ? t("you") : userDetails?.display_name}</span>
</div>
),
};
});
return {
value: userId,
query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`,
content: (
<div className="flex items-center gap-2">
<Avatar name={userDetails?.display_name} src={getFileURL(userDetails?.avatar_url ?? "")} />
<span className="flex-grow truncate">
{currentUser?.id === userId ? t("you") : userDetails?.display_name}
</span>
</div>
),
};
})
.filter((o) => !!o);
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(
<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">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`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 && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
filteredOptions.map(
(option) =>
option && (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`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 && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
)
)
) : (
<p className="px-1.5 py-1 italic text-custom-text-400">{t("no_matching_results")}</p>
)
@@ -19,6 +19,8 @@ type Props = {
export const EditorUserMention: React.FC<Props> = observer((props) => {
const { id } = props;
// router
const { projectId } = useParams();
// states
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const [referenceElement, setReferenceElement] = useState<HTMLAnchorElement | null>(null);
@@ -44,7 +46,7 @@ export const EditorUserMention: React.FC<Props> = observer((props) => {
});
// derived values
const userDetails = getUserDetails(id);
const roleDetails = getProjectMemberDetails(id)?.role;
const roleDetails = projectId ? getProjectMemberDetails(id, projectId.toString())?.role : null;
const profileLink = `/${workspaceSlug}/profile/${id}`;
if (!userDetails) {
@@ -30,6 +30,7 @@ interface LiteTextEditorWrapperProps
isSubmitting?: boolean;
showToolbarInitially?: boolean;
uploadFile: (file: File) => Promise<string>;
issue_id?: string;
}
export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {
@@ -38,6 +39,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
workspaceSlug,
workspaceId,
projectId,
issue_id,
accessSpecifier,
handleAccessChange,
showAccessSpecifier = false,
@@ -58,6 +60,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
issue_id: issue_id,
}),
});
// file size
@@ -125,6 +125,7 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId?.toString() ?? "",
issue_id: issueId?.toString(),
})
}
containerClassName={containerClassName}
@@ -44,6 +44,7 @@ export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer(
activityComment.activity_type === "COMMENT" ? (
<IssueCommentCard
projectId={projectId}
issueId={issueId}
key={activityComment.id}
workspaceSlug={workspaceSlug}
commentId={activityComment.id}
@@ -22,6 +22,7 @@ import { IssueCommentBlock } from "./comment-block";
type TIssueCommentCard = {
projectId: string;
issueId: string;
workspaceSlug: string;
commentId: string;
activityOperations: TActivityOperations;
@@ -34,6 +35,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
const {
workspaceSlug,
projectId,
issueId,
commentId,
activityOperations,
ends,
@@ -144,6 +146,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
<LiteTextEditor
workspaceId={workspaceId}
projectId={projectId}
issue_id={issueId}
workspaceSlug={workspaceSlug}
ref={editorRef}
id={comment.id}
@@ -95,6 +95,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
id={"add_comment_" + issueId}
value={"<p></p>"}
projectId={projectId}
issue_id={issueId}
workspaceSlug={workspaceSlug}
onEnterKeyPress={(e) => {
if (!isEmpty && !isSubmitting) {
@@ -36,6 +36,7 @@ export const IssueCommentRoot: FC<TIssueCommentRoot> = observer((props) => {
commentIds.map((commentId, index) => (
<IssueCommentCard
projectId={projectId}
issueId={issueId}
key={commentId}
workspaceSlug={workspaceSlug}
commentId={commentId}
+7 -2
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Search } from "lucide-react";
// hooks
// components
@@ -14,6 +15,8 @@ import { useEventTracker, useMember, useUserPermissions } from "@/hooks/store";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const ProjectMemberList: React.FC = observer(() => {
// router
const { projectId } = useParams();
// states
const [inviteModal, setInviteModal] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -24,7 +27,7 @@ export const ProjectMemberList: React.FC = observer(() => {
} = useMember();
const { allowPermissions } = useUserPermissions();
const searchedMembers = (projectMemberIds ?? []).filter((userId) => {
const memberDetails = getProjectMemberDetails(userId);
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member) return false;
@@ -33,7 +36,9 @@ export const ProjectMemberList: React.FC = observer(() => {
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);
@@ -2,6 +2,7 @@
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Ban } from "lucide-react";
// plane ui
import { Avatar, CustomSearchSelect } from "@plane/ui";
@@ -9,6 +10,7 @@ import { Avatar, CustomSearchSelect } from "@plane/ui";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useMember } from "@/hooks/store";
import { EUserPermissions } from "@/plane-web/constants";
type Props = {
value: any;
@@ -18,6 +20,8 @@ type Props = {
export const MemberSelect: React.FC<Props> = observer((props) => {
const { value, onChange, isDisabled = false } = props;
// router
const { projectId } = useParams();
// store hooks
const {
project: { projectMemberIds, getProjectMemberDetails },
@@ -25,9 +29,11 @@ export const MemberSelect: React.FC<Props> = observer((props) => {
const options = projectMemberIds
?.map((userId) => {
const memberDetails = getProjectMemberDetails(userId);
const memberDetails = projectId ? getProjectMemberDetails(userId, projectId.toString()) : null;
if (!memberDetails?.member) return;
const isGuest = memberDetails.role === EUserPermissions.GUEST;
if (isGuest) return;
return {
value: `${memberDetails?.member.id}`,
@@ -47,7 +53,7 @@ export const MemberSelect: React.FC<Props> = observer((props) => {
content: React.JSX.Element;
}[]
| undefined;
const selectedOption = getProjectMemberDetails(value);
const selectedOption = projectId ? getProjectMemberDetails(value, projectId.toString()) : null;
return (
<CustomSearchSelect
@@ -112,7 +112,7 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
Number(getWorkspaceMemberDetails(rowData.member.id)?.role) ?? EUserPermissions.GUEST
);
const isCurrentUserProjectMember = currentUser
? getProjectMemberDetails(currentUser.id)?.role === EUserPermissions.MEMBER
? getProjectMemberDetails(currentUser.id, projectId)?.role === EUserPermissions.MEMBER
: false;
const isRoleNonEditable =
isCurrentUser || (isProjectAdminOrGuest && !isWorkspaceMember) || isCurrentUserProjectMember;
@@ -36,7 +36,7 @@ export interface IProjectMemberStore {
// computed
projectMemberIds: string[] | null;
// computed actions
getProjectMemberDetails: (userId: string) => IProjectMemberDetails | null;
getProjectMemberDetails: (userId: string, projectId: string) => IProjectMemberDetails | null;
getProjectMemberIds: (projectId: string) => string[] | null;
// fetch actions
fetchProjectMembers: (workspaceSlug: string, projectId: string) => Promise<IProjectMembership[]>;
@@ -110,12 +110,10 @@ export class ProjectMemberStore implements IProjectMemberStore {
* @description get the details of a project member
* @param userId
*/
getProjectMemberDetails = computedFn((userId: string) => {
const projectId = this.routerStore.projectId;
if (!projectId) return null;
getProjectMemberDetails = computedFn((userId: string, projectId: string) => {
const projectMember = this.projectMemberMap?.[projectId]?.[userId];
if (!projectMember) return null;
console.log({ projectMember });
const memberDetails: IProjectMemberDetails = {
id: projectMember.id,
role: projectMember.role,
@@ -183,7 +181,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
* @param data
*/
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");
// original data to revert back in case of error
const originalProjectMemberData = this.projectMemberMap?.[projectId]?.[userId];
@@ -214,7 +212,7 @@ export class ProjectMemberStore implements IProjectMemberStore {
* @param userId
*/
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");
await this.projectMemberService.deleteProjectMember(workspaceSlug, projectId, memberDetails?.id).then(() => {
runInAction(() => {