react-resource-view
Authentication
Better Auth end to end: an async scope authorization, sign-in, roles and sign-out.
Better Auth or JWT#
react-resource-view depends on no authentication library: a scope only asks for an answer. Two recipes wire a real session to it, from the back end to signing out — pick the one your API already speaks.
- Better Auth — a session managed by Better Auth, whatever the API behind it. The rest of this page.
- JWT (API Platform + LexikJWT) — a Symfony API signing its users in with LexikJWTAuthenticationBundle and a refresh token, held on the front end by react-jwt-session. Authentication with JWT.
How a scope is protected#
A scope carries an authorization function. It may be synchronous or return a promise — a real session is almost always fetched — and nothing of the scope renders until it has answered: no flash of the protected page before the verdict.
true— the scope renders.false— treated as a 403.UnauthorizedError, thrown or rejected — a 401:onUnauthorizedis called, where you redirect to sign in. Without it the page goes to/.ForbiddenError, thrown or rejected — a 403:forbiddenFallbackrenders instead of the scope, an “Access denied” message by default.- Anything else goes up to your error boundary, like any render error.
The two classes are only a convenience: any error thrown or rejected with status: 401 or status: 403 is treated the same way, so an auth library can refuse access without importing react-resource-view, and two copies of it in one bundle still agree. No other status is recognized — a 419 or a 500 goes up to your error boundary.
The check runs again whenever the scope changes, and whenever you ask for it with recheckAuthorization. A promise that settles after the reader has moved on is ignored, so a slow answer about one scope never decides for the next.
The rest of this page wires it to Better Auth, from the client to signing out. Each block is meant to be copied as it is.
Better Auth: the client#
The server side — the database, the betterAuth() instance, the route handler of your framework — is Better Auth's own business, and its installation guide covers it. Add its admin plugin there too: it gives every user a role. On the front end, all you need is the client.
pnpm add better-authimport { createAuthClient } from "better-auth/react"
import { adminClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
// Where your Better Auth server is mounted. Omit it when it is the same origin.
baseURL: import.meta.env.VITE_AUTH_URL,
// Types `user.role` and adds the admin endpoints.
plugins: [adminClient()],
})No admin plugin?
A role declared as an additional field on the user works the same way — Better Auth returns it on session.user all the same.
Protecting a scope#
import type { ScopeInterface } from "react-resource-view"
import {
AdminLayout,
ForbiddenError,
UnauthorizedError,
} from "react-resource-view"
import { authClient } from "../auth-client"
export const adminScope: ScopeInterface = {
name: "admin",
label: "Administration",
resources: [articles, users],
decoratorComponent: AdminLayout,
// Awaited before anything of the scope renders.
authorization: async () => {
const { data } = await authClient.getSession()
if (!data) throw new UnauthorizedError() // 401 → onUnauthorized
if (data.user.role !== "admin") throw new ForbiddenError() // 403 → access denied
return true
},
}No session is a 401: the reader is not signed in. A session without the role is a 403: signed in, but not allowed here — signing in again would not help, so they are shown a refusal rather than a login form.
Redirecting to sign in#
onUnauthorized on the configuration receives every 401 of every scope. Pass the page the reader was on, so the login form can bring them back to it.
import { ResourceViewProvider } from "react-resource-view"
import { useRouter } from "@tanstack/react-router"
function Admin({ url }: { url: string }) {
const router = useRouter()
return (
<ResourceViewProvider
viewResourceContextParams={parseLink(url)}
configuration={{
scopes: {
admin: () => import("./scopes/admin").then((m) => m.adminScope),
},
defaultScope: "admin",
// A 401, thrown or rejected: send the reader to sign in, and back here after.
onUnauthorized: () =>
router.navigate({
to: "/login",
search: { redirect: window.location.pathname },
}),
// While the session is being fetched — instead of the default page loader.
authorizationFallback: <Spinner />,
// A 403, or an authorization returning false.
forbiddenFallback: <p>This area is for administrators.</p>,
}}
/>
)
}// /login — a plain Better Auth form, nothing specific to react-resource-view.
function LoginPage() {
const { redirect } = Route.useSearch()
const router = useRouter()
const [error, setError] = useState<string>()
async function onSubmit(email: string, password: string) {
const { error } = await authClient.signIn.email({ email, password })
if (error) return setError(error.message)
router.navigate({ to: redirect ?? "/admin" })
}
// …the form itself
}| Name | Type | Default | Description |
|---|---|---|---|
onUnauthorized | () => void | — | Called on a 401, thrown or rejected. |
authorizationFallback | ReactNode | — | Shown while authorization is pending. Defaults to the page loader. Also accepted on the scope, which wins. |
forbiddenFallback | ReactNode | — | Shown on a 403 or a false. Defaults to an “Access denied” message. Also accepted on the scope, which wins. |
Roles, permissions and menu#
The scope decides who gets in; the permissions decide what each reader is offered inside. They may answer asynchronously, but they are asked per action and per row, so they should not each ask for the session again. Load it once, keep it, and read it synchronously:
import { authClient } from "./auth-client"
type Session = typeof authClient.$Infer.Session
let pending: Promise<Session | null> | undefined
let current: Session | null = null
/** Fetches the session once; later calls share the same request. */
export function loadSession(): Promise<Session | null> {
pending ??= authClient.getSession().then(({ data }) => (current = data))
return pending
}
/** Forgets it — after a sign-out, or to pick up a role that changed. */
export function forgetSession() {
pending = undefined
current = null
}
/**
* Synchronous, for the permissions asked on every render. The scope's authorization
* has awaited loadSession() before any view of the scope renders, so the
* session is already there when this is read.
*/
export function hasRole(role: string): boolean {
// The admin plugin stores several roles comma-separated: "admin,editor".
return current?.user.role?.split(",").includes(role) ?? false
}The scope awaits it — which is what makes it safe to read afterwards:
import { hasRole, loadSession } from "../session"
export const adminScope: ScopeInterface = {
name: "admin",
// …
authorization: async () => {
const session = await loadSession()
if (!session) throw new UnauthorizedError()
if (!hasRole("admin") && !hasRole("editor")) throw new ForbiddenError()
return true
},
}The resources read the roles from it:
import { hasRole } from "../session"
export const articles = createViewResource("articles", {
path: "/api/articles",
// Everything is allowed by default: only the restrictions are written.
permissions: {
create: () => hasRole("editor") || hasRole("admin"),
update: () => hasRole("editor") || hasRole("admin"),
delete: () => hasRole("admin"),
},
})And so does the menu, through a getter on the entry:
menu: [
articles,
{
// An entry of its own, to add a getter: it names the scope its link leads to.
...createItemMenuWithResource({ resource: users, scope: "admin" }),
// `hidden` is read each time the menu renders: a getter follows the session.
get hidden() {
return !hasRole("admin")
},
},
],Inside React, useSession works too
A component of yours — a top bar, an avatar — can call authClient.useSession(). The module above exists for the places that are not components: permissions and menu entries, declared once, outside any render.
Signing out#
Signing out changes nothing the scope can see by itself. Forget the cached session, then ask the scope to check again: the authorization now finds no session, throws a 401, and onUnauthorized takes the reader to /login — no reload.
import { useScopeContext } from "react-resource-view"
import { authClient } from "./auth-client"
import { forgetSession } from "./session"
export function SignOutButton() {
const scopeContext = useScopeContext()
return (
<button
onClick={async () => {
await authClient.signOut()
forgetSession()
// Runs the scope's authorization again: no session → 401 → /login.
scopeContext?.recheckAuthorization()
}}
>
Sign out
</button>
)
}authClient.signOut()ends the session on the server.forgetSession()drops the copy the permissions read.recheckAuthorization()hides the scope behind its fallback and runsauthorizationagain.
The button has to render inside the scope to reach its context — AdminLayout's sidebarFooter is a natural place for it. The same call picks up a role granted or revoked while the reader is on the page.
The API decides#
This only protects the interface
Everything on this page runs in the browser, where anyone can change it. It decides what is shown. Your API must check the session and the role again on every request — Better Auth's auth.api.getSession() on the server, a security expression in API Platform — or the protected data is one fetch away.
Other providers#
Nothing here is specific to Better Auth: authorization only needs to end in true, false or one of the two errors. With a Symfony / API Platform back end and LexikJWTAuthenticationBundle, the session is a token and the user comes from a /me call:
import { ForbiddenError, UnauthorizedError } from "react-resource-view"
// LexikJWTAuthenticationBundle: POST /api/login_check { username, password }
// answers { token }, kept here by the login form.
authorization: async () => {
const token = localStorage.getItem("token")
if (!token) throw new UnauthorizedError()
// A /me operation of your API Platform app, returning the signed-in user.
const response = await fetch("/api/me", {
headers: { Authorization: `Bearer ${token}` },
})
if (response.status === 401) throw new UnauthorizedError() // expired or revoked
if (!response.ok) throw new Error(`/api/me answered ${response.status}`)
const me = await response.json()
if (!me.roles.includes("ROLE_ADMIN")) throw new ForbiddenError()
return true
}- The same
loadSession/hasRolemodule applies — cache the/meresponse instead ofgetSession(). - Signing out is removing the token, forgetting the cached user and calling
recheckAuthorization().
With react-jwt-session, all of this — refresh token included — is already written: see Authentication with JWT.