[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:
parent
39ecfbe7e1
commit
3528d2c934
14 changed files with 142 additions and 87 deletions
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
3
packages/types/src/workspace.d.ts
vendored
3
packages/types/src/workspace.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue