feat: teams and workspaces with wallet invitations - #136
Merged
Merged
Conversation
…itation near account (#82) - migration: session.active_team_id, team.metadata, invitation.near_account_id - Better Auth additionalFields for team metadata and invitation nearAccountId - disable default team creation and allow removing the last team - getContext reports organization.teams (id, name, areas) and a membership-validated organization.activeTeamId - setActiveTeam and listUserTeams plugin routes; team routes expose areas - toORPCError maps Better Auth APIError statusCode instead of the string status, so auth errors no longer surface as 500s Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- FEATURE_AREAS vocabulary (node-operations, finance, things, stake) exported from api/feature-areas for API and UI - requireTeam and requireTeamArea middlewares with bypass for platform admins and organization owners/admins; no active team keeps pre-teams access - node and validator mutation routes require node-operations Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- create, rename and delete teams; grant feature areas via checkboxes from the shared vocabulary; add/remove organization members - management controls for organization owners/admins, read-only view for members - auth plugin listTeamMembers allows any member of the team's organization (Better Auth restricts it to team members) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- sidebar team switcher lists the user's teams in the active organization with an all-areas option; switching refreshes the session and workspace queries - navigation items carry an optional feature area; the sidebar shows only the active team's areas (owners/admins, platform admins and users without an active team see everything) - dashboard route guard redirects to the workspace overview with a restricted-area notice; header shows the team being operated as - switching organizations clears the active team - Better Auth organization client enables teams - browser regression spec for switch, nav filtering and guard redirect Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- inviteMember passes an optional teamId through; invitation outputs include teamId - accepting a team invitation joins the team and sets it active (Better Auth built-in); acceptance page refreshes the team workspace - invite form gains an optional team picker; pending invitations show the targeted team - invitation emails link to /orgs/invites/:id instead of the missing /accept-invitation/:id route Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Invite members by NEAR account id as well as email, optionally targeting a team - Add near-invitations Better Auth plugin with accept/reject wallet endpoints - List pending wallet invitations independently of email verification - Fix createHeaders dropping cookies when given a native Headers instance - Update invite form, invitation card, and claim-link page in the dashboard - Add integration, unit, and browser regression tests plus walkthrough docs
elliotBraem
reviewed
Sep 21, 2026
elliotBraem
reviewed
Sep 21, 2026
The shell no longer threads the active team through layout props, and the UI keeps feature areas locally so it does not depend on the api package. Co-authored-by: Cursor <cursoragent@cursor.com>
Wallet invites record their network and follow the same membership limit as email invites. The workspace refreshes after team changes and invitation acceptance, and team members load when the Teams tab opens. Co-authored-by: Cursor <cursoragent@cursor.com>
This was
linked to
issues
Sep 21, 2026
Closed
Closed
elliotBraem
reviewed
Sep 21, 2026
elliotBraem
left a comment
There was a problem hiding this comment.
Also browser regession failures
1) [dev] › tests/regression/browser/specs/team-workspace.spec.ts:84:3 › team workspace › team membership, rename and deletion refresh the mounted workspace
Error: expect(locator).toHaveCount(expected) failed
Locator: getByTestId('team-switcher')
Expected: 0
Received: 1
Timeout: 5000ms
Call log:
- Expect "toHaveCount" getByTestId('team-switcher') with timeout 5000ms
- waiting for getByTestId('team-switcher')
14 × locator resolved to 1 element
- unexpected value "1"
137 | await page.getByTestId(`teams-tab-delete-${team.id}`).click();
138 | await expect(page.getByTestId("workspace-active-team")).toHaveCount(0);
> 139 | await expect(page.getByTestId("team-switcher")).toHaveCount(0);
| ^
140 | await expect(page.getByTestId(`teams-tab-team-${team.id}`)).toHaveCount(0);
141 | expectNoHydrationFailure(pageErrors);
142 | });
at /home/runner/work/citynode.app/citynode.app/tests/regression/browser/specs/team-workspace.spec.ts:139:53
Error Context: test-results/team-workspace-team-worksp-e2feb-fresh-the-mounted-workspace-dev/error-context.md
Retry #1 ───────────────────────────────────────────────────────────────────────────────────────
Error: expect(locator).toHaveCount(expected) failed
Locator: getByTestId('team-switcher')
Expected: 0
Received: 1
Timeout: 5000ms
Call log:
- Expect "toHaveCount" getByTestId('team-switcher') with timeout 5000ms
- waiting for getByTestId('team-switcher')
14 × locator resolved to 1 element
- unexpected value "1"
137 | await page.getByTestId(`teams-tab-delete-${team.id}`).click();
138 | await expect(page.getByTestId("workspace-active-team")).toHaveCount(0);
> 139 | await expect(page.getByTestId("team-switcher")).toHaveCount(0);
| ^
140 | await expect(page.getByTestId(`teams-tab-team-${team.id}`)).toHaveCount(0);
141 | expectNoHydrationFailure(pageErrors);
142 | });
at /home/runner/work/citynode.app/citynode.app/tests/regression/browser/specs/team-workspace.spec.ts:139:53
Error Context: test-results/team-workspace-team-worksp-e2feb-fresh-the-mounted-workspace-dev-retry1/error-context.md
1 failed
[dev] › tests/reg
| @@ -0,0 +1,67 @@ | |||
| import type { DecoratedMiddleware } from "@orpc/server"; | |||
There was a problem hiding this comment.
I still think this can be removed in some way? Or part of it absorbed. See packages/everything-dev contract.ts
and auth.ts
|
There are trace logs still in code too, should be cleaned up once all passes |
- Replace api/src/lib/team-auth.ts with createRequireTeamArea in team-access-policy.ts, dropping the unused requireTeam middleware - Update api/src/index.ts to build the node-operations gate from the new export - Clear the caller's active team server-side before deleteTeam when it matches the deleted team, instead of racing a client-side setActiveTeam call - Simplify useOrganizationTeams' deleteTeam mutation to call the API directly and drop the now-unneeded session/active-team dance - Update unit, integration, UI, and regression tests to match
Merge 0003_teams_workspaces and 0004_peaceful_meggan (the two unreleased migrations this branch introduced) into a single 0003_teams_workspaces migration via drizzle-kit generate, so the feature ships as one migration instead of two small follow-ups. Also syncs bun.lock now that ui no longer depends on the api workspace. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Better Auth's removeTeam endpoint blocks deleting a team that is still the caller's active team, resolving that check from the request's better-auth.session_data cookie — a short-lived cache of the session as of its last refresh, independent of any database writes made earlier in the same request. The previous fix (auth.api.setActiveTeam, then removeTeam) cleared the session row but never touched that cookie, so removeTeam kept seeing the pre-clear activeTeamId and rejected the deletion with FORBIDDEN — reproduced against the real dev stack, not just the pglite test harness, which never hit this cookie-cache path. Clear the active team directly in the database, then strip the stale session_data cookie from the headers passed to removeTeam so its own session lookup falls back to a fresh database read via the session token instead of the cached cookie payload. Verified by running tests/regression/browser/specs/team-workspace.spec.ts against a live dev stack (previously failing 2/2, now passing). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
setActiveTeam/listUserTeamsendpoints (migration0003_teams_workspaces).node-operationsarea via a newrequireTeamAreamiddleware. Org owners, org admins and platform admins bypass it, and members with no active team keep their existing access.acceptNearInvitation/rejectNearInvitationand are discovered independently of email verification./orgs/invites/:id. Better Auth's own accept is blocked for wallet invitations./dashboard?restricted=<area>.CONTEXT.mdglossary entries, a walkthrough doc and a changeset.Testing
team-auth) and integration tests (team-gating): 13 files, 116 tests passed.team-workspace.spec.tspassed against the local dev stack. It covers team creation and area grants, email and wallet invites, accepting each, restricted navigation, 403 on the node mutation probe for a Finance team, and owner/admin bypass.bun typecheck: all 8 targets passed.bun lint: passed with warnings.Skilllink. It targets the deployed UI, not this change.--maxWorkers=1.