@tula-tech/react
v0.4.0
Published
Tula's React SDK: a provider, hooks and prebuilt, themeable, accessible components for sign-up, sign-in, password reset and account management.
Readme
@tula-tech/react
React components and hooks for Tula Auth: sign-up with email verification,
sign-in, forgotten-password reset and account management, themeable from one set of tokens and
accessible by default. Built on @tula-tech/core; the components draw whatever
step the server answers with and hold no flow logic of their own.
bun add @tula-tech/react@latest # or: npm install @tula-tech/react@latestEarly release (0.x). The API may change from one release to the next. The components talk to a Tula API, which you host yourself (docs/self-host.md); everything else is in the repository.
| | |
| --- | --- |
|
|
|
|
|
|
|
|
|
Quickstart
import { SignedIn, SignedOut, SignIn, TulaLoading, TulaProvider, UserButton } from '@tula-tech/react'
import '@tula-tech/react/styles.css'
export function App() {
return (
<TulaProvider
publishableKey='tula_pk_dev_…' // safe to embed in an app
baseUrl='https://auth.example.com' // where the Tula API is served
signInUrl='/sign-in'
signUpUrl='/sign-up'
afterSignInUrl='/'
afterSignOutUrl='/sign-in'
>
<TulaLoading>Loading…</TulaLoading>
<SignedOut>
<SignIn />
</SignedOut>
<SignedIn>
<UserButton />
</SignedIn>
</TulaProvider>
)
}React 18.2 or 19. One stylesheet, no CSS framework, no icon library, no CSS-in-JS runtime. The
only runtime dependencies are @tula-tech/core and the contract's Zod-free entry points:
13.3 kB gzip for this package (21.2 kB with @tula-tech/core, React not counted) and 4.1 kB gzip of
CSS.
The app and the API must be same-site (app.example.com with auth.example.com, or two ports
on localhost) and the app's origin must be in the environment's allowed origins: see
@tula-tech/core's security notes. A working app is in
examples/react-vite.
Provider
<TulaProvider publishableKey baseUrl appearance? localization? navigate? {...urls}>
<TulaProvider client={createTulaClient(…)} …> // or bring your own @tula-tech/core clientCreates one client, calls load() once after mounting, and tries again (with a growing delay,
and when the browser comes back online) if the API cannot be reached.
| Prop | |
| --- | --- |
| publishableKey, baseUrl | The environment's key and the API's URL. A secret key is refused. |
| client | Instead of the two above: a client you created with createTulaClient. |
| navigate | (url) => void. Defaults to window.location.assign. Pass your router's function. |
| signInUrl, signUpUrl | Where the two components live; they link to each other with these. |
| afterSignInUrl, afterSignUpUrl, afterSignOutUrl | Where to go when each completes. Without one, nothing navigates: <SignedIn> / <SignedOut> swap the page. |
| userProfileUrl | Where <UserProfile> lives. Without it <UserButton> opens the profile in a dialog. |
| appearance | Theme tokens, colour scheme and class names for every component. |
| localization | Strings to change or translate. |
URLs are only ever the ones you pass. No component reads a destination from the query
string, the fragment or the server, so there is no redirect_url parameter to abuse. A URL is
followed only if it is relative or http(s); anything else (javascript:, data:) is
ignored.
Components
<SignIn>
Email first, then whatever the server asks for: a password, an emailed code or link. Where the environment offers more than one way in, the first is drawn as a form and the others as "other ways to sign in"; choosing "Email me a code" or "Email me a link" sends the email. "Forgot password?" runs the reset in the same card (email → code and new password together → signed in).
Where the environment signs in with a texted code (smsCode in its client configuration;
off by default), the first field reads "Email address or phone number". A number, typed
with its country code, leads to "Text me a code": nothing is sent before that button is
pressed, and the next screen takes the code. The screens are the same for a number no
account can sign in with, so their words never say that a message was sent. A version of
this package from before the method leaves the strategy out, as it does any it has no form
for. See Texted code.
| Prop | |
| --- | --- |
| signUpUrl, onSwitchToSignUp | Shows "New here? Create an account" as a link, or calls you instead. |
| afterSignInUrl, onComplete | Go there once signed in, or get { userId, sessionId } instead. |
| emailLinkUrl | The page emailed sign-in links lead to (it renders <EmailLinkCallback>). Without it (here or on the provider) "Email me a link" is not offered. It must be one of the environment's allowed redirect URLs, exactly, and on the same origin as the page showing <SignIn> (the link is tied to the browser through that origin's storage; another origin is refused with link.cross_origin before any email is sent). A relative value is resolved against the page. |
| initialEmail | Prefills the email field. |
| appearance, headingLevel | See below. headingLevel (1–3, default 1) is the card title's level. |
<EmailLinkCallback>
The page an emailed sign-in link leads to. It takes the link's token out of the address bar,
checks it, and says one of: you are signed in (and goes to afterSignInUrl); continue in the
tab where you started; open this link where you started (it was opened in a browser that
did not ask for it: nothing is used up, and the code from the same email works there); the
link has expired; or there is no link here.
// At the URL you pass as emailLinkUrl, e.g. /auth/link
<EmailLinkCallback signInUrl='/sign-in' afterSignInUrl='/app' />Props: signInUrl, afterSignInUrl, onComplete, appearance, headingLevel. For your own
page, useEmailLinkCallback() returns { status, error } with the same outcomes
(loading, signed_in, verified, different_browser, expired, none, error).
A link works only in the browser that asked for it, by design: someone who types another person's address must not be signed in because that person clicked the genuine email.
Signing in with Google, GitHub, Apple, Microsoft, Discord, LinkedIn, X or Facebook
<SignIn> and <SignUp> draw a "Continue with …" button for every OAuth provider the
environment has enabled (GET /v1/client/config → signIn.oauth), once the app says where the
round trip comes back to:
<TulaProvider publishableKey={key} oauthCallbackUrl='/oauth/callback'>…</TulaProvider>
// at /oauth/callback
<OAuthCallback signInUrl='/sign-in' afterSignInUrl='/app' userProfileUrl='/account' />oauthCallbackUrl (on the provider, or on <SignIn> / <SignUp> / <UserProfile>) must be
one of the environment's allowed redirect URLs, exactly, and on the same origin as the page.
Without it, or where the tab has no sessionStorage, no button is drawn.
<OAuthCallback> finishes the round trip: it signs the user in and goes on; shows the
second-factor (or enrolment) screen for a user who still has one to pass, since a provider is
only the first factor; or explains what happened (oauth.account_exists with how to connect
the provider from the account, a cancelled or expired sign-in, a sign-in started in another
browser, an account now connected). useOAuthCallback() returns the same as
{ status, code, message, identity, error, signIn } for an app that draws the page itself.
<UserProfile> gains "Connected accounts": connect, and disconnect unless it is the last way
to sign in.
The buttons are neutral, themeable buttons with each provider's mark and name. They follow the
providers' basic rules (Google's "G", Microsoft's four squares, Discord's mark and LinkedIn's
"in" in their own colours, GitHub's and Apple's marks in the text colour, "Continue with …")
but are not the
providers' own button artwork: check Google's, Apple's and Microsoft's branding guidelines
against your theme before you ship (Microsoft's own button says "Sign in with Microsoft" in
a fixed size, font and colours), and remember that App Store
Review requires an iOS app that offers another social sign-in to offer Sign in with Apple.
Discord's and LinkedIn's marks were drawn without their brand pages open: their shapes,
their colours (#5865F2, #0A66C2) and the clear space around them were not checked against
either company's brand guidelines. Check both before you ship. The same holds for X's mark
(in the text colour) and Facebook's (#0866FF): neither was checked against X's or
Meta's brand guidelines.
A user who signed up with X or Facebook has no email address: useUser().user.email is
null, and <UserButton> and <UserProfile> draw the name instead, with no address line
and no password section. Code of your own that reads user.email has to allow for null.
For such a user <UserProfile> has an "Email address" section: they type an address, are
emailed a code and confirm it, and the address is then the account's, verified. An address
another account holds gets the same screen and is sent no code. A user who signed in more
than ten minutes ago, with no passkey and no authenticator app, is told to sign out and
sign in again first. The section never changes an address an account has.
Passkeys
Where the environment has passkeys on (signIn.methods.passkey and passkeys.rpId, with the
page's origin in urls.allowedOrigins and under that rpId) and the browser has WebAuthn, the
components draw them with no props to set:
<SignIn>shows "Sign in with a passkey" on its first screen: no address is needed, and a passkey needs no second step. The address field carriesautocomplete="username webauthn", so a browser with conditional mediation offers the user's passkeys in its autofill. After an address, the passkey is one of the "other ways".- The second-factor screen and the step-up dialog offer "Use your passkey" where the server lists it, alone or next to a code.
<UserProfile>gains "Passkeys": the list (name, synced or on one device, added, last used), "Add a passkey", rename, and remove after a confirmation. Removing the last way to sign in is refused by the server, and its message is shown.
In a browser without WebAuthn the sign-in button and "Add a passkey" are not drawn, and a step
that only a passkey could pass says so. A dialog the user dismisses is not an error: the
component says so quietly (passkey.cancelled) and the button works again. Building your own
screens: useSignIn() has canUsePasskey(), withPasskey() and
submitSecondFactorWithPasskey(); the rest is useTula().user.passkeys and
useTula().session.stepUpWithPasskey(), wrapped in useStepUp() where the call is sensitive.
The strings are in the passkey group of the localization table; the parts are
passkeyIcon, passkeyList, passkeyItem and confirmation.
<SignUp>
Email and password with the live password checklist, then the emailed code. Where the
environment makes the password optional (signUp.password: 'optional'), the field is labelled
"Password (optional)" and may be left empty: the account then signs in with an emailed code.
| Prop | |
| --- | --- |
| signInUrl, onSwitchToSignIn | Shows "Already have an account? Sign in". |
| afterSignUpUrl, onComplete | As for <SignIn>. |
| collectName | Also ask for first and last name (optional for the user). |
| appearance, headingLevel | |
<UserButton>
The user's initials; a menu with "Manage account" and "Sign out". Props: afterSignOutUrl,
userProfileUrl, onManageAccount, appearance.
"Sign out" goes to afterSignOutUrl only once the server has been told. When it could not be
(offline, a 503), nothing navigates: the provider shows a dialog saying the session may still
be active on this device, with "Try again". The same holds for <UserProfile>.
<UserProfile>
Profile, change password (other devices are signed out), phone number (where the environment
sends text messages: add one with a texted code, change it, remove it), two-step verification,
"Where you're signed in" with this device marked, sign out one device or all the others, sign out. Props:
afterSignOutUrl, appearance, headingLevel.
Two-step verification
Nothing to add to your app: the screens appear when the server asks for them.
- At sign-in (and after a password reset) a user who has it on gets a second screen: the 6-digit code from their authenticator app, or "Use a backup code".
- In
<UserProfile>the "Two-step verification" section turns it on (a QR code, the setup key as text for typing by hand, then the code the app shows), shows the ten backup codes once (copy, download as a text file, and an explicit "I have saved these codes" before the screen lets go), makes new codes, and turns it off. It is hidden where the environment'smfa.policyisoff, and has no "Turn off" where it isrequired. - Where the environment requires it, a sign-in, sign-up or reset of a user without it
enrols inside the flow. Confirming signs the user in, so the backup codes are shown in a
dialog the provider owns, above whatever page your app has moved to;
onCompleteand the after-URL wait until the user has confirmed. - Step-up. When a sensitive action answers
auth.step_up_required, the provider opens a dialog asking for what the server named (the authenticator or a backup code for a user with two-step verification, otherwise the password), sends it, and the action runs again. For your own sensitive calls:
import { isStepUpRequired, useStepUp, useTula } from '@tula-tech/react'
function NewCodes() {
const tula = useTula()
const withStepUp = useStepUp()
const renew = async () => {
try {
const { codes } = await withStepUp(() => tula.mfa.regenerateBackupCodes())
show(codes) // once: keep them nowhere
} catch (error) {
if (!isStepUpRequired(error)) throw error // otherwise the user closed the dialog
}
}
return <button onClick={renew}>New backup codes</button>
}The QR code is drawn by the package itself (no dependency), as inline SVG, dark on white in both themes, and its encoder is loaded only when an enrolment is shown.
| | |
| --- | --- |
|
|
|
|
|
|
<SignedIn>, <SignedOut>, <TulaLoading>
Render their children only in that state. During server rendering and until load() answers,
the state is loading: <SignedOut> does not flash a sign-in form at a signed-in user.
How the screens are chosen
The server decides the next step; a component maps step.status to a screen and nothing else.
| Step | Screen |
| --- | --- |
| (not started), needs_identifier | Email |
| needs_password | Password |
| needs_first_factor | The form of each offered strategy this version knows (password today); unknown strategies are skipped |
| needs_email_verification | The emailed code, with resend |
| needs_new_password | The emailed code and the new password, together |
| needs_second_factor | The authenticator code, or a backup code (the options this version knows) |
| needs_factor_enrolment | Set up an authenticator app: QR code, setup key, code; then the backup codes |
| complete | "You are signed in", then onComplete or the after-URL |
| anything else (a second factor this version cannot ask for, a status a newer server added) | "This step is not supported" with "Start again" |
An old SDK against a newer server therefore shows a clear message, never a blank card, and
never guesses at an action. A new sign-in method is one more entry in the strategy table in
src/components/sign-in.tsx, not new logic.
Errors come back from the server with a code. Problems about a field (error.errors[]: every
unmet password rule, an invalid email) are shown under that field; a wrong code shows how many
attempts are left; a rate limit or lockout (retryAfterMs) becomes a countdown that disables
the action until the server would accept it again; everything else (offline, the service
unavailable) is shown above the form.
Password checklist
The checklist is the environment's own policy (client.config.get()), evaluated by
evaluatePassword, the function the server runs: they cannot disagree. The server still has
the last word (a breached password can only be checked there) and its answer is shown on the
field. For your own form:
const { checks, ok } = usePasswordChecklist(password, { email })Theming
Three layers, from broad to narrow.
1. Tokens. Every visual decision is a token from
@tula-tech/contract/theme (the same table the Swift and Kotlin SDKs
will generate constants from). Set them in CSS, anywhere above the components:
:root {
--tula-color-primary: #0f766e; /* light */
--tula-dark-color-primary: #5eead4; /* dark */
--tula-radius: 6px;
--tula-font-family: 'Inter', sans-serif;
}| Tokens (light --tula-…, dark --tula-dark-…) | |
| --- | --- |
| color-background, color-surface, color-input | Card, quiet panels, the inside of a field |
| color-text, color-text-muted | Text |
| color-primary, color-primary-hover, color-primary-text | The main button |
| color-link, color-border, color-border-strong, color-focus | Links, hairlines, control edges, the focus ring |
| color-danger, color-danger-surface, color-success, color-overlay, shadow | States, the dialog backdrop, the card shadow |
| Shared: radius, radius-small, font-family, font-family-mono, font-size, font-size-small, font-size-title, spacing | |
The scheme follows prefers-color-scheme. data-tula-theme="light" or "dark" on any
ancestor (or appearance.colorScheme) forces one. If you change colours, keep text at 4.5:1
and control edges and the focus ring at 3:1; contrastRatio from @tula-tech/contract/theme
computes it, and the default theme is held to that by a test.
2. appearance. The same tokens from JavaScript, plus class names, on the provider (all
components) or on one component (which wins):
<SignIn
appearance={{
theme: { light: { primary: '#0f766e' }, dark: { primary: '#5eead4' }, radius: '6px' },
colorScheme: 'system', // 'light' | 'dark' | 'system'
elements: { card: 'my-card', primaryButton: 'my-button' },
}}
/>Theme values given through appearance are validated, not trusted, because a brand colour
often comes from data a tenant can edit and a server-rendered theme is written into the page as
text. Each value must be a plain value of its token's type or it is dropped and the default
stays:
| Type | Accepted |
| --- | --- |
| colour | #rgb, #rgba, #rrggbb, #rrggbbaa; rgb(), rgba(), hsl(), hsla(), hwb(), lab(), lch(), oklab(), oklch() with numeric arguments; a colour keyword |
| length | 0, or a number with px rem em % ch ex pt vw vh vmin vmax |
| font family | a comma-separated list of quoted names or bare words |
| shadow | none, or layers of an optional inset, two to four lengths and at most one colour |
Nothing containing ; { } < > \ @ !, a line break, /*, url(, expression(
or var( passes, whatever the type (isValidThemeValue in @tula-tech/contract/theme). For
anything richer (a gradient, calc(), a web font), write it in your own stylesheet, which you
control.
3. Your own CSS. Every part has a stable class (tula-card) and a stable
data-tula-element attribute (card), listed in ELEMENT_NAMES:
root, card, header, title, subtitle, form, field, label, inputGroup, input,
codeInput, passwordToggle, hint, fieldError, error, status, primaryButton,
secondaryButton, dangerButton, linkButton, link, spinner, identity, strengthBar,
checklist, checklistItem, footer, branding, avatar, badge, userButton,
userButtonTrigger, menu, menuHeader, menuItem, dialog, section, sectionTitle,
profile, sessionList, sessionItem.
Every selector in the stylesheet is wrapped in :where(), so it has no specificity:
.tula-card { box-shadow: none } in your CSS wins without !important, wherever it is loaded.
The other side of that: a broad rule of yours such as button { … } or :focus-visible { … }
also wins. Scope such rules to your own markup.
Localization
Every string the components show is in one typed table, TulaLocalization; EN_LOCALIZATION
is the English one. Pass any subset:
<TulaProvider
localization={{
locale: 'es', // for relative times ("hace 2 días")
signIn: { title: 'Inicia sesión', continue: 'Continuar' },
password: { rules: { min_length: '{min} caracteres o más' } },
errors: { 'auth.invalid_credentials': 'El correo o la contraseña no son correctos.' },
}}
…
>Messages for errors the API reports are not duplicated here: they are @tula-tech/core's table,
keyed by error code, and localization.errors is handed to the client (setMessages).
Only known keys with string values are taken from the table, and strings are rendered as text,
never as HTML.
Headless hooks
For your own UI on the same engine.
const { status, isLoaded, isSignedIn, sessionId, getToken, signOut } = useAuth()
const { user, reload } = useUser()
const { sessions, revoke, revokeOthers, error } = useSession()
const tula = useTula() // the @tula-tech/core client
const signIn = useSignIn() // also useSignUp(), useResetPassword()
signIn.step // FlowStep | null: draw one screen per status
signIn.isPending
signIn.error // TulaError | null: code, message, errors[], retryAfterMs
await signIn.start({ identifier })
await signIn.submitPassword({ password }) // resolves with the next step, or null (see error)
await signIn.verifyEmail({ code })
await signIn.resendCode()
await signIn.submitSecondFactor({ method: 'totp', code }) // or { method: 'backup_code', code }
const enrolment = await signIn.startTotpEnrolment() // { secret, uri } | null: show once
const done = await signIn.confirmTotpEnrolment({ code }) // { step, backupCodes } | null
const withStepUp = useStepUp() // see Two-step verification
await signIn.prepareFirstFactor({ strategy: 'email_code' }) // or { strategy: 'email_link', redirectUrl }
await signIn.attemptFirstFactor({ strategy: 'email_code', code })
signIn.canUseEmailLink() // false where the browser refuses storage
useEffect(() => { // while the "open the link" screen is shown
const leaving = new AbortController()
void signIn.waitForEmailLink({ signal: leaving.signal })
return () => leaving.abort()
}, [])
signIn.reset() // also stops waiting and forgets the link's binding
const { status, error } = useEmailLinkCallback() // on the page a link leads toFlow actions never reject: they resolve with the next step, or null with the reason in
error. A second action while one is pending resolves null and sends nothing. Treat a status
you do not know as "unsupported", as the components do.
Organizations
Where the environment has organizations (ADR 0065), two hooks read and change the organization the session acts in.
const { organizationId, organization, role, permissions, has, isLoaded } = useOrganization()
has({ permission: 'org:billing:read' }) // true or false: exact match, no wildcard
has({ role: 'admin' }) // one check at a time: a permission or a role
const { organizations, activeOrganizationId, setActive, create } = useOrganizationList()
await setActive(organizations[0].id) // null: back to the personal account
await create({ name: 'Acme', slug: 'acme' })role, permissions and has come from the access token's claims, so they follow the session
and change at its next refresh: a removal or a demotion takes effect then, not at once.
has() is false while nothing is active and while the answer belongs to an earlier session; a
list asked for under one session is never shown under another. has() decides what to
draw, never what to allow: your server checks the same permission
(@tula-tech/nextjs's auth().has() and the middleware's access rules do).
Accessibility
Built to WCAG 2.2 AA and checked by axe in a real browser on every screen and state, in both schemes (no rule disabled).
- Real
<form>s and<label>s. Errors are tied to their field (aria-invalid,aria-describedby) and announced (role="alert"); quiet confirmations use a polite live region. - When the step changes, focus moves to the new card's title, so the change is announced and Tab continues from the top. After a failed action, focus moves to the first invalid field. The first screen of a component does not take focus: the page owns that.
- The password checklist says each rule's state as text ("Met: …", "Not met: …"), not only by colour and icon, and a polite summary ("2 of 3 password requirements met") follows typing.
- A pending button keeps its name, its focus and its place in the tab order (
aria-disabledandaria-busy, notdisabled). - The user menu is a menu button: Enter, Space or the arrow keys open it, arrows / Home / End
move, Escape closes and returns focus. The profile dialog is a native modal
<dialog>. - Code field:
inputmode="numeric",autocomplete="one-time-code", one input, so SMS/email autofill and pasting a whole code work. Password fields usecurrent-password/new-password, with the account's address in a hiddenusernamefield for password managers. - Targets are at least 24×24 px;
prefers-reduced-motionturns transitions and the spinner's rotation off; the default colours meet 4.5:1 for text and 3:1 for control edges and focus rings in both schemes (computed in@tula-tech/contract's tests). - A step change in a single-page app is not a page load: if your router swaps the page after
onComplete, move focus to the new page's heading as you would for any route change.
Server rendering
Every export is a client component ('use client'). Nothing touches window or document
while rendering, so the provider and the components can be rendered on a server: the state is
loading there, <SignedIn> and <SignedOut> render nothing, and <SignIn> renders its
first screen. The browser hydrates that without a mismatch and then finds out who is signed in.
Server-side session checks (auth(), middleware) come with @tula-tech/nextjs.
Security notes
- Tokens: see
@tula-tech/core. This package adds no storage of its own: nothing is written tolocalStorage,sessionStorage, a cookie, a URL or a DOM attribute. (@tula-tech/corekeeps one thing inlocalStoragewhile a sign-in waits for an emailed link: the link's binding, which is not a token. See its security notes.) - An emailed link signs nobody in when it is opened in a browser that did not ask for it, and its token is removed from the address bar before anything is sent.
- Passwords and codes live in component state only while their form is on screen and are cleared when it is submitted or goes away. Nothing is logged.
- An authenticator's setup key, its QR code and backup codes are shown once and held only while their screen (or the provider's dialog) is open: afterwards nothing of them is in the DOM, in web storage or in a URL. "Copy" and "Download" hand them to the user's clipboard or a file and to nothing else.
- Nothing from the server is rendered as HTML (no
dangerouslySetInnerHTML); a session's user agent is only matched against fixed patterns to name the device. - Navigation targets are the developer's props only, checked to be relative or
http(s). - Theme values are validated against a strict grammar before they reach a
styleattribute (see Theming): a value from tenant-editable data cannot inject CSS.
Development
bun run --filter @tula-tech/react test # components against @tula-tech/core's fake API, in happy-dom
bun run --filter @tula-tech/react generate # rewrite the stylesheet's token block from the contract's theme
bun run e2e:install # once: Chromium for Playwright
bun run e2e # the example app against the real API in process, with axe
bun run e2e:screenshots # regenerate the screenshots aboveDesign and reasoning: ADR 0022.
