[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 Meta:
model = Workspace
fields = ["name", "slug", "id"]
fields = ["name", "slug", "id", "logo_url"]
read_only_fields = fields
@ -90,9 +90,11 @@ class WorkspaceMemberAdminSerializer(DynamicBaseSerializer):
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 WorkSpaceMemberInviteSerializer(BaseSerializer):
"responded_at",
"created_at",
"updated_at",
"invite_link",
]

View file

@ -251,8 +251,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
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)

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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": {

View file

@ -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;
};

View file

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

View file

@ -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")}
>
<div className="flex-shrink-0">
<div className="grid h-9 w-9 place-items-center rounded">
{invitation.workspace.logo && invitation.workspace.logo.trim() !== "" ? (
<img
src={invitation.workspace.logo}
height="100%"
width="100%"
className="rounded"
alt={invitation.workspace.name}
<WorkspaceLogo
logo={invitation.workspace.logo_url}
name={invitation.workspace.name}
classNames="size-9 flex-shrink-0"
/>
) : (
<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 className="min-w-0 flex-1">
<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: (
<div className="relative inline-flex items-center gap-2">
{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}
</div>
),

View file

@ -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> = (props) => {
onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")}
>
<div className="flex-shrink-0">
<div className="grid h-9 w-9 place-items-center rounded">
{invitedWorkspace?.logo && invitedWorkspace.logo !== "" ? (
<img
src={invitedWorkspace.logo}
height="100%"
width="100%"
className="rounded"
alt={invitedWorkspace.name}
<WorkspaceLogo
logo={invitedWorkspace?.logo_url}
name={invitedWorkspace?.name}
classNames="size-9 flex-shrink-0"
/>
) : (
<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 className="min-w-0 flex-1">
<div className="text-sm font-medium">{truncateText(invitedWorkspace?.name, 30)}</div>

View file

@ -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<Props> = 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<Props> = observer((props) => {
);
};
if (!invitationDetails) return null;
if (!invitationDetails || !currentWorkspaceMemberInfo) return null;
// is the current logged in user admin
const isAdmin = allowPermissions([EUserPermissions.ADMIN], 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);
}
};
// 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
);
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<Props> = observer((props) => {
}}
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">
<span className="relative flex h-10 w-10 items-center justify-center rounded bg-gray-700 p-4 capitalize text-white">
{(invitationDetails.email ?? "?")[0]}
@ -96,7 +124,7 @@ export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => {
</div>
<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">
<p>{t("pending")}</p>
<p>{t("common.pending")}</p>
</div>
<CustomSelect
customButton={
@ -144,17 +172,43 @@ export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => {
})}
</CustomSelect>
{isAdmin && (
<Tooltip tooltipContent="Remove member" disabled={!isAdmin} isMobile={isMobile}>
<button
type="button"
onClick={() => setRemoveMemberModal(true)}
className={`pointer-events-none opacity-0 ${
isAdmin ? "group-hover:pointer-events-auto group-hover:opacity-100" : ""
}`}
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
<XCircle className="h-3.5 w-3.5 text-red-500" strokeWidth={2} />
</button>
</Tooltip>
{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>

View file

@ -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={
<div className="flex w-full items-center justify-between pt-4">
<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 && (
<CountChip count={searchedInvitationsIds.length} className="h-5 m-auto ml-2" />
)}