diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index 4c8a04d599c..e05e40a58f3 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -21,6 +21,10 @@ ) from plane.db.models import User, Workspace, WorkspaceMember, Project, ProjectMember from plane.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission +from plane.utils.membership_realtime import ( + EVENT_PROJECT_MEMBER_REMOVED, + publish_membership_removed, +) from plane.utils.openapi import ( WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER, @@ -227,6 +231,14 @@ def delete(self, request, slug, project_id, pk): project_member = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk) project_member.is_active = False project_member.save() + publish_membership_removed( + event_type=EVENT_PROJECT_MEMBER_REMOVED, + actor_id=request.user.id, + user_id=project_member.member_id, + workspace_id=project_member.workspace_id, + workspace_slug=slug, + project_id=project_id, + ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py index 973462182ab..1484ef60e79 100644 --- a/apps/api/plane/app/views/project/member.py +++ b/apps/api/plane/app/views/project/member.py @@ -22,6 +22,10 @@ from plane.bgtasks.project_add_user_email_task import project_add_user_email from plane.utils.host import base_host from plane.app.permissions.base import allow_permission, ROLE +from plane.utils.membership_realtime import ( + EVENT_PROJECT_MEMBER_REMOVED, + publish_membership_removed, +) class ProjectMemberViewSet(BaseViewSet): @@ -280,10 +284,20 @@ def partial_update(self, request, slug, project_id, pk): status=status.HTTP_403_FORBIDDEN, ) + was_active = project_member.is_active serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True) if serializer.is_valid(): serializer.save() + if was_active and serializer.instance.is_active is False: + publish_membership_removed( + event_type=EVENT_PROJECT_MEMBER_REMOVED, + actor_id=request.user.id, + user_id=project_member.member_id, + workspace_id=project_member.workspace_id, + workspace_slug=slug, + project_id=project_id, + ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -318,6 +332,14 @@ def destroy(self, request, slug, project_id, pk): project_member.is_active = False project_member.save() + publish_membership_removed( + event_type=EVENT_PROJECT_MEMBER_REMOVED, + actor_id=request.user.id, + user_id=project_member.member_id, + workspace_id=project_member.workspace_id, + workspace_slug=slug, + project_id=project_id, + ) return Response(status=status.HTTP_204_NO_CONTENT) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) diff --git a/apps/api/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py index 67c7637a8c2..9dd72119516 100644 --- a/apps/api/plane/app/views/workspace/member.py +++ b/apps/api/plane/app/views/workspace/member.py @@ -23,6 +23,10 @@ from plane.app.views.base import BaseAPIView from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue from plane.utils.cache import invalidate_cache +from plane.utils.membership_realtime import ( + EVENT_WORKSPACE_MEMBER_REMOVED, + publish_membership_removed, +) from .. import BaseViewSet @@ -147,6 +151,13 @@ def destroy(self, request, slug, pk): workspace_member.is_active = False workspace_member.save() + publish_membership_removed( + event_type=EVENT_WORKSPACE_MEMBER_REMOVED, + actor_id=request.user.id, + user_id=workspace_member.member_id, + workspace_id=workspace_member.workspace_id, + workspace_slug=slug, + ) return Response(status=status.HTTP_204_NO_CONTENT) @invalidate_cache( diff --git a/apps/api/plane/tests/unit/utils/test_membership_realtime.py b/apps/api/plane/tests/unit/utils/test_membership_realtime.py new file mode 100644 index 00000000000..2492595d7f6 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_membership_realtime.py @@ -0,0 +1,60 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest + +from plane.utils.membership_realtime import ( + EVENT_PROJECT_MEMBER_REMOVED, + EVENT_WORKSPACE_MEMBER_REMOVED, + build_membership_removed_event, + membership_realtime_channel, +) + + +@pytest.mark.unit +def test_membership_realtime_channel(): + assert membership_realtime_channel("user-1") == "plane:membership:user-1" + + +@pytest.mark.unit +def test_build_workspace_member_removed_event(): + event = build_membership_removed_event( + event_type=EVENT_WORKSPACE_MEMBER_REMOVED, + actor_id="admin-1", + user_id="user-1", + workspace_id="ws-1", + workspace_slug="acme", + ) + assert event == { + "type": "workspace.member.removed", + "actor_id": "admin-1", + "user_id": "user-1", + "workspace_id": "ws-1", + "workspace_slug": "acme", + "project_id": None, + } + + +@pytest.mark.unit +def test_build_project_member_removed_event_requires_project_id(): + assert ( + build_membership_removed_event( + event_type=EVENT_PROJECT_MEMBER_REMOVED, + actor_id="admin-1", + user_id="user-1", + workspace_slug="acme", + ) + is None + ) + + event = build_membership_removed_event( + event_type=EVENT_PROJECT_MEMBER_REMOVED, + actor_id="admin-1", + user_id="user-1", + workspace_id="ws-1", + workspace_slug="acme", + project_id="proj-1", + ) + assert event["type"] == "project.member.removed" + assert event["project_id"] == "proj-1" diff --git a/apps/api/plane/utils/membership_realtime.py b/apps/api/plane/utils/membership_realtime.py new file mode 100644 index 00000000000..e1da7b27e30 --- /dev/null +++ b/apps/api/plane/utils/membership_realtime.py @@ -0,0 +1,77 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import json + +from django.core.serializers.json import DjangoJSONEncoder + +from plane.settings.redis import redis_instance +from plane.utils.exception_logger import log_exception + +MEMBERSHIP_REALTIME_CHANNEL_PREFIX = "plane:membership:" + +EVENT_WORKSPACE_MEMBER_REMOVED = "workspace.member.removed" +EVENT_PROJECT_MEMBER_REMOVED = "project.member.removed" + + +def membership_realtime_channel(user_id) -> str: + return f"{MEMBERSHIP_REALTIME_CHANNEL_PREFIX}{user_id}" + + +def build_membership_removed_event( + *, + event_type: str, + actor_id, + user_id, + workspace_id=None, + workspace_slug: str | None = None, + project_id=None, +) -> dict | None: + if event_type not in {EVENT_WORKSPACE_MEMBER_REMOVED, EVENT_PROJECT_MEMBER_REMOVED}: + return None + if not user_id or not workspace_slug: + return None + if event_type == EVENT_PROJECT_MEMBER_REMOVED and not project_id: + return None + + return { + "type": event_type, + "actor_id": str(actor_id) if actor_id else "", + "user_id": str(user_id), + "workspace_id": str(workspace_id) if workspace_id else "", + "workspace_slug": workspace_slug, + "project_id": str(project_id) if project_id else None, + } + + +def publish_membership_removed( + *, + event_type: str, + actor_id, + user_id, + workspace_id=None, + workspace_slug: str | None = None, + project_id=None, +) -> bool: + try: + event = build_membership_removed_event( + event_type=event_type, + actor_id=actor_id, + user_id=user_id, + workspace_id=workspace_id, + workspace_slug=workspace_slug, + project_id=project_id, + ) + if event is None: + return False + + ri = redis_instance() + ri.publish( + membership_realtime_channel(user_id), + json.dumps(event, cls=DjangoJSONEncoder), + ) + return True + except Exception as e: + log_exception(e) + return False diff --git a/apps/live/src/controllers/index.ts b/apps/live/src/controllers/index.ts index 2ae3bcea07b..a2c30d70ac3 100644 --- a/apps/live/src/controllers/index.ts +++ b/apps/live/src/controllers/index.ts @@ -7,6 +7,13 @@ import { CollaborationController } from "./collaboration.controller"; import { DocumentController } from "./document.controller"; import { HealthController } from "./health.controller"; +import { MembershipController } from "./membership.controller"; import { PdfExportController } from "./pdf-export.controller"; -export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController]; +export const CONTROLLERS = [ + CollaborationController, + DocumentController, + HealthController, + MembershipController, + PdfExportController, +]; diff --git a/apps/live/src/controllers/membership.controller.ts b/apps/live/src/controllers/membership.controller.ts new file mode 100644 index 00000000000..b4ee3cd5705 --- /dev/null +++ b/apps/live/src/controllers/membership.controller.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type { Request } from "express"; +import type { WebSocket } from "ws"; +import { Controller, WebSocket as WSDecorator } from "@plane/decorators"; +import { logger } from "@plane/logger"; +import { handleAuthentication } from "@/lib/auth"; +import { membershipRealtimeHub } from "@/services/membership-realtime.service"; + +@Controller("/membership") +export class MembershipController { + [key: string]: unknown; + + @WSDecorator("/") + handleConnection(ws: WebSocket, req: Request) { + void this.bindConnection(ws, req); + } + + private async bindConnection(ws: WebSocket, req: Request) { + try { + const url = new URL(req.url || "", "http://localhost"); + const userId = url.searchParams.get("userId") || ""; + const cookie = req.headers.cookie?.toString(); + + if (!userId || !cookie) { + ws.close(4401, "Missing realtime credentials"); + return; + } + + await handleAuthentication({ cookie, userId }); + await membershipRealtimeHub.addClient(ws, { userId }); + } catch (error) { + logger.error("MEMBERSHIP_CONTROLLER: Failed to bind membership socket", error); + ws.close(1011, "Internal server error"); + } + } +} diff --git a/apps/live/src/server.ts b/apps/live/src/server.ts index 9a3906bf729..c23ea32df63 100644 --- a/apps/live/src/server.ts +++ b/apps/live/src/server.ts @@ -23,6 +23,7 @@ import { env } from "@/env"; import { HocusPocusServerManager } from "@/hocuspocus"; // redis import { redisManager } from "@/redis"; +import { membershipRealtimeHub } from "@/services/membership-realtime.service"; export class Server { private app: Express; @@ -43,6 +44,8 @@ export class Server { try { await redisManager.initialize(); logger.info("SERVER: Redis setup completed"); + await membershipRealtimeHub.initialize(); + logger.info("SERVER: Membership realtime hub initialized"); const manager = HocusPocusServerManager.getInstance(); this.hocuspocusServer = await manager.initialize(); logger.info("SERVER: HocusPocus setup completed"); diff --git a/apps/live/src/services/membership-realtime.service.ts b/apps/live/src/services/membership-realtime.service.ts new file mode 100644 index 00000000000..11178f4abe8 --- /dev/null +++ b/apps/live/src/services/membership-realtime.service.ts @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import type Redis from "ioredis"; +import type { WebSocket } from "ws"; +import { logger } from "@plane/logger"; +import type { TMembershipRealtimeEvent } from "@plane/types"; +import { redisManager } from "@/redis"; +import { getMembershipRealtimeChannel, shouldForwardMembershipEventToUser } from "@/utils/membership-realtime"; + +type TMembershipSocketContext = { + userId: string; +}; + +class MembershipRealtimeHub { + private subscriber: Redis | null = null; + private clients = new Map(); + private userClients = new Map>(); + + async initialize() { + const client = redisManager.getClient(); + if (!client) { + logger.warn("MEMBERSHIP_REALTIME: Redis unavailable, membership sockets disabled"); + return; + } + + this.subscriber = client.duplicate(); + this.subscriber.on("message", this.handleMessage); + this.subscriber.on("error", (error) => { + logger.error("MEMBERSHIP_REALTIME: Subscriber error", error); + }); + logger.info("MEMBERSHIP_REALTIME: Hub initialized"); + } + + async addClient(ws: WebSocket, context: TMembershipSocketContext) { + this.clients.set(ws, context); + const sockets = this.userClients.get(context.userId) ?? new Set(); + sockets.add(ws); + this.userClients.set(context.userId, sockets); + + if (sockets.size === 1) { + await this.subscribeToUser(context.userId); + } + + ws.on("close", () => { + void this.removeClient(ws); + }); + ws.on("error", () => { + void this.removeClient(ws); + }); + } + + private async subscribeToUser(userId: string) { + if (!this.subscriber) return; + const channel = getMembershipRealtimeChannel(userId); + await this.subscriber.subscribe(channel); + logger.info(`MEMBERSHIP_REALTIME: Subscribed to ${channel}`); + } + + private async unsubscribeFromUser(userId: string) { + if (!this.subscriber) return; + const channel = getMembershipRealtimeChannel(userId); + await this.subscriber.unsubscribe(channel); + logger.info(`MEMBERSHIP_REALTIME: Unsubscribed from ${channel}`); + } + + private handleMessage = (channel: string, message: string) => { + try { + const event = JSON.parse(message) as TMembershipRealtimeEvent; + const userId = event.user_id; + if (!userId) return; + + const sockets = this.userClients.get(userId); + if (!sockets?.size) return; + + for (const ws of sockets) { + const context = this.clients.get(ws); + if (!context) continue; + if (ws.readyState !== ws.OPEN) continue; + if ( + !shouldForwardMembershipEventToUser({ + eventUserId: event.user_id, + socketUserId: context.userId, + }) + ) { + continue; + } + ws.send(message); + } + } catch (error) { + logger.error("MEMBERSHIP_REALTIME: Failed to fan out message", error); + } + }; + + private async removeClient(ws: WebSocket) { + const context = this.clients.get(ws); + if (!context) return; + + this.clients.delete(ws); + const sockets = this.userClients.get(context.userId); + sockets?.delete(ws); + if (sockets && sockets.size === 0) { + this.userClients.delete(context.userId); + await this.unsubscribeFromUser(context.userId); + } + } +} + +export const membershipRealtimeHub = new MembershipRealtimeHub(); diff --git a/apps/live/src/utils/membership-realtime.ts b/apps/live/src/utils/membership-realtime.ts new file mode 100644 index 00000000000..62970b644fd --- /dev/null +++ b/apps/live/src/utils/membership-realtime.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export const MEMBERSHIP_REALTIME_CHANNEL_PREFIX = "plane:membership:"; + +export const getMembershipRealtimeChannel = (userId: string) => `${MEMBERSHIP_REALTIME_CHANNEL_PREFIX}${userId}`; + +export const shouldForwardMembershipEventToUser = ({ + eventUserId, + socketUserId, +}: { + eventUserId?: string; + socketUserId?: string; +}) => Boolean(eventUserId && socketUserId && eventUserId === socketUserId); diff --git a/apps/live/tests/membership-realtime.test.ts b/apps/live/tests/membership-realtime.test.ts new file mode 100644 index 00000000000..7d0f24d0bbc --- /dev/null +++ b/apps/live/tests/membership-realtime.test.ts @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { describe, expect, it } from "vitest"; +import { getMembershipRealtimeChannel, shouldForwardMembershipEventToUser } from "@/utils/membership-realtime"; + +describe("membership realtime helpers", () => { + it("builds a per-user redis channel", () => { + expect(getMembershipRealtimeChannel("user-1")).toBe("plane:membership:user-1"); + }); + + it("only forwards events to the removed user", () => { + expect( + shouldForwardMembershipEventToUser({ + eventUserId: "user-1", + socketUserId: "user-1", + }) + ).toBe(true); + expect( + shouldForwardMembershipEventToUser({ + eventUserId: "user-1", + socketUserId: "user-2", + }) + ).toBe(false); + }); +}); diff --git a/apps/web/core/hooks/use-membership-realtime.ts b/apps/web/core/hooks/use-membership-realtime.ts new file mode 100644 index 00000000000..74b8a27da55 --- /dev/null +++ b/apps/web/core/hooks/use-membership-realtime.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { mutate } from "swr"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TMembershipRealtimeEvent } from "@plane/types"; +import { useUser, useUserPermissions } from "@/hooks/store/user"; +import { useWorkspace } from "@/hooks/store/use-workspace"; +import { MembershipRealtimeService } from "@/services/membership-realtime.service"; + +const membershipRealtimeService = new MembershipRealtimeService(); + +export const useMembershipRealtime = () => { + const router = useRouter(); + const { workspaceSlug, projectId } = useParams(); + const { data: currentUser } = useUser(); + const { clearWorkspaceAccess, clearProjectAccess } = useUserPermissions(); + const { getWorkspaceRedirectionUrl, fetchWorkspaces } = useWorkspace(); + + useEffect(() => { + if (!currentUser?.id) return; + + const handleEvent = async (event: TMembershipRealtimeEvent) => { + if (event.user_id !== currentUser.id) return; + + if (event.type === "workspace.member.removed") { + const slug = event.workspace_slug; + clearWorkspaceAccess(slug); + setToast({ + type: TOAST_TYPE.WARNING, + title: "Access removed", + message: "You were removed from this workspace. An admin must invite you again to rejoin.", + }); + try { + await fetchWorkspaces(); + } catch { + // ignore refresh errors during forced logout from workspace + } + void mutate((key) => typeof key === "string" && key.includes(slug), undefined, { revalidate: false }); + router.replace(getWorkspaceRedirectionUrl() || "/"); + return; + } + + if (event.type === "project.member.removed") { + const slug = event.workspace_slug; + const removedProjectId = event.project_id; + if (!removedProjectId) return; + + clearProjectAccess(slug, removedProjectId); + setToast({ + type: TOAST_TYPE.WARNING, + title: "Access removed", + message: "You were removed from this project. An admin must add you again to rejoin.", + }); + if (workspaceSlug?.toString() === slug && projectId?.toString() === removedProjectId) { + router.replace(`/${slug}/projects`); + } + } + }; + + membershipRealtimeService.connect({ + userId: currentUser.id, + onEvent: (event) => { + void handleEvent(event); + }, + }); + + return () => { + membershipRealtimeService.disconnect(); + }; + }, [ + clearProjectAccess, + clearWorkspaceAccess, + currentUser?.id, + fetchWorkspaces, + getWorkspaceRedirectionUrl, + projectId, + router, + workspaceSlug, + ]); +}; diff --git a/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx b/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx index e74dfd55dbe..36a5e8dc1dd 100644 --- a/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx +++ b/apps/web/core/layouts/auth-layout/workspace-wrapper.tsx @@ -39,6 +39,7 @@ import { useProject } from "@/hooks/store/use-project"; import { useProjectState } from "@/hooks/store/use-project-state"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUser, useUserPermissions } from "@/hooks/store/user"; +import { useMembershipRealtime } from "@/hooks/use-membership-realtime"; import { usePlatformOS } from "@/hooks/use-platform-os"; interface IWorkspaceAuthWrapper { @@ -62,6 +63,7 @@ export const WorkspaceAuthWrapper = observer(function WorkspaceAuthWrapper(props const { loader, workspaceInfoBySlug, fetchUserWorkspaceInfo, fetchUserProjectPermissions, allowPermissions } = useUserPermissions(); const { fetchWorkspaceStates } = useProjectState(); + useMembershipRealtime(); // derived values const canPerformWorkspaceMemberActions = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -159,14 +161,16 @@ export const WorkspaceAuthWrapper = observer(function WorkspaceAuthWrapper(props
{currentUser?.email}
-
-
+
diff --git a/apps/web/core/services/membership-realtime.service.ts b/apps/web/core/services/membership-realtime.service.ts new file mode 100644 index 00000000000..27447c3bf36 --- /dev/null +++ b/apps/web/core/services/membership-realtime.service.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { LIVE_BASE_PATH, LIVE_BASE_URL, MEMBERSHIP_REALTIME_PATH } from "@plane/constants"; +import type { TMembershipRealtimeEvent } from "@plane/types"; + +type TMembershipRealtimeConnectionParams = { + userId: string; + onEvent: (event: TMembershipRealtimeEvent) => void; +}; + +const buildMembershipRealtimeUrl = (params: TMembershipRealtimeConnectionParams) => { + const liveBaseUrl = LIVE_BASE_URL?.trim() || (typeof window !== "undefined" ? window.location.origin : ""); + if (!liveBaseUrl) return null; + + const url = new URL(liveBaseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = `${LIVE_BASE_PATH}${MEMBERSHIP_REALTIME_PATH}`; + url.searchParams.set("userId", params.userId); + return url.toString(); +}; + +export class MembershipRealtimeService { + private socket: WebSocket | null = null; + private reconnectTimer: ReturnType | null = null; + private closedByClient = false; + private attempt = 0; + + connect(params: TMembershipRealtimeConnectionParams) { + this.disconnect(); + this.closedByClient = false; + this.attempt = 0; + this.open(params); + } + + disconnect() { + this.closedByClient = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.socket) { + this.socket.close(); + this.socket = null; + } + } + + private open(params: TMembershipRealtimeConnectionParams) { + const url = buildMembershipRealtimeUrl(params); + if (!url || typeof window === "undefined") return; + + const socket = new WebSocket(url); + this.socket = socket; + + socket.addEventListener("message", (message) => { + try { + const event = JSON.parse(message.data) as TMembershipRealtimeEvent; + params.onEvent(event); + } catch (error) { + console.error("Failed to parse membership realtime event", error); + } + }); + + socket.addEventListener("close", () => { + this.socket = null; + if (this.closedByClient || this.attempt >= 8) return; + const delay = Math.min(1000 * 2 ** this.attempt, 15000); + this.attempt += 1; + this.reconnectTimer = setTimeout(() => this.open(params), delay); + }); + + socket.addEventListener("error", () => { + socket.close(); + }); + } +} diff --git a/apps/web/core/store/user/base-permissions.store.ts b/apps/web/core/store/user/base-permissions.store.ts index 7afa71aad52..ccc3e767bf7 100644 --- a/apps/web/core/store/user/base-permissions.store.ts +++ b/apps/web/core/store/user/base-permissions.store.ts @@ -53,10 +53,12 @@ export interface IBaseUserPermissionStore { // actions fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise; leaveWorkspace: (workspaceSlug: string) => Promise; + clearWorkspaceAccess: (workspaceSlug: string) => void; fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise; fetchUserProjectPermissions: (workspaceSlug: string) => Promise; joinProject: (workspaceSlug: string, projectId: string) => Promise; leaveProject: (workspaceSlug: string, projectId: string) => Promise; + clearProjectAccess: (workspaceSlug: string, projectId: string) => void; hasPageAccess: (workspaceSlug: string, key: string) => boolean; } @@ -83,10 +85,12 @@ export class BaseUserPermissionStore implements IBaseUserPermissionStore { // actions fetchUserWorkspaceInfo: action, leaveWorkspace: action, + clearWorkspaceAccess: action, fetchUserProjectInfo: action, fetchUserProjectPermissions: action, joinProject: action, leaveProject: action, + clearProjectAccess: action, }); } @@ -262,17 +266,25 @@ export class BaseUserPermissionStore implements IBaseUserPermissionStore { leaveWorkspace = async (workspaceSlug: string): Promise => { try { await userService.leaveWorkspace(workspaceSlug); - runInAction(() => { - unset(this.workspaceUserInfo, workspaceSlug); - unset(this.projectUserInfo, workspaceSlug); - unset(this.workspaceProjectsPermissions, workspaceSlug); - }); + this.clearWorkspaceAccess(workspaceSlug); } catch (error) { console.error("Error user leaving the workspace", error); throw error; } }; + clearWorkspaceAccess = (workspaceSlug: string): void => { + runInAction(() => { + unset(this.workspaceUserInfo, workspaceSlug); + unset(this.projectUserInfo, workspaceSlug); + unset(this.workspaceProjectsPermissions, workspaceSlug); + const workspaceId = this.store.workspaceRoot.getWorkspaceBySlug(workspaceSlug)?.id; + if (workspaceId) { + unset(this.store.workspaceRoot.workspaces, workspaceId); + } + }); + }; + /** * @description Fetches the user's project information * @param { string } workspaceSlug @@ -344,16 +356,20 @@ export class BaseUserPermissionStore implements IBaseUserPermissionStore { leaveProject = async (workspaceSlug: string, projectId: string): Promise => { try { await userService.leaveProject(workspaceSlug, projectId); - runInAction(() => { - unset(this.workspaceProjectsPermissions, [workspaceSlug, projectId]); - unset(this.projectUserInfo, [workspaceSlug, projectId]); - unset(this.store.projectRoot.project.projectMap, [projectId]); - }); + this.clearProjectAccess(workspaceSlug, projectId); } catch (error) { console.error("Error user leaving the project", error); throw error; } }; + + clearProjectAccess = (workspaceSlug: string, projectId: string): void => { + runInAction(() => { + unset(this.workspaceProjectsPermissions, [workspaceSlug, projectId]); + unset(this.projectUserInfo, [workspaceSlug, projectId]); + unset(this.store.projectRoot.project.projectMap, [projectId]); + }); + }; } // Aliases so consumers can keep using UserPermissionStore / IUserPermissionStore diff --git a/packages/constants/src/endpoints.ts b/packages/constants/src/endpoints.ts index 630f07eba96..50e9383116f 100644 --- a/packages/constants/src/endpoints.ts +++ b/packages/constants/src/endpoints.ts @@ -19,6 +19,8 @@ export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}`); export const LIVE_BASE_URL = process.env.VITE_LIVE_BASE_URL || ""; export const LIVE_BASE_PATH = process.env.VITE_LIVE_BASE_PATH || ""; export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`); +export const MEMBERSHIP_REALTIME_PATH = "/membership"; +export const MEMBERSHIP_REALTIME_CHANNEL_PREFIX = "plane:membership:"; // Web App Base Url export const WEB_BASE_URL = process.env.VITE_WEB_BASE_URL || ""; export const WEB_BASE_PATH = process.env.VITE_WEB_BASE_PATH || ""; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f066f494629..82361ee1779 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -33,6 +33,7 @@ export * from "./issues/base"; // TODO: Remove this after development and the re export * from "./issues/issue-identifier"; export * from "./issues/issue-property-values"; export * from "./layout"; +export * from "./membership-realtime"; export * from "./module"; export * from "./navigation-preferences"; export * from "./page"; diff --git a/packages/types/src/membership-realtime.ts b/packages/types/src/membership-realtime.ts new file mode 100644 index 00000000000..6f6a0479f43 --- /dev/null +++ b/packages/types/src/membership-realtime.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export type TMembershipRealtimeEventType = "workspace.member.removed" | "project.member.removed"; + +export type TMembershipRealtimeEvent = { + type: TMembershipRealtimeEventType; + actor_id: string; + user_id: string; + workspace_id: string; + workspace_slug: string; + project_id?: string | null; +};