Embeddable React widgets for Cbox ID — a drop-in user button, sign-in / sign-out buttons, a profile card and an organization badge, wired to your Cbox ID hosted flows. Themeable, accessible, and zero-config (the stylesheet is injected for you).
Pairs with @cboxdk/id-js, which runs the login
on the server; these widgets render the signed-in user it produces.
Everything else here is a redirect shell. <SignIn/> draws the form, in the customer's own
colours, against the Frontend API:
It needs a server that implements the sign-in half.
useCboxConfig()and<UserButton/>read/frontend/v1/configand/session, which thecboxdk/laravel-idpackage serves itself.<SignIn/>posts to/frontend/v1/sign-in*, which is sign-in policy and lives in the application — Cbox ID implements it, a barelaravel-idinstall answers 404. From a browser that looks exactly like a dead network, so it arrives as "we could not reach the sign-in service" rather than as a missing route.
import { CboxIdFrontend } from '@cboxdk/id-js'
import { SignIn } from '@cboxdk/id-react'
const frontend = new CboxIdFrontend({
issuer: 'https://id.acme.com',
publishableKey: 'pk_live_…',
})
<SignIn
frontend={frontend}
authorize={{
clientId: 'your-client-id',
redirectUri: 'https://acme.com/callback',
codeChallenge, // yours — see below
}}
/>It handles the password step, the second factor, and the social buttons the configuration
lists. Its last act is a redirect to /oauth/authorize carrying a single-use login ticket
and your PKCE challenge.
It brings its own stylesheet, so the example above renders a real form with no provider
above it. To wear the environment's brand, pass the appearance useCboxConfig() derives:
const { appearance } = useCboxConfig({ issuer, publishableKey })
<SignIn frontend={frontend} authorize={…} appearance={appearance}
forgotPasswordUrl="/forgot" signUpUrl="/signup" />Before any of this works, an operator has to turn the Frontend API on
(CBOX_ID_FRONTEND_API=true) and mint a publishable key under Developers → Frontend
keys, listing the exact origins allowed to use it. A key is useless from anywhere its
owner did not name — that is what makes it safe to publish — and a refusal reaches the
browser as a CORS-shaped network error with no readable body, by design. If your first
call fails and devtools shows no response, check the allow-list before anything else.
<SignIn/> now says this itself rather than leaving you to guess. An origin the key does
not allow replaces the form with a message naming the allow-list — every request the form
would make gets the same permanent refusal, so collecting a password in order to discard
it helps nobody. Any other failure it absorbs (a 5xx, a dropped connection) leaves the
form standing and goes to console.error. Pass onError to route the same causes into
your own reporting; a FrontendApiError arrives with its code intact.
<SignIn frontend={frontend} authorize={…} onError={(cause) => report(cause)} />It never touches a token. Handing tokens to a page that proved a password is the implicit grant, which OAuth 2.1 removes. Tokens are minted by the authorize flow exactly as they were; only how the person arrived is different.
PKCE is yours, deliberately. Generating the verifier inside the component would mean it has to store the verifier across a redirect, which is the part applications get wrong. You already create one for the redirect flow — pass the challenge in and keep the verifier where you keep it now.
Every refusal reads the same. A wrong password, an unknown address and a locked account
are one message, because the server refuses to tell them apart — that is the enumeration
oracle — and a UI that distinguishes them rebuilds it where a user can see. sso_required
is the exception, and is named: telling somebody whose organization mandates SSO that their
password is wrong sends them to support rather than to their identity provider.
Pass onTicket to route the ticket yourself instead of navigating.
<CboxIdProvider> normally takes user, urls and appearance from your server. That is
the right shape when you already render the page server-side: one fewer request, and no
loading state.
For a static site, or a widget dropped into somebody else's page, there is no server to
ask. useCboxConfig reads the environment's public configuration directly, using a
publishable key:
import { CboxIdProvider, useCboxConfig } from '@cboxdk/id-react'
function Providers({ children }) {
const { appearance, loading } = useCboxConfig({
issuer: 'https://id.acme.com',
publishableKey: 'pk_live_…', // public — safe in your bundle
})
return <CboxIdProvider appearance={appearance}>{children}</CboxIdProvider>
}The key only works from the origins you registered in the console, which is what makes it safe to publish.
It is deliberately not wired into the provider automatically: a provider that makes a
network request on mount turns a server-rendered page into one with a flash of unthemed
widgets, and that trade belongs to you rather than to us. appearance is {} while
loading and when the environment has set no theme, so widgets fall back to their own
defaults rather than to nothing.
Where do
issuer,clientIdandredirectUricome from? Register an application in your environment console — see Integrate your app.
npm install @cboxdk/id-reactWrap your app once, passing the user your server resolved and the flow URLs:
import { CboxIdProvider, UserButton } from '@cboxdk/id-react';
export function AppShell({ user, children }) {
return (
<CboxIdProvider
// Pick the fields the widgets draw. A `CboxUser` from @cboxdk/id-js also carries
// `accessToken`, `refreshToken` and `idToken`, and this package is a client
// component — so on Next.js App Router the whole object would cross the RSC
// boundary and land in the HTML payload in plaintext. The provider narrows what it
// is given as a backstop; do it here too, where it is visible.
user={user && { id: user.id, email: user.email, name: user.name, organizationId: user.organizationId }}
urls={{ signIn: '/auth/sign-in', signOut: '/auth/sign-out', profile: '/account' }}
>
<header>
<UserButton />
</header>
{children}
</CboxIdProvider>
);
}<UserButton> shows the user's avatar and opens a menu with Manage account
(hosted profile management) and Sign out. When signed out, it renders a sign-in
button instead. It's keyboard- and screen-reader-accessible and closes on outside
click or Escape.
| Component | What it renders |
|---|---|
<UserButton> |
Avatar + account menu (manage / sign out); a sign-in button when signed out. |
<SignInButton> / <SignOutButton> |
Standalone buttons linking to your flow routes. |
<UserProfileCard> |
Avatar, name, email, and a manage-account link. |
<OrganizationBadge> |
The user's current organization. |
<OrganizationSwitcher> |
The active organization + a menu to switch between the user's orgs. |
Hooks: useCboxUser() and useCboxId().
Provide the user's organizations and a switchOrganization URL builder — switching is
a redirect that starts a new sign-in carrying the chosen organization_id:
<CboxIdProvider
user={{ ...user, organizations: [
{ id: 'org_a', name: 'Acme', role: 'admin' },
{ id: 'org_b', name: 'Globex', role: 'member' },
] }}
urls={{
// A route in your app that calls cboxId.createAuthorizationRequest({ organizationId })
switchOrganization: (id) => `/auth/switch-org?org=${id}`,
createOrganization: '/organizations/new', // optional footer
}}
>
<OrganizationSwitcher />
</CboxIdProvider>Inject organizations from the server (the redirect flow doesn't expose the list
client-side). Omit it — or leave the user in one org — and the switcher renders nothing.
Pass an appearance, or override the --cbox-id-* CSS variables yourself:
<CboxIdProvider
user={user}
urls={urls}
appearance={{ accent: '#0ea5e9', radius: '12px' }}
>These are presentational widgets over Cbox ID's hosted flows — sign-in, sign-out,
and hosted profile management. Changing passwords, MFA, passkeys and sessions happens
on the Cbox ID instance's own account page (where urls.profile points); the widgets
route users there rather than reimplementing it.
MIT © Cbox.