From ec0811aba51de2f5947cce66fbed339bdc339ff0 Mon Sep 17 00:00:00 2001 From: Andrew Bone Date: Tue, 4 Aug 2026 16:00:15 +0100 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20transform=20raw=20permission=20?= =?UTF-8?q?to=20expected=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/src/pages/Roles/utils/transform.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/plugins/plugin-api-permissions/admin/src/pages/Roles/utils/transform.ts b/plugins/plugin-api-permissions/admin/src/pages/Roles/utils/transform.ts index 3659f50..6daf454 100644 --- a/plugins/plugin-api-permissions/admin/src/pages/Roles/utils/transform.ts +++ b/plugins/plugin-api-permissions/admin/src/pages/Roles/utils/transform.ts @@ -45,6 +45,44 @@ export type ApiPermissionsFormat = Record< { controllers: Record> } >; +export type PermissionEntry = { + id: number; + documentId: string; + action: string; + createdAt: string; + updatedAt: string; + publishedAt: string; +}; + +/** + * Transform array of permission entries to a matrix format for easier processing. + * @param permissions Array of permission entries from the API. + * @returns Matrix format of permissions. + */ +export function permissionsToMatrix( + permissions: PermissionEntry[], +): ApiPermissionsFormat { + const matrix: ApiPermissionsFormat = {}; + + for (const entry of permissions) { + if (!entry?.action) continue; + + // Splits "api::article.article.find" into ["api::article", "article", "find"] + const parts = entry.action.split("."); + if (parts.length < 3) continue; + + const actionName = parts.pop()!; + const controllerName = parts.pop()!; + const apiKey = parts.join("."); + + matrix[apiKey] ??= { controllers: {} }; + matrix[apiKey].controllers[controllerName] ??= {}; + matrix[apiKey].controllers[controllerName][actionName] = { enabled: true }; + } + + return matrix; +} + /** * Create empty form state from layout. */ @@ -115,23 +153,26 @@ export function createEmptyFormState( * Transform API format (from role.permissions) to form state. */ export function apiToFormState( - api: ApiPermissionsFormat, + api: PermissionEntry[], layout: PermissionsLayout, ): PermissionsFormState { const form = createEmptyFormState(layout); + const matrix = permissionsToMatrix(api); const collectionUids = new Set( layout.collectionTypes.subjects.map((s) => s.uid), ); const singleUids = new Set(layout.singleTypes.subjects.map((s) => s.uid)); - for (const [typeKey, typeData] of Object.entries(api)) { + for (const [typeKey, typeData] of Object.entries(matrix)) { if (!typeData?.controllers) continue; + for (const [controllerName, controllerData] of Object.entries( typeData.controllers, )) { for (const [actionName, actionData] of Object.entries(controllerData)) { if (!actionData?.enabled) continue; + if (typeKey.startsWith("api::")) { const ctUid = `${typeKey}.${controllerName}`; if ( From 166c80cabf3eae8b6e48c2fce953e0b7b4ee6d25 Mon Sep 17 00:00:00 2001 From: Andrew Bone Date: Tue, 4 Aug 2026 16:00:46 +0100 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=90=9B=20populate=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/src/pages/Roles/EditPage.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/plugins/plugin-api-permissions/admin/src/pages/Roles/EditPage.tsx b/plugins/plugin-api-permissions/admin/src/pages/Roles/EditPage.tsx index b161871..640c453 100644 --- a/plugins/plugin-api-permissions/admin/src/pages/Roles/EditPage.tsx +++ b/plugins/plugin-api-permissions/admin/src/pages/Roles/EditPage.tsx @@ -17,7 +17,7 @@ import { useNotification, useRBAC, } from "@strapi/strapi/admin"; -import type React from "react"; +import type { FormEvent } from "react"; import { useMemo, useRef, useState } from "react"; import { useIntl } from "react-intl"; import { useMutation, useQuery } from "react-query"; @@ -26,16 +26,17 @@ import { Permissions, type PermissionsRef } from "./components/Permissions"; import { PERMISSIONS } from "./constants"; import { PermissionsProvider } from "./contexts/PermissionsContext"; import { ROLES_BASE } from "./paths"; -import { apiToFormState, type PermissionsLayout } from "./utils/transform"; +import { + apiToFormState, + type PermissionEntry, + type PermissionsLayout, +} from "./utils/transform"; type RoleData = { name?: string; description?: string; nb_users?: number; - permissions?: Record< - string, - { controllers: Record> } - >; + permissions?: PermissionEntry[]; }; export const RolesEditPage = ({ id }: { id: string }) => { @@ -68,7 +69,10 @@ export const RolesEditPage = ({ id }: { id: string }) => { const { data: roleData, isLoading: isLoadingRole } = useQuery( ["api-permissions", "roles", id], - async () => get>(`/api-permissions/roles/${id}`), + async () => + get>( + `/api-permissions/roles/${id}?populate=permissions`, + ), { enabled: !!id, onSuccess: (res) => { @@ -87,7 +91,7 @@ export const RolesEditPage = ({ id }: { id: string }) => { const permissionsForm = useMemo( () => layout && roleApiData - ? apiToFormState(roleApiData?.permissions ?? {}, layout) + ? apiToFormState(roleApiData?.permissions ?? [], layout) : null, [layout, roleApiData], ); @@ -120,7 +124,7 @@ export const RolesEditPage = ({ id }: { id: string }) => { }, ); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = (e: FormEvent) => { e.preventDefault(); setError(null); if (!name || name.length < 3) { From 27471f8ad25c715ce07a3b9659bb106b653f0358 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Fri, 7 Aug 2026 16:05:07 +0200 Subject: [PATCH 3/7] feat: config option for setting the email callback url for verification or forgotten password emails --- apps/docs/docs/better-auth/dashboard.md | 23 +++++++++++ .../plugin-better-auth-dashboard/README.md | 17 ++++++++ .../admin/src/hooks/usePluginSettings.ts | 26 +++++++++++++ .../src/pages/Users/UserDetailDrawer.tsx | 34 +++++++++++++--- .../server/src/config.ts | 39 +++++++++++++++++++ .../server/src/controllers/index.ts | 2 + .../src/controllers/settings-controller.ts | 18 +++++++++ .../server/src/index.ts | 2 + .../server/src/routes/admin/index.ts | 9 +++++ 9 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 plugins/plugin-better-auth-dashboard/admin/src/hooks/usePluginSettings.ts create mode 100644 plugins/plugin-better-auth-dashboard/server/src/config.ts create mode 100644 plugins/plugin-better-auth-dashboard/server/src/controllers/settings-controller.ts diff --git a/apps/docs/docs/better-auth/dashboard.md b/apps/docs/docs/better-auth/dashboard.md index ba16c83..13760cb 100644 --- a/apps/docs/docs/better-auth/dashboard.md +++ b/apps/docs/docs/better-auth/dashboard.md @@ -59,6 +59,29 @@ export const auth = betterAuth({ }); ``` +To enable the "Send verification email" and "Send password reset" actions in the user detail drawer, configure the dashboard plugin itself in `config/plugins.js`/`.ts`: + +```typescript title="config/plugins.ts" +export default { + "better-auth-dashboard": { + config: { + // Absolute URL of your public-facing client app. Used as the + // callback destination for email verification / password reset + // links sent from the dashboard. Unset by default — the two + // actions above stay disabled until this is configured, rather + // than guessing at a URL (e.g. the admin panel's own origin). + email_callback_url: process.env.BETTER_AUTH_DASHBOARD_CALLBACK_URL, + }, + }, +}; +``` + +#### Config reference + +| Property | Type | Required | Description | +|---|---|---|---| +| `email_callback_url` | `string` | — | Absolute URL of your public-facing client app. Used as the callback destination for email verification and password reset links sent from the user detail drawer. Not set by default, which keeps those two actions disabled rather than guessing at a URL. | + ### Start Strapi ```bash diff --git a/plugins/plugin-better-auth-dashboard/README.md b/plugins/plugin-better-auth-dashboard/README.md index 812c093..34bd4bf 100644 --- a/plugins/plugin-better-auth-dashboard/README.md +++ b/plugins/plugin-better-auth-dashboard/README.md @@ -64,6 +64,23 @@ export const auth = betterAuth({ }); ``` +To enable the "Send verification email" and "Send password reset" actions in the user detail drawer, configure the dashboard plugin itself in `config/plugins.js`/`.ts`: + +```typescript title="config/plugins.ts" +export default { + "better-auth-dashboard": { + config: { + // Absolute URL of your public-facing client app. Used as the + // callback destination for email verification / password reset + // links sent from the dashboard. Unset by default — the two + // actions above stay disabled until this is configured, rather + // than guessing at a URL (e.g. the admin panel's own origin). + email_callback_url: process.env.BETTER_AUTH_DASHBOARD_CALLBACK_URL, + }, + }, +}; +``` + ### Start Strapi ```bash diff --git a/plugins/plugin-better-auth-dashboard/admin/src/hooks/usePluginSettings.ts b/plugins/plugin-better-auth-dashboard/admin/src/hooks/usePluginSettings.ts new file mode 100644 index 0000000..38f98b3 --- /dev/null +++ b/plugins/plugin-better-auth-dashboard/admin/src/hooks/usePluginSettings.ts @@ -0,0 +1,26 @@ +import { useFetchClient } from "@strapi/strapi/admin"; +import { useQuery } from "react-query"; + +export interface PluginSettings { + /** + * Absolute URL of the public-facing client app. Used as the callback + * destination for email verification / password reset links sent from + * the dashboard, instead of falling back to the admin panel URL. + */ + email_callback_url?: string; +} + +export function usePluginSettings() { + const { get } = useFetchClient(); + + return useQuery({ + queryKey: ["dash-plugin-settings"], + queryFn: async () => { + const { data } = await get( + "/better-auth-dashboard/settings", + ); + return data; + }, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/plugins/plugin-better-auth-dashboard/admin/src/pages/Users/UserDetailDrawer.tsx b/plugins/plugin-better-auth-dashboard/admin/src/pages/Users/UserDetailDrawer.tsx index eb4e433..3945872 100644 --- a/plugins/plugin-better-auth-dashboard/admin/src/pages/Users/UserDetailDrawer.tsx +++ b/plugins/plugin-better-auth-dashboard/admin/src/pages/Users/UserDetailDrawer.tsx @@ -44,6 +44,7 @@ import { } from "../../components/FormPrimitives"; import { MediaPickerField } from "../../components/MediaPickerField"; import { useModelSchema } from "../../hooks/useModelSchema"; +import { usePluginSettings } from "../../hooks/usePluginSettings"; import { withContext } from "../../utils/dashContext"; // ─── 2FA styled components ──────────────────────────────────────────────────── @@ -182,6 +183,14 @@ export function UserDetailDrawer({ const { toggleNotification } = useNotification(); const { get, put } = useFetchClient(); const schemaQuery = useModelSchema("user"); + const settingsQuery = usePluginSettings(); + + // Where email verification / password reset links should redirect back to + // once the user completes the flow. Only the configured + // `email_callback_url` server setting is used — deliberately no fallback + // to the current (admin panel) origin, since guessing a client URL is + // exactly what caused these links to point at the admin panel before. + const emailCallbackUrl = settingsQuery.data?.email_callback_url ?? null; const userQuery = useQuery({ queryKey: ["dash-user", userId], @@ -498,9 +507,13 @@ export function UserDetailDrawer({ const sendVerificationMutation = useMutation({ mutationFn: async () => { - const callbackUrl = new URL(window.location.href, window.location.origin); + if (!emailCallbackUrl) { + throw new Error( + "Set `email_callback_url` in the better-auth-dashboard plugin config to enable this action.", + ); + } const result = await client.dash.sendVerificationEmail( - { callbackUrl: callbackUrl.toString() }, + { callbackUrl: emailCallbackUrl }, withContext({ userId }, getAuthHeaders()), ); if (result.error) throw new Error(result.error.message ?? "Failed"); @@ -522,9 +535,13 @@ export function UserDetailDrawer({ const sendResetPasswordMutation = useMutation({ mutationFn: async () => { - const callbackUrl = new URL(window.location.href, window.location.origin); + if (!emailCallbackUrl) { + throw new Error( + "Set `email_callback_url` in the better-auth-dashboard plugin config to enable this action.", + ); + } const result = await client.dash.sendResetPasswordEmail( - { callbackUrl: callbackUrl.toString() }, + { callbackUrl: emailCallbackUrl }, withContext({ userId }, getAuthHeaders()), ); if (result.error) throw new Error(result.error.message ?? "Failed"); @@ -874,7 +891,7 @@ export function UserDetailDrawer({ variant="secondary" size="S" loading={sendVerificationMutation.isLoading} - disabled={user?.emailVerified} + disabled={!emailCallbackUrl || user?.emailVerified} onClick={() => sendVerificationMutation.mutate()} style={{ width: "100%" }} > @@ -884,11 +901,18 @@ export function UserDetailDrawer({ variant="secondary" size="S" loading={sendResetPasswordMutation.isLoading} + disabled={!emailCallbackUrl} onClick={() => sendResetPasswordMutation.mutate()} style={{ width: "100%" }} > Send password reset + {!settingsQuery.isLoading && !emailCallbackUrl && ( + + Set `email_callback_url` in the better-auth-dashboard + plugin config to enable these actions. + + )} )} diff --git a/plugins/plugin-better-auth-dashboard/server/src/config.ts b/plugins/plugin-better-auth-dashboard/server/src/config.ts new file mode 100644 index 0000000..2c476e6 --- /dev/null +++ b/plugins/plugin-better-auth-dashboard/server/src/config.ts @@ -0,0 +1,39 @@ +export interface Config { + /** + * Absolute URL of the public-facing client app that email links (email + * verification, password reset) generated from the dashboard should + * redirect back to once the user completes the flow. + * + * The Better Auth server has no knowledge of the client URL on its own + * (aside from `trustedOrigins`), so without this the dashboard would have + * to guess — previously it fell back to the current admin panel URL, + * which is wrong for end users. Set this to your frontend's URL, e.g. + * `https://app.example.com/login`. + */ + email_callback_url?: string; +} + +const config: { + default: Config; + validator: (config: Config) => void; +} = { + default: {}, + validator(config) { + if (config.email_callback_url === undefined) return; + + if ( + typeof config.email_callback_url !== "string" || + config.email_callback_url.length === 0 + ) { + throw new Error("email_callback_url must be a non-empty string"); + } + + try { + new URL(config.email_callback_url); + } catch { + throw new Error("email_callback_url must be a valid absolute URL"); + } + }, +}; + +export default config; diff --git a/plugins/plugin-better-auth-dashboard/server/src/controllers/index.ts b/plugins/plugin-better-auth-dashboard/server/src/controllers/index.ts index 5991099..387851e 100644 --- a/plugins/plugin-better-auth-dashboard/server/src/controllers/index.ts +++ b/plugins/plugin-better-auth-dashboard/server/src/controllers/index.ts @@ -1,7 +1,9 @@ import authController from "./auth-controller"; import dbController from "./db-controller"; +import settingsController from "./settings-controller"; export default { "auth-controller": authController, "db-controller": dbController, + "settings-controller": settingsController, }; diff --git a/plugins/plugin-better-auth-dashboard/server/src/controllers/settings-controller.ts b/plugins/plugin-better-auth-dashboard/server/src/controllers/settings-controller.ts new file mode 100644 index 0000000..078116b --- /dev/null +++ b/plugins/plugin-better-auth-dashboard/server/src/controllers/settings-controller.ts @@ -0,0 +1,18 @@ +import type { Context } from "koa"; +import type { Config } from "../config"; +import { PLUGIN_ID } from "../utils"; + +/** + * Serves this plugin's own server config to the admin panel. + * + * Kept separate from the Better Auth `dash()` config (proxied at + * `/auth/dash/config`) since these are settings for the dashboard plugin + * itself, not something the Better Auth server knows about. + */ +const settingsController = () => ({ + async get(ctx: Context) { + ctx.body = strapi.config.get(`plugin::${PLUGIN_ID}`, {}); + }, +}); + +export default settingsController; diff --git a/plugins/plugin-better-auth-dashboard/server/src/index.ts b/plugins/plugin-better-auth-dashboard/server/src/index.ts index c8ffc5b..1583696 100644 --- a/plugins/plugin-better-auth-dashboard/server/src/index.ts +++ b/plugins/plugin-better-auth-dashboard/server/src/index.ts @@ -1,9 +1,11 @@ +import config from "./config"; import controllers from "./controllers"; import policies from "./policies"; import { register } from "./register"; import routes from "./routes"; export default { + config, controllers, routes, policies, diff --git a/plugins/plugin-better-auth-dashboard/server/src/routes/admin/index.ts b/plugins/plugin-better-auth-dashboard/server/src/routes/admin/index.ts index 5b0433b..e06bb3c 100644 --- a/plugins/plugin-better-auth-dashboard/server/src/routes/admin/index.ts +++ b/plugins/plugin-better-auth-dashboard/server/src/routes/admin/index.ts @@ -64,5 +64,14 @@ export default () => ({ prefix: "", }, }, + { + method: "GET", + path: "/better-auth-dashboard/settings", + handler: "settings-controller.get", + config: { + policies: ["has-permission"], + prefix: "", + }, + }, ], }); From d68757b80fd10f8e704d9e0aa718e9522ae4dfdc Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Fri, 7 Aug 2026 15:24:50 +0200 Subject: [PATCH 4/7] chore: add CLAUDE.md --- CLAUDE.md | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b6d72a9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,144 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +A pnpm/Turborepo monorepo of three companion Strapi v5 plugins, developed against a shared Strapi +"playground" app: + +| Package | Path | Purpose | +| ------- | ---- | ------- | +| `plugin-better-auth` | `plugins/plugin-better-auth` | Better Auth database adapter for Strapi (core) | +| `plugin-better-auth-dashboard` | `plugins/plugin-better-auth-dashboard` | Admin panel dashboard for Better Auth users/orgs/sessions | +| `plugin-api-permissions` | `plugins/plugin-api-permissions` | Auth-provider-agnostic Content API RBAC | +| `@strapi-community/dev-utils` | `packages/dev-utils` | Internal dev tooling (dev server, DB, Playwright/Vitest helpers), not published | + +`apps/playground` is the Strapi app all three plugins run and are tested against. `apps/docs` is +the documentation site. All plugins are beta — not production-ready. + +Requires Node.js >= 22 and pnpm >= 10. + +## Commands + +```bash +pnpm install # install workspace deps +pnpm build # turbo build, all packages (build order follows ^build deps) +pnpm dev # start playground against SQLite (default) +pnpm dev:postgres # start playground against Postgres (spins up Docker automatically) +pnpm dev:mysql # start playground against MySQL (spins up Docker automatically) +``` + +Linting/type-checking (Biome for lint, tsc for types — run per package via turbo): + +```bash +pnpm lint # biome check --fix, all packages +pnpm lint:ts # tsc --noEmit for both admin and server projects, all plugins +``` + +Testing: + +```bash +pnpm test:integration # vitest, SQLite, concurrency=1 across plugins +pnpm test:integration:postgres # same, Postgres (Docker) +pnpm test:integration:mysql # same, MySQL (Docker) + +pnpm build # required first — e2e runs against built plugin dist +pnpm test:e2e # playwright, SQLite +pnpm test:e2e:postgres # playwright, Postgres +pnpm test:e2e:mysql # playwright, MySQL +``` + +Run a single test by `cd`-ing into the plugin and invoking the runner directly: + +```bash +cd plugins/plugin-better-auth +pnpm test:integration -- server/test/adapter.test.ts -t "test name" # vitest run +DATABASE_CLIENT=sqlite with-db pnpm test:integration # if a DB is required and not already running + +cd plugins/plugin-api-permissions # or plugin-better-auth-dashboard +pnpm test:e2e -- admin/test/users.spec.ts # playwright test +``` + +`with-db` (from `dev-utils`) wraps a command with a Docker Postgres/MySQL service for the +requested `DATABASE_CLIENT`, exporting connection env vars, and tears the service down on exit. +Without an explicit `DATABASE_NAME`, each test process gets its own ephemeral DB +(`strapi_`), so parallel/individual test runs never collide; set `WITH_DB_SKIP_DOCKER=1` to +reuse an already-running database instead of spinning one up. + +Integration tests use `setupStrapi`/`stopStrapi` from `@strapi-community/dev-utils` to boot the +playground app; e2e tests use `createPlaywrightConfig`/`registerAuthSetup` from the same package. +Both pick a free port per worker so parallel test files don't collide. + +Pre-commit runs lint-staged (`pnpm lint` + `tsc --noEmit` on server sources) via husky. + +## Architecture + +### plugin-better-auth — the adapter + +The core of this plugin is `server/src/adapter/adapter.ts`, a Better Auth +`createAdapterFactory` implementation that makes Strapi's Document Service the database backend +for Better Auth. Key mechanics: + +- **Model → Strapi UID mapping**: Better Auth model names are resolved back to their original + schema key via `getDefaultModelName` (so a renamed `modelName` doesn't change the UID), then + kebab-cased into `plugin::better-auth.` (see `getModelUid` in `adapter.ts`). +- **CRUD methods** (`create`/`update`/`updateMany`/`delete`/`deleteMany`/`findOne`/`findMany`/`count`) + translate Better Auth's `where`/`sortBy`/`select` into Strapi filters/sort/field selections via + `server/src/adapter/transformers/` (`filters.ts`, `sort.ts`, `output.ts`), then call + `strapi.documents(uid)`. +- **Schema generation** (`createSchema`, invoked by `npx auth generate`) is the unusual part: it + boots a *second*, throwaway Strapi app instance in-process (`adapter/cli/`, via + `getStrapiApp`/`cleanupStrapiApp`/`cleanupDistDirectory`) purely to reach the + content-type-builder service, then calls `updateStrapiSchema` + (`transformers/schema/transformer.ts`) to create/update content-type JSON schemas on disk from + Better Auth's table definitions. `transformers/schema/utils.ts` maps Better Auth field types to + Strapi attribute types and derives naming conventions (UID, table/collection name with a + configurable `table_prefix`, global ID, display name). It returns `true` to opt out of Better + Auth's own file-writing (see the `@ts-expect-error` comment in `adapter.ts` — this is a + documented workaround for a Better Auth bug/limitation). +- Better Auth content types are hidden from the Content Manager nav + (`pluginOptions['content-manager'].visible = false`) except `user`; `bootstrap.ts` manually + registers them as subjects on the `content-manager.explorer.*` actions and re-syncs Super Admin + permissions so relation fields into these content types still resolve correctly. + +**Request handling**: `auth-service.ts` locates and requires the user's Better Auth config +(an `auth.ts`/`auth.js` exporting `auth`, searched under app root and dist — see +`POSSIBLE_CONFIG_LOCATIONS`/`getPluginService` in `utils`). `auth-controller.ts` is a reverse +proxy: it converts the incoming Koa `ctx` into a Fetch `Request`, passes it to `auth.handler`, +and copies the Fetch `Response` back onto `ctx` (with special-cased `Set-Cookie` handling since +joining multiple cookies with `, ` breaks date parsing). `register.ts` mounts these routes at +Better Auth's configured `basePath` (stripped of the API prefix to avoid doubling it) and, if +`plugin-api-permissions` is installed, registers a session resolver with it that calls +`auth.api.getSession` and loads the matching Strapi user + roles. It throws at boot if no Better +Auth config is found, or if `users-permissions` is also installed (mutually exclusive). + +### plugin-api-permissions — Content API RBAC + +Auth-provider agnostic: it exposes a `session` service with `registerSessionResolver(fn)`, where +`fn(ctx)` returns `{ user, roles }` or `null`. A `content-api` authentication strategy +(`strategies/content-api.ts`) runs on every Content API request, calls the registered resolver, +loads permissions for the resolved role(s) (falling back to the **Public** role when +unauthenticated), and builds a CASL ability attached to the request. `role`/`permission` content +types back a Roles admin UI (Settings → API Permissions → Roles); **Public** and **Authenticated** +roles are seeded on first boot, and users are reassigned to Public when their role is deleted +(`middlewares/reassign-orphaned-users.ts`). `plugin-better-auth`'s `register.ts` auto-registers +its session resolver with this plugin when both are installed; otherwise a resolver must be wired +manually and `user_uid` set in plugin config. + +### plugin-better-auth-dashboard — admin UI + +Depends on the Better Auth `dash()` plugin from `@better-auth/infra` plus the `jwt()` plugin being +configured in the user's `auth.ts`, and only works with Better Auth's default `basePath` +(`/api/auth`). It reads user/session/organization data through that infra rather than talking to +Strapi's content types directly. Feature panels (ban, 2FA, email verification, organizations) +detect and adapt to whichever Better Auth plugins are actually configured. + +### Build/type system + +Each plugin builds via `strapi-plugin build`/`watch`/`verify` (from `@strapi/sdk-plugin`), with +separate `server` and `admin` TypeScript projects (`tsconfig.json`/`tsconfig.build.json` in each), +each with its own `lint:ts:server`/`lint:ts:admin` script. `plugin-better-auth` has no `admin` +package — it's server-only. Package `exports` maps `./strapi-server`/`./strapi-admin` to built +output for Strapi's plugin loader, and `plugin-better-auth` additionally exports `.` (the adapter +itself) for direct import as `strapiAdapter` in a consumer's `auth.ts`. From ff1300cf85d6f211bf912963bfa0bc9d6f71931a Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Fri, 7 Aug 2026 15:27:14 +0200 Subject: [PATCH 5/7] fix: make fields with key 'name' a string field instead of a text field to allow it to be selected as the main field --- .../better-auth/content-types/organization/schema.json | 2 +- .../extensions/better-auth/content-types/team/schema.json | 2 +- .../extensions/better-auth/content-types/user/schema.json | 2 +- apps/playground/types/generated/contentTypes.d.ts | 6 +++--- .../server/src/adapter/transformers/schema/utils.ts | 3 +++ plugins/plugin-better-auth/server/test/schema.test.ts | 2 +- 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/playground/src/extensions/better-auth/content-types/organization/schema.json b/apps/playground/src/extensions/better-auth/content-types/organization/schema.json index 9da9615..454ebc4 100644 --- a/apps/playground/src/extensions/better-auth/content-types/organization/schema.json +++ b/apps/playground/src/extensions/better-auth/content-types/organization/schema.json @@ -19,7 +19,7 @@ }, "attributes": { "name": { - "type": "text", + "type": "string", "configurable": false, "pluginOptions": { "better-auth": { diff --git a/apps/playground/src/extensions/better-auth/content-types/team/schema.json b/apps/playground/src/extensions/better-auth/content-types/team/schema.json index 8e4e829..d44955d 100644 --- a/apps/playground/src/extensions/better-auth/content-types/team/schema.json +++ b/apps/playground/src/extensions/better-auth/content-types/team/schema.json @@ -19,7 +19,7 @@ }, "attributes": { "name": { - "type": "text", + "type": "string", "configurable": false, "pluginOptions": { "better-auth": { diff --git a/apps/playground/src/extensions/better-auth/content-types/user/schema.json b/apps/playground/src/extensions/better-auth/content-types/user/schema.json index 7972013..7d8f660 100644 --- a/apps/playground/src/extensions/better-auth/content-types/user/schema.json +++ b/apps/playground/src/extensions/better-auth/content-types/user/schema.json @@ -19,7 +19,7 @@ }, "attributes": { "name": { - "type": "text", + "type": "string", "configurable": false, "pluginOptions": { "better-auth": { diff --git a/apps/playground/types/generated/contentTypes.d.ts b/apps/playground/types/generated/contentTypes.d.ts index 38eb911..3cf3142 100644 --- a/apps/playground/types/generated/contentTypes.d.ts +++ b/apps/playground/types/generated/contentTypes.d.ts @@ -889,7 +889,7 @@ export interface PluginBetterAuthOrganization managed: true; }; }>; - name: Schema.Attribute.Text & + name: Schema.Attribute.String & Schema.Attribute.Required & Schema.Attribute.SetPluginOptions<{ 'better-auth': { @@ -1026,7 +1026,7 @@ export interface PluginBetterAuthTeam extends Struct.CollectionTypeSchema { 'plugin::better-auth.team' > & Schema.Attribute.Private; - name: Schema.Attribute.Text & + name: Schema.Attribute.String & Schema.Attribute.Required & Schema.Attribute.SetPluginOptions<{ 'better-auth': { @@ -1229,7 +1229,7 @@ export interface PluginBetterAuthUser extends Struct.CollectionTypeSchema { 'plugin::better-auth.user' > & Schema.Attribute.Private; - name: Schema.Attribute.Text & + name: Schema.Attribute.String & Schema.Attribute.Required & Schema.Attribute.SetPluginOptions<{ 'better-auth': { diff --git a/plugins/plugin-better-auth/server/src/adapter/transformers/schema/utils.ts b/plugins/plugin-better-auth/server/src/adapter/transformers/schema/utils.ts index ec94f4b..cebbe1c 100644 --- a/plugins/plugin-better-auth/server/src/adapter/transformers/schema/utils.ts +++ b/plugins/plugin-better-auth/server/src/adapter/transformers/schema/utils.ts @@ -66,6 +66,9 @@ export function createAttributeProperties( // Special field type overrides if (fieldName === "email") properties.type = "email"; + // "text" (long text) fields can't be used as a Content Manager entry + // title (mainField), so identifying fields need to stay "string" (short text). + if (fieldName === "name") properties.type = "string"; return properties; } diff --git a/plugins/plugin-better-auth/server/test/schema.test.ts b/plugins/plugin-better-auth/server/test/schema.test.ts index b7a5578..6a55462 100644 --- a/plugins/plugin-better-auth/server/test/schema.test.ts +++ b/plugins/plugin-better-auth/server/test/schema.test.ts @@ -126,7 +126,7 @@ describe("transformTable — collectionName", () => { collectionName: "ba_user", attributes: { name: { - type: "text", + type: "string", required: true, pluginOptions: { "better-auth": { managed: true } }, }, From 94fa4ab20878abb8624febef8e6effac6343be46 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Fri, 7 Aug 2026 15:31:02 +0200 Subject: [PATCH 6/7] fix: sync the dashboard crud permissions with the CM crud permissions to fix #30 --- .../server/src/bootstrap.ts | 64 +++++++++++++++++++ .../server/src/index.ts | 2 + 2 files changed, 66 insertions(+) create mode 100644 plugins/plugin-better-auth-dashboard/server/src/bootstrap.ts diff --git a/plugins/plugin-better-auth-dashboard/server/src/bootstrap.ts b/plugins/plugin-better-auth-dashboard/server/src/bootstrap.ts new file mode 100644 index 0000000..9e8f7f0 --- /dev/null +++ b/plugins/plugin-better-auth-dashboard/server/src/bootstrap.ts @@ -0,0 +1,64 @@ +import type { Core } from "@strapi/types"; +import { PLUGIN_ID } from "./utils"; + +type AliasSubject = { + /** The dashboard permission subCategory this content type is gated behind. */ + resource: "user" | "organization"; + uid: string; +}; + +/** + * Better Auth content types (`user`, `organization`, ...) are hidden from the + * Content Manager navigation, so Strapi never grants `plugin::content-manager.explorer.*` + * for them — meaning relation fields pointing at these content types can't resolve + * a mainField and fall back to displaying the document ID instead of a name. + * + * Rather than blanket-granting Content Manager access to everyone, we alias each + * dashboard permission (`plugin::better-auth-dashboard..`) to the + * matching `plugin::content-manager.explorer.` action, scoped to that + * resource's content type. Whoever already has the dashboard permission to + * manage a resource transparently gains the ability to see its name in relation + * fields elsewhere in the Content Manager — nothing is granted beyond that. + */ +export default async ({ strapi }: { strapi: Core.Strapi }) => { + const provider = strapi.admin.services.permission.actionProvider; + + // strapi.plugin("better-auth").contentTypes is typed as `{ schema: ContentType }` + // but at runtime each entry *is* the content type itself (no `.schema` wrapper) — + // go through strapi.contentTypes directly instead so the UID check is reliable. + const candidates: AliasSubject[] = [ + { resource: "user", uid: "plugin::better-auth.user" }, + { resource: "organization", uid: "plugin::better-auth.organization" }, + ]; + + const subjects = candidates.filter(({ uid }) => uid in strapi.contentTypes); + + for (const { resource, uid } of subjects) { + for (const action of ["create", "read", "update", "delete"]) { + const explorerActionId = `plugin::content-manager.explorer.${action}`; + const dashboardAction = provider.get( + `plugin::${PLUGIN_ID}.${resource}.${action}`, + ); + + if (!dashboardAction) continue; + if (!dashboardAction.aliases) dashboardAction.aliases = []; + + const alreadyAliased = dashboardAction.aliases.some( + ({ + actionId, + subjects: aliasSubjects, + }: { + actionId: string; + subjects?: string[]; + }) => actionId === explorerActionId && aliasSubjects?.includes(uid), + ); + + if (!alreadyAliased) { + dashboardAction.aliases.push({ + actionId: explorerActionId, + subjects: [uid], + }); + } + } + } +}; diff --git a/plugins/plugin-better-auth-dashboard/server/src/index.ts b/plugins/plugin-better-auth-dashboard/server/src/index.ts index 1583696..4290156 100644 --- a/plugins/plugin-better-auth-dashboard/server/src/index.ts +++ b/plugins/plugin-better-auth-dashboard/server/src/index.ts @@ -1,4 +1,5 @@ import config from "./config"; +import bootstrap from "./bootstrap"; import controllers from "./controllers"; import policies from "./policies"; import { register } from "./register"; @@ -10,4 +11,5 @@ export default { routes, policies, register, + bootstrap, }; From 9dac49775dcf89d1d54ec250231111bfd1b2fadc Mon Sep 17 00:00:00 2001 From: Andrew Bone Date: Thu, 13 Aug 2026 14:52:06 +0100 Subject: [PATCH 7/7] Add support for permission reading and saving --- .../admin/src/pages/Roles/CreatePage.tsx | 123 +++++++----- .../admin/src/pages/Roles/EditPage.tsx | 185 +++++++++++------- .../admin/src/pages/Roles/ListPage.tsx | 81 +++++--- .../src/pages/Roles/components/TableBody.tsx | 25 +-- .../admin/src/pages/Roles/index.tsx | 7 +- .../admin/src/pages/Roles/paths.ts | 4 +- .../admin/test/e2e/roles.spec.ts | 31 ++- plugins/plugin-api-permissions/package.json | 1 + .../server/src/controllers/role.ts | 47 ++++- .../server/src/services/permission.ts | 119 +++++++++++ pnpm-lock.yaml | 4 + 11 files changed, 460 insertions(+), 167 deletions(-) diff --git a/plugins/plugin-api-permissions/admin/src/pages/Roles/CreatePage.tsx b/plugins/plugin-api-permissions/admin/src/pages/Roles/CreatePage.tsx index 7e4354c..7b0a40f 100644 --- a/plugins/plugin-api-permissions/admin/src/pages/Roles/CreatePage.tsx +++ b/plugins/plugin-api-permissions/admin/src/pages/Roles/CreatePage.tsx @@ -17,56 +17,51 @@ import { useNotification, useRBAC, } from "@strapi/strapi/admin"; -import type React from "react"; -import { useRef, useState } from "react"; +import type { FormEvent } from "react"; +import { useMemo, useState } from "react"; import { useIntl } from "react-intl"; import { useMutation, useQuery } from "react-query"; +import { useNavigate } from "react-router-dom"; import type { GenericResponse } from "../../types/content-api"; -import { Permissions, type PermissionsRef } from "./components/Permissions"; +import { Permissions } from "./components/Permissions"; import { PERMISSIONS } from "./constants"; -import { PermissionsProvider } from "./contexts/PermissionsContext"; -import { ROLES_BASE } from "./paths"; +import { + PermissionsProvider, + usePermissions, +} from "./contexts/PermissionsContext"; +import { ROLES_BASE, ROLES_ROUTE_BASE } from "./paths"; import { createEmptyFormState, type PermissionsFormState, type PermissionsLayout, } from "./utils/transform"; -export const RolesCreatePage = () => { +type RolesCreatePageContentProps = { + layout: PermissionsLayout; + permissions: PermissionsFormState; +}; + +const RolesCreatePageContent = ({ + layout, + permissions, +}: RolesCreatePageContentProps) => { const { formatMessage } = useIntl(); const { toggleNotification } = useNotification(); - const { get, post } = useFetchClient(); - const permissionsRef = useRef(null); - const goBack = () => { - if (typeof window !== "undefined") window.location.href = ROLES_BASE; - }; - - const { - allowedActions: { canCreate }, - } = useRBAC({ - create: PERMISSIONS.createRole, - }); + const { post } = useFetchClient(); + const { modifiedData } = usePermissions(); + const navigate = useNavigate(); + const goBack = () => navigate(ROLES_ROUTE_BASE); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [error, setError] = useState(null); - const { data: layoutData, isLoading: isLoadingLayout } = useQuery( - ["api-permissions", "permissions", "layout"], - async () => - get>( - "/api-permissions/permissions/layout", - ), - ); - - const layout = layoutData?.data?.data?.sections ?? null; - - const permissionsForm: PermissionsFormState = layout - ? createEmptyFormState(layout) - : { collectionTypes: {}, singleTypes: {}, plugins: {}, settings: {} }; - const createMutation = useMutation( - (body: { name: string; description: string }) => + (body: { + name: string; + description: string; + permissions: PermissionsFormState; + }) => post("/api-permissions/roles", { data: body, }), @@ -93,7 +88,7 @@ export const RolesCreatePage = () => { }, ); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = (e: FormEvent) => { e.preventDefault(); setError(null); if (!name || name.length < 3) { @@ -105,17 +100,9 @@ export const RolesCreatePage = () => { ); return; } - createMutation.mutate({ name, description }); + createMutation.mutate({ name, description, permissions: modifiedData }); }; - if (!canCreate) { - return ; - } - - if (isLoadingLayout || !layout) { - return ; - } - return ( @@ -173,6 +160,7 @@ export const RolesCreatePage = () => { defaultMessage: "Details", })} +
{formatMessage({ id: "Settings.roles.form.description", @@ -259,13 +247,7 @@ export const RolesCreatePage = () => { - - - + @@ -274,6 +256,49 @@ export const RolesCreatePage = () => { ); }; +export const RolesCreatePage = () => { + const { get } = useFetchClient(); + + const { + isLoading: isLoadingForPermissions, + allowedActions: { canCreate }, + } = useRBAC({ + create: PERMISSIONS.createRole, + }); + + const { data: layoutData, isLoading: isLoadingLayout } = useQuery( + ["api-permissions", "permissions", "layout"], + async () => + get>( + "/api-permissions/permissions/layout", + ), + ); + + const layout = layoutData?.data?.data?.sections ?? null; + const permissions = useMemo( + () => (layout ? createEmptyFormState(layout) : null), + [layout], + ); + + if (isLoadingForPermissions) { + return ; + } + + if (!canCreate) { + return ; + } + + if (isLoadingLayout || !layout || !permissions) { + return ; + } + + return ( + + + + ); +}; + export const ProtectedRolesCreatePage = () => ( diff --git a/plugins/plugin-api-permissions/admin/src/pages/Roles/EditPage.tsx b/plugins/plugin-api-permissions/admin/src/pages/Roles/EditPage.tsx index 640c453..90072d7 100644 --- a/plugins/plugin-api-permissions/admin/src/pages/Roles/EditPage.tsx +++ b/plugins/plugin-api-permissions/admin/src/pages/Roles/EditPage.tsx @@ -21,14 +21,19 @@ import type { FormEvent } from "react"; import { useMemo, useRef, useState } from "react"; import { useIntl } from "react-intl"; import { useMutation, useQuery } from "react-query"; +import { useNavigate } from "react-router-dom"; import type { GenericResponse } from "../../types/content-api"; import { Permissions, type PermissionsRef } from "./components/Permissions"; import { PERMISSIONS } from "./constants"; -import { PermissionsProvider } from "./contexts/PermissionsContext"; -import { ROLES_BASE } from "./paths"; +import { + PermissionsProvider, + usePermissions, +} from "./contexts/PermissionsContext"; +import { ROLES_BASE, ROLES_ROUTE_BASE } from "./paths"; import { apiToFormState, type PermissionEntry, + type PermissionsFormState, type PermissionsLayout, } from "./utils/transform"; @@ -39,65 +44,44 @@ type RoleData = { permissions?: PermissionEntry[]; }; -export const RolesEditPage = ({ id }: { id: string }) => { +type RolesEditPageProps = { + description?: string; + id: string; + layout: PermissionsLayout; + name: string; + permissions: PermissionsFormState; + usersCount: number; +}; + +export const RolesEditPage = ({ + description, + id, + layout, + name, + permissions, + usersCount = 0, +}: RolesEditPageProps) => { const { formatMessage } = useIntl(); const { toggleNotification } = useNotification(); - const { get, put } = useFetchClient(); + const { put } = useFetchClient(); const permissionsRef = useRef(null); - const goBack = () => { - if (typeof window !== "undefined") window.location.href = ROLES_BASE; - }; + const navigate = useNavigate(); + const goBack = () => navigate(ROLES_ROUTE_BASE); const { + isLoading: isLoadingForPermissions, allowedActions: { canUpdate }, - } = useRBAC({ - update: PERMISSIONS.updateRole, - }); + } = useRBAC({ update: PERMISSIONS.updateRole }); - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); const [error, setError] = useState(null); - const [initialised, setInitialised] = useState(false); - - const { data: layoutData, isLoading: isLoadingLayout } = useQuery( - ["api-permissions", "permissions", "layout"], - async () => - get>( - "/api-permissions/permissions/layout", - ), - ); - - const { data: roleData, isLoading: isLoadingRole } = useQuery( - ["api-permissions", "roles", id], - async () => - get>( - `/api-permissions/roles/${id}?populate=permissions`, - ), - { - enabled: !!id, - onSuccess: (res) => { - if (!initialised) { - setName(res.data?.data?.name ?? ""); - setDescription(res.data?.data?.description ?? ""); - setInitialised(true); - } - }, - }, - ); - - const layout = layoutData?.data?.data?.sections ?? null; - const roleApiData = roleData?.data?.data ?? null; - - const permissionsForm = useMemo( - () => - layout && roleApiData - ? apiToFormState(roleApiData?.permissions ?? [], layout) - : null, - [layout, roleApiData], - ); + const { modifiedData } = usePermissions(); const updateMutation = useMutation( - (body: { name: string; description: string }) => + (body: { + name: string; + description: string; + permissions: PermissionsFormState; + }) => put(`/api-permissions/roles/${id}`, { data: body, }), @@ -126,6 +110,11 @@ export const RolesEditPage = ({ id }: { id: string }) => { const handleSubmit = (e: FormEvent) => { e.preventDefault(); + + const { name = "", description = "" } = Object.fromEntries( + new FormData(e.currentTarget as HTMLFormElement).entries(), + ) as Record; + setError(null); if (!name || name.length < 3) { setError( @@ -136,10 +125,10 @@ export const RolesEditPage = ({ id }: { id: string }) => { ); return; } - updateMutation.mutate({ name, description }); + updateMutation.mutate({ name, description, permissions: modifiedData }); }; - if (isLoadingLayout || isLoadingRole || !permissionsForm || !layout) { + if (isLoadingForPermissions) { return ; } @@ -147,8 +136,6 @@ export const RolesEditPage = ({ id }: { id: string }) => { return ; } - const usersCount = roleApiData?.nb_users ?? 0; - return ( @@ -206,6 +193,7 @@ export const RolesEditPage = ({ id }: { id: string }) => { defaultMessage: "Details", })} +
{formatMessage({ id: "Settings.roles.form.description", @@ -260,11 +248,7 @@ export const RolesEditPage = ({ id }: { id: string }) => { defaultMessage: "Name", })} - setName(e.target.value)} - type="text" - /> + @@ -281,10 +265,7 @@ export const RolesEditPage = ({ id }: { id: string }) => { defaultMessage: "Description", })} -