Skip to content

fix: add invited members to agent's team using stored team_id#302

Merged
turisanapo merged 4 commits into
mainfrom
claude-issue-301-20260320-0933
Mar 21, 2026
Merged

fix: add invited members to agent's team using stored team_id#302
turisanapo merged 4 commits into
mainfrom
claude-issue-301-20260320-0933

Conversation

@heiwen

@heiwen heiwen commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Pass the agent's team_id through the invite form as a hidden field instead of re-querying the agent on submit. The team_id is already available from the parent route's loader data.

Closes #301

Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Team-scoped member invitations: invites now include team context, are submitted with team info, and pending invites are shown per team.
    • Invite form and members list automatically associate and filter invitations by the current agent/team.
  • Bug Fixes

    • Improved handling of pending invitations to display consistent fields and support resend behavior.

Pass the agent's team_id through the invite form as a hidden field
instead of re-querying the agent on submit. The team_id is already
available from the parent route's loader data.

Closes #301

Co-authored-by: Heinrich Wendel <heiwen@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@hebo-claude-assistant[bot] has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 10 minutes and 8 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 47333056-c8d2-45f9-a171-cb0a26fd2f6f

📥 Commits

Reviewing files that changed from the base of the PR and between 681b3a6 and 6bd2e28.

📒 Files selected for processing (3)
  • apps/console/app/lib/auth/better-auth.ts
  • apps/console/app/routes/_shell.agent.$agentSlug.settings/members.tsx
  • apps/console/app/routes/_shell.agent.$agentSlug.settings/route.tsx
📝 Walkthrough

Walkthrough

Agent team_id is threaded through the invite flow: Prisma annotation changed to expose team_id for inputs; auth service inviteMember signatures accept an optional teamId; member settings form and routes supply teamId to the service which forwards it to the auth API.

Changes

Cohort / File(s) Summary
Prisma Configuration
apps/api/prisma/schema.prisma
Changed agents.team_id annotation from /// @prismabox.hide to `/// `@prismabox.input.hide (field type unchanged).
Auth Service Interface & Implementations
apps/console/app/lib/auth/types.ts, apps/console/app/lib/auth/better-auth.ts, apps/console/app/lib/auth/dummy-auth.ts
Extended inviteMember(email, role)inviteMember(email, role, teamId?). OrgInvitation type gains optional teamId. better-auth forwards teamId conditionally to authClient.organization.inviteMember; dummy-auth accepts the extra param (unused).
Member Settings UI & Routes
apps/console/app/routes/_shell.agent.$agentSlug.settings/members.tsx, apps/console/app/routes/_shell.agent.$agentSlug.settings/route.tsx, apps/console/app/routes/_shell.agent.$agentSlug.settings.members/route.tsx
MembersSettings now receives agent: { team_id }, filters invitations by teamId, includes a hidden teamId input in invite form, and route clientAction passes submission.value.teamId into authService.inviteMember.
Invite Schema
apps/console/app/routes/_shell.agent.$agentSlug.settings.members/invite-schema.ts
Added optional teamId: z.string().optional() to inviteSchema so submissions may include teamId.

Sequence Diagram(s)

sequenceDiagram
  participant User as "User (Console UI)"
  participant Route as "Route Action\n(_members/route.tsx)"
  participant AuthSvc as "AuthService\n(better-auth)"
  participant AuthAPI as "Auth API\n(authClient.organization)"

  User->>Route: Submit invite form (email, role, hidden teamId)
  Route->>AuthSvc: authService.inviteMember(email, role, teamId)
  AuthSvc->>AuthAPI: organization.inviteMember({ email, role, resend: true, ...(teamId && { teamId }) })
  AuthAPI-->>AuthSvc: 200 OK / invite result
  AuthSvc-->>Route: result / success
  Route-->>User: render success / error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped a little thread so fine,
From form to service, line by line,
team_id tucked into the mail,
Now invites follow the team trail,
Hooray — members join the vine! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: passing team_id through the invite form to properly associate invited members with the agent's team.
Linked Issues check ✅ Passed The PR addresses issue #301 by passing team_id through the invite form and filtering invitations by team, enabling invited members to be associated with the agent's team.
Out of Scope Changes check ✅ Passed All changes are scoped to the team_id feature: schema validation, type definitions, auth service signatures, UI form handling, and invitation filtering—all directly supporting the objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude-issue-301-20260320-0933
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@heiwen heiwen marked this pull request as ready for review March 20, 2026 10:03

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
apps/console/app/routes/_shell.agent.$agentSlug.settings.members/route.tsx (1)

15-21: Consider adding teamId to the invite schema for defense-in-depth validation.

The teamId is read directly from formData and bypasses Zod validation (the inviteSchema only validates email and role). While the auth server should validate team membership permissions, adding format validation here provides an extra layer of protection against malformed input.

💡 Optional: extend inviteSchema

In invite-schema.ts:

 export const inviteSchema = z.object({
   email: z.email("Enter a valid email address"),
   role: z.enum(["member", "admin"]),
+  teamId: z.string().uuid().optional(),
 });

Then in the route:

-    const teamId = formData.get("teamId");
     try {
       await authService.inviteMember(
         submission.value.email,
         submission.value.role,
-        typeof teamId === "string" ? teamId : undefined,
+        submission.value.teamId,
       );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/console/app/routes/_shell.agent`.$agentSlug.settings.members/route.tsx
around lines 15 - 21, The route currently reads teamId from formData and passes
it to authService.inviteMember without Zod validation; update the invite schema
(inviteSchema) to include teamId (e.g., a string().optional() or
string().nullable() with any format constraints you want), validate the incoming
submission against the extended inviteSchema so submission.value contains
teamId, and then pass the validated submission.value.teamId into
authService.inviteMember instead of reading formData.get("teamId"); ensure any
existing callers and types that use inviteSchema are updated accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/console/app/routes/_shell.agent`.$agentSlug.settings.members/route.tsx:
- Around line 15-21: The route currently reads teamId from formData and passes
it to authService.inviteMember without Zod validation; update the invite schema
(inviteSchema) to include teamId (e.g., a string().optional() or
string().nullable() with any format constraints you want), validate the incoming
submission against the extended inviteSchema so submission.value contains
teamId, and then pass the validated submission.value.teamId into
authService.inviteMember instead of reading formData.get("teamId"); ensure any
existing callers and types that use inviteSchema are updated accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8efcf4bc-b2be-4922-9ef8-31a151799593

📥 Commits

Reviewing files that changed from the base of the PR and between e95dcec and 03d8b9b.

📒 Files selected for processing (7)
  • apps/api/prisma/schema.prisma
  • apps/console/app/lib/auth/better-auth.ts
  • apps/console/app/lib/auth/dummy-auth.ts
  • apps/console/app/lib/auth/types.ts
  • apps/console/app/routes/_shell.agent.$agentSlug.settings.members/route.tsx
  • apps/console/app/routes/_shell.agent.$agentSlug.settings/members.tsx
  • apps/console/app/routes/_shell.agent.$agentSlug.settings/route.tsx

@heiwen heiwen requested a review from turisanapo March 20, 2026 10:11
@heiwen

heiwen commented Mar 20, 2026

Copy link
Copy Markdown
Contributor Author

@turisanapo for review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 03d8b9bbdf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/console/app/lib/auth/better-auth.ts
@heiwen

heiwen commented Mar 20, 2026

Copy link
Copy Markdown
Contributor Author

@claude what do you think about the latest comments?

@hebo-agent

hebo-agent Bot commented Mar 20, 2026

Copy link
Copy Markdown

Claude finished @heiwen's task in 54s —— View job


Assessment of review comments

  • Read the review comments
  • Read relevant source files for context
  • Provide assessment

Here's my take on each comment:


1. CodeRabbit — Add teamId to Zod schema (nitpick)

Agree, worth doing. Right now teamId bypasses validation entirely — it's read raw from formData at route.tsx:15. Adding it to inviteSchema as z.string().optional() is a small change that keeps all form validation in one place and avoids the manual typeof teamId === "string" check. Low effort, cleaner code.


2. Codex P1 — Invitation list not scoped to agent team

Valid observation, but out of scope for this PR. The getOrganization() call at route.tsx:12 returns org-wide members and invitations, and OrgInvitation (types.ts:44-49) has no teamId field. So yes, in a multi-agent org, invitations from Agent A would show on Agent B's settings page.

However, this PR's goal is narrowly to pass team_id when creating an invite — it doesn't claim to fix invitation display scoping. That's a separate improvement worth tracking as a follow-up issue. The risk of revoking the "wrong" invite is real but only matters once you have multiple agents in one org, which may not be the current use case.


3. Codex P2 — Re-inviting doesn't update existing invitations

Low risk, but worth noting. This is a Better Auth behavior question — whether inviteMember with a different teamId for the same email will create a second invite or update the existing one. The fix would be passing resend: true or enabling cancelPendingInvitationsOnReInvite in the Better Auth config. Again, this isn't something introduced by this PR (the same issue existed before without teamId), so it's a pre-existing concern.


Summary

Comment Verdict Action
CodeRabbit: validate teamId in schema Agree Quick fix — add teamId: z.string().optional() to inviteSchema
Codex P1: scope invitation display Valid but out of scope Track as follow-up issue
Codex P2: re-invite behavior Pre-existing, low risk Consider adding resend: true as a separate improvement

The CodeRabbit suggestion is the only one I'd act on in this PR. The Codex comments are valid architectural observations but they're not regressions introduced here — they're pre-existing limitations that deserve their own issues. Let me know if you'd like me to implement the schema change.

@heiwen

heiwen commented Mar 20, 2026

Copy link
Copy Markdown
Contributor Author

@claude for (1), how do we handle that in other forms? I think (2) is worth fixing. let's be a good citizen and add resend for (3).

@hebo-agent

hebo-agent Bot commented Mar 20, 2026

Copy link
Copy Markdown

Claude finished @heiwen's task in 2m 10s —— View job


Addressing review feedback

  • Investigate how other forms handle schema validation (for teamId in Zod schema)
  • Add teamId to invite schema following existing patterns
  • Scope invitation display to agent team (P1)
  • Add resend support for re-invitations (P2)

Changes made

1. Schema validation for teamId — Looking at other forms in this codebase (e.g. modelConfigSchema), all form fields go through Zod validation. Added teamId: z.string().optional() to inviteSchema and updated the route to use submission.value.teamId instead of raw formData.get("teamId"). This follows the same pattern as other form schemas in the project.

2. Scope invitations to agent team (P1) — Three changes:

  • Added teamId?: string to OrgInvitation type
  • Updated getOrganization() in better-auth.ts to preserve the teamId field from Better Auth's response (it was being dropped by the type cast)
  • Added allInvitations.filter((i) => i.teamId === agent.team_id) in MembersSettings so each agent's settings page only shows its own team's invitations

3. Resend support (P2) — Added resend: true to the inviteMember call in better-auth.ts. This ensures that re-inviting an email (e.g. from a different agent) will update the existing pending invitation rather than silently keeping the old one.

View job |

- Add teamId to inviteSchema for Zod validation instead of raw formData
- Add teamId to OrgInvitation type and preserve it from Better Auth response
- Filter displayed invitations by agent's team_id
- Add resend: true to inviteMember to update existing pending invitations

Co-authored-by: Heinrich Wendel <heiwen@users.noreply.github.com>
@heiwen

heiwen commented Mar 20, 2026

Copy link
Copy Markdown
Contributor Author

@claude you are scoping to the teamId in the frontend. Are you sure that is the right / secure way to do things?

@hebo-agent

hebo-agent Bot commented Mar 20, 2026

Copy link
Copy Markdown

Claude finished @heiwen's task in 1m 2s —— View job


Security review: frontend vs backend scoping of invitations

  • Read current code changes
  • Analyze security implications
  • Provide assessment

Good catch — you're right to question this. There are two separate concerns here:


1. Invitation display filtering is client-side only (members.tsx:57)

const invitations = allInvitations.filter((i) => i.teamId === agent.team_id);

This is purely cosmetic. getOrganization() fetches all org-wide invitations from Better Auth, and the filtering happens in the React component. Any user can see all org invitations in the network tab / dev tools. While this isn't a critical leak (they're in the same org), it's not the right pattern. The filtering should be done in the loader or, ideally, the API should support querying invitations by team.

2. The teamId is a user-controllable hidden field (members.tsx:164)

<input type="hidden" name="teamId" value={agent.team_id} />

A user can trivially change this to any teamId via dev tools. The clientAction in route.tsx passes it straight through to authService.inviteMember()authClient.organization.inviteMember(). The only security gate is Better Auth's server-side authorization — whether it validates the caller has permission to invite to the given team.

Since this is a client-side SPA (clientLoader / clientAction), there's no server-side route handler where you could independently verify the teamId. But that's exactly why you shouldn't trust the hidden field. Instead:

Recommended approach: Derive the teamId from the route context (the agent slug) in the clientAction, rather than accepting it from the form. The agent's team_id is already available from the parent route's loader data — the action should read it from there instead of from user-submitted form data. This way:

  • The teamId can't be tampered with via form manipulation
  • The invitation list filtering could also move into the loader, keeping the component simpler

Something like:

// In clientAction — read teamId from the parent route's agent data
// instead of from formData
import { unstable_useRoute } from "react-router";
// Or pass it via route context / match data

The exact mechanism depends on whether React Router's clientAction can access parent loader data (it may need to go through unstable_routeContext or similar). If that's not straightforward, an alternative is to re-derive the agent from the slug param in the action — the slug is in the URL and can't be spoofed to a different agent's page.

TL;DR: The frontend filtering is fine for UX, but the teamId should not come from a hidden form field. Derive it server-side (or from trusted route context) so it can't be tampered with. Better Auth's API authorization is the last line of defense, but defense-in-depth means not relying solely on it.

@heiwen

heiwen commented Mar 20, 2026

Copy link
Copy Markdown
Contributor Author

@claude got it, we're fine then from a security perspective.

But I guess this additional map that you introduced here is unnessecary

    const invitations = ((data.invitations ?? []) as unknown as (OrgInvitation & Record<string, unknown>)[])
      .filter((i) => i.status === "pending")
      .map(({ id, email, role, expiresAt, status, teamId }) => ({
        id,
        email,
        role,
        expiresAt,
        status,
        teamId: typeof teamId === "string" ? teamId : undefined,
      }));

and the filtering itself should already happen in the client loader of the agent settings page?

@hebo-agent

hebo-agent Bot commented Mar 20, 2026

Copy link
Copy Markdown

Claude finished @heiwen's task in 2m 10s —— View job


Simplifying invitation handling

  • Read current code for context
  • Remove unnecessary .map() in better-auth.ts
  • Move team filtering to settings route component
  • Commit and push

Changes

  1. Removed redundant .map() in better-auth.ts:191-200 — The manual field-picking map was unnecessary since OrgInvitation already has teamId?: string. A simple cast to OrgInvitation[] is sufficient.

  2. Moved team filtering from members.tsx to route.tsx — The filter now lives in the settings route component where both loader data and agent context (useRoute) are available. MembersSettings receives already-filtered invitations. Note: can't move it into the clientLoader function itself since parent route loader data isn't accessible there (hooks like useRoute only work in components), but the settings component is the earliest point where both data sources meet.

… route

Remove redundant .map() in getOrganization() — the cast to OrgInvitation[]
is sufficient since the type already includes teamId. Move team-scoped
filtering from MembersSettings component up to the settings route component
where both loader data and agent context are available.

Co-authored-by: Heinrich Wendel <heiwen@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@heiwen

heiwen commented Mar 20, 2026

Copy link
Copy Markdown
Contributor Author

@turisanapo for final review

@turisanapo turisanapo merged commit 481681e into main Mar 21, 2026
2 checks passed
@turisanapo turisanapo deleted the claude-issue-301-20260320-0933 branch March 21, 2026 01:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Adding a member to an agent does not add them to the agent's team

2 participants