@inovusmedical/org-switcher
v1.7.0
Published
Organisation switching, seat-based access gating, and role detection for the Totum ecosystem
Readme
@inovusmedical/org-switcher
Organisation switching, seat-based access gating, and role detection for the Totum ecosystem.
Installation
npm install @inovusmedical/org-switcherPeer Dependencies
The following must already be installed in your application:
react^18 or ^19react-dom^18 or ^19react-router-dom^6@supabase/supabase-js^2org.inovus.unified-auth^1lucide-react>=0.542.0
Quick Start
Simple org switcher (no seat gating)
import { OrgSwitcherProvider, OrgSwitcher } from '@inovusmedical/org-switcher';
import { AuthProvider } from 'org.inovus.unified-auth';
function App() {
return (
<AuthProvider>
<OrgSwitcherProvider config={{}}>
<Header>
<OrgSwitcher />
</Header>
<Routes>...</Routes>
</OrgSwitcherProvider>
</AuthProvider>
);
}Seat-gated app
Add the app's own Stripe product id to your .env — just the one:
VITE_PRODUCT_ID=prod_U4eNgRQiMHpluUProducts that include your app (e.g. a Totum Sim licence that also grants Totum Tasks) are not listed: SeatGate learns them from the verified verify-org-role response (product_inclusions claim), or from the licence builder's product_inclusions table in totum-subs, and accepts a seat on any of them. No Cognito group list is needed either; see Product inclusions and the migration notes.
Then wrap your app:
import { OrgSwitcherProvider, SeatGate, OrgSwitcher } from '@inovusmedical/org-switcher';
import { AuthProvider } from 'org.inovus.unified-auth';
function App() {
return (
<AuthProvider>
<OrgSwitcherProvider
config={{
productId: import.meta.env.VITE_PRODUCT_ID,
// requireSeatGroup: true, // optional layer-3 check: the seat's Cognito group must be on the token
}}
>
<SeatGate>
<Header>
<OrgSwitcher />
</Header>
<Routes>...</Routes>
</SeatGate>
</OrgSwitcherProvider>
</AuthProvider>
);
}API
OrgSwitcherProvider
The main context provider. All shared values (Totum API URL, Supabase connections) are built into the package — only app-specific config is needed.
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| productId | string | No | The app's own Stripe product id. Enables seat gating; including products come from the inclusion catalogue. A comma-separated list is still accepted as a deprecated fallback (every listed id counts as a seat; Admin rules use only the own product once a catalogue is known). |
| cognitoGroup | string | No | Deprecated. Cognito group name(s), comma-separated. Still enforced when set, but a seat that grants the app whose cognito_group_name is on the token satisfies it too (seatInfo.userHasAppGroup). Prefer requireSeatGroup. |
| requireSeatGroup | boolean | No | Layer-3 check without a list: the token must carry the cognito_group_name of one of the user's granting seats (default: false). |
| productInclusionsFromSubs | boolean | No | When the verified claims carry no product_inclusions, read the subs product_inclusions table as the signed-in user (default: true). |
| productInclusionsUrl | string | No | Override that table's PostgREST URL (default: <subscriptionsSupabaseUrl>/rest/v1/product_inclusions) |
| adminRoles | string[] | No | Matched against Cognito groups (global admin) and Totum roleName values (org admin). Totum ClientAdmin / SystemAdmin always count as org admins in addition to this list; see AGENT.md |
| localStorageKey | string | No | Key for persisting selected group (default: currentGroupGuid) |
| cacheTtlMs | number | No | Cache TTL for API responses (default: 10 minutes) |
| totumApiUrl | string | No | Override the Totum API URL |
| subscriptionsSupabaseUrl | string | No | Override the subscriptions Supabase URL |
| subscriptionsSupabaseAnonKey | string | No | Override the subscriptions Supabase anon key |
| userPrefsSupabaseUrl | string | No | Override the user prefs Supabase URL |
| userPrefsSupabaseAnonKey | string | No | Override the user prefs Supabase anon key |
| verifyOrgRole | boolean | No | Call the verify-org-role edge function when an org is selected/switched (default: true). Fail-soft. |
| verifyOrgRoleUrl | string | No | Override the edge function URL (default: <subscriptionsSupabaseUrl>/functions/v1/verify-org-role) |
| verifyOrgRoleTimeoutMs | number | No | Abandon a verify call after this long (default: 5000) |
| orgClaimsJwksUrl | string | No | JWKS used to verify the ES256 signed_claims token (default: <verifyOrgRoleUrl>/.well-known/jwks.json) |
| revalidateOnFocus | boolean | No | Re-check groups, roles, verification and seats in the background on window focus / tab visible (default: true; 1.5.0) |
| revalidateMinIntervalMs | number | No | Throttle for that re-check when nothing failed and the token is unchanged (default: 30000) |
Tokens, failures and retries (1.5.0)
Every request the package makes (Totum roles, verify-org-role, seat_assignments, user prefs) first obtains a fresh Cognito token — refreshed through unified-auth when the stored one is expired or within a minute of expiry — and, on a 401, refreshes once more and retries. A failed lookup is never shown as "no roles" / "no seats": the previous answer stays in place, the failure is exposed (useGroup().groupsError, seatInfo.error, orgRoleVerification.stale / error), and the package retries with backoff (1 s → 16 s), on window focus / visibility, when the token changes, and on useGroup().revalidateAccess(). Apps need no recovery code of their own (widgets-dashboard's useOrgAccessRecovery becomes redundant). See CHANGELOG.md.
Role layers
Three independent layers are all honoured:
- Cognito —
UserorAdmin(Admin is Inovus-only). Exposed asuseIsAdmin().isGlobalAdminviaadminRoles. - Totum role —
SystemAdmin,ClientAdminor a client user, fetched from the Totum API bytotumGroupService.ClientAdmin/SystemAdminalways makeuseIsAdmin().isGroupAdmin(andisTotumAdmin) true for that org. The Manager roles (ClientManager,SystemManager) are a separate, narrower scope exposed asuseIsAdmin().isOrgManager/canManageOrg— only from the signedverify-org-roleclaims (1.7.0), never from the role name. - App roles — a
seat_assignmentsrow plus the matchingcognito_groupsgroup. Any seat row counts as a seat forSeatGate, whatever itsrole_type(Member, Standard, User, Reviewer, …). Onlyrole_typeAdmin(case-insensitive) is an admin seat.
verify-org-role
Whenever an org is selected or switched (including the remembered org on first load), the provider POSTs the user's Cognito token (Authorization: Bearer, no body) to the verify-org-role Supabase edge function before reading seat_assignments. The function checks the user's Totum role for every org at once and caches verified ClientAdmin/SystemAdmin rows that row-level security relies on. It answers 200 { system_admin, admin_orgs, expires_at }, or { error } with 401 (bad token), 502 (Totum API unavailable) or 500 (config).
Because one call covers all orgs, the result is cached per token until expires_at (falling back to cacheTtlMs); switching orgs only re-calls the function when the cache is missing or expired, so there is no per-switch delay.
The call is non-blocking and fail-soft: timeouts, network errors and non-2xx responses are logged with console.warn and the app continues. Login and org switching never depend on it. The last result is exposed as useGroup().orgRoleVerification.
Verified seats and signed org claims (1.2.0+)
Newer versions of the edge function also return signed_claims: a short-lived JWT signed with ES256 (kid in the header) carrying iss: "verify-org-role", sub (the user's Cognito sub), system_admin, admin_orgs, seats ({ group_guid, stripe_product_id, role_type, is_admin, subscription_status, subscription_active }[] for every seat the user holds, across all orgs), iat and exp — plus, from claims_version: 2, system_manager and manager_orgs (see "Verified Manager role" below). The function publishes its public keys at <verifyOrgRoleUrl>/.well-known/jwks.json (default project nalmtqrfthdqjvsawzwk; override with orgClaimsJwksUrl).
Every check happens inside this package; there is no per-app secret. Before anything from the token is exposed, the client verifies it with verifyOrgClaims — the same function the server entry uses — checking the ES256 signature against the JWKS (fetched once, cached by kid, refetched once on an unknown kid so key rotation needs no reload), iss, exp (60 s clock skew) and that sub matches the signed-in user. Only a token that passes is exposed as orgRoleVerification.signedClaims / getSignedOrgClaims(), and seats always come from the verified token (a plain seats array in the body is never trusted). A rejected token leaves seats: [], signedClaims: null and sets claimsStatus: 'failed' with a claimsError; the verification itself stays verified (admin flags still apply) and a console.warn explains why.
All of this is fail-soft: an older function that omits signed_claims still verifies normally (seats: [], claimsStatus: 'missing'). The token is cached with the per-token verification cache; the entry expires at the earlier of expires_at and the claims' exp, so an expired token is never handed out. Seat role_type is free-form; only Admin (any case) is an admin seat (is_admin is derived from role_type).
import { useVerifiedSeats, getSignedOrgClaims, getVerifiedOrgClaims, createOrgClaimsSupabaseClient } from '@inovusmedical/org-switcher';
// Seats for the current org from the *verified* token (scoped to a product, or to the configured productId list).
const { hasSeat, hasActiveSeat, hasAdminSeat, seats, claimsStatus } = useVerifiedSeats('prod_ABC');
// Latest verified token / claim set, or null when nothing fresh is cached (sync, no network).
const token = getSignedOrgClaims();
const claims = getVerifiedOrgClaims();
// A Supabase client for the app's own project that attaches `x-org-role-claims`
// (and the Cognito bearer token) to every request, refreshing the token as it expires.
// Verify it on the receiving side with `@inovusmedical/org-switcher/server`.
export const appSupabase = createOrgClaimsSupabaseClient(APP_SUPABASE_URL, APP_SUPABASE_ANON_KEY);SeatGate is unchanged: it still decides from seat_assignments rows and Cognito groups, not from signed_claims.
Verified Manager role (1.7.0+, claims_version: 2)
totum-subs verify-org-role now also signs the user's Totum Manager scope, as an additive v2 of the signed_claims contract:
| Claim | Type | Absent (v1) | Meaning |
|-------|------|-------------|---------|
| claims_version | number | 1 | Contract version. 2 once the manager fields are signed. |
| system_manager | boolean | false | The user holds a system-level SystemManager assignment → manager of every org. |
| manager_orgs | string[] | [] | group_guids where the user is a ClientManager, or holds a group-level SystemManager assignment. |
The scope is derived by the signer from the authoritative Totum role assignment × instance; this package never infers it from a role name, never reads it from the unsigned response body, and never folds managers into system_admin / admin_orgs. verifyOrgClaims normalises every token to one shape — a v1 token yields claims_version: 1, system_manager: false, manager_orgs: [] — and honours the manager fields only when claims_version >= 2, so an unsigned-contract field grants nothing. Every v1 claim keeps its name, type and derivation.
import { useIsAdmin, useGroup, getVerifiedOrgClaims, isOrgManager, canManageOrg } from '@inovusmedical/org-switcher';
const { canManage, isOrgManager: manager, canManageOrg: canEdit } = useIsAdmin();
// canManage — unchanged: Cognito Admin / Totum ClientAdmin / SystemAdmin (licences, seats, billing, AdminRoute)
// isOrgManager — the *verified* v2 token names the current org in manager_orgs, or carries system_manager
// canManageOrg — canManage || isOrgManager: the common content-management predicate for Tasks / widgets
const { systemManager, managerOrgs, claimsVersion } = useGroup().orgRoleVerification; // verified token only
// Non-hook: the same predicates over the verified claim set (null when nothing fresh is cached).
const claims = getVerifiedOrgClaims();
const ok = claims ? canManageOrg(claims, orgId) : false;Fail-closed: isOrgManager is false while nothing is verified, against a v1 / missing / expired / tampered / wrong-key / wrong-user token, for orgs not in manager_orgs, and for an admin who holds no manager scope. It is recomputed from the cached verification on every org switch. Managers gain nothing else: isGroupAdmin, isTotumAdmin, canManage, AdminRoute, SeatGate, useVerifiedSeats().hasAdminSeat, isOrgAdmin and isAppAdmin are unchanged.
Server-side seat check (@inovusmedical/org-switcher/server)
A React-free entry point for backends that receive x-org-role-claims: Supabase edge functions (Deno), Cloudflare Workers, Node 18+, Bun. Its only dependency is jose (Web Crypto). It verifies the token against the public JWKS — no shared secret to distribute — and gives you typed claims plus seat/admin predicates.
import {
verifyOrgClaims, // (token, { jwksUrl?, expectedSub?, clockToleranceSec?, fetch?, requestInit? }?) => Promise<OrgClaims>
getOrgClaimsFromRequest, // (req, options?) => Promise<OrgClaims> — reads x-org-role-claims; throws OrgClaimsError
hasSeat, // (claims, appKey, seatType?, { orgId?, includeInactive?, inclusions? }?) => boolean
isOrgAdmin, // (claims, orgId) => boolean — Totum ClientAdmin of that org or SystemAdmin; no seat, no subscription involved; managers do not count
isOrgManager, // (claims, orgId) => boolean — Totum ClientManager of that org or system-level SystemManager (verified v2 claims); admins do not count (1.7.0)
canManageOrg, // (claims, orgId) => boolean — isOrgAdmin || isOrgManager: the shared content-management predicate (1.7.0)
isAppAdmin, // (claims, appKey, { orgId? }?) => boolean — an "Admin" seat for that product only whose subscription still grants admin
getAppSeats, // (claims, appKey, { orgId?, inclusions? }?) => VerifiedSeat[]
resolveAppProducts, ownAppProducts, fetchProductInclusions, parseProductInclusions, // product inclusions (1.4.0, below)
isAdminSeatSubscriptionActive, // (seat) => boolean — the subscription rule behind isAppAdmin
isAdminGrantingSeat, // (seat) => boolean — Admin role_type AND isAdminSeatSubscriptionActive
ADMIN_SEAT_SUBSCRIPTION_STATUSES, // ['active', 'trialing', 'paused', 'past_due']
OrgClaimsError, // .code: 'missing' | 'malformed' | 'unsupported_alg' | 'jwks_unavailable' | 'unknown_kid'
// | 'invalid_signature' | 'expired' | 'not_yet_valid' | 'invalid_issuer' | 'invalid_subject' | 'invalid_claims'
TOTUM_ORG_CLAIMS_HEADER, // 'x-org-role-claims'
DEFAULT_ORG_CLAIMS_JWKS_URL, buildOrgClaimsJwksUrl,
getOrgMemberSeats, membersHoldSeat, memberHoldsSeat, OrgMemberSeatsError, // other members' seats, read from totum-subs as the caller (1.3.0+, below)
} from '@inovusmedical/org-switcher/server';appKeyis the app's Stripe product id (or a list of them), matched exactly againststripe_product_id— widened (1.4.0) to every product whose seat includes the app, from the token'sproduct_inclusionsclaim or an explicit catalogue (see "Product inclusions" below). Admin checks use only the app's own product.hasSeatcounts any seat row whatever its free-formrole_type(passseatTypeto require one, matched case-insensitively — the package never hard-codes seat type names other thanAdmin). Only seats withsubscription_activecount unlessincludeInactive: true.isAppAdminmatchesrole_type === "Admin"(any case) for that product only, and since 1.2.1 the seat's subscription must still grant admin rights. The rule (isAdminSeatSubscriptionActive) reads the seat'ssubscription_status/subscription_activefrom the verified claims:subscription_status∈ADMIN_SEAT_SUBSCRIPTION_STATUSES=active,trialing,paused,past_due(case-insensitive) → admin, even when the server sayssubscription_active: false, so admins can still manage a paused, trialling or past-due subscription.past_dueis deliberately included: Stripe keeps the subscription in service while it retries payment.- any other status —
canceled/cancelled,expired,incomplete,incomplete_expired,unpaid,endedor anything unknown — → not admin, whateversubscription_activesays. subscription_status: null(older tokens) → falls back tosubscription_active.
isOrgAdminis the separate Totum layer (admin_orgs/system_admin): it needs no seat and never looks at subscriptions. Combine them as your app needs:isOrgAdmin(claims, orgId) || isAppAdmin(claims, PRODUCT_ID, { orgId }).isOrgManager(1.7.0) reads only the verified v2system_manager/manager_orgsclaims (false for any v1 token, and for admins without manager scope);canManageOrg = isOrgAdmin || isOrgManager. UsecanManageOrgfor content management and keepisOrgAdmin/isAppAdminfor licence, seat and billing administration — managers never satisfy those.claims.claims_versiontells you which contract the token was signed under.- Pass
expectedSub(the Cognitosubfrom the request's own bearer token) so a token cannot be replayed for another user.
Supabase edge function (Deno):
// supabase/functions/cases/index.ts
import { getOrgClaimsFromRequest, hasSeat, isAppAdmin, isOrgAdmin, canManageOrg, OrgClaimsError } from 'npm:@inovusmedical/org-switcher/server';
import { decodeJwt } from 'npm:jose';
const PRODUCT_ID = Deno.env.get('APP_PRODUCT_ID')!;
Deno.serve(async (req) => {
// The user's own Cognito token (verify it as you already do); its `sub` must match the claims.
const cognitoSub = decodeJwt(req.headers.get('Authorization')!.slice(7)).sub;
const orgId = new URL(req.url).searchParams.get('org');
let claims;
try {
claims = await getOrgClaimsFromRequest(req, { expectedSub: cognitoSub });
} catch (e) {
const code = e instanceof OrgClaimsError ? e.code : 'error';
return Response.json({ error: `org claims ${code}` }, { status: 401 });
}
if (!hasSeat(claims, PRODUCT_ID, undefined, { orgId })) {
return Response.json({ error: 'No seat for this org' }, { status: 403 });
}
// Licence / seat / billing administration: admins only (Totum or app Admin seat).
const canAdminister = isOrgAdmin(claims, orgId) || isAppAdmin(claims, PRODUCT_ID, { orgId });
// Day-to-day content management: Totum admin OR verified manager of this org (1.7.0).
const canManage = canManageOrg(claims, orgId);
// ... use claims.sub, orgId, canAdminister and canManage with the service-role client
return Response.json({ ok: true, canAdminister, canManage });
});Cloudflare Worker:
import { getOrgClaimsFromRequest, hasSeat, isAppAdmin, OrgClaimsError } from '@inovusmedical/org-switcher/server';
export default {
async fetch(req: Request, env: { APP_PRODUCT_ID: string; ORG_CLAIMS_JWKS_URL?: string }) {
let claims;
try {
claims = await getOrgClaimsFromRequest(req, {
jwksUrl: env.ORG_CLAIMS_JWKS_URL, // optional: defaults to the Totum subscriptions project
expectedSub: await resolveCognitoSub(req), // your existing bearer-token check
});
} catch (e) {
return new Response(e instanceof OrgClaimsError ? e.message : 'Unauthorized', { status: 401 });
}
const orgId = req.headers.get('x-org-id');
if (!hasSeat(claims, env.APP_PRODUCT_ID, 'Reviewer', { orgId })) return new Response('Forbidden', { status: 403 });
if (new URL(req.url).pathname.startsWith('/admin') && !isAppAdmin(claims, env.APP_PRODUCT_ID, { orgId })) {
return new Response('Admins only', { status: 403 });
}
return Response.json({ user: claims.sub });
},
};The JWKS is fetched lazily on the first verification in each isolate/process and cached in memory by kid; an unknown kid triggers one refetch (rate-limited to once per 30 s) so key rotation on totum-subs needs no redeploy. Pass fetch / requestInit to customise the JWKS request, clockToleranceSec to widen the default 60 s skew, and clearOrgClaimsKeyCache() to drop cached keys. The CommonJS build (dist/server.cjs) requires Node ≥ 20.19 / 22.12 because jose v6 is ESM-only; ESM consumers have no such constraint.
Seats of other org members (1.3.0+) — getOrgMemberSeats / membersHoldSeat
x-org-role-claims only describes the caller. When a backend must check that other members hold a seat — e.g. Tasks' curriculum assign checking every assignee — use these helpers instead of talking to totum-subs yourself. Apps never hard-code the subs URL or key: the package carries the nalmtqrfthdqjvsawzwk project URL and its public anon key (DEFAULT_SUBSCRIPTIONS_SUPABASE_URL / DEFAULT_SUBSCRIPTIONS_SUPABASE_ANON_KEY), overridable per call for staging.
import { getOrgMemberSeats, membersHoldSeat, memberHoldsSeat, OrgMemberSeatsError } from '@inovusmedical/org-switcher/server';
// The caller's own Cognito access token (from the incoming Authorization header) — never a service-role key.
const callerToken = req.headers.get('Authorization')!.slice(7);
const check = await membersHoldSeat({
orgId, // Totum group_guid
userIds: assigneeSubs, // Cognito subs of the members to check
appKey: APP_PRODUCT_ID, // Stripe product id(s); omit for any product
callerToken,
// seatType?: 'Reviewer' // free-form role_type (case-insensitive); only 'Admin' is special
// includeInactive?: boolean // also count seats whose subscription is not active
// inclusions?: 'subs' (default) | ProductInclusions | resolver | false // products that include the app also count (1.4.0, below)
// subscriptionsSupabaseUrl?, subscriptionsSupabaseAnonKey?, verifyOrgRoleUrl?, seatAssignmentsUrl?, productInclusionsUrl?, fetch?, timeoutMs?, signal?
});
if (!check.all) {
// check.missing: ids without a seat *visible to the caller*; check.holders / check.byUser for the rest
const reason = check.caller.orgAdmin ? 'no seat' : 'not visible to a non-admin caller';
return Response.json({ error: `Assignees without a seat (${reason})`, missing: check.missing }, { status: 403 });
}
// Lower level: the normalised rows themselves (+ seatsByUser, caller) — same shape as VerifiedSeat plus user_guid / user_name / user_email / row.
const { seats, seatsByUser, caller } = await getOrgMemberSeats({ orgId, userIds: assigneeSubs, appKey: APP_PRODUCT_ID, callerToken });
memberHoldsSeat(seats, assigneeSubs[0], { appKey: APP_PRODUCT_ID, seatType: 'Admin' }); // pure, reusable on cached dataHow it works — the same two requests the client org switcher makes when it loads an org's seats, with the caller's token as the bearer:
POST <subs>/functions/v1/verify-org-role(fail-soft) so totum-subs' RLS sees the caller's verified Totum role. The answer is reported ascaller: { sub, verification: 'verified' | 'failed' | 'skipped', systemAdmin, orgAdmin, error }; skip it withwarmRoleCache: false.GET <subs>/rest/v1/seat_assignments?select=*&group_guid=eq.<orgId>&user_guid=in.(…)[&stripe_product_id=in.(…)]withapikey: <anon key>+Authorization: Bearer <callerToken>. LonguserIdslists are split into requests of 50.
RLS decides what is visible: a Totum ClientAdmin of the org or a SystemAdmin sees the org's seats; a regular member only their own. So a member in missing either has no seat or is not visible to this caller — read caller.orgAdmin to tell the two apart. There is no service-role key, no shared secret and no new subs endpoint.
Rules (shared with hasSeat / isAppAdmin): any role_type counts unless seatType narrows it (case-insensitive; no seat type names other than Admin are hard-coded); seatType: 'Admin' applies the admin-granting subscription rule (active / trialing / paused / past_due); only subscription_active seats count unless includeInactive. seat_assignments rows carry no subscription_status / subscription_active today, so those fields are null / true (a row exists only while the seat is assigned — the rule SeatGate already applies); if totum-subs adds them to the read, the status-aware rules take effect automatically.
Errors are thrown as OrgMemberSeatsError with code: 'invalid_options' | 'unauthorized' | 'http_error' | 'invalid_response' | 'timeout' | 'aborted' | 'network' (plus status / body). The helpers are also exported from the main entry.
Product inclusions (1.4.0) — a seat on a product that includes the app
Some licences grant more than their own app: a Totum Sim seat also grants Totum Tasks, without an individual Tasks seat. Every "does this user hold a seat for this app" decision in the package goes through one resolver, resolveAppProducts(appKey, inclusions): the app's own product id(s) plus every product the inclusion catalogue says includes them (transitively — a bundle that includes Sim includes Tasks). Used by SeatGate (via seatInfo.appProducts), hasSeat / getAppSeats, memberHoldsSeat / membersHoldSeat / getOrgMemberSeats, selectVerifiedSeats / useVerifiedSeats and subscriptionService.getProductSeatAssignments.
No product mapping is hard-coded and no env list is required. The catalogue is a ProductInclusions map — { [stripe_product_id]: included_product_id[] }, e.g. { prod_SIM: ['prod_TASKS'] } — maintained in totum-subs' licence builder and read from data:
| Source | Where it applies | How |
|--------|------------------|-----|
| product_inclusions claim in the verified signed_claims token | SeatGate / seatInfo, useVerifiedSeats, hasSeat / getAppSeats (server and browser) | automatic (OrgClaims.product_inclusions); preferred source |
| totum-subs product_inclusions table, read as the caller | SeatGate / seatInfo when the claims carry none (productInclusionsFromSubs, default on); getOrgMemberSeats / membersHoldSeat by default (inclusions: 'subs'); fetchProductInclusions({ callerToken }) anywhere | GET <subs>/rest/v1/product_inclusions?select=stripe_product_id,included_product_id with the anon apikey + the caller's bearer, cached 5 min per URL; fail-soft |
| an explicit map | any predicate: hasSeat(claims, APP, undefined, { inclusions }), membersHoldSeat({ …, inclusions }), memberHoldsSeat(seats, id, { inclusions }) | for tests or a custom source; inclusions: null / false forces the exact product |
seatInfo tells you what the gate decided with: appProducts (the resolved product list), productInclusions, inclusionsSource: 'claims' | 'subs' | 'none'.
import { membersHoldSeat, hasSeat, fetchProductInclusions, resolveAppProducts, ownAppProducts } from '@inovusmedical/org-switcher/server';
// Tasks' assign check: Sim / bundle holders count as Tasks seat holders — the catalogue is read from subs by default.
const check = await membersHoldSeat({ orgId, userIds: assigneeSubs, appKey: TASKS_PRODUCT_ID, callerToken });
check.inclusions; // { source: 'none' | 'option' | 'subs' | 'resolver', map, products, error } — which products counted and why
// The caller's own seat: the product_inclusions claim is applied automatically; override or disable per call.
hasSeat(claims, TASKS_PRODUCT_ID, undefined, { orgId }); // claim-driven
hasSeat(claims, TASKS_PRODUCT_ID, undefined, { orgId, inclusions: null }); // exact product only
hasSeat(claims, TASKS_PRODUCT_ID, undefined, { orgId, inclusions: await fetchProductInclusions({ callerToken }) });
resolveAppProducts('prod_TASKS', { prod_SIM: ['prod_TASKS'], prod_BUNDLE: ['prod_SIM'] }); // ['prod_TASKS', 'prod_SIM', 'prod_BUNDLE']
ownAppProducts(['prod_TASKS', 'prod_SIM'], { prod_SIM: ['prod_TASKS'] }); // ['prod_TASKS'] — the Admin scopeInside getOrgMemberSeats the widened list also goes into the PostgREST stripe_product_id=in.(…) filter, so the including products' rows are actually fetched. When no catalogue is available (no claim, table not deployed yet → 404, reported on result.inclusions.error / seatInfo.inclusionsSource: 'none') every predicate falls back to the exact stripe_product_id match.
Admin stays per-product — behaviour change in 1.4.0. isAppAdmin, hasSeat(…, 'Admin'), memberHoldsSeat / membersHoldSeat with seatType: 'Admin', useVerifiedSeats().adminSeats / hasAdminSeat and subscriptionService.getAdminSeats only look at the app's own product: a Sim Admin seat is a Sim admin, not a Tasks admin. This now holds even when the app passes several product ids (appKey: [TASKS, SIM], or the deprecated multi-value productId): with a catalogue, ownAppProducts drops every listed product that merely includes another listed one before the Admin seats are looked up. Before 1.4.0 such a list made a Sim Admin an app admin. Without any catalogue the list is still taken at face value. Seat checks (hasSeat, SeatGate) are not affected — a Sim seat still is a Tasks seat.
Layer 3 (seat + app Cognito group) without a list. Each seat_assignments row records the cognito_group_name the licence builder put the holder in. seatInfo.userHasAppGroup is true when one of the user's granting seats has that group on the token. SeatGate enforces it with requireSeatGroup: true, and accepts it in place of the deprecated cognitoGroup list, so an included-app holder passes a Tasks gate that still lists only Tasks' groups. For included apps no group check is strictly needed beyond this: the Sim holder is in Sim's group, which is exactly what the row says — there is no Tasks group to require.
What totum-subs provides (being added alongside this release): the licence builder's "also grants access to" field persisted as public.product_inclusions (stripe_product_id text, included_product_id text, primary key (stripe_product_id, included_product_id)), readable by signed-in users, and the same catalogue in verify-org-role's body and signed_claims payload as product_inclusions: { [stripe_product_id]: included_product_id[] }. verifyOrgClaims parses it leniently (parseProductInclusions; an array of { stripe_product_id, included_product_id } rows is accepted too).
Migrating from env lists (1.3.0 and earlier)
Before 1.4.0 an app that could be reached through other licences had to enumerate every granting product and every Cognito group in its env (Tasks listed seven product ids and seven prod_…_standard groups), and its backend had to pass the same list to membersHoldSeat. Now:
| Before | After |
|--------|-------|
| productId: 'prod_TASKS,prod_SIM,prod_BUNDLE,…' | productId: 'prod_TASKS' — the app's own product only; Sim and the bundle come from the catalogue |
| cognitoGroup: 'prod_tasks_standard,prod_sim_standard,…' | drop it; use requireSeatGroup: true if you want the layer-3 group check (no list) |
| membersHoldSeat({ appKey: [TASKS, SIM, …] }) / hasSeat(claims, [TASKS, SIM, …]) | appKey: TASKS_PRODUCT_ID — inclusions are applied from the claim / subs by default |
| isAppAdmin(claims, [TASKS, SIM, …]) treated a Sim Admin as a Tasks admin | isAppAdmin(claims, TASKS_PRODUCT_ID); with a catalogue the old list no longer grants admin via Sim either (see above) |
| useVerifiedSeats() with the configured list | unchanged call; inclusions applied, adminSeats on the own product |
Nothing breaks if you do not migrate: the multi-value productId, the cognitoGroup list and multi-id appKeys keep working as deprecated fallbacks (the gate also recognises a seat whose own group is on the token, so holders of newly included products pass without the list being extended). The only observable difference is the Admin rule above. Both lists will be removed in a later major version.
Hooks
useGroup()
const {
currentGroup, // GroupMembership | null
groups, // GroupMembership[]
isLoading, // boolean
hasFetchedOnce, // boolean
switchGroup, // (groupGuid: string) => void
refreshGroups, // () => Promise<void>
defaultGroupGuid, // string | null — user's persisted default org
setDefaultGroup, // (groupGuid: string) => Promise<void>
seatInfo, // GroupSeatInfo
refreshSeatInfo, // () => Promise<void>
orgRoleVerification, // OrgRoleVerification — last verify-org-role outcome
groupsError, // string | null — why the last group/role load failed (previous groups kept) (1.5.0)
isRevalidating, // boolean — a background re-check is running or a retry is scheduled (1.5.0)
revalidateAccess, // () => Promise<void> — re-check everything now, keeping current answers (1.5.0)
} = useGroup();GroupMembership:
{
id: string;
group_guid: string;
group_name: string;
totumRoles: { roleId: string; roleName: string }[];
}GroupSeatInfo:
{
groupHasSubscription: boolean; // does the org have seats for the product?
userHasSeat: boolean; // does the current user have a seat?
userSeats: SeatAssignment[]; // the user's seats in this org
groupSeats: SeatAssignment[]; // all seats for this org
isFetchingSeats: boolean;
appProducts: string[]; // own product(s) + products that include them (1.4.0)
productInclusions: ProductInclusions | null;
inclusionsSource: 'claims' | 'subs' | 'none';
userHasAppGroup: boolean;
groupGuid: string | null; // the org these seats belong to (1.5.0)
error: string | null; // why the last seat read failed; previous answer kept, retry scheduled (1.5.0)
}OrgRoleVerification:
{
status: 'idle' | 'pending' | 'verified' | 'failed' | 'skipped';
systemAdmin: boolean; // Totum SystemAdmin (admin of every org)
adminOrgs: string[]; // group_guids where the user is ClientAdmin
systemManager: boolean; // system-level Totum SystemManager, from the *verified* v2 signed_claims only (1.7.0)
managerOrgs: string[]; // group_guids where the user is ClientManager / group-level SystemManager, verified v2 claims only (1.7.0)
claimsVersion: number | null; // claims_version of the verified token (1 or 2), null without one (1.7.0)
seats: VerifiedSeat[]; // seats across every org, from the *verified* signed_claims only ([] when missing/rejected)
signedClaims: string | null; // verified ES256 JWT for the x-org-role-claims header (null when absent/expired/rejected)
signedClaimsExpiresAt: string | null; // ISO expiry from the verified exp
claims: OrgClaims | null; // the verified claim set { iss, sub, claims_version, system_admin, admin_orgs, system_manager, manager_orgs, seats, product_inclusions?, iat, exp }
claimsStatus: 'missing' | 'verified' | 'failed';
claimsError: string | null; // why the token was rejected, when claimsStatus is 'failed'
expiresAt: string | null; // server-provided cache expiry
fromCache: boolean; // served from memory, no network call
httpStatus: number | null; // 200 / 401 / 502 / 500 when a response was received
error: string | null; // reason when failed/skipped (or why the last re-verification failed, when stale)
stale: boolean; // verified answer kept after a failed re-verification (1.5.0)
isRevalidating: boolean; // re-verifying behind a verified answer, no 'pending' flicker (1.5.0)
}VerifiedSeat:
{
group_guid: string;
stripe_product_id: string;
role_type: string; // free-form (Admin, Standard, Reviewer, …)
is_admin: boolean; // true only when role_type is "Admin" (any case)
subscription_status: string | null; // Stripe status: "active", "trialing", "paused", "past_due", "canceled", "unpaid", … or null
subscription_active: boolean; // server's verdict for regular access (hasSeat / activeSeats)
}useVerifiedSeats(productId?)
const {
status, // mirrors orgRoleVerification.status
isVerifying, // status === 'pending'
claimsStatus, // 'missing' | 'verified' | 'failed' — whether the signed token behind `seats` verified
claimsError, // reason when claimsStatus is 'failed'
seats, // VerifiedSeat[] for the current org (+ product scope), from the verified token only
activeSeats, // seats with subscription_active
adminSeats, // role_type "Admin" (any case) AND subscription active / trialing / paused / past_due (same rule as isAppAdmin)
hasSeat, // seats.length > 0 — any role type counts
hasActiveSeat,
hasAdminSeat, // adminSeats.length > 0 — a cancelled / expired / unpaid Admin seat does not count
allSeats, // every verified seat, all orgs, unfiltered
} = useVerifiedSeats('prod_ABC'); // omit productId to use the configured productId list (or every product when none)Everything is empty until a verification succeeds and the signed_claims token passes verifyOrgClaims (claimsStatus === 'verified'); it stays empty with an older edge function or a rejected token. The org guid is matched case-insensitively; product ids exactly — plus, when the token carries a product_inclusions claim, seats on products that include the app (seats / activeSeats / hasSeat / hasActiveSeat; adminSeats / hasAdminSeat stay on the exact product — see "Product inclusions").
getSignedOrgClaims() / ensureSignedOrgClaims() / createOrgClaimsSupabaseClient()
import {
getSignedOrgClaims, // (): string | null — sync, cached, verified token or null when nothing fresh
getVerifiedOrgClaims, // (): OrgClaims | null — the verified claim set behind it
ensureSignedOrgClaims, // ({ force? }?): Promise<string | null> — re-verifies when stale
getOrgClaimsHeaders, // (): Promise<{ 'x-org-role-claims': string } | {}>
withOrgClaimsFetch, // ({ cognitoBearer?, fetch? }?): typeof fetch
createOrgClaimsSupabaseClient, // (url, anonKey, { cognitoBearer?, fetch?, supabase? }?) => SupabaseClient
TOTUM_ORG_CLAIMS_HEADER, // 'x-org-role-claims'
} from '@inovusmedical/org-switcher';
// Recommended: one client per app, created at module scope.
export const appSupabase = createOrgClaimsSupabaseClient(
import.meta.env.VITE_APP_SUPABASE_URL,
import.meta.env.VITE_APP_SUPABASE_ANON_KEY,
// { cognitoBearer: false } if the app signs in with Supabase Auth instead of the Cognito token
);
// Manual alternative for a raw fetch to an edge function:
await fetch(url, { headers: { ...(await getOrgClaimsHeaders()), apikey: ANON_KEY } });The wrapper reads the token from the per-token cache and only calls verify-org-role when nothing fresh is cached, so it adds no round-trip to normal requests. When no verified token is available (older function, verifyOrgRole: false, failed or rejected verification, logged out) the request is sent without the header and the receiving side decides (typically getOrgClaimsFromRequest throwing missing → 401).
useIsAdmin()
const {
isGlobalAdmin, // boolean — Cognito groups (Inovus Admin, LMS admin group, …)
isGroupAdmin, // boolean — Totum ClientAdmin/SystemAdmin or adminRoles match on currentGroup
isTotumAdmin, // boolean — ClientAdmin/SystemAdmin only (Totum API or verify-org-role)
canManage, // boolean — isGlobalAdmin || isGroupAdmin (admin authority: licences, seats, billing, AdminRoute)
isOrgManager, // boolean — verified v2 signed_claims name the current org in manager_orgs, or carry system_manager (1.7.0)
canManageOrg, // boolean — canManage || isOrgManager (content management; grants no seat / billing rights) (1.7.0)
} = useIsAdmin();isOrgManager is never derived from Totum role names or from the unsigned verify-org-role body: only the signed token the package verified counts, and it is re-evaluated against the current org on every switch. A manager-only user has canManage: false, so AdminRoute and every licence / seat / billing affordance stay admin-only.
Components
SeatGate
Blocks the app until the user holds a seat for it in the current org. Automatically disabled when neither productId nor cognitoGroup are configured.
The decision is data-driven (1.4.0): with productId set to the app's own product, the gate requires a seat_assignments row for the current org on that product or on any product that includes it (seatInfo.appProducts, from the verified product_inclusions claim, else the subs product_inclusions table). Optional layer-3 check: requireSeatGroup: true also demands that the granting seat's cognito_group_name is on the token (seatInfo.userHasAppGroup). The deprecated cognitoGroup list is still enforced when set, but that seat-group match satisfies it. Verified seats claims never replace the row read: seat presence always comes from seat_assignments. See Product inclusions.
<SeatGate
loadingComponent={<YourSpinner />}
noAccessComponent={<YourCustomScreen />}
>
<Routes>...</Routes>
</SeatGate>The built-in no-access screen includes the org switcher so users can switch to an org with valid seats.
OrgSwitcher
Dropdown for switching between organisations. Shows role badges with hierarchy sorting.
<OrgSwitcher
variant="header" // "header" | "sidebar" | "minimal"
dropdownAlign="right" // "right" | "left"
searchThreshold={8} // show search when list exceeds this count
maxDropdownHeight="18rem"
onBeforeSwitch={(fromGuid, toGuid) => true}
onAfterSwitch={(group) => navigate('/dashboard')}
/>When only one org exists, it renders as a static label that inherits the parent's text colour.
AdminRoute
Protects admin-only routes.
<Route path="/admin/*" element={
<AdminRoute redirectTo="/dashboard">
<AdminPage />
</AdminRoute>
} />NoAccessScreen
Standalone screen shown when access is denied. Includes the org switcher and a sign-out button.
Sign Out (1.6.0) calls signOut(): in one click it clears this package's caches and the local unified-auth session, ends the Totum ID session (unified-auth authService.logoutEverywhere() → the ID app's /api/logout) and lands the user on the sign-in page — no reload needed. Before 1.6.0 the button only called authService.logout(), which wipes the per-tab tokens but never navigates or reaches the Totum ID app, so nothing visibly happened until a refresh (and a surviving refresh-token cookie could sign the user straight back in). Pass onLogout (sync or async) to replace the default.
<NoAccessScreen
title="Access Unavailable"
message="Contact your organisation administrator."
onLogout={async () => {
await saveDraft();
signOut();
}}
/>signOut() (1.6.0)
The same one-click sign-out, for the app's own menus and buttons:
import { signOut } from '@inovusmedical/org-switcher';
<button onClick={() => signOut()}>Sign out</button>clearOrgSwitcherState()— drops the package's per-user caches (Totum groups / roles,verify-org-rolememo, token tracking).authService.logout(forceCookieRemoval?)— per-tab tokens and, ontotum.surgeryhosts, the current tenant cookie (newer unified-auth builds also broadcast the logout to the app's other tabs).- cookie mode (deployed apps):
authService.logoutEverywhere()— the Totum ID app ends the Cognito-backed session, clears the auth cookies on every Totum domain and shows the sign-in page. local mode (localhost):window.location.assign(localLoginPath ?? '/login'), like unified-auth's own local-mode logout.
Options: mode (override the detected authService.authMode), localLoginPath, forceCookieRemoval. Returns { mode, redirect } where redirect is 'id-app-logout', 'local-login', 'main-app-login' (a unified-auth build without logoutEverywhere() — only the tenant login page could be reached, with a console.warn) or 'none' (no window). Synchronous: the navigation has started when it returns.
useAuth().logout()from unified-auth clears the local session and goes to the tenant's/login, but does not end the Totum ID session either — prefersignOut()for a sign-out that sticks across Totum apps.
SwitchWarningModal
Confirmation modal for organisation switching.
<SwitchWarningModal
isOpen={showWarning}
onConfirm={handleConfirm}
onCancel={handleCancel}
title="Switch Organisation?"
message="Any unsaved changes may be lost."
/>Services
subscriptionService
const seats = await subscriptionService.getUserSeatAssignments(userGuid);
const groupSeats = await subscriptionService.getGroupSeatAssignments(groupGuid);
const productSeats = subscriptionService.getProductSeatAssignments(seats); // configured productId list, exact
const bundled = subscriptionService.getProductSeatAssignments(seats, seatInfo.productInclusions); // + products that include them (1.4.0)
const ownOnly = subscriptionService.getOwnProductSeatAssignments(seats, seatInfo.productInclusions); // the app's own product(s) only
const adminSeats = subscriptionService.getAdminSeats(seats, seatInfo.productInclusions); // role_type "Admin" (case-insensitive) on the own product(s)
const otherSeats = subscriptionService.getNonAdminSeats(seats); // every other role_type
const memberSeats = subscriptionService.getMemberSeats(seats); // deprecated alias of getNonAdminSeatstotumGroupService
const groups = await totumGroupService.loadUserGroups(); // strict (1.5.0): throws TotumGroupsError on any failed call
const legacy = await totumGroupService.getUserGroups(); // fail-soft: [] on failure (cannot tell "failed" from "none")
const user = await totumGroupService.fetchCurrentUser();
totumGroupService.clearCache();Every call carries a fresh token (getFreshAccessToken()), refreshed when expired or about to expire, and is retried once after a refresh on a 401. The strict load* methods (loadUserGroups, subscriptionService.loadGroupSeatAssignments / loadUserSeatAssignments) throw TotumGroupsError / SeatLookupError so a failed lookup is never mistaken for an empty answer; the older getters keep returning []. The same plumbing is exported for app code that calls Totum or subs itself: authFetch(url, init) / authFetchJson(url, init) (fresh bearer + one 401 retry) and getFreshAccessToken().
verifyOrgRoleService
const result = await verifyOrgRoleService.verify(); // never throws; cached per token until expires_at
// result: { ok, skipped, fromCache, status, systemAdmin, adminOrgs, expiresAt, error, raw,
// systemManager, managerOrgs, claimsVersion, // verified v2 token only (1.7.0)
// seats, signedClaims, signedClaimsExpiresAt, claims, claimsStatus, claimsError }
isVerifiedAdminFor(result, groupGuid); // systemAdmin || adminOrgs includes groupGuid
isVerifiedManagerFor(result, groupGuid); // systemManager || managerOrgs includes groupGuid (1.7.0)
verifyOrgRoleService.peek(); // fresh cached result or null, no network
verifyOrgRoleService.isEnabled();
verifyOrgRoleService.clearCache();Utilities
import {
hasAdminRole, // Cognito groups vs adminRoles
isUserAdmin,
isTotumAdminRole, // "ClientAdmin" | "SystemAdmin" (case-insensitive)
hasTotumAdminRole,
hasGroupAdminRole, // Totum roles vs adminRoles + built-in Totum admin roles
isAdminSeatRoleType, // role_type === "Admin" (case-insensitive)
isAdminSeat,
isValidSeat, // any seat row
decodeJwtPayload,
extractUserSub,
extractUserId,
extractCognitoGroups,
signOut, // local session + Totum ID session, then the sign-in page (1.6.0)
clearOrgSwitcherState, // drop the package's per-user caches
detectAuthMode, // 'cookie' | 'local' from unified-auth
// Org claims verification — the same code as the /server entry, also usable in the browser
verifyOrgClaims,
hasSeat,
isOrgAdmin,
isOrgManager, // verified v2 manager claims for an org (1.7.0)
canManageOrg, // isOrgAdmin || isOrgManager (1.7.0)
isAppAdmin,
isAdminSeatSubscriptionActive, // subscription rule behind isAppAdmin / hasAdminSeat
isAdminGrantingSeat,
ADMIN_SEAT_SUBSCRIPTION_STATUSES,
OrgClaimsError,
// Seats of other org members, read from totum-subs as the caller (1.3.0+)
getOrgMemberSeats,
membersHoldSeat,
memberHoldsSeat,
OrgMemberSeatsError,
DEFAULT_SUBSCRIPTIONS_SUPABASE_URL,
DEFAULT_SUBSCRIPTIONS_SUPABASE_ANON_KEY,
} from '@inovusmedical/org-switcher';Development
npm install
npm run lint # tsc for src and tests
npm test # vitest (jsdom)
npm run build # dist/Environment Variables
Only needed for seat-gated apps. Simple org switcher apps require no env vars.
| Variable | Description |
|----------|-------------|
| VITE_PRODUCT_ID | Stripe product ID(s) for this app. Comma-separated for multiple. |
| VITE_COGNITO_GROUP | Required Cognito group name(s). Comma-separated for multiple. |
More documentation
- AGENT.md — standalone integration and reference for developers and AI agents (architecture, config, runtime, licensing, hooks, components, services, Totum/Supabase, database, build, troubleshooting). Distribute this file alone if you are not shipping the
docs/folder. - docs/ — optional maintainer-oriented split of topics for working in this repository (architecture, build, configuration, runtime, hooks, data layer, UI, database, extending, troubleshooting).
To design other Totum-internal npm packages (for example notifications or an event bus) and to keep keys and credentials handled safely, read docs/totum-ecosystem-packages.md.
License
UNLICENSED — Internal use only.
