npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@saastemly/better-admin-ui

v0.1.0

Published

better-auth-ui plugin that gives your app an auto-generated, capability-scoped API dashboard — ra-core providers for shadcn-admin-kit plus hooks, over Better Auth's resolved schema and betterCommerce.

Readme

better-admin-ui

An auto-generated, capability-scoped way to reach your backend.

Add one server plugin and every table Better Auth — or any of its plugins — declares becomes a screen at /api-dashboard, with no registration step. Add the organization plugin and organizations appear. Add betterCommerce and carts, orders, order lines and entitlements appear.

The part that is not obvious from that sentence: it is not an admin panel. /api-dashboard answers every caller, and answers each one differently.

| Who opens it | What they get | |---|---| | a logged-out visitor | the public surface — the catalog, categories, regions, public store settings | | a signed-in customer | their own orders, order lines, returns, subscriptions, points | | a merchant's staff | their merchant's rows | | a platform admin | everything, as before |

There is no "you are not allowed here" page, because there is no single "here". A caller who may reach nothing gets an empty dashboard, not a 403.

Upgrading from the admin panel

/admin-ui/* is now /api-dashboard/*, and the app route /admin is now /api-dashboard.

This is pre-1.0 and the old paths are not kept as aliases. That is deliberate: /admin-ui/* was admin-gated by assumption, and leaving it answering a bookmarked path would leave an unscoped surface in production. A request to the old paths now 404s.

What changed about who can see what. The manifest at GET /api-dashboard/resources used to be a catalogue of every non-sensitive table, returned only to platform admins; it is now the result of authorization, computed per caller. A resource a caller may not touch is not in their manifest at all, and every record route re-authorizes against that same manifest — so a hand-written request is refused exactly as the UI refuses it. Logged-out callers are answered rather than sent to a login page.

If you do not want a public surface, set apiDashboard({ anonymous: false }) and every route demands a session again. That is the operator's opt-out, and it is the only supported one.

Renames that go with it, all mechanical:

| before | after | |---|---| | betterAdmin() | apiDashboard() | | BetterAdminOptions | ApiDashboardOptions | | adminUIPlugin() (client) | apiDashboardPlugin() | | lib/auth/admin-ui-plugin.ts | lib/auth/api-dashboard-plugin.ts | | components/auth/admin-ui/* | components/auth/api-dashboard/* | | @better-admin-ui/admin-ui | @better-admin-ui/api-dashboard | | i18n keys under betterAdmin.* | apiDashboard.* |

The Better Auth server plugin id stays better-admin — it identifies an installed plugin in a resolved config, and churning it would break consumers for no user-visible gain. The routes moved; the identity did not. The better-auth-ui client plugin id did move, admin-ui → api-dashboard, because that slot is one this package owns end to end.

Install (2 steps)

npm i better-admin-ui
npx shadcn@latest add @better-admin-ui/all
// auth.ts
import { apiDashboard } from "@saastemly/better-admin-ui/server";

plugins: [admin(), commerce({ ... }), apiDashboard()]

Then one page, which is the whole dashboard:

// pages/api-dashboard/index.tsx
import { lazy, Suspense } from "react";

const GeneratedAdmin = lazy(() => import("@/components/auth/api-dashboard/generated-admin"));

export default () => (
  <Suspense fallback={null}>
    <GeneratedAdmin />
  </Suspense>
);
// your root layout
import { adminPlugin } from "@/lib/auth/admin-plugin"              // better-auth-ui's own, owns Users
import { apiDashboardPlugin } from "@/lib/auth/api-dashboard-plugin"

<AuthProvider authClient={authClient} navigate={navigate}
  plugins={[adminPlugin(), apiDashboardPlugin()]}>

Do not put the page behind a role gate. A logged-out visitor reaching it is the intended behaviour, and the server decides what they see. If your router has an auth guard, exclude this route from it.

lazy() matters on edge runtimes — shadcn-admin-kit's module graph is large enough to take a Cloudflare Workers dev runtime down if it is resolved server-side. And do not pass <Admin basename>: ra-core routes with a hash router by default, so the dashboard's routes live under #/… and a basename would be compared against the hash and silently render nothing.

Installed files follow better-auth-ui's conventions — the plugin factory at lib/auth/api-dashboard-plugin.ts, components under components/auth/api-dashboard/ — so it sits in your tree like any built-in plugin.

The scope ladder

Every resource declares one scope, in code, beside the resource. The ladder is total: a caller at a higher rung sees everything below it.

| Scope | Who | Rows they see | Writes | |---|---|---|---| | public | anyone, including anonymous | rows the resource itself calls public (active, public: true) | none | | self | any signed-in user | rows whose owner column is their user id | none by default; opt-in per resource | | merchant | merchant staff (merchants() installed) | rows belonging to a merchant they are a member of | their own merchant's rows only | | platform | Better Auth admin role | everything | everything |

A resource with no declared scope is platform. That default is the safety property, not a convenience: a plugin that adds a table and forgets to think about visibility exposes it to nobody rather than to everybody. src/server/scopes.ts is read as security code for exactly that reason.

via covers the common indirection — an orderLine has no userId, but its order does, so a customer's line is reachable through one join the server performs itself.

There is deliberately no policy DSL and no UI for editing permissions. A permission model an operator can misconfigure from a form is a security incident waiting for a Tuesday.

Enforcement

Every record route resolves the caller once, then applies three things in order:

  1. Resource gate. Not in the caller's manifest → 404, not 403: a customer should not learn that a merchantLedgerEntry table exists. Present but the action is not permitted → 403, which is honest and actionable.
  2. Mandatory row filter. The scope's filter is appended to the caller's own filters and cannot be overridden — a client-supplied userId filter narrows, it never widens. Two conflicting equality clauses on the owner column AND to the empty set, which is the point.
  3. Field projection. Rows are projected to the fields the manifest advertised, so a field hidden in the manifest cannot be read through the record route either. Writes reject any field outside the writable set with the field named, rather than silently dropping it.

Sort and filter inputs are validated against the manifest, so an undescribed field cannot become an oracle for data the caller cannot see.

Field visibility below platform is an allowlist, not a denylist: a plugin that adds order.internalNote tomorrow does not leak it to the customer whose order it is.

The client's gate is cosmetic

authProvider.canAccess({ action, resource }) answers from the caller's manifest, and shapes the menu and hides buttons. It is not the security boundary — the server's re-authorization is. This matters because shadcn-admin-kit assumes everything is allowed when canAccess is absent, so a missing gate is a silent open door in the UI; here it is present, manifest-backed, and cached once per session (dropped on login, logout, a 401, a 403 and a locale change).

checkAuth resolves for anonymous callers rather than redirecting to sign-in — the change that lets a logged-out visitor use the dashboard at all — and throws on 401, which is exactly what apiDashboard({ anonymous: false }) answers. Sign-in becomes an affordance in the identity bar, not a gate.

Safe by default

A generic dashboard over an auth database is a liability if it is naive about secrets, so:

  • session, account and verification are excluded unless you explicitly include them.
  • Fields Better Auth marks returned: false (account.accessToken, password, …) are dropped.
  • A denylist covers secrets the metadata does not mark — notably session.token and verification.value, which carry no returned: false flag. Trusting the metadata alone would hand out session-hijacking and account-takeover material.
  • Writes accept only fields the manifest marks editable, so id and input: false fields cannot be forged.
apiDashboard({
  include: ["user", "order", "entitlement"],   // or omit for everything non-sensitive
  exclude: ["commerceWebhookEvent"],
  readOnly: true,
  anonymous: false,                            // no public surface at all
  resources: { order: { label: "Sales", hidden: ["providerPaymentId"] } },
  hasPermission: (user) => user.role === "owner",
})

Rate limiting

Opening the dashboard to logged-out callers turns a convenience into an abuse target, so the plugin declares its own Better Auth limits: reads generously, writes tightly, keyed by session or IP.

apiDashboard({ rateLimit: { window: 60, max: 120, writeWindow: 60, writeMax: 30 } })  // the defaults
apiDashboard({ rateLimit: false })                                                    // global limit only

Two honest limits, documented rather than hidden: a distributed flood from many addresses is a CDN's problem, not a plugin's; and rate limiting is not authorization — it bounds abuse of what a caller is already allowed to do.

The honest trade

Even correctly scoped, a public dashboard is a discovery surface: it tells the world which tables exist for anonymous callers. That is a deliberate trade for "an auto-generated way to reach the backend". An operator who does not want it sets apiDashboard({ anonymous: false }).

Routes

| Route | Method | What | |---|---|---| | /api-dashboard/resources | GET | the caller-scoped manifest — identity block plus resources | | /api-dashboard/records | GET | a list, filtered and sorted within the caller's scope | | /api-dashboard/record | GET | one record, or 404 if it is outside the caller's scope | | /api-dashboard/record/create | POST | create; the server, not the client, decides who owns the row | | /api-dashboard/record/update | POST | update, with the scope clauses on the WHERE | | /api-dashboard/record/delete | POST | delete, same |

The manifest is the shape everything else reads:

{
  "identity": { "kind": "anonymous" | "customer" | "merchant" | "platform", "merchantIds": ["…"] },
  "resources": [
    { "name": "product", "label": "Products", "actions": ["list", "show"], "fields": [ /* redacted for this caller */ ] }
  ]
}

actions is computed, never assumed: the intersection of what the resource allows at that scope and what the caller's rung permits.

Beyond CRUD

On top of generic CRUD it adds the actions the schema cannot express — and the generated list renders a row action only when the caller's manifest grants the matching verb, so a customer looking at their own orders gets a list and a show and no Refund button:

  • user — ban / unban, via Better Auth's admin plugin
  • order — refund, which calls the payment provider and revokes the entitlements it granted
  • entitlement — revoke
const dataProvider = useDataProvider<BetterAdminDataProvider>();
await dataProvider.refundOrder(record.id);       // provider refund + entitlement revoke
await dataProvider.revokeEntitlement(record.id);
await dataProvider.banUser(record.id, "fraud");

Generated screens, not guessed ones

Screens read their components from the manifest rather than from a sample record, which is what the kit's ListGuesser/ShowGuesser do. That difference is not cosmetic: a guesser shown an order whose discountTotal is 0 infers a date field and renders 1/1/1970, and it cannot know that userId is a foreign key. The manifest carries the declared type, the reference and whether a number is money, so amounts render as $49.00 against the record's own currency and userId resolves through a ReferenceField.

The generated screens land in your repo like any shadcn component, so restyle them, or pass a hand-written list/show/edit for a resource you want to take over — getResources() gives you the same manifest they read.

i18n

apiDashboardI18nProvider() resolves a key in five steps: the requested locale, English, an optional base provider for ra-core's own ra.* keys, ra-core's _ fallback, then a humanised last key segment. The last two mean the dashboard reads sensibly (ra.action.create → "Create") with no i18n packages installed at all.

import polyglotI18nProvider from "ra-i18n-polyglot";
import englishMessages from "ra-language-english";

createApiDashboardI18nProvider({
  base: polyglotI18nProvider(() => englishMessages),
  messages: { fr: { apiDashboard: { action: { signIn: "Se connecter" } } } },
});

Resource and field labels are deliberately not in the catalogue. They arrive on the manifest already localised by the server when translations() is installed, and translating them again on the client would be a second source of truth racing the first. Changing the locale therefore refetches the manifest.

Hooks (no ra-core required)

@saastemly/better-admin-ui/react mirrors better-commerce-ui's hook layer, so a better-auth-ui app can render dashboard lists without adopting ra-core:

import { useAdminManifest, useAdminList, useAdminActions } from "@saastemly/better-admin-ui/react";

const { data } = useAdminManifest(dataProvider);        // identity + scoped resources
const orders = useAdminList(dataProvider, "orders", { pagination: { page: 1, perPage: 20 } });
const { refundOrder, banUser } = useAdminActions(dataProvider);

With ra-core or shadcn-admin-kit, prefer their useGetList/useDataProvider — the same provider backs both.

Using it without shadcn-admin-kit

The providers are plain ra-core contracts, so they also work with CoreAdminContext and ra-core's hooks directly:

<MemoryRouter>
  <CoreAdminContext dataProvider={dataProvider} authProvider={authProvider}>
    <YourOwnTables />   {/* useGetList("orders"), useDataProvider(), … */}
  </CoreAdminContext>
</MemoryRouter>

Assignability is enforced, not assumed. test/ra-contract.test.ts assigns the providers to ra-core's own DataProvider/AuthProvider types, so bun run typecheck fails if the shapes drift. This matters more than it sounds: ra-core declares every data-provider method generic over RecordType and keys records by string | number, and a provider that omits either is rejected by <Admin>/<CoreAdminContext> at compile time even though every runtime test passes.

Zero runtime dependencies: the providers are structural implementations of the ra-core contracts, tested headlessly against a real betterAuth handler.

Where this sits

better-auth  ──────────────┐   server core
  └── better-commerce      │   server plugin (cart, checkout, orders, entitlements)
                           │
better-auth-ui ────────────┘   client core
  ├── better-commerce-ui       UI plugin — storefront
  └── better-admin-ui          UI plugin — the API dashboard   ← this package

The client plugin's id is api-dashboard, distinct from better-auth-ui's built-in admin plugin — that one configures user-management UI (roles, impersonation), this one supplies the dashboard and its resource/data layer. They compose; register both.

A working reference app lives in testApp2/api — all backend plus this dashboard, with the dashboard reachable logged-out. The buyer-facing site is a separate static app that calls it over HTTP, which is the arrangement this plugin is designed for: the dashboard is not the storefront's admin area, it is the API made navigable. The design it implements is docs/API-DASHBOARD-DESIGN.md.

What you get out of the box

The panel is generated, but it is not a raw table dump:

  • A dashboard. Revenue, orders, average order value and refunds over a window, with a revenue chart, order-status breakdown and best sellers. It reads /commerce/admin/analytics, which never sums across currencies — a shop selling in more than one gets figures for one of them and the names of the rest, stated on the page.
  • Grouped navigation with icons. Ninety-odd tables in one alphabetical list is not navigation; it puts "Order" between "Notification" and "Order Line". Resources are filed into Sales, Catalog, Customers, Marketing, Content, Fulfilment, Finance, Operations and Settings.
  • An account page, reached from the user menu beside "Log out" — name, email, password, active sessions, and what this deployment will let you do. It is deliberately about the PERSON, not the shop: settings that configure a shop are rows (Store Setting, Region, Sales Channel) and already have generated screens, so a second hand-written editor beside them would be two places to change one thing. Every write goes to Better Auth's own endpoints, so password rules and email verification stay where the app configured them.

Grouping and icons are derived by convention from the resource name, not from a hand-kept list. That is deliberate: the panel's promise is that a plugin's tables appear with no registration, and a hardcoded map would break that the first time somebody wrote a plugin nobody here had seen. An unrecognised resource lands in Other with a neutral icon — still fully usable, just not filed. See src/dashboard/taxonomy.ts.

Charts are hand-drawn SVG. A bar chart and a sparkline are about forty lines each, and pulling in a charting library would put half a megabyte into every app that installs this — every dependency here is external, so that weight lands on the consumer.

Mount it, do not own it

import { ApiDashboard } from "@saastemly/better-admin-ui/dashboard";
import { authClient } from "@/lib/auth-client";

export default () => <ApiDashboard fetch={authClient.$fetch} title="Your store" />;

Your app also registers the plugin once, so the panel's screens can resolve its providers through better-auth-ui:

import { apiDashboardPlugin } from "@saastemly/better-admin-ui/dashboard";
<AuthProvider plugins={[apiDashboardPlugin()]}>

Tailwind must be told to look inside the package, because the class names live in built code your app never scans:

@import "tailwindcss";
@source "../node_modules/better-admin-ui/dist";

Without that line the panel renders with no styles at all.

And Vite must pre-bundle react-hook-form as its own entry:

// vite.config.ts
optimizeDeps: {
  include: ["react-hook-form", "ra-core", "react-router", "@tanstack/react-query"],
},

Without it, Vite's dep optimizer inlines a second copy of react-hook-form inside deps/ra-core.js, while this package — which builds with react-hook-form external — imports deps/react-hook-form.js. Two copies means two React contexts: ra-core's FilterLiveForm populates one and the panel's inputs read the other, so every list screen dies on useFormContext(...) is null while the dashboard, which has no form, keeps rendering fine. resolve.dedupe and resolve.alias cannot fix it — the duplicate is created during pre-bundling, after resolution has already run.

Why mounted rather than copied

The dashboard used to ship only as a shadcn registry — ~126 component files copied into each app. That is right for a button you want to restyle and wrong for a panel generated from your schema: owning the source invites hand-edits, and a hand-edited generated panel drifts from the API it exists to describe.

The registry is still there and still supported for anyone who genuinely wants to own a screen — a bespoke field type, a different layout. It is a deliberate second path now, not the default one.

Testing

bun install
bun test          # providers + scope ladder exercised end-to-end against a real betterAuth handler
bun run typecheck # also proves ra-core assignability (see test/ra-contract.test.ts)

test/scopes.test.ts is the one to read first: it drives each rung of the ladder through the real handler, including hand-written requests that bypass the UI entirely.