Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions apps/api/plane/app/views/workspace/invite.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.utils import timezone
from django.utils.crypto import constant_time_compare

# Third party modules
from rest_framework import status
Expand Down Expand Up @@ -236,9 +237,34 @@ def post(self, request, slug, pk):
)

def get(self, request, slug, pk):
workspace_invitation = WorkspaceMemberInvite.objects.get(workspace__slug=slug, pk=pk)
# Require the invitation token from the email link; without it,
# the endpoint would leak invitation details (including the token
# itself via the serializer) to any unauthenticated caller.
token = request.GET.get("token", "")
forbidden_response = Response(
{"error": "You do not have permission to access this invitation"},
status=status.HTTP_403_FORBIDDEN,
)
if not token:
return forbidden_response

try:
workspace_invitation = WorkspaceMemberInvite.objects.get(
Comment thread
sriramveeraghanta marked this conversation as resolved.
workspace__slug=slug, pk=pk
)
except WorkspaceMemberInvite.DoesNotExist:
return forbidden_response

if not constant_time_compare(workspace_invitation.token, token):
return forbidden_response

serializer = WorkSpaceMemberInviteSerializer(workspace_invitation)
return Response(serializer.data, status=status.HTTP_200_OK)
data = {
key: value
for key, value in serializer.data.items()
if key not in ("token", "invite_link")
}
return Response(data, status=status.HTTP_200_OK)


class UserWorkspaceInvitationsViewSet(BaseViewSet):
Expand Down
8 changes: 5 additions & 3 deletions apps/web/app/(all)/workspace-invitations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ function WorkspaceInvitationPage() {
const { data: currentUser } = useUser();

const { data: invitationDetail, error } = useSWR(
invitation_id && slug && WORKSPACE_INVITATION(invitation_id.toString()),
invitation_id && slug
? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString())
invitation_id && slug && token && WORKSPACE_INVITATION(invitation_id.toString()),
invitation_id && slug && token
Comment on lines +42 to +43
? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString(), token.toString())
: null
Comment on lines 41 to 45
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# First, let's check if the file exists and read the relevant section
cat -n apps/web/app/\(all\)/workspace-invitations/page.tsx | sed -n '35,55p'

Repository: makeplane/plane

Length of output: 956


🏁 Script executed:

# Also check the structure of WORKSPACE_INVITATION function to understand what it returns
grep -n "WORKSPACE_INVITATION" apps/web/app/\(all\)/workspace-invitations/page.tsx -B 2 -A 2

Repository: makeplane/plane

Length of output: 604


🏁 Script executed:

# Search for WORKSPACE_INVITATION definition across the codebase
rg "WORKSPACE_INVITATION\s*=" --type ts --type tsx -A 3

Repository: makeplane/plane

Length of output: 87


🏁 Script executed:

# Search for WORKSPACE_INVITATION definition
rg "WORKSPACE_INVITATION" -A 3 | head -50

Repository: makeplane/plane

Length of output: 3581


🏁 Script executed:

# Check what the function returns
fd "fetch-keys" -type f | head -5

Repository: makeplane/plane

Length of output: 230


🏁 Script executed:

# Let's read the fetch-keys file to see WORKSPACE_INVITATION definition
find . -name "*fetch-keys*" -type f

Repository: makeplane/plane

Length of output: 99


🏁 Script executed:

# Let's check the fetch-keys file to see the full context
cat -n apps/web/core/constants/fetch-keys.ts | head -50

Repository: makeplane/plane

Length of output: 2168


🏁 Script executed:

# Check if there are other usages of SWR with token in cache keys
rg "useSWR.*token" -B 2 -A 3 | head -100

Repository: makeplane/plane

Length of output: 41


🏁 Script executed:

# Look at the auth-header.tsx example more closely
cat -n apps/web/core/components/account/auth-forms/auth-header.tsx | grep -A 10 -B 5 "WORKSPACE_INVITATION"

Repository: makeplane/plane

Length of output: 765


🏁 Script executed:

# Check if there are other invitation-related SWR calls with token parameters
rg "getWorkspaceInvitation|joinWorkspace" -B 5 -A 5 | head -80

Repository: makeplane/plane

Length of output: 5954


🏁 Script executed:

# Let's check if invitation_id is globally unique or workspace-specific by looking at the API
rg "invitations.*invitation_id" --type ts -B 5 | head -60

Repository: makeplane/plane

Length of output: 41


🏁 Script executed:

# Check SWR caching behavior and if there are comments about token-based caching
cat -n apps/web/app/\(all\)/workspace-invitations/page.tsx | sed -n '1,80p'

Repository: makeplane/plane

Length of output: 3194


🏁 Script executed:

# Let's also verify the WORKSPACE_INVITATION constant more thoroughly
rg "export const WORKSPACE_INVITATION" -A 2

Repository: makeplane/plane

Length of output: 350


🏁 Script executed:

# Check the workspace service getWorkspaceInvitation method signature
cat -n apps/web/core/services/workspace.service.ts | grep -A 15 "async getWorkspaceInvitation"

Repository: makeplane/plane

Length of output: 615


🏁 Script executed:

# Look at API endpoint to understand if token is user-specific or invitation-specific
rg "invitations.*join" -B 3 -A 8 | head -40

Repository: makeplane/plane

Length of output: 2927


🏁 Script executed:

# Check if there are other places in the codebase where token is included in SWR keys
rg "useSWR.*token.*\[" -B 1 -A 5 | head -60

Repository: makeplane/plane

Length of output: 41


🏁 Script executed:

# Check if there are any comments or documentation about SWR key design in the project
rg "SWR.*key|cache.*key" --type ts -B 2 -A 2 | head -50

Repository: makeplane/plane

Length of output: 1143


🏁 Script executed:

# Let's verify the exact behavior difference - check if slug is used in other similar patterns
rg "WORKSPACE_INVITATION\(" -B 3 -A 3

Repository: makeplane/plane

Length of output: 754


🏁 Script executed:

# Verify the diff format is correct for SWR array keys
rg "useSWR.*\[" -B 1 -A 3 | head -50

Repository: makeplane/plane

Length of output: 41


Include token in the SWR cache key.

The fetcher requires token as a parameter (sent as a query parameter to the API), but the cache key only includes invitation_id. If the page loads with a valid token, then the token is changed in the URL, SWR will still return the cached response from the first token instead of fetching with the new token. Each unique token should have a separate cache entry since it represents a different authorization context.

Suggested cache-key fix
   const { data: invitationDetail, error } = useSWR(
-    invitation_id && slug && token && WORKSPACE_INVITATION(invitation_id.toString()),
+    invitation_id && slug && token
+      ? [WORKSPACE_INVITATION(invitation_id.toString()), slug.toString(), token.toString()]
+      : null,
     invitation_id && slug && token
       ? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString(), token.toString())
       : null
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { data: invitationDetail, error } = useSWR(
invitation_id && slug && WORKSPACE_INVITATION(invitation_id.toString()),
invitation_id && slug
? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString())
invitation_id && slug && token && WORKSPACE_INVITATION(invitation_id.toString()),
invitation_id && slug && token
? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString(), token.toString())
: null
const { data: invitationDetail, error } = useSWR(
invitation_id && slug && token
? [WORKSPACE_INVITATION(invitation_id.toString()), slug.toString(), token.toString()]
: null,
invitation_id && slug && token
? () => workspaceService.getWorkspaceInvitation(slug.toString(), invitation_id.toString(), token.toString())
: null
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(all)/workspace-invitations/page.tsx around lines 41 - 45, The
SWR cache key currently only includes invitation_id (via WORKSPACE_INVITATION)
but not token, so changing token won't cause a refetch; update the useSWR key to
include token (and slug if desired) so each distinct token has its own cache
entry — e.g., when calling useSWR ensure the first arg contains token (for
example an array or a key string that includes token) and keep the same fetcher
workspaceService.getWorkspaceInvitation(slug.toString(),
invitation_id.toString(), token.toString()) so SWR will create separate entries
per token.

);

Expand All @@ -58,6 +58,7 @@ function WorkspaceInvitationPage() {
} else {
router.push("/");
}
return;
})
.catch((err: unknown) => console.error(err));
};
Expand All @@ -71,6 +72,7 @@ function WorkspaceInvitationPage() {
})
.then(() => {
router.push("/");
return;
})
.catch((err: unknown) => console.error(err));
};
Expand Down
17 changes: 11 additions & 6 deletions apps/web/core/components/account/auth-forms/auth-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { WorkspaceService } from "@/services/workspace.service";
type TAuthHeader = {
workspaceSlug: string | undefined;
invitationId: string | undefined;
invitationToken: string | undefined;
invitationEmail: string | undefined;
authMode: EAuthModes;
currentAuthStep: EAuthSteps;
Expand Down Expand Up @@ -58,13 +59,17 @@ const Titles = {
const workSpaceService = new WorkspaceService();

export const AuthHeader = observer(function AuthHeader(props: TAuthHeader) {
const { workspaceSlug, invitationId, invitationEmail, authMode, currentAuthStep } = props;
const { workspaceSlug, invitationId, invitationToken, invitationEmail, authMode, currentAuthStep } = props;
// plane imports
const { t } = useTranslation();

const { data: invitation, isLoading } = useSWR(
workspaceSlug && invitationId ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null,
async () => workspaceSlug && invitationId && workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId),
workspaceSlug && invitationId && invitationToken ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null,
async () =>
workspaceSlug &&
invitationId &&
invitationToken &&
workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId, invitationToken),
Comment on lines 66 to +72
Comment on lines 66 to +72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/core/components/account/auth-forms/auth-header.tsx | head -80

Repository: makeplane/plane

Length of output: 3073


🏁 Script executed:

# Get the full extent of the useSWR call and understand the context
sed -n '60,80p' apps/web/core/components/account/auth-forms/auth-header.tsx

Repository: makeplane/plane

Length of output: 785


🏁 Script executed:

# Check if invitationToken is a query parameter in the URL
rg "invitationToken" apps/web/core/components/account/auth-forms/auth-header.tsx -A 2 -B 2

Repository: makeplane/plane

Length of output: 865


Include invitationToken in the SWR cache key.

The cache key only uses workspaceSlug and invitationId, but the API authorization requires all three parameters including invitationToken. This allows responses from one token to be reused when the same invitation is accessed with a different token within the same session, bypassing re-authorization.

Suggested cache-key fix
   const { data: invitation, isLoading } = useSWR(
-    workspaceSlug && invitationId && invitationToken ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null,
+    workspaceSlug && invitationId && invitationToken
+      ? ["WORKSPACE_INVITATION", workspaceSlug, invitationId, invitationToken]
+      : null,
     async () =>
       workspaceSlug &&
       invitationId &&
       invitationToken &&
       workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId, invitationToken),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { data: invitation, isLoading } = useSWR(
workspaceSlug && invitationId ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null,
async () => workspaceSlug && invitationId && workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId),
workspaceSlug && invitationId && invitationToken ? `WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}` : null,
async () =>
workspaceSlug &&
invitationId &&
invitationToken &&
workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId, invitationToken),
const { data: invitation, isLoading } = useSWR(
workspaceSlug && invitationId && invitationToken
? ["WORKSPACE_INVITATION", workspaceSlug, invitationId, invitationToken]
: null,
async () =>
workspaceSlug &&
invitationId &&
invitationToken &&
workSpaceService.getWorkspaceInvitation(workspaceSlug, invitationId, invitationToken),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/core/components/account/auth-forms/auth-header.tsx` around lines 66
- 72, The SWR cache key used in useSWR (currently
`WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}`) omits invitationToken,
allowing stale/unauthorized responses to be reused; update the key to include
invitationToken (e.g.,
`WORKSPACE_INVITATION_${workspaceSlug}_${invitationId}_${invitationToken}`) and
ensure the same workspaceSlug && invitationId && invitationToken condition
guards the hook so the cache differentiates requests by token for
workSpaceService.getWorkspaceInvitation.

{
revalidateOnFocus: false,
shouldRetryOnError: false,
Expand All @@ -74,11 +79,11 @@ export const AuthHeader = observer(function AuthHeader(props: TAuthHeader) {
const getHeaderSubHeader = (
step: EAuthSteps,
mode: EAuthModes,
invitation: IWorkspaceMemberInvitation | undefined,
inviteData: IWorkspaceMemberInvitation | undefined,
email: string | undefined
) => {
if (invitation && email && invitation.email === email && invitation.workspace) {
const workspace = invitation.workspace;
if (inviteData && email && inviteData.email === email && inviteData.workspace) {
const workspace = inviteData.workspace;
return {
header: (
<div className="relative inline-flex items-center gap-2">
Expand Down
10 changes: 6 additions & 4 deletions apps/web/core/components/account/auth-forms/auth-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
// query params
const emailParam = searchParams.get("email");
const invitation_id = searchParams.get("invitation_id");
const invitation_token = searchParams.get("token");
const workspaceSlug = searchParams.get("slug");
const error_code = searchParams.get("error_code");
// props
Expand Down Expand Up @@ -121,6 +122,7 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
<AuthHeader
workspaceSlug={workspaceSlug?.toString() || undefined}
invitationId={invitation_id?.toString() || undefined}
invitationToken={invitation_token?.toString() || undefined}
invitationEmail={email || undefined}
authMode={authMode}
currentAuthStep={authStep}
Expand All @@ -137,10 +139,10 @@ export const AuthRoot = observer(function AuthRoot(props: TAuthRoot) {
authStep={authStep}
authMode={authMode}
email={email}
setEmail={(email) => setEmail(email)}
setAuthMode={(authMode) => setAuthMode(authMode)}
setAuthStep={(authStep) => setAuthStep(authStep)}
setErrorInfo={(errorInfo) => setErrorInfo(errorInfo)}
setEmail={(value) => setEmail(value)}
setAuthMode={(value) => setAuthMode(value)}
setAuthStep={(value) => setAuthStep(value)}
setErrorInfo={(value) => setErrorInfo(value)}
currentAuthMode={currentAuthMode}
/>
)}
Expand Down
11 changes: 9 additions & 2 deletions apps/web/core/services/workspace.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,15 @@ export class WorkspaceService extends APIService {
});
}

async getWorkspaceInvitation(workspaceSlug: string, invitationId: string): Promise<IWorkspaceMemberInvitation> {
return this.get(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, { headers: {} })
async getWorkspaceInvitation(
workspaceSlug: string,
invitationId: string,
token: string
): Promise<IWorkspaceMemberInvitation> {
return this.get(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, {
headers: {},
params: { token },
})
Comment on lines +172 to +180
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# Verify the method exists at the stated lines and get context
cat -n apps/web/core/services/workspace.service.ts | sed -n '165,185p'

Repository: makeplane/plane

Length of output: 803


🏁 Script executed:

# Check the type definition of IWorkspaceMemberInvitation
cat packages/types/src/workspace.ts | grep -A 20 "IWorkspaceMemberInvitation"

Repository: makeplane/plane

Length of output: 467


🏁 Script executed:

# Verify the backend endpoint mentioned in the review
cat apps/api/plane/app/views/workspace/invite.py | sed -n '255,275p'

Repository: makeplane/plane

Length of output: 757


🏁 Script executed:

# Check all imports in workspace.service.ts to see if types are properly imported
head -50 apps/web/core/services/workspace.service.ts

Repository: makeplane/plane

Length of output: 1396


🏁 Script executed:

# Find all usages of getWorkspaceInvitation
rg "getWorkspaceInvitation" --type ts --type tsx -B 2 -A 2

Repository: makeplane/plane

Length of output: 87


🏁 Script executed:

# Check if there's an existing public invitation type or similar pattern in workspace.ts
rg "Invitation|Invite" packages/types/src/workspace.ts -i

Repository: makeplane/plane

Length of output: 213


🏁 Script executed:

# Check the entire WorkspaceService to see if there are similar type mismatches
rg "Promise<I" apps/web/core/services/workspace.service.ts

Repository: makeplane/plane

Length of output: 1756


🏁 Script executed:

# Fix the file type search - use ts for TypeScript files
rg "getWorkspaceInvitation" -t ts -B 2 -A 5

Repository: makeplane/plane

Length of output: 6372


🏁 Script executed:

# Check if userWorkspaceInvitations and workspaceInvitations also have similar issues
rg "userWorkspaceInvitations|workspaceInvitations" apps/web/core/services/workspace.service.ts -B 2 -A 10

Repository: makeplane/plane

Length of output: 896


🏁 Script executed:

# Find where getWorkspaceInvitation is called to understand how the return value is used
rg "getWorkspaceInvitation" --type-list | head -5

Repository: makeplane/plane

Length of output: 449


🏁 Script executed:

# Search for files that call getWorkspaceInvitation
find apps/web -type f -name "*.ts" -o -name "*.tsx" | xargs grep -l "getWorkspaceInvitation" 2>/dev/null

Repository: makeplane/plane

Length of output: 346


🏁 Script executed:

# Check how the invitation data is used in workspace-invitations/page.tsx
sed -n '1,100p' apps/web/app/\(all\)/workspace-invitations/page.tsx | head -80

Repository: makeplane/plane

Length of output: 2634


🏁 Script executed:

# Check the full usage of invitationDetail in workspace-invitations/page.tsx
grep -A 20 "invitationDetail" apps/web/app/\(all\)/workspace-invitations/page.tsx | head -50

Repository: makeplane/plane

Length of output: 1832


🏁 Script executed:

# Check how the invitation data is used in auth-header.tsx
grep -B 5 -A 20 "getWorkspaceInvitation" apps/web/core/components/account/auth-forms/auth-header.tsx

Repository: makeplane/plane

Length of output: 1026


🏁 Script executed:

# Check the backend endpoints for userWorkspaceInvitations and workspaceInvitations
# to see if they also strip token and invite_link
rg "token.*invite_link|invite_link.*token" apps/api/plane/app/views/workspace/invite.py -B 5 -A 5

Repository: makeplane/plane

Length of output: 401


🏁 Script executed:

# Check other invitation-related endpoints to understand the pattern
grep -n "def.*invitation" apps/api/plane/app/views/workspace/invite.py -i

Repository: makeplane/plane

Length of output: 41


Narrow this method's return type to match the public invite shape.

getWorkspaceInvitation returns IWorkspaceMemberInvitation, but the backend endpoint strips token and invite_link from the response. This breaks the type contract—callers compile against fields that are no longer returned. Update the return type using Omit to reflect the actual response shape:

Suggested fix
+type TPublicWorkspaceMemberInvitation = Omit<IWorkspaceMemberInvitation, "token" | "invite_link">;
+
   async getWorkspaceInvitation(
     workspaceSlug: string,
     invitationId: string,
     token: string
-  ): Promise<IWorkspaceMemberInvitation> {
+  ): Promise<TPublicWorkspaceMemberInvitation> {
     return this.get(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, {
       headers: {},
       params: { token },
     })

Per coding guidelines: "Enable TypeScript strict mode and ensure all files must be typed."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/core/services/workspace.service.ts` around lines 172 - 180,
getWorkspaceInvitation currently declares it returns IWorkspaceMemberInvitation
but the API response omits token and invite_link; change the method's return
type to reflect that by using Omit to remove those keys (e.g.
Promise<Omit<IWorkspaceMemberInvitation, 'token' | 'invite_link'>>), update any
related imports/signatures if needed, and ensure callers are adjusted to the
narrower shape so TypeScript strict mode is satisfied; keep the
getWorkspaceInvitation implementation unchanged aside from the updated return
type.

.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
Expand Down
Loading