Business Team Members
The Team section of /app/business/[id]/content/edit is now a Members surface: a roster of members and pending invitations, search, a role filter, role changes, removal, and invitations one at a time or as a batch of emails.

Where the data comes from
A business maps 1:1 to a better-auth organization (businessOrgService). Members and invitations are not separate tables we own — they come straight from getFullOrganization, which joins the organization's member and invitation rows and nests each member's user:
const org = await businessOrgService.getOrgForBusiness(event, businessId)
org.members // [{ id, userId, role, createdAt, user: { name, email, image } }]
org.invitations // [{ id, email, role, status, expiresAt, ... }]members.get.ts returns { members, invitations } (pending invitations only).
Invitation links
Each invitation has a stable accept URL — the same one the invitation email uses:
${APP_URL}/accept-invitation?id=<invitationId>because better-auth has no single multi-email link, the "Multiple emails" mode creates one invitation per address and returns every accept URL, which the modal lists with copy buttons. Emails are still sent; the links are a convenience for sharing directly.

Role policy — admin is never assignable here
The only roles this surface hands out are owner and member. admin is refused by two layers, because "prevent at all cost" means the UI gate is not the guard:
| Layer | Where | Behaviour |
|---|---|---|
| Route validation | invite.post.ts / members/[memberId].patch.ts | z.enum(['owner', 'member']) → admin is a 400 before any auth call |
| Service guard | business-team.service.ts | isAssignableRole() rejects anything outside ASSIGNABLE_TEAM_ROLES |
ASSIGNABLE_TEAM_ROLES = ['owner', 'member'] (exported) is the single source of truth; TEAM_ROLES still lists admin for reading existing members. Platform admins are created only from /app/admin/users.
An existing admin member (created in the admin area) renders as a read-only Admin badge — no select, and it is excluded from removal so admin membership stays managed centrally.
The invite role defaults to member; the Role field carries a help line ("Admins are created from the admin area, never here.").
API
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/business/[id]/members | { members, invitations } |
POST | /api/v1/business/[id]/invite | { emails[], role } → { invitations: [{ email, acceptUrl }] } |
PATCH | /api/v1/business/[id]/members/[memberId] | { role } (owner / member) |
DELETE | /api/v1/business/[id]/members/[memberId] | remove (member id or email) |
DELETE | /api/v1/business/[id]/invitations/[invitationId] | cancel a pending invitation |
Routes stay thin; everything lives in businessTeamService, which returns ServiceResponse<T> and never throws. useAuthApi wraps the plugin endpoints (createInvitation, listInvitations, cancelInvitation, removeMember, updateMemberRole) with the request headers injected.
Files
packages/auth/server/services/business-team.service.ts(new)packages/auth/server/utils/useAuthApi.ts(added the member/invitation endpoints)packages/connect/server/api/v1/business/[id]/members.get.ts(rewritten)packages/connect/server/api/v1/business/[id]/invite.post.ts(rewritten — batch)packages/connect/server/api/v1/business/[id]/members/[memberId].{patch,delete}.ts(new)packages/connect/server/api/v1/business/[id]/invitations/[invitationId].delete.ts(new)packages/connect/app/components/connect/business/components/TeamMembers.vue(new, replacesInviteTeamMember.vue)packages/site/app/pages/app/business/business.json(team.*, 4 locales)
Gotchas
@breaks vue-i18n messages. An email placeholder likecolleague@example.comthrows "Invalid linked format (error code: 10)" and 500s the page —@is vue-i18n's linked-message operator. Escape it:colleague{'@'}example.com.- Role labels are a separate key set. The chip/select labels use
team.roleOwner/team.roleAdmin/team.roleMember; the olderrole_member/role_adminkeys are unrelated. A missing label renders the raw dotted key, which is easy to miss. - Only plain members are re-rolable. Owners and admins are rendered read-only so an existing admin never lands in a select whose options exclude
admin(which would render blank). - The Teams and Roles & permissions tabs from the reference design are not built — this plugin has no
teamsand no custom access control.
Verify
pnpm --filter @local-monorepo/site exec playwright test business-team.spec.ts --reporter=listThe spec seeds a member and two invitations, captures the roster and the invite modal, asserts admin is rejected by both the invite and role-change APIs (400) and is absent from the role filter, then removes the member.