npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ab-ecosystem-sso

v0.3.1

Published

Drop-in React auth & account UI for the partner-ecosystem **Auth-as-a-Service** backend. Ships production-ready screens — login (with 2FA), signup, forgot-password, session validation, and a full **Account Settings** modal (profile, wallet, plans & add-on

Readme

ab-ecosystem-sso

Drop-in React auth & account UI for the partner-ecosystem Auth-as-a-Service backend. Ships production-ready screens — login (with 2FA), signup, forgot-password, session validation, and a full Account Settings modal (profile, wallet, plans & add-ons, credit wallets) — all wired to the /api/v1/projects/:projectId/... endpoints, plus low-level helpers (getUser, logout, useSession) for building your own UI.

  • ⚛️ React 18 / 19, shipped as ESM + CJS with full TypeScript types.
  • 🎨 Self-contained, theme-aware CSS that won't leak into or be overridden by your app.
  • 🌗 Built-in light and dark mode — per component, via a single mode prop.
  • 🔌 Use it declaratively (<AccountSettings />) or imperatively (openAccountSettings()).
  • 👥 Multi-account aware — account picker, account switching, invitations, roles & members.
  • 🔑 Stores the session token for you and falls back to it everywhere, so token props are optional.
  • 🧭 Ships its own routes — spread them into your router, or let the package match the path.
  • 🧩 Every network call also available as a plain function for custom UIs.

Table of contents


Installation

npm install ab-ecosystem-sso

react and react-dom (v18 or v19) are peer dependencies — install them in your app.

Import the stylesheet once

import "ab-ecosystem-sso/styles.css";

The CSS is scoped (a [data-aas] root + the sso: utility prefix) and shipped unlayered, so it never touches your app's elements and your app's resets (e.g. Tailwind's preflight) never override it. No extra @layer setup needed.


Configuration basics

Almost everything takes the same three inputs:

| Input | Description | | --- | --- | | projectId | Your project id, e.g. 436582-forms.abprojects.com. Required. | | token | The auth JWT from the login response. Required for authenticated calls. | | apiBaseUrl | API origin, e.g. https://partner-api.example.com. Falls back to the VITE_AUTH_API_BASE_URL env var. |

You own the token: persist it (e.g. localStorage) on onSuccess, pass it back into the authenticated components/functions, and clear it on logout.


Token handling

The package stores the session token for you. Every screen that receives one — login, 2FA, account selection, account switching, signup, invite acceptance, magic links — writes it to localStorage, and every call the package makes falls back to it. So token props are optional now, and this is enough:

<Login projectId="your-project-id" />          // token stored on success
<AccountSettings open onOpenChange={setOpen} projectId="your-project-id" />
<SessionProvider projectId="your-project-id">{children}</SessionProvider>

It's cleared on logout(), on LogoutButton, and whenever a session turns out to be invalid. Account switching replaces it, so later calls can't keep acting as the previous account.

import { configureTokenStorage } from "ab-ecosystem-sso";

configureTokenStorage({ key: "authToken" });          // match an existing app's key
configureTokenStorage({ storage: "sessionStorage" }); // per-tab instead of persistent
configureTokenStorage({ enabled: false });            // opt out; you handle the token

| Option | Default | Notes | | --- | --- | --- | | key | "ab-sso:accessToken" | Namespaced on purpose — a bare accessToken collides with whatever your app already keeps under that name. | | storage | "localStorage" | Or "sessionStorage", "memory", "none". | | enabled | true | false turns off both reading and writing. |

Per component, persistToken={false} skips the write for that screen and tokenStorageKey overrides the key.

A bearer token in localStorage is readable by any script on your origin. That's the same thing most hosts were already doing by hand, but it is now the default — switch to sessionStorage, or turn it off, if your threat model says otherwise.

Reading it back, and the helpers for your own calls:

import { getStoredToken, clearStoredToken, onStoredTokenChange } from "ab-ecosystem-sso";

getStoredToken();                       // "" when signed out
clearStoredToken();                     // sign out locally
onStoredTokenChange((t) => …);          // fires on login / switch / logout (this tab)

Storage access throws in private windows and with site data blocked, so every read and write is guarded and falls back to an in-memory value for the life of the page — it degrades, it never throws at you.

Sending the token to your own backend

The package does not patch your app's HTTP client, and that's deliberate: a blanket fetch patch would attach your bearer token to every outbound request on the page, third-party scripts included. Three explicit options instead, in order of how much they touch:

1. getAuthHeader() — one line in whatever client you already have:

import { getAuthHeader } from "ab-ecosystem-sso";

axios.interceptors.request.use((cfg) => {
  cfg.headers = { ...cfg.headers, ...getAuthHeader() };
  return cfg;
});

2. authFetch() — a fetch wrapper that also handles the 401 split:

import { authFetch, configureAuthHandlers } from "ab-ecosystem-sso";

configureAuthHandlers({
  onUnauthorized: () => navigate("/login"),        // session gone
  onAccountAccessLost: () => showAccountPicker(),  // A403: this account only
});

const res = await authFetch("/api/orders");

That second handler is the real win here: it's what makes "access to one account was revoked" behave correctly for your endpoints too, not just ours. An Authorization header you set yourself is never overwritten, and the response body reaches you unread.

3. installAuthInterceptor({ origins }) — for apps with too many call sites to touch. It patches fetch and XMLHttpRequest, but only for an explicit allowlist:

import { installAuthInterceptor } from "ab-ecosystem-sso";

const stop = installAuthInterceptor({
  origins: ["self", "https://api.example.com"],   // "self" = your own origin (covers /api/…)
  onUnauthorized: () => navigate("/login"),
  onAccountAccessLost: () => showAccountPicker(),
});

There is no wildcard and the allowlist is required — it throws without one. Anything not listed is left completely alone, which is what keeps the token away from analytics, CDNs and payment widgets. stop() restores the originals.


Routes

Six routes are load-bearing for any app embedding this package:

| Path | Screen | Reached by | | --- | --- | --- | | /login | Login | your app, and the package's own redirects | | /signup | Signup | the login screen | | /forgot-password | ForgotPassword | the login screen | | /reset-password | ForgotPassword (reset step) | the emailed reset link | | /verify/:token | VerifyEmail | the emailed verification link | | /accept-invite | AcceptInvite | invite links (inviteLinkBase) |

A library can't discover your app's routes — react-router keeps no readable registry at runtime, and Next's routes are filesystem and build-time — so the package publishes what it expects and gives you three ways to satisfy it.

Already have a router? Spread the route objects in, so the screens sit inside your layouts and guards:

import { ssoRoutes } from "ab-ecosystem-sso";
import { useNavigate } from "react-router-dom";

const navigate = useNavigate();
createBrowserRouter([
  ...ssoRoutes({ projectId: "your-project-id", onNavigate: navigate, redirectTo: "/dashboard" }),
  ...appRoutes,
]);

Pass onNavigate so the screens move through your router — the default history.pushState won't re-render it. Token-bearing routes are registered both bare and with a :token segment, since links arrive both ways.

No router? Mount the whole set and let the package match the path:

import { SsoAuthRoutes } from "ab-ecosystem-sso";

<SsoAuthRoutes projectId="your-project-id" redirectTo="/dashboard" fallback={<App />} />

File-based routing (Next)? One tiny file per route:

// app/login/page.tsx
import { SsoAuthPage } from "ab-ecosystem-sso";
export default () => <SsoAuthPage screen="login" projectId="your-project-id" />;

All three wire the screens to each other, store the token on success, and then leave for redirectTo (default /). A ?next= parameter wins over redirectTo — and is rejected unless it's a same-site path, so the login screen can't be turned into an open redirect. Replace the whole behaviour with onAuthenticated.

Paths aren't fixed:

import { configureSsoRoutes } from "ab-ecosystem-sso";
configureSsoRoutes({ login: "/sign-in", acceptInvite: "/invite" });

basePath="/auth" mounts everything under a prefix. getSsoRoutes() returns what's in effect, and SessionProvider uses it for its default redirect (with ?next= back to where the user was), so a remapped login route is honoured everywhere.

⚠️ Your server has to serve the app shell for these paths. If /accept-invite isn't rewritten to index.html, the user gets a 404 before any JavaScript runs, and no client-side package can fix that. For a static SPA host that's a catch-all rewrite to /index.html; on nginx, try_files $uri /index.html;.


Auth screens

Login

import { Login } from "ab-ecosystem-sso";
import "ab-ecosystem-sso/styles.css";

function LoginPage() {
  return (
    <Login
      projectId="your-project-id"
      apiBaseUrl="https://your-api"
      onSuccess={({ token, user }) => {
        localStorage.setItem("token", token);
        navigate("/dashboard");
      }}
      onError={(err) => toast.error(err.message)}
      onForgotPassword={() => navigate("/forgot-password")}
      onSignupClick={() => navigate("/signup")}
    />
  );
}

The password field has a show/hide (eye) toggle — it always starts masked, and the same control appears on Signup, the ForgotPassword reset step, and the Account Settings change-password modal.

Login first fetches the project's auth-config (captcha provider/site-key, branding, which links to show) and renders accordingly. If the account has 2FA enabled it automatically shows a segmented 6-digit OTP step (with captcha when the project requires it), and only calls onSuccess after verification succeeds.

Magic-link sign-in works automatically: point your magic link at /login#<access_token> (a bare /login#access_token=<token> is also accepted). On mount, Login reads the token from the URL hash, validates it with a /me call, and — if valid — strips it from the URL and fires onSuccess({ token, user }), exactly like a password sign-in. While it validates, a loading state shows; if the token is invalid or expired, it falls back to the normal sign-in form with a short notice (and calls onError). No extra wiring needed beyond routing the link to your login page.

Signup and ForgotPassword

import { Signup, ForgotPassword } from "ab-ecosystem-sso";

<Signup projectId="…" onSuccess={handleAuth} onLoginClick={() => navigate("/login")} />
<ForgotPassword projectId="…" onBackToLogin={() => navigate("/login")} />

Signup loads the project's currencies from GET /currency and offers only those. Its country dropdown uses a bundled default list; pass countries (an array of { code, name }) to replace it:

<Signup
  projectId="…"
  countries={[
    { code: "US", name: "United States" },
    { code: "IN", name: "India" },
  ]}
  onSuccess={handleAuth}
/>

Password reset. ForgotPassword has two steps. With no token it shows the request-a-link form (emails a reset link). When the user returns via that link, extract the token from the URL and pass it as resetToken — the component then renders the reset step (new password + confirm + captcha) and POSTs to /auth/reset-password:

// e.g. reset link is /reset-password?token=abc123
const token = new URLSearchParams(location.search).get("token") ?? undefined;

<ForgotPassword
  projectId="…"
  resetToken={token}
  onResetSuccess={() => navigate("/login")}
  onBackToLogin={() => navigate("/login")}
/>

The new password must be at least 8 characters and match the confirmation. On success it shows a confirmation and calls onResetSuccess. Extracting the token is the host's responsibility (the link format is yours to define).

| ForgotPassword prop | Type | Notes | | --- | --- | --- | | resetToken | string | Present ⇒ render the reset-password step instead of request-a-link. | | onResetSuccess | () => void | Fires after the password is reset. | | onBackToLogin | () => void | Back-to-login link (both steps). |

VerifyEmail

The email-verification screen for a /verify/:token route — the link you email to new users. It shows a captcha (site key from the project's auth-config), then posts the captcha + token to /auth/verify-email.

import { VerifyEmail } from "ab-ecosystem-sso";

// e.g. React Router: <Route path="/verify/:token" element={<VerifyPage />} />
function VerifyPage() {
  const { token } = useParams();
  return (
    <VerifyEmail
      projectId="your-project-id"
      apiBaseUrl="https://your-api"
      token={token}
      onVerified={() => navigate("/login")}
      onLoginClick={() => navigate("/login")}
      onSignupClick={() => navigate("/signup")}
      onError={(err) => console.error(err)}
    />
  );
}

On success it calls onVerified (send the user to login) and shows a Continue to sign in button. On failure it shows a Verification failed card with the reason plus Go to sign in / Go to sign up buttons. Verification counts as successful only when the response is 2xx and success: true.

| Prop | Type | Notes | | --- | --- | --- | | token | string | The token from your route params. Falls back to the last path segment of the URL. | | onVerified | () => void | Fires once the email is verified. | | onLoginClick | () => void | Navigate to login (shown on success and failure). | | onSignupClick | () => void | Navigate to signup (shown on failure). |

Prefer passing token from your router. The URL fallback only exists so the component works on a plain /verify/:token page with no router wired up.

Shared auth props

| Prop | Type | Notes | | --- | --- | --- | | projectId | string | Required. | | apiBaseUrl | string | Optional API origin. | | config | AuthConfig | Pre-fetched auth-config to skip the internal request. | | onSuccess | ({ token, user }) => void | On successful auth. | | onError | (error: Error) => void | On failure. | | classNames | AuthClassNames | Per-element class overrides: card, input, label, button, link, captcha, error, … | | mode | "light" \| "dark" | Colour scheme. Default "light". See Light & dark mode. | | persistToken | boolean | Store the issued token. Default true. See Token handling. | | tokenStorageKey | string | Storage key override for this screen. |

Per-screen extras: Login adds onForgotPassword / onSignupClick / client (see Multiple accounts); Signup adds inviteToken / inviteEmail (see Invitations); Signup adds onLoginClick / countries; ForgotPassword adds onBackToLogin / resetToken / onResetSuccess; VerifyEmail adds token / onVerified / onLoginClick / onSignupClick (and has no onSuccess, since no session is created).


Session management

Wrap authenticated areas in SessionProvider. It validates the token against /me and renders children only when the session is valid; otherwise it calls onSessionInvalid (redirect to login there).

import { SessionProvider } from "ab-ecosystem-sso";

// `token` and `onSessionInvalid` are both optional: it reads the stored token
// and falls back to redirecting to the login route.
<SessionProvider projectId="your-project-id" apiBaseUrl="https://your-api">
  <Dashboard />
</SessionProvider>

| Prop | Type | Notes | | --- | --- | --- | | projectId | string | Required. | | token | string \| null | Omit to use the stored token; null states explicitly that there's no session. | | onSessionInvalid | () => void | Called on invalid/expired session or logout. Optional — defaults to redirecting to the login route with ?next=. | | apiBaseUrl | string | Optional. | | enforceActiveSubscription | boolean | See below. | | onTokenChange | (token: string) => void | Receives a fresh token when the user switches account from the built-in picker. | | onAccountAccessLost | ({ message, code }) => void | Access to this account was revoked/expired (A403) — handle it instead of the built-in picker. | | mode | "light" \| "dark" | Colour scheme for the built-in picker. |

Enforce an active subscription

With enforceActiveSubscription, once authenticated the provider checks the user's subscription. If none is active it opens the Account Settings modal on the Plans section — and blocks closing until a subscription exists (it re-checks on every close attempt).

<SessionProvider … enforceActiveSubscription>
  <Dashboard />
</SessionProvider>

Multiple accounts

A login can belong to more than one account (their own, plus any they've been invited to). When it does, /auth/login and /auth/verify-2fa return no token — they return an account picker instead:

{
  "success": true,
  "accountSelectionRequired": true,
  "accounts": [{ "account_id": "66f…b2", "name": "Bob's Co", "is_owner": false, "role": "Manager", "access_expiry": null }],
  "selectionTicket": "eyJ…"
}

Login handles this for you. It renders the picker as a third step — exactly like the existing 2FA step — exchanges the chosen account's selectionTicket via /auth/select-account, and only then calls onSuccess, with the same data.accessToken shape a single-account login gives you (plus the chosen account). No change is needed in your onSuccess:

<Login
  projectId="your-project-id"
  client="webapp"                        // optional: recorded on the session
  onSuccess={(res) => save(res.data.accessToken)}
/>

⚠️ If you were reading data.accessToken straight out of the login response in your own code, add the accountSelectionRequired branch — on that branch there is no token, and the selectionTicket is only valid for five minutes.

Switching account on a live session

AccountPicker is also exported on its own. Given a token instead of a ticket it lists the accounts from GET /accounts and switches via /auth/switch-account, handing you a fresh token to store in place of the old one (the previous token stays valid until it expires or is logged out):

import { AccountPicker } from "ab-ecosystem-sso";

<AccountPicker
  projectId="your-project-id"
  token={token}
  variant="list"                 // no card chrome — drops into a dropdown or panel
  currentAccountId={accountId}   // marks the current one
  onSelect={({ accessToken, account }) => save(accessToken, account.account_id)}
/>

The Account Settings modal has this built in as an Accounts section — see onAccountSwitch.

| Prop | Type | Notes | | --- | --- | --- | | projectId | string | Required. | | selectionTicket | string | Ticket mode (post-login). Pass with accounts. | | accounts | AccountOption[] | The list to choose from; fetched automatically in session mode. | | token | string | Session mode — picking switches account instead. | | client | string | Recorded on the session by select-account, e.g. "webapp". | | variant | "card" \| "list" | Full auth card (default) or a bare list. | | currentAccountId | string | Marks the account the session is already on. | | onSelect | ({ accessToken, account }) => void | Called with the new token. | | onCancel / cancelLabel | () => void / string | Renders a secondary action under the list. | | title / description | string | Card copy overrides. | | onError, classNames, mode, config, apiBaseUrl | | As elsewhere. |

When access to one account is revoked

If an owner revokes or expires a member's access, that member's next request to any endpoint fails with 401 and code A403. Their login is still fine — only that one account became unreachable — so this is not a logout.

SessionProvider tells the two apart. Give it onTokenChange and it renders the account picker itself; give it onAccountAccessLost to handle the case yourself:

<SessionProvider
  projectId="your-project-id"
  token={token}
  onTokenChange={save}                       // picker appears, new token lands here
  onSessionInvalid={() => navigate("/login")} // genuine logout
>
  <Dashboard />
</SessionProvider>

useSession() also exposes accountAccessLost ({ message, code } | null). For your own fetch wrappers, isAccountAccessError(err) returns true for exactly this case:

import { isAccountAccessError } from "ab-ecosystem-sso";

catch (err) {
  if (isAccountAccessError(err)) showAccountPicker();
  else if (err.status === 401) logout();
}

Invitations

An owner invites someone by email, and the backend returns a link token — it sends no email, so the link is yours to deliver. Build the invitee's route with AcceptInvite:

import { AcceptInvite } from "ab-ecosystem-sso";

// e.g. /accept-invite?token=…
<AcceptInvite
  projectId="your-project-id"
  inviteToken={new URLSearchParams(location.search).get("token") ?? ""}
  token={session}                              // if they're already signed in
  onAccepted={({ accessToken }) => accessToken && save(accessToken)}
  onNeedLogin={() => navigate("/login?next=/accept-invite" + location.search)}
  onSuccess={(res) => save(res.data.accessToken)}  // the signup branch
/>

It resolves the token first (GET /invitations/:token) and then renders whichever branch the backend asked for:

  • mode: "signup" — they have no login here yet. It renders Signup with inviteToken set and the invited address prefilled and read-only. This works even when the project has signups disabled, and the account is created already verified and active (no verification email).
  • mode: "accept" — they already log in here. With a token it shows the invite details and an accept button, then switches the session onto the joined account. Without one it calls onNeedLogin so you can route them through your own login and come back.

| Prop | Type | Notes | | --- | --- | --- | | projectId | string | Required. | | inviteToken | string | Required — the token from the link. | | token | string | A live session token; required by the "accept" branch. | | switchOnAccept | boolean | Switch onto the joined account after accepting. Default true. | | onAccepted | ({ accountId, accessToken }) => void | After accepting. accessToken is the switched session. | | onNeedLogin | (invitation) => void | The invite needs an existing login and none was given. | | onSuccess | (res) => void | Forwarded to Signup on the signup branch. | | countries, onError, classNames, mode, config, apiBaseUrl | | As elsewhere. |

The email is always rendered read-only: on signup the server takes it from the invite record and ignores anything posted, and on accept a mismatch is rejected ("This invitation was sent to a different email").


Team administration

Owners get three more Account Settings sections — Members, Roles and Invitations — on top of the usual ones. They need live API mode (projectId + token):

openAccountSettings({
  projectId: "your-project-id",
  token,
  initialSection: "members",
  inviteLinkBase: "https://app.example.com/accept-invite",  // where invite links point
  currentAccountId: accountId,
  onAccountSwitch: ({ accessToken }) => save(accessToken),  // also enables the Accounts tab
});
  • Members — for an owner, list active or revoked members; edit a member's role and access expiry; revoke access. Revoking leaves their login alone: only this account becomes unreachable, from their next request onward, and re-inviting the same person restores the membership.
  • Roles — create, edit and delete roles using the permission matrix (see below). System roles are assignable but neither editable nor deletable, and a role still held by anyone can't be deleted — the list carries each role's member count so the warning comes before the attempt, not after it.
  • Invitations — create invites (email, member/user, role, access expiry), filter by status, and revoke pending ones. After creating one, the link is shown once with a copy button — the token is never returned again and no email is sent.

The role editor

The editor is built from GET /account/permissions, the product's own catalogue: rows are features (grouped and ordered as the API returns them) and columns are verbs from that response — nothing is hardcoded, so a new verb or feature appears without a package release. A cell exists only where the feature actually has an action for that verb; everywhere else renders —.

The catalogue is cached per project (clearPermissionCache() busts it). It's product documentation — identical for every account bar the entitled flags — so it's fetched once, not per role.

Four rules the editor enforces for you:

  1. Action keys only. Ticking a feature's label selects every leaf action in that row, never the feature key itself — CRM.CONTACT is rejected, since anything prefix-matching would read as a wildcard.
  2. implies is expanded transitively before saving. CRM.CHAT.TRANSFER pulls CRM.CHAT.LIST and TEAM.LIST, and those can pull more. The complete effective set is what's sent, because the server stores exactly what it receives. Auto-added cells show ticked but locked, with a tooltip naming what pulled them in — unticking them while the action requiring them is still on would be a lie.
  3. Record access is one control, not a column. data_scope is a single scalar on the role ("own" or "all"), so there's one radio pair, with an "Affects:" line naming the features where scopable is true. Features where it's false are untouched by the setting, so they're not listed. On create the scope is always sent (the server defaults to "own"); on update it's sent only if it was actually changed, so a title-only edit can't silently reset it for every holder.
  4. Plan-locked rows stay checkable. entitled: false greys the row and badges it with inclusion.name, but the owner can still tick it — the role is stored as configured and starts working on upgrade. Nothing is packaged today, so nothing renders locked yet; the state is built and waiting.

A role may hold at most 1000 permissions. If the catalogue can't be fetched, the editor falls back to entering action keys by hand so roles stay editable.

The matrix is exported on its own for a custom roles screen:

import { PermissionMatrix, DataScopeControl, getPermissionsApi, expandPermissions } from "ab-ecosystem-sso";

const catalogue = await getPermissionsApi(projectId);
<PermissionMatrix catalogue={catalogue} selected={picked} onChange={setPicked} />
// save expandPermissions(catalogue, picked), never `picked` alone

Owners and members see different things

The modal works out which side the signed-in person is on from context.act in GET /me, and renders accordingly — no prop needed:

| | Members | Roles | Invitations | | --- | --- | --- | --- | | Owner | full administration | ✅ | ✅ | | Team member | their team, read-only | hidden | hidden |

Roles is gated on context.act === 'owner' before any request goes out — the server enforces it anyway, but a member shouldn't be shown a screen that then 403s. A member's Members tab lists their colleagues and each one's role from GET /account/team, headed by which account they're in and the role they hold themselves. Roles and Invitations are hidden for them rather than shown and then refused — those endpoints are owner-only and would answer "Only the account owner can do this", which the owner-side sections still display as-is if a non-owner ever reaches them.

Detection is deliberately cautious: only an explicit act of "member" or "user" counts as a member. An unrecognised value, or no context at all, is treated as the owner — the behaviour from before per-member sessions existed. Override it with isOwner if you already know, and hide the three sections entirely with showTeamSections={false}.

Account context

The same context block is useful outside the modal — it says which account the session is in, the member's role, and what that role grants:

import { getAccountContext, hasPermission } from "ab-ecosystem-sso";

const ctx = await getAccountContext(projectId);
// { accountId, act, isOwner, role: { id, title, version }, permission,
//   accessExpiry, accountName, accountEmail } — or null when there's no context

if (!ctx?.isOwner) showMemberUi(ctx?.role?.title);
if (hasPermission(ctx, "CHAT.TRANSFER")) enableTransfer();

hasPermission is case-insensitive, passes owners everything, and returns true when there's no context — so it degrades to "unrestricted" rather than locking people out of an app that hasn't adopted roles yet. Permission strings aren't validated against a catalogue server-side, so a misspelled one simply never matches.

The three panels are also exported for use in your own admin page:

import { MembersSection, RolesSection, InvitationsSection } from "ab-ecosystem-sso";

<MembersSection projectId="your-project-id" token={token} />

They're styled for the settings modal, so render them inside an element carrying data-aas (and data-aas-theme for dark mode — see Light & dark mode).


Account Settings modal

A complete settings surface with these sections: General (profile), Account (email, change password, 2FA, sessions), Wallet & Balance (balances + transaction history), Plans & Add-ons (subscribe, multi-subscribe add-ons, markdown plan details), Credit Wallets (per-wallet balance, logs, top-up), plus the owner-only Members, Roles and Invitations sections and an optional Accounts switcher (see Team administration). It loads and saves via the live API whenever projectId + token are supplied (otherwise it renders sample data).

Imperative (recommended)

Open it from any click handler; it mounts into <body> and unmounts on close:

import { openAccountSettings } from "ab-ecosystem-sso";

<button onClick={() => openAccountSettings({
  projectId: "your-project-id",
  token,
  apiBaseUrl: "https://your-api",
  initialSection: "plans",             // optional
  onLogout: () => { localStorage.clear(); navigate("/login"); },
})}>
  Settings
</button>

openAccountSettings(options) returns a handle: { close() } to dismiss it programmatically.

Declarative

import { AccountSettings } from "ab-ecosystem-sso";

const [open, setOpen] = useState(false);

<AccountSettings
  open={open}
  onOpenChange={setOpen}
  projectId="your-project-id"
  token={token}
  apiBaseUrl="https://your-api"
  initialSection="general"
  onLogout={() => { localStorage.clear(); navigate("/login"); }}
/>

Props

| Prop | Type | Notes | | --- | --- | --- | | open | boolean | Required (declarative). | | onOpenChange | (open: boolean) => void | Required (declarative). | | projectId | string | Enables live API mode (with token). | | token | string | Bearer token. Optional — falls back to the stored one. | | apiBaseUrl | string | Optional API origin. | | initialSection | AccountSettingsSection | "general", "account", "wallet", "plans", "credits", "accounts", "members", "roles" or "invitations". Default "general". | | customComponents | AccountSettingsCustomPanel[] | Add your own panels to the modal. See Custom panels. | | syncUrlHash | boolean | Mirror open state + active section in the URL hash. Default true. See URL hash & refresh. | | title | string | Modal heading. Default "Settings". | | mode | "light" \| "dark" | Colour scheme. Default "light". See Light & dark mode. | | isOwner | boolean | Overrides owner-vs-member detection. Omitted ⇒ read from context.act in GET /me. | | showTeamSections | boolean | false hides Members / Roles / Invitations entirely. Default true. | | inviteLinkBase | string | Where invite links point; the token is appended as ?token=…. Default <origin>/accept-invite. | | onAccountSwitch | ({ accessToken, accountId }) => void | Enables the Accounts switcher. Store the fresh token in place of the old one. | | currentAccountId | string | Marked "Current" in the switcher. | | data | Partial<AccountSettingsData> | Override sample data (non-API mode / previews). | | onLogout | () => void | Sign-out action (closes the modal, then runs this). | | onSaveProfile | (profile) => void | Fires alongside the profile save. | | onChangePassword | (data) => void | Fires alongside the password change. | | onChangePlan | (planId) => void | Fires when a plan is subscribed. | | onUpdateWhatsApp, onToggleTwoFactor, onRevokeSession, onAddFunds | callbacks | Observers for the respective actions. |

In live mode the modal performs the API calls itself (save profile, change password, subscribe, recharge, etc.); the callbacks are observation hooks, not required wiring.

Custom panels

Add your own screens to the modal via customComponents — an array of AccountSettingsCustomPanel. Each entry renders a trigger (in the sidebar, pinned to the sidebar bottom, or in the header next to the close button) and shows its component in the modal body when activated.

<AccountSettings
  open={open}
  onOpenChange={setOpen}
  projectId="your-project-id"
  token={token}
  customComponents={[
    {
      id: "referrals",
      location: "sidebar",            // "sidebar" | "sidebar-bottom" | "header"
      label: "Referrals",
      icon: <GiftIcon />,             // any node (an inline SVG works best)
      component: <ReferralsPanel />,  // rendered in the modal body
    },
    {
      id: "help",
      location: "header",
      label: "Help",
      icon: <HelpIcon />,
      component: <HelpCenter />,
    },
  ]}
/>

| Field | Type | Notes | | --- | --- | --- | | id | string | Required, unique. Tracks which panel is active. | | location | "sidebar" \| "sidebar-bottom" \| "header" | Where the trigger appears. Default "sidebar". | | label | string | Sidebar text; header button tooltip + aria-label. | | icon | ReactNode | Optional trigger icon. | | component | ReactNode | Rendered in the body when the panel is active. |

The same customComponents array works with the imperative openAccountSettings({ customComponents: [...] }). Panels are host-owned — they don't affect the URL hash, and activating one doesn't run the built-in section's API calls.

URL hash & refresh

While the modal is open it reflects the active section in the URL hash as #settings-<section> (e.g. #settings-general, #settings-plans), updating as the user switches tabs and clearing it on close. This means a page refresh can re-open the modal on the same section. It uses history.replaceState, so it never adds browser-history entries and never clobbers an unrelated #anchor. Opt out with syncUrlHash={false}.

  • Declarative <AccountSettings>: keep it mounted (even while closed) and wire open/onOpenChange normally — on load it reads the hash and calls onOpenChange(true) for you.

  • Imperative openAccountSettings(): call restoreAccountSettings() once on app load to re-open it after a refresh:

    import { restoreAccountSettings } from "ab-ecosystem-sso";
    
    useEffect(() => {
      restoreAccountSettings({ projectId: "your-project-id", token, apiBaseUrl: "https://your-api" });
    }, []);

    It opens the modal (on the hash's section) only when a #settings-* hash is present, and returns the modal handle (or null).


Logout

Three options, from most convenient to most raw:

import { LogoutButton, logout, logoutApi } from "ab-ecosystem-sso";

// 1) Ready-made button (works standalone, no SessionProvider needed)
<LogoutButton
  projectId="your-project-id"
  token={token}
  apiBaseUrl="https://your-api"
  onSuccess={() => { localStorage.clear(); navigate("/login"); }}
/>

// 2) A method to attach to any custom control (e.g. a profile-dropdown item)
onClick={() => logout({
  projectId: "your-project-id",
  token,
  apiBaseUrl: "https://your-api",
  onSuccess: () => { localStorage.clear(); navigate("/login"); },
  onError: (e) => console.error(e),
})}

// 3) The raw endpoint call
await logoutApi("your-project-id", token, "https://your-api");

<LogoutButton /> also takes mode="dark". logout POSTs to /auth/logout and always runs onSuccess once the request settles — so the user is logged out client-side even if the server call fails (onError fires for observability). Inside a SessionProvider, <LogoutButton /> with no props uses the provider's logout instead.


Fetching the current user

Populate your own header / profile-dropdown from /me:

import { getUser } from "ab-ecosystem-sso";

const res = await getUser("your-project-id", token, "https://your-api");
// res = { success, data: { id, username, profile: { name, email, address, … }, mainWallet, coinWallet } }
const firstName = res.data.profile.name.first;

getUser returns the raw response envelope and throws AuthApiError on a non-2xx response.


Fetching user data

When you want clean, ready-to-render data instead of the raw envelope, use these getters. Each fetches and maps in one call, and all share the same (projectId, token, apiBaseUrl?) signature.

import {
  getUserProfile,
  getUserWallet,
  getUserCreditWallet,
  getUserSubscription,
} from "ab-ecosystem-sso";

const profile       = await getUserProfile("your-project-id", token, "https://your-api");
const { mainWallet, coinWallet } = await getUserWallet("your-project-id", token, "https://your-api");
const creditWallets = await getUserCreditWallet("your-project-id", token, "https://your-api");
const subscription  = await getUserSubscription("your-project-id", token, "https://your-api");

| Function | Endpoint | Returns | | --- | --- | --- | | getUserProfile | GET /me | MappedProfile — flat fields (name, email/username, address, phone, mainWallet/coinWallet). | | getUserWallet | GET /me | UserWallets — just { mainWallet, coinWallet } balances. | | getUserCreditWallet | GET /credit-wallets | CreditWallet[] — each { id, label, balance, balanceFormatted, creditUnit, fundSource, rechargeable }. | | getUserSubscription | GET /subscription | The raw subscription object from the API (unmapped). |

getUserProfile and getUserWallet both read /me; getUserWallet just narrows the same response to the wallet balances. getUserSubscription returns the API response as-is (the unwrapped data), unlike the others which map to a friendly shape. All four throw AccountApiError on a non-2xx response.


Two-factor setup

For a settings page outside the modal:

import { TwoFactorSetup } from "ab-ecosystem-sso";

<TwoFactorSetup
  projectId="your-project-id"
  token={token}
  apiBaseUrl="https://your-api"
  mode="dark"            // optional — "light" (default) or "dark"
  onEnabled={() => …}
  onDisabled={() => …}
  onError={(err) => …}
/>

Hooks

| Hook | Returns | | --- | --- | | useSession() | { isAuthenticated, isValidating, logout } — must be inside a SessionProvider. | | useOptionalSession() | Same, or null when there is no provider. | | useAuthConfig() | The project's auth-config (captcha, branding, card layout). |


Light & dark mode

Every component renders in light mode by default. Pass mode="dark" to switch:

<Login projectId="…" mode="dark" onSuccess={handleAuth} />
<Signup projectId="…" mode="dark" onLoginClick={() => navigate("/login")} />
<ForgotPassword projectId="…" mode="dark" />
<VerifyEmail projectId="…" mode="dark" />
<AccountSettings open={open} onOpenChange={setOpen} mode="dark" />
<TwoFactorSetup projectId="…" token={token} mode="dark" />
<LogoutButton mode="dark" />

openAccountSettings({ projectId, token, mode: "dark" });

mode is per component, not global — each component root carries a data-aas-theme="light" | "dark" attribute and the palette inherits from there, so a dark login card and a light settings modal can coexist on the same page.

Follow your app's own theme by passing the value you already track:

const { theme } = useTheme();          // your app's theme state
<Login projectId="…" mode={theme === "dark" ? "dark" : "light"} />

To follow the OS setting instead:

import type { AuthMode } from "ab-ecosystem-sso";

const [mode, setMode] = useState<AuthMode>(
  () => (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
);

useEffect(() => {
  const mq = window.matchMedia("(prefers-color-scheme: dark)");
  const onChange = (e: MediaQueryListEvent) => setMode(e.matches ? "dark" : "light");
  mq.addEventListener("change", onChange);
  return () => mq.removeEventListener("change", onChange);
}, []);

<Login projectId="…" mode={mode} />

The package deliberately does not read prefers-color-scheme itself — an auth screen that silently disagrees with the host app's theme is worse than one that follows it, and only your app knows which theme it's in.


Theming

The UI reads --aas-* CSS custom properties, so you can retheme without touching the components. Override them on :root (or any ancestor) after importing the stylesheet:

:root {
  --aas-primary: oklch(0.55 0.22 265);
  --aas-primary-foreground: oklch(0.99 0 0);
  --aas-background: oklch(1 0 0);
  --aas-foreground: oklch(0.15 0 0);
  --aas-muted: oklch(0.97 0 0);
  --aas-muted-foreground: oklch(0.55 0 0);
  --aas-border: oklch(0.92 0 0);
  --aas-radius: 0.625rem;
}

Dark mode is the same variables redeclared on [data-aas-theme='dark'], so retheming dark is symmetrical — override there and light stays untouched:

[data-aas-theme="dark"] {
  --aas-background: oklch(0.16 0.01 265);
  --aas-card: oklch(0.21 0.01 265);
  --aas-primary: oklch(0.72 0.17 265);
  --aas-primary-foreground: oklch(0.16 0.01 265);
}

Beyond the shadcn-style tokens above, a few extra ones exist so dark mode is a pure variable swap — override them to fine-tune either palette:

| Token | Used for | | --- | --- | | --aas-sidebar | Account Settings sidebar panel. | | --aas-hover / --aas-hover-strong | Nav/icon-button hover and active fills. | | --aas-overlay / --aas-overlay-strong | The blur overlays on loading cards and settings sections. | | --aas-success / --aas-success-bg | "Verified" badges and credit amounts. | | --aas-switch-track, --aas-switch-knob, --aas-switch-knob-on | The settings toggle switch. |

The styles are theme-aware and scoped to the components — they won't affect the rest of your app, and your app's global resets can't override them.


API reference

Beyond the components, these functions are exported for building custom UIs:

| Function | Endpoint | | --- | --- | | getAuthConfig(projectId, apiBaseUrl?) | GET /auth-config → AuthConfig (captcha, branding, card layout; no token) | | verifyEmailApi(projectId, { token, captchaToken }, apiBaseUrl?) | POST /auth/verify-email | | resetPasswordApi(projectId, { token, password, captchaToken }, apiBaseUrl?) | POST /auth/reset-password | | getCurrenciesApi(projectId, apiBaseUrl?) | GET /currency → ProjectCurrency[] | | getUser(projectId, token, apiBaseUrl?) | GET /me (raw envelope) | | getUserProfile(projectId, token, apiBaseUrl?) | GET /me → MappedProfile | | getUserWallet(projectId, token, apiBaseUrl?) | GET /me → UserWallets | | getUserCreditWallet(projectId, token, apiBaseUrl?) | GET /credit-wallets → CreditWallet[] | | getUserSubscription(projectId, token, apiBaseUrl?) | GET /subscription → raw subscription object | | selectAccountApi(projectId, { selection_ticket, account_id, client? }, apiBaseUrl?) | POST /auth/select-account → accessToken (no auth; the ticket is the credential) | | switchAccountApi(projectId, accountId, token, apiBaseUrl?) | POST /auth/switch-account → a fresh accessToken | | listAccountsApi(projectId, token, apiBaseUrl?) | GET /accounts → AccountOption[] | | getInvitationApi(projectId, inviteToken, apiBaseUrl?) | GET /invitations/:token → InvitationLookup (no auth) | | acceptInvitationApi(projectId, inviteToken, token, apiBaseUrl?) | POST /invitations/:token/accept → account_id | | listRolesApi · createRoleApi · updateRoleApi · deleteRoleApi | /account/roles (owner only) | | listInvitationsApi · createInvitationApi · revokeInvitationApi | /account/invites (owner only) | | listMembersApi · updateMemberApi · revokeMemberApi | /account/members (owner only) | | listTeamApi(projectId, token?, apiBaseUrl?) | GET /account/team → TeamMember[] — the caller's colleagues, for a member | | getPermissionsApi(projectId, token?, apiBaseUrl?) | GET /account/permissions → PermissionCatalogue (cached; any member) | | expandPermissions(catalogue, keys) | Closes a selection over implies, transitively — what you save | | getAccountContext(projectId, token?, apiBaseUrl?) | GET /me → AccountContext \| null (owner vs member, role, permissions) | | validateSession(projectId, token, apiBaseUrl?) | GET /me → { valid, accountAccessLost, message, code } | | logout(options) | POST /auth/logout + callbacks | | logoutApi(projectId, token, apiBaseUrl?) | POST /auth/logout |

The Account Settings modal also drives a wider set of endpoints internally — profile (/me, /profile), password (/account), 2FA, WhatsApp, plans/subscription (/plans, /subscribe, /subscription), wallet (/wallet/logs, /recharge-links), and credit wallets (/credit-wallets, /credit-wallets/:id/logs, /credit-wallets/:id/recharge). If you need any of these as standalone helpers, open an issue and they can be exported.


TypeScript

Everything is fully typed. Notable exports:

import type {
  LoginProps, SignupProps, ForgotPasswordProps, VerifyEmailProps, AuthClassNames, AuthMode,
  AccountSettingsProps, AccountSettingsSection, AccountSettingsData, ProfileFormValues,
  OpenAccountSettingsOptions, AccountSettingsHandle,
  AuthConfig, CaptchaConfig, CardLayoutConfig,
  LoginRequest, SignupRequest, ForgotPasswordRequest, AuthResponse, LogoutOptions,
  MappedProfile, WalletInfo, UserWallets, CreditWallet, MappedSubscription,
  AccountPickerProps, AcceptInviteProps,
  AccountOption, SelectAccountRequest, AccessTokenResponse, InvitationLookup, SessionValidation,
  Role, CreateRolePayload, UpdateRolePayload,
  Invitation, InvitationStatus, CreateInvitePayload, CreatedInvite,
  Member, MemberStatus, UpdateMemberPayload, TeamMember,
  AccountContext, AccountRoleRef, DataScope,
  PermissionCatalogue, PermissionGroup, PermissionFeature, PermissionAction, PermissionVerb, PermissionInclusion,
  PermissionMatrixProps, DataScopeControlProps,
  TokenStorageConfig, TokenStorageKind,
  AuthFetchInit, AuthHandlers, AuthInterceptorOptions, UnauthorizedInfo,
  SsoRoutePaths, SsoScreen, SsoRouteMatch, SsoRouteObject,
  SsoScreenProps, SsoAuthPageProps, SsoAuthRoutesProps,
} from "ab-ecosystem-sso";

import { AuthApiError, isAccountAccessError } from "ab-ecosystem-sso";

License

Proprietary — © combot.ltd. Internal partner-ecosystem use.