@groo.dev/auth-react
v0.2.10
Published
React hooks and components for Groo Auth SDK
Readme
@groo.dev/auth-react
React hooks and components for integrating Groo authentication into your React/Next.js applications.
AuthProvider supports two modes:
redirect(default) — session cookie + your own backend proxy via@groo.dev/auth-server. Unchanged from prior versions; see "Redirect mode" below.spa— no backend required. OAuth authorization-code + PKCE flow straight from the browser toaccounts.groo.dev, with in-memory access tokens and an HttpOnly refresh cookie for silent session restore. See "SPA mode quick start" below.
Both modes ship the same styled components (SignInButton, SignUpButton, UserButton) and hooks (useAuth, useUser, useScopes) — signIn()/signOut() on the auth context are the API surface these components call, so anything they do you can do yourself.
spa mode additionally ships <UserProfile> — a full self-serve account-settings panel (profile, security, connected apps, API tokens, danger zone) — plus the five hooks that back it (useProfile, usePasskeys, useDevices, useConnectedApps, useApiTokens). These are spa-mode only: they call the Bearer-scoped /v1/account/* API directly, which redirect-mode apps have no token to call. In redirect mode, point users at accounts-web's own settings pages instead (UserButton does this automatically — see below).
Installation
npm install @groo.dev/auth-reactPrerequisites
Before using this SDK, you need a registered application at accounts.groo.dev with a client_id:
- For
redirectmode: any application type, plus a backend API using@groo.dev/auth-serverto proxy authentication. - For
spamode: the application must be registered as aspa-type application (a public client, no client secret) with your app's URL listed as a redirect URI origin. No backend is required for auth itself.
SPA mode quick start
import { AuthProvider, SignInButton, UserButton } from '@groo.dev/auth-react'
import '@groo.dev/auth-react/styles.css'
<AuthProvider
mode="spa"
baseUrl="https://accounts.groo.dev"
clientId="app_yourclientid"
redirectUri="https://yourapp.example/"
>
<header>
<SignInButton />
<UserButton />
</header>
</AuthProvider>Notes:
- Import
@groo.dev/auth-react/styles.cssonce (e.g. in your root layout) — it provides the default look and the theming variables below. Components render unstyled without it. spamode requires theaccounts:profilescope: the SDK loads the signed-in user from/v1/account/profile, andAuthProviderthrows at render time if the requestedscopesarray omits it. The default scope list (openid profile email offline_access accounts:profile accounts:passkeys accounts:devices accounts:apps accounts:tokens) already includes it, plus the other fouraccounts:*scopes each<UserProfile>tab needs — only a concern if you pass a customscopesprop (see "Account management hooks" below for which scope backs which tab).- Sign-in and sign-out redirect the top-level page to
accounts.groo.dev; there is no embedded credential UI in your app (by design — see the design spec's non-goals). SignInButton,SignUpButton, andUserButtonwork in both modes without changes to your code — only theAuthProviderprops differ.- In
spamode,signOut()is app-local: it revokes this app's tokens only, and the accounts.groo.dev SSO session persists, so the nextsignIn()round-trips silently without prompting for credentials again.
Redirect mode (unchanged)
If you don't pass mode="spa" (or pass mode="redirect" explicitly), AuthProvider behaves exactly as before: session cookie + /v1/__auth/me proxy via @groo.dev/auth-server. Existing redirect-mode consumers (pass, drive, ai, ops) need no changes to keep working, and SignInButton/SignUpButton/UserButton work there too.
Theming
Override any of these CSS custom properties on :root (or an ancestor of the components) after importing @groo.dev/auth-react/styles.css:
| Variable | Purpose | Default |
|---|---|---|
| --groo-auth-accent | Primary brand color (buttons, avatar background, focus ring) | #4f46e5 |
| --groo-auth-accent-fg | Text/icon color on top of the accent color | #ffffff |
| --groo-auth-bg | Background color for menus/panels | #ffffff |
| --groo-auth-fg | Primary text color | #111827 |
| --groo-auth-muted | Secondary text color (e.g. email under name) | #6b7280 |
| --groo-auth-border | Border color for menus, secondary buttons | #e5e7eb |
| --groo-auth-danger | Color for destructive actions (e.g. sign out) | #dc2626 |
| --groo-auth-radius | Corner radius for buttons, menus, avatars | 8px |
| --groo-auth-shadow | Box shadow for popover menus | 0 8px 24px rgb(0 0 0 / 0.12) |
| --groo-auth-font | Font family | system-ui, -apple-system, 'Segoe UI', sans-serif |
| --groo-auth-font-size | Base font size | 14px |
| --groo-auth-focus-ring | box-shadow used for keyboard focus | 0 0 0 2px var(--groo-auth-bg), 0 0 0 4px var(--groo-auth-accent) |
Dark mode applies automatically via prefers-color-scheme: dark; override explicitly with data-groo-theme="dark" or data-groo-theme="light" on :root (or any ancestor). Every component also accepts a className prop, and internal nodes carry stable groo-auth-* classes for deeper overrides.
Quick Start (redirect mode)
1. Set up your backend API
Your API needs to use @groo.dev/auth-server:
// api/src/index.ts
import { Hono } from 'hono'
import { grooAuth } from '@groo.dev/auth-server'
import { GrooHonoMiddleware } from '@groo.dev/auth-server/hono'
type Env = {
CLIENT_ID: string
CLIENT_SECRET: string
}
const hono = new GrooHonoMiddleware<Env>((env) => grooAuth({
clientId: env.CLIENT_ID,
clientSecret: env.CLIENT_SECRET,
}))
const app = new Hono<{ Bindings: Env }>()
app.use('*', hono.init)
app.route('/v1', hono.routes)
export default app2. Proxy API calls from your frontend
Configure your frontend to proxy /v1/* requests to your API:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: '/v1/:path*',
destination: 'https://api.myapp.groo.dev/v1/:path*',
},
]
},
}
export default nextConfig3. Wrap your app with AuthProvider
// app/providers.tsx
'use client'
import { AuthProvider } from '@groo.dev/auth-react'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider
baseUrl="https://accounts.groo.dev"
clientId="your-client-id"
redirectUri="https://myapp.groo.dev"
>
{children}
</AuthProvider>
)
}4. Use the auth hooks
'use client'
import { useAuth, LoginButton, LogoutButton } from '@groo.dev/auth-react'
export default function Page() {
const { user, isLoading, error } = useAuth()
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error: {error.message}</div>
return user ? (
<div>
<p>Welcome, {user.name || user.email}!</p>
<LogoutButton>Sign Out</LogoutButton>
</div>
) : (
<div>
<p>Not logged in</p>
<LoginButton>Sign In</LoginButton>
</div>
)
}API Reference
AuthProvider
Provides authentication context to your app.
<AuthProvider
baseUrl="https://accounts.groo.dev" // Required: Groo accounts service URL
clientId="your-client-id" // Required: Your application's client ID
redirectUri="https://myapp.groo.dev" // Required: Where to redirect after login
mode="redirect" // Optional: 'redirect' (default) or 'spa'
scopes={['openid', 'profile', 'email', 'offline_access', 'accounts:profile', 'accounts:passkeys', 'accounts:devices', 'accounts:apps', 'accounts:tokens']} // Optional, spa mode only
>
{children}
</AuthProvider>mode and scopes only matter for spa mode — see "SPA mode quick start" above. Redirect-mode apps can omit both.
useAuth()
Hook to access authentication state.
const { user, isLoading, error, loginUrl, logoutUrl, scopes, mode, accountsUrl, signIn, signOut, refetch, authedFetch, stepUp, getToken } = useAuth()Returns:
user: User | null- Current user or null if not authenticatedisLoading: boolean- True while fetching user dataerror: Error | null- Error if authentication check failedloginUrl: string- URL to redirect for login (redirect mode)logoutUrl: string- URL to redirect for logout (redirect mode)scopes: string[]- OAuth scopes granted: consent record in redirect mode, token scope in spa modemode: 'redirect' | 'spa'- The active provider modeaccountsUrl: string- Normalized accounts base URL (no trailing slash), for deep linkssignIn: (options?: { screen?: 'signup' }) => Promise<void>- Starts sign-in (or sign-up, via{ screen: 'signup' }); this is whatSignInButton/SignUpButtoncallsignOut: () => Promise<void>- Signs out; this is whatUserButton's menu callsrefetch: () => Promise<void>- Function to refetch user dataauthedFetch: (path: string, init?: RequestInit) => Promise<Response>- Authenticated fetch (spamode only; throws inredirectmode) — see "authedFetch" belowstepUp: () => Promise<void>- Step-up re-auth round trip (spamode only; throws inredirectmode) — see "Step-up re-authentication" belowgetToken: () => Promise<string | null>- Current access token, refreshing if expired,nullwhen signed out (spamode only; throws inredirectmode) — see "authedFetch" below
SignInButton / SignUpButton
Branded buttons that start the sign-in / sign-up flow, in either provider mode (redirect navigation in redirect mode, PKCE authorize redirect in spa mode). <SignUpButton> sends the user to accounts-web's signup screen; because new accounts are approval-gated, a successful signup always lands on a "pending approval" screen rather than resuming your app immediately — the OAuth context (the redirect back into your app) is preserved via a continue link on that screen's "sign in" link, so once an admin approves the account the user signs in and the original flow resumes.
import { SignInButton, SignUpButton } from '@groo.dev/auth-react'
<SignInButton className="btn">Sign In</SignInButton>
<SignUpButton className="btn">Sign Up</SignUpButton>UserButton
Avatar chip that opens a dropdown menu: name/email header, "Manage account", any app-supplied custom items, and sign out. Keyboard-navigable, portal-rendered, tracks the trigger on scroll/resize.
import { UserButton } from '@groo.dev/auth-react'
<UserButton
items={[{ label: 'Settings', onClick: () => router.push('/settings') }]} // optional custom items
manageAccount="modal" // optional: force 'modal' or 'link' — see below
manageAccountHref="/custom" // optional: override the 'link' destination
/>Renders nothing when signed out — pair with SignInButton for a typical header (see the SPA mode quick start above).
"Manage account" auto-wiring: the manageAccount prop controls what clicking "Manage account" does, and defaults based on provider mode:
spamode (default'modal') — opens<UserProfile mode="modal">in place, no navigation.redirectmode (default'link') — a plain link to${accountsUrl}/settings(ormanageAccountHrefif you pass one).
You can override the default, but passing manageAccount="modal" in redirect mode is a silent no-op: <UserProfile> requires spa mode internally and renders nothing (with a one-time console.warn) when it doesn't have one, so the menu item renders but clicking it does nothing visible. Don't force 'modal' unless the provider is in spa mode.
UserProfile
Tabbed account-settings panel — Profile, Security (passkeys + devices/sessions), Connected apps, API tokens, and a Danger zone (sign out everywhere, delete account). spa mode only — like the hooks it's built on, it renders null (with a one-time console.warn) if the provider isn't in spa mode.
import { UserProfile } from '@groo.dev/auth-react'
// Inline (default) — renders the panel in place, e.g. on your app's /account route.
<UserProfile />
// Modal — render conditionally and control it yourself.
const [open, setOpen] = useState(false)
<UserProfile mode="modal" open={open} onClose={() => setOpen(false)} />Props: mode?: 'inline' | 'modal' (default 'inline'), open?: boolean (modal only), onClose?: () => void (modal only — called on Escape or backdrop click), className?: string.
You usually don't render <UserProfile mode="modal"> yourself — <UserButton> does it automatically in spa mode (see "Manage account" auto-wiring above). Render it directly when you want an inline /account page, or a modal you trigger from your own UI.
Focus management: modal mode moves focus into the dialog on open and restores it to whatever was focused before opening once the dialog closes (Escape, backdrop click, or unmount while open). This is not a full focus trap (Tab can still leave the dialog) — that's out of scope for now.
Account management hooks (spa mode only)
Each hook backs one <UserProfile> tab and returns { <data>, isLoading, error, refetch, <mutations> }. Mutations throw on failure — including StepUpRequiredError for the ones gated on recent authentication (see "Step-up re-authentication" below). Import them directly if you're building custom account-settings UI instead of using <UserProfile>.
import { useProfile, usePasskeys, useDevices, useConnectedApps, useApiTokens } from '@groo.dev/auth-react'useProfile()→{ user, isLoading, error, updateProfile({ name?, phone? }), changePassword({ currentPassword?, newPassword }), requestEmailChange(newEmail), verifyCurrentEmail(code), verifyNewEmail(code), deleteAccount(confirmEmail) }. Email change is a 3-step flow (request → verify current → verify new);changePasswordanddeleteAccountare step-up gated.deleteAccountsigns the user out on success.usePasskeys()→{ passkeys, isLoading, error, refetch, rename(id, name), remove(id) }. Registering a new passkey stays on accounts-web (WebAuthn RP ID constraint) — this hook only manages existing ones.useDevices()→{ devices, isLoading, error, refetch, revoke(id), signOutEverywhere() }.signOutEverywhereis step-up gated.useConnectedApps()→{ apps, isLoading, error, refetch, revoke(applicationId) }— lists OAuth grants (apps the user has authorized) and lets them be revoked.useApiTokens()→{ tokens, isLoading, error, refetch, create(input): Promise<string>, revoke(id) }.create({ name, scopes, description?, expiresInDays? })is step-up gated and resolves to the plaintext token secret — shown once; the API never returns it again.
Each hook requires one accounts:* scope:
| Hook | Required scope |
|---|---|
| useProfile | accounts:profile |
| usePasskeys | accounts:passkeys |
| useDevices | accounts:devices |
| useConnectedApps | accounts:apps |
| useApiTokens | accounts:tokens |
The SDK's default spa-mode scopes already include all five, so this table only matters if you pass a custom scopes prop to AuthProvider — omitting one of these scopes will make the corresponding hook (and <UserProfile> tab) fail with an insufficient-scope error.
Step-up re-authentication (StepUpRequiredError)
Password change, email change, account deletion, and API-token creation require a recent sign-in (checked via the access token's auth_time). If the token is stale, the API rejects the mutation with 403 stale_auth, and the hooks above throw StepUpRequiredError instead of a generic Error.
import { StepUpRequiredError } from '@groo.dev/auth-react'
import { useAuth } from '@groo.dev/auth-react'
const { stepUp } = useAuth()
try {
await changePassword({ currentPassword, newPassword })
} catch (err) {
if (err instanceof StepUpRequiredError) {
// Show a "Confirm it's you" prompt, then call stepUp() from its button.
} else {
// Handle as a normal error.
}
}stepUp() (from useAuth()) round-trips the top-level page through accounts-web's /authorize with max_age, which redirects to /login only if the session is actually stale — otherwise it returns immediately with a fresh token. Call it from the click handler of your "Confirm it's you" button; the browser navigates away and back, and your mutation form's local state (e.g. the password fields) is preserved because it's a full authorize round trip through the existing SSO session, not a fresh login. <UserProfile>'s built-in tabs already implement this pattern (see StepUpNotice internally) — reuse it as a reference if you're building custom UI with the hooks above.
authedFetch — calling your own Bearer-protected API
If your app has its own backend and wants to call it with the same access token the SDK uses for /v1/account/* (rather than managing a second auth flow), useAuth() exposes the same authedFetch the hooks use internally:
const { authedFetch } = useAuth()
const response = await authedFetch('https://api.myapp.example/v1/widgets', { method: 'POST', body: JSON.stringify(data) })authedFetch(url, init?) attaches the current access token as an Authorization: Bearer header, and — like the account hooks — refreshes and retries once on a 401 before giving up. Pass an absolute URL for your own API (a path starting with / is resolved against the accounts base URL, which is only useful for calling accounts endpoints directly). spa mode only: in redirect mode authedFetch throws immediately, since there's no client-held access token to attach — use your existing server-proxied fetch instead.
getToken — calling your own API from non-fetch clients
If your app authenticates its own API calls with axios, GraphQL, or another non-fetch client, useAuth() also exposes getToken() — the raw access token authedFetch attaches internally, so you can attach it yourself:
const { getToken } = useAuth()
api.interceptors.request.use(async (config) => {
const token = await getToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})getToken() returns the current access token, refreshing it first if it's expired, or null if the user is signed out. spa mode only: in redirect mode getToken throws immediately — redirect-mode apps authenticate with the session cookie (credentials: 'include') instead.
Audience caveat: the access token's audience is accounts — don't send it to third-party hosts, and note that other first-party Groo APIs will reject it as well until the audience-validation rollout across those services completes. authedFetch and getToken are intended for calling accounts's own /v1/account/* endpoints (or your own backend, which can treat the token as an opaque bearer credential to forward/verify itself) — not as a general-purpose token for calling arbitrary Groo services yet.
Example app
examples/spa-demo/ in this repo is a minimal Vite + React SPA wiring up AuthProvider in spa mode end-to-end: SignInButton/SignUpButton/UserButton in the header, and <UserProfile> rendered inline on an /account view. See its own README.md for the app-registration and environment-variable setup needed to run it.
LoginButton
A link that redirects to the login page with OAuth parameters.
<LoginButton className="btn">
Sign In
</LoginButton>The login URL includes:
client_id- Your application's client IDredirect_uri- Current page URL (for redirect after login)
LogoutButton
A link that redirects to the logout endpoint. Clears the session and redirects back to your app.
<LogoutButton className="btn">
Sign Out
</LogoutButton>The logout URL redirects to {baseUrl}/v1/auth/logout?redirect_uri={origin} which:
- Clears the session cookie
- Redirects the user back to your app's origin
RequireAuth
Wrapper component that only renders children when user is authenticated.
<RequireAuth
fallback={<div>Loading...</div>} // Optional: Show while loading
redirectTo="/login" // Optional: Redirect if not authenticated
>
<ProtectedContent />
</RequireAuth>User Object
interface User {
id: string
email: string | null
phone: string | null
name: string | null
role: string
}
// When using auth-server middleware, you also get consent info:
interface ConsentedUser extends User {
consent: {
id: string
consentedAt: string
lastAccessedAt: string
revokedAt: string | null
appData: Record<string, unknown> // App-specific data storage
}
}How It Works
Login Flow
AuthProviderchecks authentication by calling/v1/__auth/meon your API- Your API validates the session via
accounts.groo.dev/v1/client/auth/me LoginButtonredirects toaccounts.groo.dev/login?client_id=...&redirect_uri=...- User authenticates and grants consent to your application
- User is redirected back with a session cookie
- SDK detects the session and fetches user data
Logout Flow
LogoutButtonredirects toaccounts.groo.dev/v1/auth/logout?redirect_uri=...- Session is deleted and cookie is cleared
- User is redirected back to your app
Related Packages
@groo.dev/auth-server- Server-side middleware for Hono/Cloudflare Workers@groo.dev/auth-core- Shared types and utilities
License
MIT
