[WEB-3368] feat: enhance workspace invitations with copyable invite links (#6601)

* feat: invitation link url

* feat: copy invite link from workspace invitations list

* invitation reponse cleanup and logo url fix

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Prateek Shourya 2025-02-13 23:35:25 +05:30 committed by GitHub
parent 39ecfbe7e1
commit 3528d2c934
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 142 additions and 87 deletions

View file

@ -59,7 +59,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
class WorkspaceLiteSerializer(BaseSerializer): class WorkspaceLiteSerializer(BaseSerializer):
class Meta: class Meta:
model = Workspace model = Workspace
fields = ["name", "slug", "id"] fields = ["name", "slug", "id", "logo_url"]
read_only_fields = fields read_only_fields = fields
@ -90,9 +90,11 @@ class WorkspaceMemberAdminSerializer(DynamicBaseSerializer):
class WorkSpaceMemberInviteSerializer(BaseSerializer): class WorkSpaceMemberInviteSerializer(BaseSerializer):
workspace = WorkSpaceSerializer(read_only=True) workspace = WorkspaceLiteSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True) invite_link = serializers.SerializerMethodField()
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
def get_invite_link(self, obj):
return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}"
class Meta: class Meta:
model = WorkspaceMemberInvite model = WorkspaceMemberInvite
@ -106,6 +108,7 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
"responded_at", "responded_at",
"created_at", "created_at",
"updated_at", "updated_at",
"invite_link",
] ]
@ -148,12 +151,12 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
def create(self, validated_data): def create(self, validated_data):
# Filtering the WorkspaceUserLink with the given url to check if the link already exists. # Filtering the WorkspaceUserLink with the given url to check if the link already exists.
url = validated_data.get("url") url = validated_data.get("url")
workspace_user_link = WorkspaceUserLink.objects.filter( workspace_user_link = WorkspaceUserLink.objects.filter(
url=url, url=url,
workspace_id=validated_data.get("workspace_id"), workspace_id=validated_data.get("workspace_id"),
owner_id=validated_data.get("owner_id") owner_id=validated_data.get("owner_id")
) )
@ -170,8 +173,8 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
url = validated_data.get("url") url = validated_data.get("url")
workspace_user_link = WorkspaceUserLink.objects.filter( workspace_user_link = WorkspaceUserLink.objects.filter(
url=url, url=url,
workspace_id=instance.workspace_id, workspace_id=instance.workspace_id,
owner=instance.owner owner=instance.owner
) )

View file

@ -251,8 +251,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
super() super()
.get_queryset() .get_queryset()
.filter(email=self.request.user.email) .filter(email=self.request.user.email)
.select_related("workspace", "workspace__owner", "created_by") .select_related("workspace")
.annotate(total_members=Count("workspace__workspace_member"))
) )
@invalidate_cache(path="/api/workspaces/", user=False) @invalidate_cache(path="/api/workspaces/", user=False)

View file

@ -672,7 +672,9 @@
"disconnect": "Disconnect", "disconnect": "Disconnect",
"disconnecting": "Disconnecting", "disconnecting": "Disconnecting",
"installing": "Installing", "installing": "Installing",
"install": "Install" "install": "Install",
"pending": "Pending",
"invite": "Invite"
}, },
"form": { "form": {
@ -1279,6 +1281,7 @@
"members": { "members": {
"title": "Members", "title": "Members",
"add_member": "Add member", "add_member": "Add member",
"pending_invites": "Pending invites",
"invitations_sent_successfully": "Invitations sent successfully", "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.", "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": { "details": {

View file

@ -842,7 +842,9 @@
"disconnect": "Desconectar", "disconnect": "Desconectar",
"disconnecting": "Desconectando", "disconnecting": "Desconectando",
"installing": "Instalando", "installing": "Instalando",
"install": "Instalar" "install": "Instalar",
"pending": "Pendiente",
"invite": "Invitar"
}, },
"form": { "form": {
@ -1448,6 +1450,7 @@
"members": { "members": {
"title": "Miembros", "title": "Miembros",
"add_member": "Agregar miembro", "add_member": "Agregar miembro",
"pending_invites": "Invitaciones pendientes",
"invitations_sent_successfully": "Invitaciones enviadas exitosamente", "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.", "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": { "details": {

View file

@ -842,7 +842,9 @@
"disconnect": "Déconnecter", "disconnect": "Déconnecter",
"disconnecting": "Déconnexion", "disconnecting": "Déconnexion",
"installing": "Installation", "installing": "Installation",
"install": "Installer" "install": "Installer",
"pending": "En attente",
"invite": "Inviter"
}, },
"form": { "form": {
@ -1448,6 +1450,7 @@
"members": { "members": {
"title": "Membres", "title": "Membres",
"add_member": "Ajouter un membre", "add_member": "Ajouter un membre",
"pending_invites": "Invitations en attente",
"invitations_sent_successfully": "Invitations envoyées avec succès", "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.", "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": { "details": {

View file

@ -842,7 +842,9 @@
"disconnect": "切断", "disconnect": "切断",
"disconnecting": "切断中", "disconnecting": "切断中",
"installing": "インストール中", "installing": "インストール中",
"install": "インストール" "install": "インストール",
"pending": "保留中",
"invite": "招待"
}, },
"form": { "form": {
@ -1448,6 +1450,7 @@
"members": { "members": {
"title": "メンバー", "title": "メンバー",
"add_member": "メンバーを追加", "add_member": "メンバーを追加",
"pending_invites": "保留中の招待",
"invitations_sent_successfully": "招待が正常に送信されました", "invitations_sent_successfully": "招待が正常に送信されました",
"leave_confirmation": "ワークスペースから退出してもよろしいですか?このワークスペースにアクセスできなくなります。この操作は取り消せません。", "leave_confirmation": "ワークスペースから退出してもよろしいですか?このワークスペースにアクセスできなくなります。この操作は取り消せません。",
"details": { "details": {

View file

@ -842,7 +842,9 @@
"disconnect": "断开连接", "disconnect": "断开连接",
"disconnecting": "正在断开连接", "disconnecting": "正在断开连接",
"installing": "正在安装", "installing": "正在安装",
"install": "安装" "install": "安装",
"pending": "待处理",
"invite": "邀请"
}, },
"form": { "form": {
@ -1448,6 +1450,7 @@
"members": { "members": {
"title": "成员", "title": "成员",
"add_member": "添加成员", "add_member": "添加成员",
"pending_invites": "待处理邀请",
"invitations_sent_successfully": "邀请发送成功", "invitations_sent_successfully": "邀请发送成功",
"leave_confirmation": "您确定要离开工作区吗?您将无法再访问此工作区。此操作无法撤消。", "leave_confirmation": "您确定要离开工作区吗?您将无法再访问此工作区。此操作无法撤消。",
"details": { "details": {

View file

@ -40,9 +40,10 @@ export interface IWorkspaceMemberInvitation {
responded_at: Date; responded_at: Date;
role: TUserPermissions; role: TUserPermissions;
token: string; token: string;
invite_link: string;
workspace: { workspace: {
id: string; id: string;
logo: string; logo_url: string;
name: string; name: string;
slug: string; slug: string;
}; };

View file

@ -107,7 +107,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
onSubmit={handleWorkspaceInvite} onSubmit={handleWorkspaceInvite}
/> />
<section <section
className={cn("w-full overflow-y-auto", { className={cn("w-full h-full overflow-y-auto", {
"opacity-60": !canPerformWorkspaceMemberActions, "opacity-60": !canPerformWorkspaceMemberActions,
})} })}
> >

View file

@ -17,6 +17,7 @@ import type { IWorkspaceMemberInvitation } from "@plane/types";
import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// components // components
import { EmptyState } from "@/components/common"; import { EmptyState } from "@/components/common";
import { WorkspaceLogo } from "@/components/workspace/logo";
import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys"; import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys";
// helpers // helpers
import { truncateText } from "@/helpers/string.helper"; import { truncateText } from "@/helpers/string.helper";
@ -167,21 +168,11 @@ const UserInvitationsPage = observer(() => {
onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="grid h-9 w-9 place-items-center rounded"> <WorkspaceLogo
{invitation.workspace.logo && invitation.workspace.logo.trim() !== "" ? ( logo={invitation.workspace.logo_url}
<img name={invitation.workspace.name}
src={invitation.workspace.logo} classNames="size-9 flex-shrink-0"
height="100%" />
width="100%"
className="rounded"
alt={invitation.workspace.name}
/>
) : (
<span className="grid h-9 w-9 place-items-center rounded bg-gray-700 px-3 py-1.5 uppercase text-white">
{invitation.workspace.name[0]}
</span>
)}
</div>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium">{truncateText(invitation.workspace.name, 30)}</div> <div className="text-sm font-medium">{truncateText(invitation.workspace.name, 30)}</div>

View file

@ -79,7 +79,7 @@ export const AuthHeader: FC<TAuthHeader> = observer((props) => {
header: ( header: (
<div className="relative inline-flex items-center gap-2"> <div className="relative inline-flex items-center gap-2">
{t("common.join")}{" "} {t("common.join")}{" "}
<WorkspaceLogo logo={workspace?.logo} name={workspace?.name} classNames="w-8 h-9 flex-shrink-0" />{" "} <WorkspaceLogo logo={workspace?.logo_url} name={workspace?.name} classNames="size-9 flex-shrink-0" />{" "}
{workspace.name} {workspace.name}
</div> </div>
), ),

View file

@ -9,6 +9,7 @@ import { IWorkspaceMemberInvitation } from "@plane/types";
import { Button, Checkbox, Spinner } from "@plane/ui"; import { Button, Checkbox, Spinner } from "@plane/ui";
// constants // constants
// helpers // helpers
import { WorkspaceLogo } from "@/components/workspace/logo";
import { truncateText } from "@/helpers/string.helper"; import { truncateText } from "@/helpers/string.helper";
import { getUserRole } from "@/helpers/user.helper"; import { getUserRole } from "@/helpers/user.helper";
// hooks // hooks
@ -94,21 +95,11 @@ export const Invitations: React.FC<Props> = (props) => {
onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="grid h-9 w-9 place-items-center rounded"> <WorkspaceLogo
{invitedWorkspace?.logo && invitedWorkspace.logo !== "" ? ( logo={invitedWorkspace?.logo_url}
<img name={invitedWorkspace?.name}
src={invitedWorkspace.logo} classNames="size-9 flex-shrink-0"
height="100%" />
width="100%"
className="rounded"
alt={invitedWorkspace.name}
/>
) : (
<span className="grid h-9 w-9 place-items-center rounded bg-gray-700 px-3 py-1.5 uppercase text-white">
{invitedWorkspace?.name[0]}
</span>
)}
</div>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm font-medium">{truncateText(invitedWorkspace?.name, 30)}</div> <div className="text-sm font-medium">{truncateText(invitedWorkspace?.name, 30)}</div>

View file

@ -3,17 +3,16 @@
import { useState, FC } from "react"; import { useState, FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { ChevronDown, XCircle } from "lucide-react"; import { ChevronDown, LinkIcon, Trash2 } from "lucide-react";
// plane imports // plane imports
import { ROLE , EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { ROLE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; 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 // components
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace"; import { ConfirmWorkspaceMemberRemove } from "@/components/workspace";
// constants
// hooks // hooks
import { useMember, useUserPermissions } from "@/hooks/store"; import { useMember, useUserPermissions } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = { type Props = {
invitationId: string; invitationId: string;
@ -21,22 +20,31 @@ type Props = {
export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => { export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => {
const { invitationId } = props; const { invitationId } = props;
// states
const [removeMemberModal, setRemoveMemberModal] = useState(false);
// router // router
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// states
const [removeMemberModal, setRemoveMemberModal] = useState(false);
// plane hooks
const { t } = useTranslation();
// store hooks // store hooks
const { allowPermissions, workspaceInfoBySlug } = useUserPermissions(); const { allowPermissions, workspaceInfoBySlug } = useUserPermissions();
const { t } = useTranslation();
const { const {
workspace: { updateMemberInvitation, deleteMemberInvitation, getWorkspaceInvitationDetails }, workspace: { updateMemberInvitation, deleteMemberInvitation, getWorkspaceInvitationDetails },
} = useMember(); } = useMember();
const { isMobile } = usePlatformOS();
// derived values // derived values
const invitationDetails = getWorkspaceInvitationDetails(invitationId); const invitationDetails = getWorkspaceInvitationDetails(invitationId);
const currentWorkspaceMemberInfo = workspaceInfoBySlug(workspaceSlug.toString()); const currentWorkspaceMemberInfo = workspaceInfoBySlug(workspaceSlug.toString());
const currentWorkspaceRole = currentWorkspaceMemberInfo?.role; 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 () => { const handleRemoveInvitation = async () => {
if (!workspaceSlug || !invitationDetails) return; if (!workspaceSlug || !invitationDetails) return;
@ -58,21 +66,41 @@ export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => {
); );
}; };
if (!invitationDetails) return null; if (!invitationDetails || !currentWorkspaceMemberInfo) return null;
// is the current logged in user admin const handleCopyText = () => {
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); 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);
}
};
// role change access- const MENU_ITEMS: TContextMenuItem[] = [
// 1. user cannot change their own role {
// 2. only admin or member can change role key: "copy-link",
// 3. user cannot change role of higher role action: handleCopyText,
const hasRoleChangeAccess = allowPermissions( title: t("common.actions.copy_link"),
[EUserPermissions.ADMIN, EUserPermissions.MEMBER], icon: LinkIcon,
EUserPermissionsLevel.WORKSPACE shouldRender: !!invitationDetails.invite_link,
); },
{
if (!currentWorkspaceMemberInfo) return null; key: "remove",
action: () => setRemoveMemberModal(true),
title: t("common.remove"),
icon: Trash2,
shouldRender: isAdmin,
className: "text-red-500",
iconClassName: "text-red-500",
},
];
return ( return (
<> <>
@ -85,7 +113,7 @@ export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => {
}} }}
onSubmit={handleRemoveInvitation} onSubmit={handleRemoveInvitation}
/> />
<div className="group flex items-center justify-between px-3 py-4 hover:bg-custom-background-90 w-full"> <div className="group flex items-center justify-between px-3 py-4 hover:bg-custom-background-90 w-full h-full">
<div className="flex items-center gap-x-4 gap-y-2"> <div className="flex items-center gap-x-4 gap-y-2">
<span className="relative flex h-10 w-10 items-center justify-center rounded bg-gray-700 p-4 capitalize text-white"> <span className="relative flex h-10 w-10 items-center justify-center rounded bg-gray-700 p-4 capitalize text-white">
{(invitationDetails.email ?? "?")[0]} {(invitationDetails.email ?? "?")[0]}
@ -96,7 +124,7 @@ export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => {
</div> </div>
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<div className="flex items-center justify-center rounded bg-yellow-500/20 px-2.5 py-1 text-center text-xs font-medium text-yellow-500"> <div className="flex items-center justify-center rounded bg-yellow-500/20 px-2.5 py-1 text-center text-xs font-medium text-yellow-500">
<p>{t("pending")}</p> <p>{t("common.pending")}</p>
</div> </div>
<CustomSelect <CustomSelect
customButton={ customButton={
@ -144,17 +172,43 @@ export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => {
})} })}
</CustomSelect> </CustomSelect>
{isAdmin && ( {isAdmin && (
<Tooltip tooltipContent="Remove member" disabled={!isAdmin} isMobile={isMobile}> <CustomMenu ellipsis placement="bottom-end" closeOnSelect>
<button {MENU_ITEMS.map((item) => {
type="button" if (item.shouldRender === false) return null;
onClick={() => setRemoveMemberModal(true)} return (
className={`pointer-events-none opacity-0 ${ <CustomMenu.MenuItem
isAdmin ? "group-hover:pointer-events-auto group-hover:opacity-100" : "" key={item.key}
}`} onClick={(e) => {
> e.preventDefault();
<XCircle className="h-3.5 w-3.5 text-red-500" strokeWidth={2} /> e.stopPropagation();
</button> item.action();
</Tooltip> }}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
)} )}
</div> </div>
</div> </div>

View file

@ -62,10 +62,11 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }>
isOpen={showPendingInvites} isOpen={showPendingInvites}
onToggle={() => setShowPendingInvites((prev) => !prev)} onToggle={() => setShowPendingInvites((prev) => !prev)}
buttonClassName="w-full" buttonClassName="w-full"
className="h-full"
title={ title={
<div className="flex w-full items-center justify-between pt-4"> <div className="flex w-full items-center justify-between pt-4">
<div className="flex"> <div className="flex">
<h4 className="text-xl font-medium pt-2 pb-2">{t("pending_invites")}</h4> <h4 className="text-xl font-medium pt-2 pb-2">{t("workspace_settings.settings.members.pending_invites")}</h4>
{searchedInvitationsIds && ( {searchedInvitationsIds && (
<CountChip count={searchedInvitationsIds.length} className="h-5 m-auto ml-2" /> <CountChip count={searchedInvitationsIds.length} className="h-5 m-auto ml-2" />
)} )}
@ -75,7 +76,7 @@ export const WorkspaceMembersList: FC<{ searchQuery: string; isAdmin: boolean }>
} }
> >
<Disclosure.Panel> <Disclosure.Panel>
<div className="ml-auto items-center gap-1.5 rounded-md bg-custom-background-100 py-1.5"> <div className="ml-auto items-center gap-1.5 rounded-md bg-custom-background-100 py-1.5">
{searchedInvitationsIds?.map((invitationId) => ( {searchedInvitationsIds?.map((invitationId) => (
<WorkspaceInvitationsListItem key={invitationId} invitationId={invitationId} /> <WorkspaceInvitationsListItem key={invitationId} invitationId={invitationId} />
))} ))}