mirror of
https://github.com/makeplane/plane.git
synced 2026-04-30 04:59:41 -05:00
[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:
@@ -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"]
|
||||
|
||||
Vendored
+1
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user