-
Notifications
You must be signed in to change notification settings - Fork 17
feat: add team page with member management and invite flow #1403
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Introduce a new Team page accessible from the sidebar that shows organization members and pending invites. Backend includes a new teams Goa service with endpoints for listing members, inviting, cancelling/resending invites, and removing members. Data is stored in a new team_invites table with soft delete support. Frontend is scaffolded with placeholder API hooks (TODOs for wiring to generated SDK). Email sending via Loops is designed for but not yet configured. Co-Authored-By: Claude Opus 4.5 <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: 6a81715 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Replace placeholder data and TODO stubs with actual Speakeasy SDK react-query hooks for all team operations: - useListTeamMembersSuspense for fetching org members - useListTeamInvitesSuspense for fetching pending invites - useInviteTeamMemberMutation for sending invites - useRemoveTeamMemberMutation for removing members - useCancelTeamInviteMutation for cancelling invites - useResendTeamInviteMutation for resending invites All mutations invalidate both member and invite caches on success, and show loading states on submit buttons during pending operations. Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Add acceptInvite backend endpoint (Goa design, SQL queries, service impl) - Validate token, expiry, email match; add user to org; invalidate cache - Add /invite frontend route with auto-accept and login redirect - Add Loops email client + team invite email sending - Replace inline ErrorFallback with branded FullPageError component - Regenerate Goa server, sqlc, and Speakeasy SDK Co-Authored-By: Claude Opus 4.5 <[email protected]>
Co-Authored-By: Claude Opus 4.5 <[email protected]>
🚀 Preview Environment (PR #1403)Preview URL: https://pr-1403.dev.getgram.ai
Gram Preview Bot |
- Wrap AcceptInvite in DB transaction with atomic status check - Add Referrer-Policy meta tag on invite page to prevent token leak - Validate redirect param to prevent open redirect - Mask invite email for non-matching callers in GetInviteInfo - Rate limit invite creation (50/org/24h) and resend (5min cooldown) - Rotate invite token on resend to invalidate old links - Log startup warning when DevMode is enabled Co-Authored-By: Claude Opus 4.5 <[email protected]>
Co-Authored-By: Claude Opus 4.5 <[email protected]>
Co-Authored-By: Claude Opus 4.5 <[email protected]>
Add team_invites table with support for pending, accepted, expired, and cancelled invite statuses. Includes unique partial index on (organization_id, email) for non-deleted pending invites, and a unique token index for invite lookups. Co-Authored-By: Claude Opus 4.5 <[email protected]>
5621627 to
c101e5e
Compare
# Conflicts: # server/gen/http/agents/client/cli.go # server/gen/http/assets/client/cli.go # server/gen/http/auth/client/cli.go # server/gen/http/chat/client/cli.go # server/gen/http/chat_sessions/client/cli.go # server/gen/http/cli/gram/cli.go # server/gen/http/deployments/client/cli.go # server/gen/http/domains/client/cli.go # server/gen/http/environments/client/cli.go # server/gen/http/features/client/cli.go # server/gen/http/functions/client/cli.go # server/gen/http/integrations/client/cli.go # server/gen/http/keys/client/cli.go # server/gen/http/mcp_metadata/client/cli.go # server/gen/http/packages/client/cli.go # server/gen/http/projects/client/cli.go # server/gen/http/slack/client/cli.go # server/gen/http/telemetry/client/cli.go # server/gen/http/templates/client/cli.go # server/gen/http/toolsets/client/cli.go # server/gen/http/variations/client/cli.go
Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Capture submitted email before mutate() to prevent toast showing wrong address if input changes during the request - Soft-delete organization_user_relationship after Speakeasy removal so the member list updates immediately (local cache optimisation) - Document that org slug == workspace slug (from SSO connection) Co-Authored-By: Claude Opus 4.5 <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Devin Review found 1 new potential issue.
🟡 1 issue in files not directly in the diff
🟡 Pending invite uniqueness is case-sensitive in DB, allowing duplicate invites for same email with different casing (server/migrations/20260129231659_team-invites.sql:21-22)
The application treats invite emails as case-insensitive (e.g. GetPendingInviteByEmail matches on lower(email) = lower(@email)), but the database unique index that is supposed to prevent duplicate pending invites is case-sensitive and does not normalize casing.
Actual behavior: two concurrent requests (or requests from different clients) can create two pending invites for [email protected] and [email protected] in the same org because the unique index is on the raw email value.
Expected behavior: the DB should enforce case-insensitive uniqueness for pending invites so duplicates cannot be created even under races.
Impact: duplicated pending invites can be created and emailed, and later flows that assume a single pending invite per email/org can behave unexpectedly.
Click to expand
Where the mismatch happens
-
DB constraint is case-sensitive:
server/migrations/20260129231659_team-invites.sql:21-22
CREATE UNIQUE INDEX "team_invites_organization_id_email_pending_key" ON "team_invites" ("organization_id", "email") WHERE ((deleted IS FALSE) AND (status = 'pending'::text));
-
App queries treat email as case-insensitive:
server/internal/teams/queries.sql:40-45
WHERE organization_id = @organization_id AND lower(email) = lower(@email) AND status = 'pending' AND deleted IS FALSE;
How it can be triggered
- Request A invites
[email protected]. - Request B (near-simultaneously) invites
[email protected]. - Both
GetPendingInviteByEmailchecks can pass (race). - Both inserts succeed because the unique index doesn’t consider lowercasing.
Recommendation: Enforce case-insensitive uniqueness at the DB layer, e.g. by indexing on lower(email) (and updating queries accordingly), or by using citext for email and indexing the normalized value. Ensure the unique constraint matches the application’s lower(email) semantics.
View issue and 19 additional flags in Devin Review.
Gram enforces single-org membership. When a user who is already a member of a Speakeasy org tries to accept an invite, redirect them back to the invite page with an error instead of silently failing. Co-Authored-By: Claude Opus 4.5 <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Devin Review found 1 new potential issue.
🟡 1 issue in files not directly in the diff
🟡 Pending invite uniqueness is case-sensitive in DB, allowing duplicate invites for same email with different casing (server/migrations/20260129231659_team-invites.sql:21-22)
The application treats invite emails as case-insensitive (e.g. GetPendingInviteByEmail matches on lower(email) = lower(@email)), but the database unique index that is supposed to prevent duplicate pending invites is case-sensitive and does not normalize casing.
Actual behavior: two concurrent requests (or requests from different clients) can create two pending invites for [email protected] and [email protected] in the same org because the unique index is on the raw email value.
Expected behavior: the DB should enforce case-insensitive uniqueness for pending invites so duplicates cannot be created even under races.
Impact: duplicated pending invites can be created and emailed, and later flows that assume a single pending invite per email/org can behave unexpectedly.
Click to expand
Where the mismatch happens
-
DB constraint is case-sensitive:
server/migrations/20260129231659_team-invites.sql:21-22
CREATE UNIQUE INDEX "team_invites_organization_id_email_pending_key" ON "team_invites" ("organization_id", "email") WHERE ((deleted IS FALSE) AND (status = 'pending'::text));
-
App queries treat email as case-insensitive:
server/internal/teams/queries.sql:40-45
WHERE organization_id = @organization_id AND lower(email) = lower(@email) AND status = 'pending' AND deleted IS FALSE;
How it can be triggered
- Request A invites
[email protected]. - Request B (near-simultaneously) invites
[email protected]. - Both
GetPendingInviteByEmailchecks can pass (race). - Both inserts succeed because the unique index doesn’t consider lowercasing.
Recommendation: Enforce case-insensitive uniqueness at the DB layer, e.g. by indexing on lower(email) (and updating queries accordingly), or by using citext for email and indexing the normalized value. Ensure the unique constraint matches the application’s lower(email) semantics.
View issue and 23 additional flags in Devin Review.
Previously sendInviteEmail was fire-and-forget — if the email failed the invite row stayed in the DB with no way for the invitee to receive the link, and the duplicate check blocked retrying. Now the invite is cancelled on email failure and the error surfaces to the frontend toast. Co-Authored-By: Claude Opus 4.5 <[email protected]>
Replace the separate count-then-insert with an atomic INSERT ... WHERE that only succeeds if the org has fewer than 50 invites in the last 24h. Wrap the entire InviteMember flow in a transaction so a failed email send automatically rolls back the invite row. Co-Authored-By: Claude Opus 4.5 <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Devin Review found 2 new potential issues.
🟡 1 issue in files not directly in the diff
🟡 Pending invite uniqueness is case-sensitive in DB, allowing duplicate invites for same email with different casing (server/migrations/20260129231659_team-invites.sql:21-22)
The application treats invite emails as case-insensitive (e.g. GetPendingInviteByEmail matches on lower(email) = lower(@email)), but the database unique index that is supposed to prevent duplicate pending invites is case-sensitive and does not normalize casing.
Actual behavior: two concurrent requests (or requests from different clients) can create two pending invites for [email protected] and [email protected] in the same org because the unique index is on the raw email value.
Expected behavior: the DB should enforce case-insensitive uniqueness for pending invites so duplicates cannot be created even under races.
Impact: duplicated pending invites can be created and emailed, and later flows that assume a single pending invite per email/org can behave unexpectedly.
Click to expand
Where the mismatch happens
-
DB constraint is case-sensitive:
server/migrations/20260129231659_team-invites.sql:21-22
CREATE UNIQUE INDEX "team_invites_organization_id_email_pending_key" ON "team_invites" ("organization_id", "email") WHERE ((deleted IS FALSE) AND (status = 'pending'::text));
-
App queries treat email as case-insensitive:
server/internal/teams/queries.sql:40-45
WHERE organization_id = @organization_id AND lower(email) = lower(@email) AND status = 'pending' AND deleted IS FALSE;
How it can be triggered
- Request A invites
[email protected]. - Request B (near-simultaneously) invites
[email protected]. - Both
GetPendingInviteByEmailchecks can pass (race). - Both inserts succeed because the unique index doesn’t consider lowercasing.
Recommendation: Enforce case-insensitive uniqueness at the DB layer, e.g. by indexing on lower(email) (and updating queries accordingly), or by using citext for email and indexing the normalized value. Ensure the unique constraint matches the application’s lower(email) semantics.
View issues and 23 additional flags in Devin Review.
- Switch to retryablehttp client in loops and speakeasy connections - Remove email PII from structured log attributes - Fix dev mode check to use environment flag instead of localEnvPath - Add description for LOOPS_API_KEY in mise.toml Co-Authored-By: Claude Opus 4.5 <[email protected]>
Move tx.Commit before sendInviteEmail so the invite is persisted before the email goes out. If email delivery fails, cancel the committed invite so it can be retried. Co-Authored-By: Claude Opus 4.5 <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Devin Review found 2 new potential issues.
🟡 1 issue in files not directly in the diff
🟡 Pending invite uniqueness is case-sensitive in DB, allowing duplicate invites for same email with different casing (server/migrations/20260129231659_team-invites.sql:21-22)
The application treats invite emails as case-insensitive (e.g. GetPendingInviteByEmail matches on lower(email) = lower(@email)), but the database unique index that is supposed to prevent duplicate pending invites is case-sensitive and does not normalize casing.
Actual behavior: two concurrent requests (or requests from different clients) can create two pending invites for [email protected] and [email protected] in the same org because the unique index is on the raw email value.
Expected behavior: the DB should enforce case-insensitive uniqueness for pending invites so duplicates cannot be created even under races.
Impact: duplicated pending invites can be created and emailed, and later flows that assume a single pending invite per email/org can behave unexpectedly.
Click to expand
Where the mismatch happens
-
DB constraint is case-sensitive:
server/migrations/20260129231659_team-invites.sql:21-22
CREATE UNIQUE INDEX "team_invites_organization_id_email_pending_key" ON "team_invites" ("organization_id", "email") WHERE ((deleted IS FALSE) AND (status = 'pending'::text));
-
App queries treat email as case-insensitive:
server/internal/teams/queries.sql:40-45
WHERE organization_id = @organization_id AND lower(email) = lower(@email) AND status = 'pending' AND deleted IS FALSE;
How it can be triggered
- Request A invites
[email protected]. - Request B (near-simultaneously) invites
[email protected]. - Both
GetPendingInviteByEmailchecks can pass (race). - Both inserts succeed because the unique index doesn’t consider lowercasing.
Recommendation: Enforce case-insensitive uniqueness at the DB layer, e.g. by indexing on lower(email) (and updating queries accordingly), or by using citext for email and indexing the normalized value. Ensure the unique constraint matches the application’s lower(email) semantics.
View issues and 22 additional flags in Devin Review.
…cache RemoveMember was reading workspace slugs from the caller's cached user info, which can be empty or stale. Now looks up the org slug from the database (organization_metadata), consistent with how processInviteToken resolves slugs for AddUserToOrg. Co-Authored-By: Claude Opus 4.5 <[email protected]>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Devin Review found 4 new potential issues.
🟡 2 issues in files not directly in the diff
🟡 team_invites.organization_id foreign key uses ON DELETE CASCADE (can delete invites on org deletion unexpectedly) (server/migrations/20260129231659_team-invites.sql:12-15)
The new team_invites table defines organization_id with ON DELETE CASCADE.
Actual: deleting an organization row will cascade-delete all associated invites.
Expected: per repo DB guidelines, foreign keys should use ON DELETE SET NULL to avoid accidental cascading data loss; plus invite rows could be retained for audit/debugging even if an org is removed.
Click to expand
Migration:
CONSTRAINT "team_invites_organization_id_fkey" FOREIGN KEY ("organization_id")
REFERENCES "organization_metadata" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,server/migrations/20260129231659_team-invites.sql:12-15
Recommendation: Change to ON DELETE SET NULL (and make organization_id nullable if required), or explicitly document why cascade deletion is desired here.
🟡 Pending invite uniqueness is case-sensitive in DB, allowing duplicate invites for same email with different casing (server/migrations/20260129231659_team-invites.sql:21-22)
The application treats invite emails as case-insensitive (e.g. GetPendingInviteByEmail matches on lower(email) = lower(@email)), but the database unique index that is supposed to prevent duplicate pending invites is case-sensitive and does not normalize casing.
Actual behavior: two concurrent requests (or requests from different clients) can create two pending invites for [email protected] and [email protected] in the same org because the unique index is on the raw email value.
Expected behavior: the DB should enforce case-insensitive uniqueness for pending invites so duplicates cannot be created even under races.
Impact: duplicated pending invites can be created and emailed, and later flows that assume a single pending invite per email/org can behave unexpectedly.
Click to expand
Where the mismatch happens
-
DB constraint is case-sensitive:
server/migrations/20260129231659_team-invites.sql:21-22
CREATE UNIQUE INDEX "team_invites_organization_id_email_pending_key" ON "team_invites" ("organization_id", "email") WHERE ((deleted IS FALSE) AND (status = 'pending'::text));
-
App queries treat email as case-insensitive:
server/internal/teams/queries.sql:40-45
WHERE organization_id = @organization_id AND lower(email) = lower(@email) AND status = 'pending' AND deleted IS FALSE;
How it can be triggered
- Request A invites
[email protected]. - Request B (near-simultaneously) invites
[email protected]. - Both
GetPendingInviteByEmailchecks can pass (race). - Both inserts succeed because the unique index doesn’t consider lowercasing.
Recommendation: Enforce case-insensitive uniqueness at the DB layer, e.g. by indexing on lower(email) (and updating queries accordingly), or by using citext for email and indexing the normalized value. Ensure the unique constraint matches the application’s lower(email) semantics.
View issues and 28 additional flags in Devin Review.
| // Update session to point at the invited org. | ||
| session.ActiveOrganizationID = invite.OrganizationID | ||
| if err := s.sessions.UpdateSession(ctx, *session); err != nil { | ||
| s.logger.ErrorContext(ctx, "failed to update session after invite accept", | ||
| attr.SlogError(err), | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Invite acceptance can succeed but leave session pointing at wrong organization when UpdateSession fails
During OAuth callback invite processing, processInviteToken updates session.ActiveOrganizationID and then calls s.sessions.UpdateSession(ctx, *session) but logs and ignores any error.
Actual: if session cache update fails (e.g., redis outage), the callback handler still returns a successful redirect to /<orgSlug> with the session cookie, but the stored session may still have the old ActiveOrganizationID (or empty), leading to authorization failures or landing the user in the wrong org.
Expected: a failure to persist the updated session should fail the invite acceptance flow (or at least avoid redirecting to the invited org) so the user isn’t redirected into an org they can’t access.
Click to expand
Relevant code:
// Update session to point at the invited org.
session.ActiveOrganizationID = invite.OrganizationID
if err := s.sessions.UpdateSession(ctx, *session); err != nil {
s.logger.ErrorContext(ctx, "failed to update session after invite accept",
attr.SlogError(err),
)
}server/internal/auth/impl.go:684-690
Impact:
- User may be redirected to the invited org route but still be treated as not in that org.
- Can manifest as confusing unauthorized/forbidden errors immediately after accepting an invite.
Recommendation: Return an error (and revoke/clear the session if appropriate) when UpdateSession fails, or re-store the session and only redirect to the invited org after persistence succeeds.
Was this helpful? React with 👍 or 👎 to provide feedback.
| * [acceptInvite](#acceptinvite) - acceptInvite teams | ||
| * [cancelInvite](#cancelinvite) - cancelInvite teams | ||
| * [getInviteInfo](#getinviteinfo) - getInviteInfo teams | ||
| * [inviteMember](#invitemember) - inviteMember teams | ||
| * [listInvites](#listinvites) - listInvites teams | ||
| * [listMembers](#listmembers) - listMembers teams | ||
| * [removeMember](#removemember) - removeMember teams | ||
| * [resendInvite](#resendinvite) - resendInvite teams | ||
|
|
||
| ## acceptInvite | ||
|
|
||
| Accept a team invite using a token from an invite email. | ||
|
|
||
| ### Example Usage | ||
|
|
||
| <!-- UsageSnippet language="typescript" operationID="acceptTeamInvite" method="post" path="/rpc/teams.acceptInvite" --> | ||
| ```typescript | ||
| import { Gram } from "@gram/client"; | ||
|
|
||
| const gram = new Gram(); | ||
|
|
||
| async function run() { | ||
| const result = await gram.teams.acceptInvite({ | ||
| serveChatAttachmentSignedForm: { | ||
| token: "<value>", | ||
| }, | ||
| }); | ||
|
|
||
| console.log(result); | ||
| } | ||
|
|
||
| run(); | ||
| ``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 teams SDK docs list a non-existent/incorrect acceptInvite operation
The generated Teams SDK documentation includes an acceptInvite operation (operationID="acceptTeamInvite" and path /rpc/teams.acceptInvite) but there is no corresponding endpoint in the OpenAPI for this PR and no generated client function in client/sdk/src/funcs.
Actual: SDK consumers relying on docs will attempt to call an endpoint that doesn't exist (and may get runtime 404s).
Expected: Teams docs should only list operations that exist in the API spec and generated client.
Click to expand
Evidence:
- Docs claim operation exists:
client/sdk/docs/sdks/teams/README.md:9-41(acceptInvite section). - No implementation generated (search in SDK source): no matches for
acceptTeamInvite/teamsAcceptInviteinclient/sdk/src.
Recommendation: Regenerate SDK docs from the correct OpenAPI, or remove the stray acceptInvite section so docs match the actual generated client/API.
Was this helpful? React with 👍 or 👎 to provide feedback.
| URLDomainKey = semconv.URLDomainKey | ||
| URLFullKey = semconv.URLFullKey | ||
| URLOriginalKey = semconv.URLOriginalKey | ||
| UserEmailKey = attribute.Key("gram.user.email") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove this attribute and any associated usage of it
simplesagar
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.


Summary
Adds in-app team management to Gram — replaces the external Speakeasy team settings redirect with a native Team page and invite flow.
What changed
Team management page (
/:org/:project/team)Invite acceptance via OAuth callback
/invite?token=...page shows invite preview (org name, inviter, masked email) and "Accept invite" buttonAcceptInviteAPI endpointBackend teams service (7 endpoints)
listMembers,inviteMember,listInvites,cancelInvite,resendInvite,getInviteInfo,removeMemberEmail delivery
server/internal/thirdparty/loops/client.go)LOOPS_API_KEYis unconfiguredDatabase
team_invitestable with soft delete, unique constraints on pending org+email and token20260129231659_team-invites.sqlFrontend changes
AcceptInvite.tsx— standalone invite page with Gram/Speakeasy branding, status handling (expired/accepted/cancelled)Team.tsx— full team management page wired to SDK react-query hooksAuth.tsx— added/inviteto unauthenticated paths, addedFullPageErrorboundaryapp-sidebar.tsx— replaced external Speakeasy team link with in-app routeTest plan
/invite?token=...while logged out → redirects through OAuth login/invite?token=...while logged in → shows invite details, accept redirects through OAuth🤖 Generated with Claude Code