feat: add team management page and invite UI#2004
feat: add team management page and invite UI#2004adaam2 wants to merge 1 commit intofeat/team-endpointsfrom
Conversation
- Team page at org level with member list and invite management - AcceptInvite page for handling team invitation links - Replace external Speakeasy team link in org sidebar with internal route - Add /invite to unauthenticated paths in Auth context Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
🔴 New "team" org route missing from ORG_ROUTE_PATHS causes redirect collision with project slugs
The PR adds a new org-level route at /:orgSlug/team, but does not add "team" to the ORG_ROUTE_PATHS array in client/dashboard/src/contexts/Auth.tsx:268-274. This array is used by the backwards-compat redirect logic (Auth.tsx:282-293) to prevent org-level route paths from being mistaken for project slugs. If an organization has a project with slug "team", navigating to /:orgSlug/team will be incorrectly redirected to /:orgSlug/projects/team, making the new Team settings page completely inaccessible for that organization.
(Refers to lines 268-274)
Was this helpful? React with 👍 or 👎 to provide feedback.
| const removeMemberMutation = useRemoveTeamMemberMutation({ | ||
| onSuccess: async () => { | ||
| await invalidateTeamData(); | ||
| toast.success(`${memberToRemove?.displayName} has been removed`); | ||
| setMemberToRemove(null); | ||
| }, | ||
| onError: () => { | ||
| toast.error("Failed to remove member"); | ||
| }, | ||
| }); |
There was a problem hiding this comment.
🟡 Stale closure in removeMemberMutation and cancelInviteMutation onSuccess callbacks
The onSuccess callbacks for removeMemberMutation (Team.tsx:89-93) and cancelInviteMutation (Team.tsx:100-104) are defined at the hook level and close over memberToRemove and inviteToCancel state respectively. If the user dismisses the confirmation dialog (e.g. pressing Escape) while the mutation is in-flight, the state is set to null via the dialog's onOpenChange handler, causing the onSuccess toast to render as "undefined has been removed" or "Invite to undefined has been cancelled". The fix is to pass onSuccess to .mutate() instead (like inviteMutation already does at Team.tsx:148-155) and capture the display values in local variables.
Prompt for agents
In client/dashboard/src/pages/team/Team.tsx, the onSuccess callbacks for removeMemberMutation (lines 88-97) and cancelInviteMutation (lines 99-108) read from component state (memberToRemove and inviteToCancel) that can become null if the dialog is dismissed while the mutation is pending.
Fix for removeMemberMutation:
1. Remove the onSuccess option from the useRemoveTeamMemberMutation hook call (lines 89-93)
2. In handleRemoveMember (line 159), capture the display name before mutating: const name = memberToRemove.displayName;
3. Pass onSuccess to the .mutate() call like this:
removeMemberMutation.mutate({ request: { organizationId: organization.id, userId: memberToRemove.id } }, { onSuccess: async () => { await invalidateTeamData(); toast.success(`${name} has been removed`); setMemberToRemove(null); } });
Apply the same pattern for cancelInviteMutation:
1. Remove the onSuccess option from useCancelTeamInviteMutation (lines 100-104)
2. In handleCancelInvite (line 170), capture: const email = inviteToCancel.email;
3. Pass onSuccess to .mutate(): cancelInviteMutation.mutate({ request: { inviteId: inviteToCancel.id } }, { onSuccess: async () => { await invalidateTeamData(); toast.success(`Invite to ${email} has been cancelled`); setInviteToCancel(null); } });
Was this helpful? React with 👍 or 👎 to provide feedback.
| render: (member) => | ||
| member.id !== user.id ? ( | ||
| <Button | ||
| variant="tertiary" | ||
| size="sm" | ||
| onClick={() => setMemberToRemove(member)} | ||
| className="hover:text-destructive" | ||
| > | ||
| <Button.LeftIcon> | ||
| <Trash2 className="h-4 w-4" /> | ||
| </Button.LeftIcon> | ||
| <Button.Text className="sr-only">Remove member</Button.Text> | ||
| </Button> |
There was a problem hiding this comment.
🚩 No authorization check on Team page - any org member can remove others
The Team page (Team.tsx) allows any authenticated org member to remove other members and cancel invites. There's no client-side role check (e.g., admin-only) gating these destructive actions — only the current user's own row is hidden behind member.id !== user.id. While server-side authorization should enforce proper access control, the UI doesn't restrict non-admin users from seeing or clicking the remove/cancel buttons. This may be intentional (all members can manage the team) or an oversight. Worth confirming the intended access model.
Was this helpful? React with 👍 or 👎 to provide feedback.
| header: "Email", | ||
| width: "1fr", | ||
| render: (invite) => { | ||
| const isExpired = invite.expiresAt < new Date(); |
There was a problem hiding this comment.
🚩 invite.expiresAt < new Date() comparison assumes Date object type
The invite expiration check at Team.tsx:283 (invite.expiresAt < new Date()) assumes invite.expiresAt is a Date object. If the API returns a string (ISO timestamp), this comparison would use string comparison rather than date comparison, which may produce incorrect results. The same pattern appears at lines 315, 328, and 340. This depends on whether the generated client models deserialize date fields automatically. Worth verifying the TeamInvite.expiresAt type from the generated client.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
/inviteto unauthenticated pathsStacked on #2003 (backend endpoints + SDK).
Test plan
/:orgSlug/team/invite?token=...page renders without auth🤖 Generated with Claude Code