ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: self-service workspace invite approval

This commit is contained in:
DCCONSTRUCTIONS
2026-05-09 22:57:07 +03:00
parent 42bc0fb0e6
commit 0be8f01283
22 changed files with 707 additions and 77 deletions
@@ -238,9 +238,9 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
shouldRenderPeekSurface
? "flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
: "h-full w-full",
!embedIssue && "absolute z-[25]",
!embedIssue && "absolute z-[80]",
!embedIssue && {
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
"top-[5.35rem] right-3 bottom-[5.85rem] w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
@@ -273,7 +273,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
>
{shouldAllowPeekResize && peekMode === "side-peek" && (
<div
className="absolute top-0 left-0 z-[26] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
className="absolute top-0 left-0 z-[81] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
onMouseDown={startPeekResizing}
role="separator"
aria-label="Resize issue panel"
@@ -61,26 +61,24 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[40rem]">
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" />
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-[rgba(var(--nodedc-accent-rgb),0.16)] sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-[rgb(var(--nodedc-accent-rgb))]" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-16 leading-6 font-medium text-primary">
{currentUser?.id === userDetails.id
? "Leave workspace?"
: `Remove ${userDetails?.display_name}?`}
? "Покинуть рабочее пространство?"
: `Удалить ${userDetails?.display_name}?`}
</Dialog.Title>
<div className="mt-2">
{currentUser?.id === userDetails.id ? (
<p className="text-13 text-secondary">
Are you sure you want to leave the workspace? You will no longer have access to this
workspace. This action cannot be undone.
Вы потеряете доступ к этому рабочему пространству. Действие нельзя отменить.
</p>
) : (
<p className="text-13 text-secondary">
Are you sure you want to remove member-{" "}
<span className="font-bold">{userDetails?.display_name}</span>? They will no longer have
access to this workspace. This action cannot be undone.
Вы уверены, что хотите удалить <span className="font-bold">{userDetails?.display_name}</span>?
Доступ к этому рабочему пространству будет закрыт. Действие нельзя отменить.
</p>
)}
</div>
@@ -89,16 +87,16 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" onClick={handleClose}>
Cancel
Отменить
</Button>
<Button variant="error-fill" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
<Button variant="primary" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
{currentUser?.id === userDetails.id
? isRemoving
? "Leaving"
: "Leave"
? "Выход..."
: "Покинуть"
: isRemoving
? "Removing"
: "Remove"}
? "Удаление..."
: "Удалить"}
</Button>
</div>
</Dialog.Panel>
@@ -7,9 +7,8 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { AlertTriangle } from "lucide-react";
// ui
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useUser } from "@/hooks/store/user";
@@ -30,7 +29,6 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
const [isRemoving, setIsRemoving] = useState(false);
// store hooks
const { data: currentUser } = useUser();
const { t } = useTranslation();
const handleClose = () => {
onClose();
@@ -49,23 +47,24 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-danger-subtle sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-danger-primary" aria-hidden="true" />
<div className="mx-auto flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[rgba(var(--nodedc-accent-rgb),0.16)] sm:mx-0 sm:h-10 sm:w-10">
<AlertTriangle className="h-6 w-6 text-[rgb(var(--nodedc-accent-rgb))]" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-h5-medium leading-6 text-primary">
{currentUser?.id === userDetails.id ? "Leave workspace?" : `Remove ${userDetails?.display_name}?`}
{currentUser?.id === userDetails.id
? "Покинуть рабочее пространство?"
: `Удалить ${userDetails?.display_name}?`}
</h3>
<div className="mt-2">
{currentUser?.id === userDetails.id ? (
<p className="text-body-xs-regular text-secondary">
{t("workspace_settings.settings.members.leave_confirmation")}
Вы потеряете доступ к этому рабочему пространству. Действие нельзя отменить.
</p>
) : (
<p className="text-body-xs-regular text-secondary">
{/* TODO: Add translation here */}
Are you sure you want to remove member- <span className="font-bold">{userDetails?.display_name}</span>
? They will no longer have access to this workspace. This action cannot be undone.
Вы уверены, что хотите удалить <span className="font-bold">{userDetails?.display_name}</span>? Доступ
к этому рабочему пространству будет закрыт. Действие нельзя отменить.
</p>
)}
</div>
@@ -74,16 +73,16 @@ export const ConfirmWorkspaceMemberRemove = observer(function ConfirmWorkspaceMe
</div>
<div className="flex justify-end gap-2 p-4 sm:px-6">
<Button variant="secondary" size="lg" onClick={handleClose}>
{t("cancel")}
Отменить
</Button>
<Button variant="error-fill" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
<Button variant="primary" size="lg" tabIndex={1} onClick={handleDeletion} loading={isRemoving}>
{currentUser?.id === userDetails.id
? isRemoving
? t("leaving")
: t("leave")
? "Выход..."
: "Покинуть"
: isRemoving
? t("removing")
: t("remove")}
? "Удаление..."
: "Удалить"}
</Button>
</div>
</ModalCore>
@@ -10,11 +10,9 @@ import { useParams } from "next/navigation";
// plane imports
import { ROLE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { LinkIcon, TrashIcon, ChevronDownIcon } from "@plane/propel/icons";
import { ChevronDownIcon, CopyLinkIcon, TrashIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
import { copyTextToClipboard } from "@plane/utils";
import { cn, copyTextToClipboard } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// components
import { ConfirmWorkspaceMemberRemove } from "@/components/workspace/confirm-workspace-member-remove";
@@ -26,6 +24,8 @@ type Props = {
invitationId: string;
};
type NodeDCInvitationApprovalStatus = "not_required" | "pending" | "approved" | "rejected";
export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitationsListItem(props: Props) {
const { invitationId } = props;
// router
@@ -43,6 +43,10 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
const invitationDetails = getWorkspaceInvitationDetails(invitationId);
const currentWorkspaceMemberInfo = workspaceInfoBySlug(workspaceSlug.toString());
const currentWorkspaceRole = currentWorkspaceMemberInfo?.role;
const nodedcApprovalStatus =
(invitationDetails as { nodedc_approval_status?: NodeDCInvitationApprovalStatus } | undefined)
?.nodedc_approval_status ?? "not_required";
const isNodeDCApprovalLocked = nodedcApprovalStatus === "pending" || nodedcApprovalStatus === "rejected";
// is the current logged in user admin
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
// role change access-
@@ -61,15 +65,15 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
await deleteMemberInvitation(workspaceSlug.toString(), invitationDetails.id);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Invitation removed successfully.",
title: "Инвайт удалён",
message: "Пользователь больше не увидит это приглашение.",
});
} catch (err: unknown) {
const error = err as { error?: string };
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error || "Something went wrong. Please try again.",
title: "Ошибка",
message: error?.error || "Не удалось удалить инвайт. Попробуйте ещё раз.",
});
}
};
@@ -78,39 +82,19 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
const handleCopyText = async () => {
try {
if (!invitationDetails.invite_link) return;
const inviteLink = new URL(invitationDetails.invite_link, window.location.origin).href;
await copyTextToClipboard(inviteLink);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("entity.link_copied_to_clipboard", { entity: t("common.invite") }),
title: "Инвайт скопирован",
message: "Передайте ссылку приглашённому пользователю.",
});
} catch (error) {
console.error("Error generating invite link:", error);
}
};
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "copy-link",
action: () => void handleCopyText(),
title: t("common.actions.copy_link"),
icon: LinkIcon,
shouldRender: !!invitationDetails.invite_link,
},
{
key: "remove",
action: () => {
setRemoveMemberModal(true);
},
title: t("common.remove"),
icon: TrashIcon,
shouldRender: isAdmin,
className: "text-danger-primary",
iconClassName: "text-danger-primary",
},
];
return (
<>
<ConfirmWorkspaceMemberRemove
@@ -132,8 +116,8 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
</div>
</div>
<div className="flex items-center gap-2 text-11">
<div className="flex items-center justify-center rounded-full bg-label-yellow-bg-strong/20 px-2.5 py-1 text-center text-caption-sm-medium text-label-yellow-text">
<p>{t("common.pending")}</p>
<div className={cn("nodedc-settings-chip flex min-h-10 min-w-[9.25rem] items-center justify-center px-4 py-2 text-center text-caption-sm-medium", nodedcInviteStatusClassName(nodedcApprovalStatus))}>
<p>{nodedcInviteStatusLabel(nodedcApprovalStatus, t("common.pending"))}</p>
</div>
<SelectionDropdown
options={Object.keys(ROLE)
@@ -167,7 +151,7 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
},
}))}
menuButton={
<div className="nodedc-settings-chip item-center flex gap-1 px-3 py-1">
<div className="nodedc-settings-chip flex min-h-10 min-w-[6.5rem] items-center justify-center gap-1 px-4 py-2">
<span
className={`flex items-center rounded-sm text-caption-sm-medium ${
hasRoleChangeAccess ? "" : "text-placeholder"
@@ -182,14 +166,48 @@ export const WorkspaceInvitationsListItem = observer(function WorkspaceInvitatio
)}
</div>
}
disabled={!hasRoleChangeAccess}
disabled={!hasRoleChangeAccess || isNodeDCApprovalLocked}
placement="bottom-end"
/>
{isAdmin && (
<ActionDropdown placement="bottom-end" items={MENU_ITEMS} />
<div className="flex items-center gap-1.5">
{invitationDetails.invite_link && (
<button
aria-label={`Копировать инвайт ${invitationDetails.email}`}
className="grid h-10 w-10 place-items-center rounded-full border-0 bg-white/6 text-secondary transition-colors hover:bg-white/10 hover:text-primary"
title="Копировать инвайт"
type="button"
onClick={() => void handleCopyText()}
>
<CopyLinkIcon className="h-3.5 w-3.5" />
</button>
)}
<button
aria-label={`Удалить инвайт ${invitationDetails.email}`}
className="grid h-10 w-10 place-items-center rounded-full border-0 bg-white/6 text-secondary transition-colors hover:bg-red-500/15 hover:text-red-300"
title="Удалить"
type="button"
onClick={() => setRemoveMemberModal(true)}
>
<TrashIcon className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
</>
);
});
function nodedcInviteStatusLabel(status: string, fallback: string) {
if (status === "pending") return "Ожидает";
if (status === "approved") return "Подтверждено NDC";
if (status === "rejected") return "Отклонено";
return fallback;
}
function nodedcInviteStatusClassName(status: string) {
if (status === "approved") return "bg-lime-400/15 text-lime-300";
if (status === "rejected") return "bg-red-500/15 text-red-300";
return "bg-label-yellow-bg-strong/20 text-label-yellow-text";
}
@@ -53,7 +53,8 @@ export const WorkspaceMembersList = observer(function WorkspaceMembersList(props
await fetchWorkspaceMemberInvitations(workspaceSlug.toString());
await fetchWorkspaceMembers(workspaceSlug.toString());
}
: null
: null,
{ refreshInterval: 5000 }
);
if (!workspaceMemberIds && !workspaceMemberInvitationIds) return <MembersSettingsLoader />;
@@ -59,8 +59,11 @@ export function WorkspaceMembersSettingsContent({ workspaceSlug }: TWorkspaceMem
setInviteModal(false);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: t("workspace_settings.settings.members.invitations_sent_successfully"),
title: nodedcWorkspacePolicy?.invite_approval === "nodedc" ? "Запрос отправлен" : "Success!",
message:
nodedcWorkspacePolicy?.invite_approval === "nodedc"
? "Приглашение отправлено на подтверждение NODE.DC."
: t("workspace_settings.settings.members.invitations_sent_successfully"),
});
} catch (error: unknown) {
const message = error instanceof Error ? (error as Error & { error?: string }).error : undefined;