diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 7105302d3b0..52333c2463e 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -59,7 +59,7 @@ class Meta: class WorkspaceLiteSerializer(BaseSerializer): class Meta: model = Workspace - fields = ["name", "slug", "id"] + fields = ["name", "slug", "id", "logo_url"] read_only_fields = fields @@ -90,9 +90,11 @@ class Meta: class WorkSpaceMemberInviteSerializer(BaseSerializer): - workspace = WorkSpaceSerializer(read_only=True) - total_members = serializers.IntegerField(read_only=True) - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") + workspace = WorkspaceLiteSerializer(read_only=True) + invite_link = serializers.SerializerMethodField() + + def get_invite_link(self, obj): + return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}" class Meta: model = WorkspaceMemberInvite @@ -106,6 +108,7 @@ class Meta: "responded_at", "created_at", "updated_at", + "invite_link", ] @@ -148,12 +151,12 @@ def validate_url(self, value): def create(self, validated_data): # Filtering the WorkspaceUserLink with the given url to check if the link already exists. - + url = validated_data.get("url") workspace_user_link = WorkspaceUserLink.objects.filter( - url=url, - workspace_id=validated_data.get("workspace_id"), + url=url, + workspace_id=validated_data.get("workspace_id"), owner_id=validated_data.get("owner_id") ) @@ -170,8 +173,8 @@ def update(self, instance, validated_data): url = validated_data.get("url") workspace_user_link = WorkspaceUserLink.objects.filter( - url=url, - workspace_id=instance.workspace_id, + url=url, + workspace_id=instance.workspace_id, owner=instance.owner ) diff --git a/apiserver/plane/app/views/workspace/invite.py b/apiserver/plane/app/views/workspace/invite.py index 486a3c93bdf..fd3f97c197b 100644 --- a/apiserver/plane/app/views/workspace/invite.py +++ b/apiserver/plane/app/views/workspace/invite.py @@ -251,8 +251,7 @@ def get_queryset(self): super() .get_queryset() .filter(email=self.request.user.email) - .select_related("workspace", "workspace__owner", "created_by") - .annotate(total_members=Count("workspace__workspace_member")) + .select_related("workspace") ) @invalidate_cache(path="/api/workspaces/", user=False) diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 517daf4f464..30cfe1a99ff 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -672,7 +672,9 @@ "disconnect": "Disconnect", "disconnecting": "Disconnecting", "installing": "Installing", - "install": "Install" + "install": "Install", + "pending": "Pending", + "invite": "Invite" }, "form": { @@ -1279,6 +1281,7 @@ "members": { "title": "Members", "add_member": "Add member", + "pending_invites": "Pending invites", "invitations_sent_successfully": "Invitations sent successfully", "leave_confirmation": "Are you sure you want to leave the workspace? You will no longer have access to this workspace. This action cannot be undone.", "details": { diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index 046c17cee71..f3564b2594d 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -842,7 +842,9 @@ "disconnect": "Desconectar", "disconnecting": "Desconectando", "installing": "Instalando", - "install": "Instalar" + "install": "Instalar", + "pending": "Pendiente", + "invite": "Invitar" }, "form": { @@ -1448,6 +1450,7 @@ "members": { "title": "Miembros", "add_member": "Agregar miembro", + "pending_invites": "Invitaciones pendientes", "invitations_sent_successfully": "Invitaciones enviadas exitosamente", "leave_confirmation": "¿Estás seguro de que quieres abandonar el espacio de trabajo? Ya no tendrás acceso a este espacio de trabajo. Esta acción no se puede deshacer.", "details": { diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index 670a63f0454..8d066d42978 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -842,7 +842,9 @@ "disconnect": "Déconnecter", "disconnecting": "Déconnexion", "installing": "Installation", - "install": "Installer" + "install": "Installer", + "pending": "En attente", + "invite": "Inviter" }, "form": { @@ -1448,6 +1450,7 @@ "members": { "title": "Membres", "add_member": "Ajouter un membre", + "pending_invites": "Invitations en attente", "invitations_sent_successfully": "Invitations envoyées avec succès", "leave_confirmation": "Êtes-vous sûr de vouloir quitter l'espace de travail ? Vous n'aurez plus accès à cet espace de travail. Cette action ne peut pas être annulée.", "details": { diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index 16b34ff7fb9..66e8f681eca 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -842,7 +842,9 @@ "disconnect": "切断", "disconnecting": "切断中", "installing": "インストール中", - "install": "インストール" + "install": "インストール", + "pending": "保留中", + "invite": "招待" }, "form": { @@ -1448,6 +1450,7 @@ "members": { "title": "メンバー", "add_member": "メンバーを追加", + "pending_invites": "保留中の招待", "invitations_sent_successfully": "招待が正常に送信されました", "leave_confirmation": "ワークスペースから退出してもよろしいですか?このワークスペースにアクセスできなくなります。この操作は取り消せません。", "details": { diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 2f52c4793b4..914d2662e3d 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -842,7 +842,9 @@ "disconnect": "断开连接", "disconnecting": "正在断开连接", "installing": "正在安装", - "install": "安装" + "install": "安装", + "pending": "待处理", + "invite": "邀请" }, "form": { @@ -1448,6 +1450,7 @@ "members": { "title": "成员", "add_member": "添加成员", + "pending_invites": "待处理邀请", "invitations_sent_successfully": "邀请发送成功", "leave_confirmation": "您确定要离开工作区吗?您将无法再访问此工作区。此操作无法撤消。", "details": { diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index 9320f9b5943..d72dad7cc67 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -40,9 +40,10 @@ export interface IWorkspaceMemberInvitation { responded_at: Date; role: TUserPermissions; token: string; + invite_link: string; workspace: { id: string; - logo: string; + logo_url: string; name: string; slug: string; }; diff --git a/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx index 74b45236c9a..8be7a9d22f5 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/members/page.tsx @@ -107,7 +107,7 @@ const WorkspaceMembersSettingsPage = observer(() => { onSubmit={handleWorkspaceInvite} />
diff --git a/web/app/invitations/page.tsx b/web/app/invitations/page.tsx index 1c5887beff9..df6befa6869 100644 --- a/web/app/invitations/page.tsx +++ b/web/app/invitations/page.tsx @@ -17,6 +17,7 @@ import type { IWorkspaceMemberInvitation } from "@plane/types"; import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components import { EmptyState } from "@/components/common"; +import { WorkspaceLogo } from "@/components/workspace/logo"; import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; // helpers import { truncateText } from "@/helpers/string.helper"; @@ -167,21 +168,11 @@ const UserInvitationsPage = observer(() => { onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} >
-
- {invitation.workspace.logo && invitation.workspace.logo.trim() !== "" ? ( - {invitation.workspace.name} - ) : ( - - {invitation.workspace.name[0]} - - )} -
+
{truncateText(invitation.workspace.name, 30)}
diff --git a/web/core/components/account/auth-forms/auth-header.tsx b/web/core/components/account/auth-forms/auth-header.tsx index b609fe0a5c5..b33c694bab5 100644 --- a/web/core/components/account/auth-forms/auth-header.tsx +++ b/web/core/components/account/auth-forms/auth-header.tsx @@ -79,7 +79,7 @@ export const AuthHeader: FC = observer((props) => { header: (
{t("common.join")}{" "} - {" "} + {" "} {workspace.name}
), diff --git a/web/core/components/onboarding/invitations.tsx b/web/core/components/onboarding/invitations.tsx index f1882fe93fd..1bfdd063cc2 100644 --- a/web/core/components/onboarding/invitations.tsx +++ b/web/core/components/onboarding/invitations.tsx @@ -9,6 +9,7 @@ import { IWorkspaceMemberInvitation } from "@plane/types"; import { Button, Checkbox, Spinner } from "@plane/ui"; // constants // helpers +import { WorkspaceLogo } from "@/components/workspace/logo"; import { truncateText } from "@/helpers/string.helper"; import { getUserRole } from "@/helpers/user.helper"; // hooks @@ -94,21 +95,11 @@ export const Invitations: React.FC = (props) => { onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} >
-
- {invitedWorkspace?.logo && invitedWorkspace.logo !== "" ? ( - {invitedWorkspace.name} - ) : ( - - {invitedWorkspace?.name[0]} - - )} -
+
{truncateText(invitedWorkspace?.name, 30)}
diff --git a/web/core/components/workspace/settings/invitations-list-item.tsx b/web/core/components/workspace/settings/invitations-list-item.tsx index 1fba5d45e61..dc815b52cc3 100644 --- a/web/core/components/workspace/settings/invitations-list-item.tsx +++ b/web/core/components/workspace/settings/invitations-list-item.tsx @@ -3,17 +3,16 @@ import { useState, FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { ChevronDown, XCircle } from "lucide-react"; +import { ChevronDown, LinkIcon, Trash2 } from "lucide-react"; // plane imports -import { ROLE , EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { ROLE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { CustomSelect, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; +import { CustomSelect, TOAST_TYPE, setToast, TContextMenuItem, CustomMenu } from "@plane/ui"; +import { cn, copyTextToClipboard } from "@plane/utils"; // components import { ConfirmWorkspaceMemberRemove } from "@/components/workspace"; -// constants // hooks import { useMember, useUserPermissions } from "@/hooks/store"; -import { usePlatformOS } from "@/hooks/use-platform-os"; type Props = { invitationId: string; @@ -21,22 +20,31 @@ type Props = { export const WorkspaceInvitationsListItem: FC = observer((props) => { const { invitationId } = props; - // states - const [removeMemberModal, setRemoveMemberModal] = useState(false); // router const { workspaceSlug } = useParams(); + // states + const [removeMemberModal, setRemoveMemberModal] = useState(false); + // plane hooks + const { t } = useTranslation(); // store hooks const { allowPermissions, workspaceInfoBySlug } = useUserPermissions(); - const { t } = useTranslation(); - const { workspace: { updateMemberInvitation, deleteMemberInvitation, getWorkspaceInvitationDetails }, } = useMember(); - const { isMobile } = usePlatformOS(); // derived values const invitationDetails = getWorkspaceInvitationDetails(invitationId); const currentWorkspaceMemberInfo = workspaceInfoBySlug(workspaceSlug.toString()); const currentWorkspaceRole = currentWorkspaceMemberInfo?.role; + // is the current logged in user admin + const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + // role change access- + // 1. user cannot change their own role + // 2. only admin or member can change role + // 3. user cannot change role of higher role + const hasRoleChangeAccess = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); const handleRemoveInvitation = async () => { if (!workspaceSlug || !invitationDetails) return; @@ -58,21 +66,41 @@ export const WorkspaceInvitationsListItem: FC = observer((props) => { ); }; - if (!invitationDetails) return null; - - // is the current logged in user admin - const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + if (!invitationDetails || !currentWorkspaceMemberInfo) return null; - // role change access- - // 1. user cannot change their own role - // 2. only admin or member can change role - // 3. user cannot change role of higher role - const hasRoleChangeAccess = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.WORKSPACE - ); + const handleCopyText = () => { + try { + const inviteLink = new URL(invitationDetails.invite_link, window.location.origin).href; + copyTextToClipboard(inviteLink).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: t("common.link_copied"), + message: t("entity.link_copied_to_clipboard", { entity: t("common.invite") }), + }); + }); + } catch (error) { + console.error("Error generating invite link:", error); + } + }; - if (!currentWorkspaceMemberInfo) return null; + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "copy-link", + action: handleCopyText, + title: t("common.actions.copy_link"), + icon: LinkIcon, + shouldRender: !!invitationDetails.invite_link, + }, + { + key: "remove", + action: () => setRemoveMemberModal(true), + title: t("common.remove"), + icon: Trash2, + shouldRender: isAdmin, + className: "text-red-500", + iconClassName: "text-red-500", + }, + ]; return ( <> @@ -85,7 +113,7 @@ export const WorkspaceInvitationsListItem: FC = observer((props) => { }} onSubmit={handleRemoveInvitation} /> -
+
{(invitationDetails.email ?? "?")[0]} @@ -96,7 +124,7 @@ export const WorkspaceInvitationsListItem: FC = observer((props) => {
-

{t("pending")}

+

{t("common.pending")}

= observer((props) => { })} {isAdmin && ( - - - + + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + disabled={item.disabled} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); + })} +
)}
diff --git a/web/core/components/workspace/settings/members-list.tsx b/web/core/components/workspace/settings/members-list.tsx index 5b5bd0983a1..61d22d0789e 100644 --- a/web/core/components/workspace/settings/members-list.tsx +++ b/web/core/components/workspace/settings/members-list.tsx @@ -62,10 +62,11 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }> isOpen={showPendingInvites} onToggle={() => setShowPendingInvites((prev) => !prev)} buttonClassName="w-full" + className="h-full" title={
-

{t("pending_invites")}

+

{t("workspace_settings.settings.members.pending_invites")}

{searchedInvitationsIds && ( )} @@ -75,7 +76,7 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }> } > -
+
{searchedInvitationsIds?.map((invitationId) => ( ))}