x-studio-id.
Two layers of membership
The web app reads the first through
authClient and the auth server helpers, and the second through performApi at /coaches. They are linked by Coach.externalUserId, with email as a fallback. coaches/page.tsx uses both to find which coach row belongs to the organization owner.
Role mapping
resolveStudioContext converts the organization role before every domain call:
Role labels for the organization roles come from
useOrganizationMemberRoles() in modules/organizations/hooks/member-roles.ts (organizations.roles.*).
Checking roles in the web app
The web app hides or shows UI by role. Enforcement is the core API’s job.
What admins see that members do not:
- In the sidebar’s settings group: general, billing (when billing is attached to organizations), plans and pricing, notifications, integrations, AutoFit. Members see only team and branding there.
settings/integrationsandsettings/autofitreturnnotFound()for non admins.settings/generalshows the feature toggles, MBP settings, WhatsApp contact, SmartSend chat and the delete form only to admins.settings/membersshowsInviteMemberFormonly to admins.
Sub coach permissions
coaches/team-permissions.ts describes the per coach permission blob returned by GET /v1/web/coaches. The keys are trainees, plans, forms, content and subscriptions. elevatedPermissions(coach) returns the keys where a sub coach holds the elevated value:
Head coaches and owners bypass the blob, so their permission column stays empty. The team page only displays these. The core API applies them.
The active organization
There are two notions of “active” and they usually agree.- By URL. On a studio page,
params.organizationSlugdecides.ActiveOrganizationProviderqueries by slug. - By session. On account pages there is no slug, so the provider falls back to
session.activeOrganizationId.
setActiveOrganization(slug):
- Starts the progress bar.
authClient.organization.setActive({ organizationSlug }).authClient.updateUser({ lastActiveOrganizationId }).- Patches the cached session.
- Refetches the active organization and prefetches its purchases.
Organization selection
(account)/page.tsx decides where a signed in user lands on /:
- Exactly one studio: redirect straight to
/{slug}. - Several studios:
OrganizationsGridlists them andAccountStartGuidesits below. - None: the start guide only.
requireOrganizationis off, so nobody is forced to/new-organization.
CreateOrganizationForm (at /new-organization) uses useCreateOrganizationMutation: it asks the backend for a slug with orpcClient.organizations.generateSlug({ name }), then calls authClient.organization.create, then navigates to /{slug}.
Invitations
InviteMemberFormvalidatesemailandrole(member,owneroradmin, defaultmember) with Zod.OrganizationInvitationsListshows pending invitations.OrganizationMembersListchanges roles withauthClient.organization.updateMemberRoleand removes members withremoveMember./organization-invitation/[invitationId]loads the invitation on the server withgetInvitation(id). A missing invitation redirects to/. The page showsOrganizationInvitationModalwith accept and reject. Accept goes to/{organizationSlug}, reject to/./loginand/signupcarry?invitationId=through.LoginFormshowsOrganizationInvitationAlertand redirects to the invitation page after sign in./signuponly accepts an invitation that exists, ispendingand has not expired.
Team members
The team page (/{slug}/coaches) is the day to day way to add staff. Its actions in coaches/actions.ts call the core API, not better-auth directly:
Adding a member past the plan’s seat count is refused by the API with a plan limit reason.
TeamMemberDialog opens PlanLimitDialog from it. See Billing.
Organization settings components
Super admin
A user withrole === "admin" on the user record is a platform admin. They see /admin/*, count as organization admin everywhere through isOrganizationAdmin, and can impersonate studio owners.
Two helpers named adminApi exist:
The language switch in the user menu is separately gated by email.
canSwitchLanguage(email) in modules/shared/lib/language-switch.ts checks the comma separated config.languageSwitchEmails. See i18n.