rvlib-pb-shell
v1.3.0
Published
PocketBase app shell for Mantine+MobX+react-router apps: observable auth/session, login page, account & password modals, avatar profile menu, AppShell layout with configurable nav, route guards, table kit (sort/selection/filters/row-limit), server-paged g
Readme
rvlib-pb-shell
The authenticated-app skeleton for PocketBase + Mantine + MobX + react-router apps: install and you have login, session, an app shell with avatar menu, and the shared list-page kit — day zero.
// pb.ts — the project owns the typed client
import PocketBase from "pocketbase"
import { PbAuth } from "rvlib-pb-shell"
import type { CollectionResponses, TypedPocketBase } from "./pb.types"
export const pb = new PocketBase(URL) as TypedPocketBase
export const auth = new PbAuth<CollectionResponses>({ pb, stampLastLogin: "last_login" })// router.tsx
import { AppLayout, LoginPage, RouteErrorElement, loginOnly } from "rvlib-pb-shell"
import { Icons } from "rvlib-mantine"
const Home = () => (
<AppLayout
auth={auth}
logo={<img src={logo} style={{ height: 34 }} />}
accentColor="gold.4"
navItems={[
{ to: "/", label: "Dashboard", icon: <Icons.dashboard />, end: true },
{ to: "/clients", label: "Clients", icon: <Icons.groups />, show: () => can("read:clients") },
]}
/>
)
export const router = createBrowserRouter([
{ path: "/", Component: loginOnly(auth, Home), ErrorBoundary: RouteErrorElement, children: [/* pages */] },
{ path: "/login", Component: () => <LoginPage auth={auth} logo={<img src={logo} />} />, ErrorBoundary: RouteErrorElement },
])- Session: restore from localStorage, background
authRefreshso role changes land without re-login, single login path againstusers(_superusersstays for backend scripts). Because everyone authenticates againstusers, a privileged account is a role VALUE and never setsisSuperAdmin: tellAppLayoutwhich values those are withadminRoles={["Admin"]}and they get the privileged badge. - Shell:
AppLayouttakesheaderBg/navbarBg/mainBg,accentColor/burgerColor/roleBadgeColor,headerHeight/navbarWidth,logo/subtitle/alert,adminUrl(rendered whenever set, so gate it yourself), and forwardsusersCollection/emailChangeEndpointto the profile menu, which is what makes the account modal's email field editable. Still literals, so a fully branded shell keeps one small fork reason: the badgevariant, the privileged"grape", and the burger's"white"default. - Changing your own email needs a project route, because PocketBase will not let a user change their own address without re-verifying the password. Point
emailChangeEndpointat one: it receivesPOSTwith JSON{ email, password }, verifies the password server side, writes the new address, and answers 2xx. The modal then signs the user out and sends them to/login, since PocketBase revokes the token. Without the prop the email field is read only. - List pages:
useSort/SortTh,useSelection/SelHeadTh/SelRowTd,matchMultifilters,useRowLimit— the display limit never affects aggregates or exports. - Grids and relations:
LiveGridrenders a server-pagedLiveListwith sortable headers and a pager,RelatedGridrenders a paged related table in one line (one-to-many and many-to-many),RefCellrenders a relation cell. Columns are declared once per collection withdefineCollectionUI. See below. - Identity: the typed sign-in identity goes through
normalizeIdentitybefore it is sent, and so does a new address in the account modal. See below, because the default is deliberately conservative. - Deletes:
guardedDelete({ pb, checks: [{ collection, filter, label }], … })blocks with an explanation when references exist. - Language: English and French out of the box, and EVERY string is overridable.
configurePbShellI18n({ lang, dict })is the one call, the dicts are exported asshellEn/shellFr, see below.
Grids, and a related table in one line
rvlib-pb-mobx does the paging on the server: LiveList is one page plus the true total, LiveChildren is every child of one record. This lib renders them, and useLive (from rvlib-pb-mobx/react) is what holds one across renders.
Declare what a collection looks like once, beside the collection itself:
import { defineCollectionUI } from "rvlib-pb-shell"
defineCollectionUI(db.orders, {
columns: [
{ label: "Ref", key: "ref", render: (o) => o.ref },
{ label: "Total", key: "total", align: "right", render: (o) => euros(o.total) },
{ label: "Status", render: (o) => <Badge>{o.status}</Badge> },
],
label: (o) => o.ref, // one line of text, for a relation cell or a title
})A column carrying a key is sortable, and that key is the SERVER field the header sorts on, so it must be a real column of the collection being paged. A column without one renders a plain header. Every surface reads this registration: LiveGrid takes the columns, LiveCards the card, RefCell the label. A component asked to render a collection that registered nothing throws, naming the collection and the call that fixes it, because an empty table reads as "no rows" and sends the reader looking at the data.
A full page of orders, sorted and paged by the server:
const orders = useLive(() => new LiveList(db.orders, { perPage: 25, sort: "-created" }), [])
return <LiveGrid list={orders} pageSize onRowClick={(o) => navigate(`/orders/${o.id}`)} />Every order of one client, on that client's page:
<RelatedGrid record={client} to={db.orders} via="client_ref" perPage={20} />via is the relation field ON THE CHILD that points back at the record. The table is scoped to that record and it pages: a related table that stops at the first N rows hides the rest behind a screen that looks complete. The scope is not a filter, so a filter prop narrows within this client's orders and can never widen past them, and a record whose id is not loaded yet matches nothing rather than everything.
Many to many is the same component, naming the junction:
<RelatedGrid record={order} through={db.order_items} via="order_ref" to={db.products} toField="product_ref" />The rows being paged here are the junction's, which is the honest answer: products carries no field pointing back at the order, so order_items is the only side the server can count and page. Each page then resolves its far records in one extra query and renders the columns registered for products. Those far headers render but do not toggle, because a product field is not a column of order_items.
Around those:
LiveCardsis the same paged list as a card wall (colsis a MantineSimpleGridvalue,cardfalls back to the registered one).LivePageris the pager alone, for a list you render by hand. It hides entirely on a single page unless you passpageSize, which adds the rows-per-page select.RefCellrenders one many-to-one cell through a sharedLiveRef, so a page of rows costs one extra query and not one per cell. An id that never came back (deleted, or hidden by an API rule) renders a marker, never a blank cell.selectionaccepts your ownuseSelection(list.rows)state, not onlytrue, so the page can read what was picked.LiveGridaccepts aLiveList(or aLiveChildren) and nothing else. ALiveTopChildrenis a cell for a list of parents and clamps attakeby design, so binding one to a grid is a compile error rather than a table quietly missing rows.- Every string these components render comes from the dict (see below) and takes a
labelsprop for a per-instance override.
Language
Every string the lib AUTHORS ships in English and French. English is the default and the source of truth. The French dict is typed against it, so the two can never drift: a missing key is a compile error. Messages the SERVER sends (a PocketBase validation error, a rate limit) pass through raw, in whatever language PocketBase wrote them. The one exception is the generic credentials rejection on the login form, which carries no detail and is worded by the dict.
// main.tsx, beside configurePbMobx
import { configurePbShellI18n } from "rvlib-pb-shell"
configurePbShellI18n({ lang: "fr" })Three levels, narrowest wins:
configurePbShellI18n({ lang })picks the locale. It persists underrv-lang, and a switch re-renders every observer. The name says i18n because it configures the copy and nothing else, so a runtime locale switch never touches the rest of the wiring.configurePbShellI18n({ dict })patches the dicts. It takes a deep partial, per locale, and repeated calls merge, so you override exactly the strings you disagree with and nothing else:configurePbShellI18n({ dict: { fr: { menu: { signOut: "Quitter" }, guard: { cannotDelete: "Suppression bloquée" } }, en: { menu: { signOut: "Log out" } }, }, })The locale key is required rather than implied, so a patch survives a locale switch instead of silently applying to whichever language happened to be active. Read what you are patching from the exported dicts:
import { shellEn, shellFr } from "rvlib-pb-shell".A
labelsprop overrides one instance:<ProfileMenuUI auth={auth} labels={{ signOut: "Quitter" }} />. Carried byLoginPage/LoginForm,AccountModal,PasswordModal,ProfileMenuUI,AppLayout,RowLimitSelectandguardedDelete. Single-string variants:labelonSelHeadTh/SelRowTd,loadingLabelonProtectedRoute. The two modalsProfileMenuUIopens read the dict: reach them with a level-2 overlay, or render them yourself.
Sort collation follows the active locale. Stored values stay language-free: the row-limit option is "All" in every locale and only its label changes.
The engine is rvlib-i18n, one instance per string-owning package. This lib never reads the app's own i18n singleton: that dict is typed against the app's keys, so injecting it forces every consumer to carry these ones.
Sign-in identity
PocketBase resolves an identity with an exact-match filter, which SQLite evaluates case-sensitively on a text column (probed on PocketHost, SDK js 0.27). An account stored as [email protected] refuses a sign in typed [email protected], and the API answers with the same generic credentials rejection a wrong password gets. Nothing on the screen tells the user what went wrong, and the account is simply unreachable. It is a total lockout, and it is easy to create: one address typed with a capital in an admin form.
PbAuth puts the typed identity through normalizeIdentity before sending it, and exposes the same function as auth.normalizeIdentity so AccountModal normalizes a new address with it too. What gets written is the exact shape a later sign in will send.
import { PbAuth, normalizeEmail } from "rvlib-pb-shell"
export const auth = new PbAuth({ pb, normalizeIdentity: normalizeEmail })The default is trimIdentity, surrounding whitespace only. On an email identity it cannot lose a match: PocketBase refuses to store an email with spaces around it, so anything a trim changes was a guaranteed failure a moment ago. An auth collection can declare other identity fields, a username or any text field. Those are not email-validated and can hold surrounding whitespace, so a project signing in through one passes normalizeIdentity: (s) => s to send the field raw.
normalizeEmail (trim plus lower-case) is the fix for the case-sensitivity lockout, and it is opt-in on purpose, because it has two halves that must ship together:
- every address the app WRITES goes through it. The account modal already does, and your own user-creation form must call it.
- the addresses already STORED are lower-cased once, by a migration you run.
Pass it without step 2 and you lock out every account holding a capital, which is the same bug in mirror. Once the data is clean, pass it.
The login field keeps whatever was typed: only the wire value is normalized, so nothing rewrites itself under the cursor.
The account modal compares NORMALIZED against NORMALIZED to decide whether the address was edited (resolveEmailChange, exported). Under normalizeEmail that means a stored [email protected] opened in the modal is not treated as an edit, so a user changing their name is not asked for a password and not signed out. It also means the modal cannot repair a stored address's own casing: that is step 2's job.
A create-a-user form and the browser's password manager
An admin app that creates other users hits a hazard this lib cannot fix for you, because the form is yours. A <form> holding a text field followed by an <input type="password">, with no names and no autocomplete hints, is exactly the shape Chrome and Safari look for when they offer a saved login. On an admin's own origin, they fill it with the ADMIN's saved credentials, so a new account gets silently created with the wrong address, or the admin's password.
autoComplete="off" on the form is not enough, because Chrome ignores it on password fields. Two things together work:
<form onSubmit={submit} autoComplete="off">
<TextInput label="Email" name="new-user-email" autoComplete="off" {...form.getInputProps("email")} />
<PasswordInput label="Password" name="new-user-password" autoComplete="new-password" {...form.getInputProps("password")} />
</form>Field names that no login heuristic maps to a credential pair, and new-password on the password field, which is the one token Chrome honours. It stays a recipe rather than a component because the only generic part is these two attributes, and the field names have to be yours.
Peer deps: @mantine/* v8, rvlib-mantine, rvlib-pb-mobx, pocketbase, mobx v7, react ≥18, react-router v7. Ships as TypeScript source.
