From 8b29096deb1c9de5721e9a6d6cd2eefd1bb33b37 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:55:49 -0400 Subject: [PATCH] feat: bridge Better Auth UI to BTST v3 providers --- .github/workflows/release.yml | 103 +++++++++- docs/content/docs/integrations/btst-v3.mdx | 191 ++++++++++++++++++ docs/content/docs/integrations/meta.json | 1 + src/client.ts | 1 + src/index.ts | 1 + .../__tests__/better-auth-provider.test.ts | 174 ++++++++++++++++ src/lib/__tests__/package-metadata.test.ts | 31 +++ .../__tests__/plugin-context-bridge.test.tsx | 166 ++++++++++++++- src/lib/better-auth-provider-shared.ts | 58 ++++++ src/lib/better-auth-provider.ts | 78 +++++++ src/lib/better-auth-server-provider.ts | 104 ++++++++++ src/lib/plugin-context-bridge.tsx | 100 ++++++--- src/lib/stack-adapters.ts | 15 ++ src/plugins/auth-plugin.ts | 26 --- src/server.ts | 8 + 15 files changed, 993 insertions(+), 64 deletions(-) create mode 100644 docs/content/docs/integrations/btst-v3.mdx create mode 100644 src/lib/__tests__/better-auth-provider.test.ts create mode 100644 src/lib/better-auth-provider-shared.ts create mode 100644 src/lib/better-auth-provider.ts create mode 100644 src/lib/better-auth-server-provider.ts create mode 100644 src/lib/stack-adapters.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54f92d09d..b30556e8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,20 @@ name: Better Auth UI Release on: release: types: [published] + workflow_dispatch: + inputs: + release_tag: + description: Existing v* tag to publish + required: true + type: string + prerelease: + description: Publish with the npm next dist-tag + required: true + default: true + type: boolean permissions: - contents: write + contents: read id-token: write jobs: @@ -15,10 +26,13 @@ jobs: - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: + ref: ${{ github.event.release.tag_name || inputs.release_tag }} fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + with: + version: 10.26.2 - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 @@ -26,8 +40,8 @@ jobs: node-version: '22.18.0' registry-url: 'https://registry.npmjs.org' - - name: Update npm - run: npm install -g npm@latest + - name: Install npm with trusted publishing support + run: npm install -g npm@11.17.0 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -38,15 +52,18 @@ jobs: - name: Resolve release metadata id: release-metadata env: - GITHUB_PRERELEASE: ${{ github.event.release.prerelease }} + GITHUB_PRERELEASE: ${{ github.event.release.prerelease || inputs.prerelease }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} run: | + set -euo pipefail + PKG_VERSION=$(node -p "require('./package.json').version") - if [ -n "${{ github.event.release.tag_name }}" ]; then - RAW_TAG="${{ github.event.release.tag_name }}" - else - RAW_TAG="${GITHUB_REF#refs/tags/}" + if [[ ! "$RELEASE_TAG" =~ ^v ]]; then + echo "Release tag must start with v; found $RELEASE_TAG" + exit 1 fi - TAG_VERSION="${RAW_TAG#v}" + + TAG_VERSION="${RELEASE_TAG#v}" if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then echo "Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)" exit 1 @@ -62,4 +79,70 @@ jobs: echo "Publishing @btst/better-auth-ui@$PKG_VERSION with dist-tag '$NPM_DIST_TAG'" - name: Publish to NPM - run: npm publish --access public --tag "${{ steps.release-metadata.outputs.npm_dist_tag }}" + env: + NPM_DIST_TAG: ${{ steps.release-metadata.outputs.npm_dist_tag }} + PKG_VERSION: ${{ steps.release-metadata.outputs.package_version }} + run: | + set -euo pipefail + + if npm view "@btst/better-auth-ui@$PKG_VERSION" version >/dev/null 2>&1; then + echo "@btst/better-auth-ui@$PKG_VERSION is already published; skipping." + exit 0 + fi + npm publish --access public --provenance --tag "$NPM_DIST_TAG" + + - name: Verify published package + env: + NPM_DIST_TAG: ${{ steps.release-metadata.outputs.npm_dist_tag }} + PKG_VERSION: ${{ steps.release-metadata.outputs.package_version }} + run: | + set -euo pipefail + + PUBLISHED_VERSION="" + for attempt in {1..12}; do + PUBLISHED_VERSION=$(npm view "@btst/better-auth-ui@$NPM_DIST_TAG" version 2>/dev/null || true) + if [ "$PUBLISHED_VERSION" = "$PKG_VERSION" ]; then + break + fi + echo "Waiting for npm to resolve $NPM_DIST_TAG to $PKG_VERSION (attempt $attempt/12)" + sleep 10 + done + + if [ "$PUBLISHED_VERSION" != "$PKG_VERSION" ]; then + echo "@btst/better-auth-ui@$NPM_DIST_TAG resolves to ${PUBLISHED_VERSION:-nothing}, expected $PKG_VERSION" + exit 1 + fi + + INTEGRITY=$(npm view "@btst/better-auth-ui@$PKG_VERSION" dist.integrity) + if [ -z "$INTEGRITY" ]; then + echo "Published package integrity metadata is missing" + exit 1 + fi + + LATEST_SUMMARY="" + if [ "$NPM_DIST_TAG" = "next" ]; then + LATEST_VERSION=$(npm view "@btst/better-auth-ui@latest" version 2>/dev/null || true) + if [ "$LATEST_VERSION" = "$PKG_VERSION" ] || [[ "$LATEST_VERSION" == *-* ]]; then + echo "Prerelease publication must not move latest to a prerelease; found $LATEST_VERSION" + exit 1 + fi + LATEST_SUMMARY="@btst/better-auth-ui@latest remains at ${LATEST_VERSION:-no published version}" + fi + + { + echo "### npm release" + echo "" + echo "@btst/better-auth-ui@$PKG_VERSION published with dist-tag $NPM_DIST_TAG." + echo "Integrity: \`$INTEGRITY\`" + if [ -n "$LATEST_SUMMARY" ]; then + echo "$LATEST_SUMMARY" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload npm logs on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: npm-debug-logs + path: /home/runner/.npm/_logs/ + retention-days: 7 diff --git a/docs/content/docs/integrations/btst-v3.mdx b/docs/content/docs/integrations/btst-v3.mdx new file mode 100644 index 000000000..565e4a8b1 --- /dev/null +++ b/docs/content/docs/integrations/btst-v3.mdx @@ -0,0 +1,191 @@ +--- +title: BTST v3 +description: Configure Better Auth UI as the client and server auth provider for BTST v3 +--- + +`@btst/better-auth-ui` supplies the Better Auth client plugins, page routes, and +the first-party auth-provider adapters used by BTST v3. Router, API, +notification, localization, and auth services are configured once at the top +level instead of repeated in each plugin override. + +## Install the release candidate + +```bash +pnpm add @btst/stack@next @btst/better-auth-ui@next @btst/yar@^1.3.0 +``` + +## Register the client plugins + +```tsx title="lib/stack-client.tsx" +import { createStackClient } from "@btst/stack/client" +import { + accountClientPlugin, + authClientPlugin, + organizationClientPlugin, +} from "@btst/better-auth-ui/client" + +export function getStackClient() { + return createStackClient({ + basePath: "/p", + plugins: { + auth: authClientPlugin({ + siteBasePath: "/p", + siteBaseURL: process.env.NEXT_PUBLIC_APP_URL!, + }), + account: accountClientPlugin({ + siteBasePath: "/p", + siteBaseURL: process.env.NEXT_PUBLIC_APP_URL!, + }), + organization: organizationClientPlugin({ + siteBasePath: "/p", + siteBaseURL: process.env.NEXT_PUBLIC_APP_URL!, + }), + }, + }) +} +``` + +## Configure the client provider + +`createBetterAuthProvider` maps the Better Auth session to BTST identity. It +leaves Better Auth UI's native permission hook in place unless an explicit +authorization mapping is configured. Set `permissionProvider` only when the +matching Better Auth client plugin is installed; resource/action checks are +then sent to that plugin's `hasPermission` endpoint and anonymous checks fail +closed. + +```tsx title="app/p/layout.tsx" +"use client" + +import { StackProvider, type StackI18nProvider, type StackNotifyProvider } from "@btst/stack/context" +import { nextRouter } from "@btst/stack/next" +import { createBetterAuthProvider } from "@btst/better-auth-ui" +import { + type AccountPluginOverrides, + type AuthPluginOverrides, + type OrganizationPluginOverrides, +} from "@btst/better-auth-ui/client" +import { toast } from "sonner" +import { authClient } from "@/lib/auth-client" +import { translateForApp } from "@/lib/i18n" + +type PluginOverrides = { + auth: AuthPluginOverrides + account: AccountPluginOverrides + organization: OrganizationPluginOverrides +} + +const stackAuth = createBetterAuthProvider(authClient, { + loginPath: "/p/auth/sign-in", + permissionProvider: "organization", +}) + +const notify: StackNotifyProvider = { + success: toast.success, + error: toast.error, + info: toast.info, + warning: toast.warning, +} + +const i18n: StackI18nProvider = { + translate: (key, defaultValue, params) => + translateForApp(key, { defaultValue, ...params }), +} + +export default function PagesLayout({ children }: { children: React.ReactNode }) { + const baseURL = + typeof window === "undefined" + ? process.env.NEXT_PUBLIC_APP_URL! + : window.location.origin + + return ( + + basePath="/p" + router={nextRouter()} + api={{ baseURL, basePath: "/api/data" }} + auth={stackAuth} + notify={notify} + i18n={i18n} + overrides={{ + auth: { + authClient, + basePath: "/p/auth", + redirectTo: "/p/account/settings", + }, + account: { + authClient, + basePath: "/p/account", + account: true, + }, + organization: { + authClient, + basePath: "/p/organization", + organization: true, + }, + }} + > + {children} + + ) +} +``` + +Better Auth UI localization keys are passed to the top-level translator as +`better-auth-ui.KEY`, with the package's English string as `defaultValue`. +Toasts use the top-level notification provider, and navigation/session refresh +use the top-level router. + +## Configure the server provider + +The server entry exports the corresponding adapter for `stack({ auth })`. It +passes the incoming headers to Better Auth and memoizes the session result for +the request. BTST also shares that identity with all lifecycle hooks handling +the request. + +```ts title="lib/stack.ts" +import { stack } from "@btst/stack/api" +import { createBetterAuthProvider } from "@btst/better-auth-ui/server" +import { auth } from "@/lib/auth" +import { adapter } from "@/lib/btst-adapter" + +export const { handler } = stack({ + basePath: "/api/data", + adapter, + plugins: {}, + auth: createBetterAuthProvider(auth, { + permissionProvider: "organization", + }), +}) +``` + +If the Better Auth organization plugin is not configured, omit +`permissionProvider`. To use the admin plugin instead, set it to `"admin"` on +both the client and server adapters. + +## Add the v3 entry factories + +```ts title="app/api/data/[[...all]]/route.ts" +import { toNextRouteHandlers } from "@btst/stack/next" +import { handler } from "@/lib/stack" + +export const { GET, POST, PUT, PATCH, DELETE } = toNextRouteHandlers(handler) +``` + +```tsx title="app/p/[[...all]]/page.tsx" +import { createNextPage } from "@btst/stack/next" +import { getOrCreateQueryClient } from "@/lib/query-client" +import { getStackClient } from "@/lib/stack-client" + +export const dynamic = "force-dynamic" + +const page = createNextPage({ + getStackClient, + getQueryClient: getOrCreateQueryClient, +}) + +export default page.Page +export const generateMetadata = page.generateMetadata +``` + +Finally, import `@btst/better-auth-ui/css` from the application's global +stylesheet. diff --git a/docs/content/docs/integrations/meta.json b/docs/content/docs/integrations/meta.json index f17996a74..21483c635 100644 --- a/docs/content/docs/integrations/meta.json +++ b/docs/content/docs/integrations/meta.json @@ -2,6 +2,7 @@ "icon": "LayoutGrid", "defaultOpen": true, "pages": [ + "btst-v3", "next-js", "tanstack-start", "react" diff --git a/src/client.ts b/src/client.ts index 210620a7b..9ba8fd30f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,6 @@ // Re-export plugins and types +export * from "./lib/better-auth-provider" export type { AccountClientConfig, AccountPageProps, diff --git a/src/index.ts b/src/index.ts index 63193a087..3341ee5fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ export * from "./hooks/use-auth-data" export * from "./hooks/use-authenticate" export * from "./hooks/use-current-organization" export * from "./lib/auth-ui-provider" +export * from "./lib/better-auth-provider" export * from "./lib/social-providers" export { getViewByPath } from "./lib/utils" export * from "./lib/view-paths" diff --git a/src/lib/__tests__/better-auth-provider.test.ts b/src/lib/__tests__/better-auth-provider.test.ts new file mode 100644 index 000000000..be1ce29c1 --- /dev/null +++ b/src/lib/__tests__/better-auth-provider.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from "vitest" +import { createBetterAuthProvider } from "../better-auth-provider" +import { createBetterAuthServerProvider } from "../better-auth-server-provider" + +describe("createBetterAuthProvider", () => { + it("maps the Better Auth session user to a stack identity", async () => { + const getSession = vi.fn().mockResolvedValue({ + data: { + user: { + id: "user-1", + name: "Ada", + email: "ada@example.com", + image: null, + role: "admin" + } + } + }) + + const provider = createBetterAuthProvider({ getSession }) + + await expect(provider.getIdentity()).resolves.toEqual({ + id: "user-1", + name: "Ada", + email: "ada@example.com", + role: "admin" + }) + expect(provider.loginPath).toBe("/auth/sign-in") + }) + + it("returns null for an unauthenticated session", async () => { + const provider = createBetterAuthProvider({ + getSession: vi.fn().mockResolvedValue({ data: null }) + }) + + await expect(provider.getIdentity()).resolves.toBeNull() + }) + + it("leaves permissions to Better Auth unless a mapping is configured", () => { + const provider = createBetterAuthProvider({ + getSession: vi.fn().mockResolvedValue({ data: null }) + }) + + expect(provider.can).toBeUndefined() + }) + + it("maps checks to the Better Auth organization permission endpoint", async () => { + const hasPermission = vi.fn().mockResolvedValue({ + data: { success: true } + }) + const provider = createBetterAuthProvider( + { + getSession: vi.fn().mockResolvedValue({ data: null }), + organization: { hasPermission } + }, + { permissionProvider: "organization" } + ) + + await expect( + provider.can?.({ + resource: "post", + action: "update", + params: { organizationId: "org-1" }, + identity: { id: "user-1" } + }) + ).resolves.toBe(true) + expect(hasPermission).toHaveBeenCalledWith({ + organizationId: "org-1", + permissions: { post: ["update"] } + }) + }) + + it("maps checks to the Better Auth admin permission endpoint", async () => { + const hasPermission = vi.fn().mockResolvedValue({ success: false }) + const provider = createBetterAuthProvider( + { + getSession: vi.fn().mockResolvedValue({ data: null }), + admin: { hasPermission } + }, + { loginPath: "/login", permissionProvider: "admin" } + ) + + await expect( + provider.can?.({ + resource: "user", + action: "ban", + params: { organizationId: "ignored-for-admin" }, + identity: { id: "user-1" } + }) + ).resolves.toBe(false) + expect(hasPermission).toHaveBeenCalledWith({ + permissions: { user: ["ban"] } + }) + expect(provider.loginPath).toBe("/login") + }) +}) + +describe("createBetterAuthServerProvider", () => { + it("omits server authorization when no mapping is configured", () => { + const provider = createBetterAuthServerProvider({ + api: { getSession: vi.fn().mockResolvedValue(null) } + }) + + expect(provider.can).toBeUndefined() + }) + + it("resolves a request identity from headers once per request", async () => { + const getSession = vi.fn().mockResolvedValue({ + user: { + id: "user-1", + name: "Ada", + image: "https://example.com/ada.png" + } + }) + const provider = createBetterAuthServerProvider({ + api: { getSession } + }) + const request = new Request("https://example.com/api/data", { + headers: { cookie: "session=token" } + }) + + const first = provider.getIdentity({ + headers: request.headers, + request + }) + const second = provider.getIdentity({ + headers: request.headers, + request + }) + + await expect(first).resolves.toEqual({ + id: "user-1", + name: "Ada", + image: "https://example.com/ada.png" + }) + await expect(second).resolves.toEqual({ + id: "user-1", + name: "Ada", + image: "https://example.com/ada.png" + }) + expect(getSession).toHaveBeenCalledTimes(1) + expect(getSession).toHaveBeenCalledWith({ headers: request.headers }) + }) + + it("maps server checks to Better Auth organization permissions", async () => { + const hasPermission = vi.fn().mockResolvedValue({ success: true }) + const provider = createBetterAuthServerProvider( + { + api: { + getSession: vi.fn().mockResolvedValue(null), + hasPermission + } + }, + { permissionProvider: "organization" } + ) + const headers = new Headers({ cookie: "session=token" }) + + await expect( + provider.can?.({ + resource: "member", + action: "delete", + params: { organizationId: "org-1" }, + identity: { id: "user-1" }, + headers + }) + ).resolves.toBe(true) + expect(hasPermission).toHaveBeenCalledWith({ + headers, + body: { + organizationId: "org-1", + permissions: { member: ["delete"] } + } + }) + }) +}) diff --git a/src/lib/__tests__/package-metadata.test.ts b/src/lib/__tests__/package-metadata.test.ts index d9e1d0a00..057695871 100644 --- a/src/lib/__tests__/package-metadata.test.ts +++ b/src/lib/__tests__/package-metadata.test.ts @@ -14,6 +14,14 @@ async function readPackageManifest(): Promise { } describe("package dependency compatibility", () => { + it("exports the client provider factory from the package root", async () => { + const entrypoint = await readFile(resolve("src/index.ts"), "utf8") + + expect(entrypoint).toContain( + 'export * from "./lib/better-auth-provider"' + ) + }) + /** * @see https://github.com/better-stack-ai/better-stack/issues/163 */ @@ -47,3 +55,26 @@ describe("package dependency compatibility", () => { expect(manifest.dependencies).not.toHaveProperty("better-call") }) }) + +describe("RC publishing", () => { + it("can safely publish or retry a prerelease without moving latest", async () => { + const workflow = await readFile( + resolve(".github/workflows/release.yml"), + "utf8" + ) + + expect(workflow).toContain("workflow_dispatch:") + expect(workflow).toContain( + "ref: $" + + "{{ github.event.release.tag_name || inputs.release_tag }}" + ) + expect(workflow).toContain("npm install -g npm@11.17.0") + expect(workflow).toContain("npm publish --access public --provenance") + expect(workflow).toContain( + 'npm view "@btst/better-auth-ui@$PKG_VERSION"' + ) + expect(workflow).toContain( + 'npm view "@btst/better-auth-ui@$NPM_DIST_TAG"' + ) + }) +}) diff --git a/src/lib/__tests__/plugin-context-bridge.test.tsx b/src/lib/__tests__/plugin-context-bridge.test.tsx index 7396692b1..23f153e54 100644 --- a/src/lib/__tests__/plugin-context-bridge.test.tsx +++ b/src/lib/__tests__/plugin-context-bridge.test.tsx @@ -17,7 +17,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest" // ─── mocks ──────────────────────────────────────────────────────────────────── vi.mock("@btst/stack/context", () => ({ - usePluginOverrides: vi.fn() + useCan: vi.fn(), + useIdentity: vi.fn(), + useNotify: vi.fn(), + usePluginOverrides: vi.fn(), + useStack: vi.fn(), + useTranslate: vi.fn() })) // Capture queryFns passed to useAuthData so we can call them directly in tests. @@ -51,7 +56,14 @@ vi.mock("../organization-refetcher", () => ({ // ─── imports after mocks ────────────────────────────────────────────────────── -import { usePluginOverrides } from "@btst/stack/context" +import { + useCan, + useIdentity, + useNotify, + usePluginOverrides, + useStack, + useTranslate +} from "@btst/stack/context" import { AuthUIContext, type AuthUIContextType } from "../auth-ui-provider" import { BetterAuthPluginProvider } from "../plugin-context-bridge" @@ -132,6 +144,26 @@ function renderBridge( beforeEach(() => { vi.clearAllMocks() + vi.mocked(useStack).mockReturnValue({ + basePath: "/p", + overrides: {}, + router: {} + }) + vi.mocked(useCan).mockReturnValue({ can: true, isPending: false }) + vi.mocked(useIdentity).mockReturnValue({ + identity: null, + isPending: false, + refetch: vi.fn() + }) + vi.mocked(useNotify).mockReturnValue({ + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + }) + vi.mocked(useTranslate).mockReturnValue( + (_key, defaultValue) => defaultValue + ) // biome-ignore lint/suspicious/useIterableCallbackReturn: acceptable use of forEach Object.keys(capturedQueryFns).forEach((k) => delete capturedQueryFns[k]) }) @@ -555,10 +587,138 @@ describe("hooks — useListTeamMembers uses POST (not GET)", () => { const ctx = renderBridge() ctx.hooks.useListUserTeams() - const qfn = capturedQueryFns["listUserTeams"] + const qfn = capturedQueryFns.listUserTeams expect(qfn).toBeDefined() qfn() expect(mockFetch).toHaveBeenCalledWith("/organization/list-user-teams") }) }) + +describe("BTST v3 top-level providers", () => { + it("routes organization permission hooks through stack can", () => { + vi.mocked(useStack).mockReturnValue({ + auth: { can: vi.fn(), getIdentity: vi.fn() }, + basePath: "/p", + overrides: {}, + router: {} + }) + + const context = renderBridge() + const permission = context.hooks.useHasPermission({ + organizationId: "org-1", + permissions: { member: ["update"] } + }) + + expect(permission.data?.success).toBe(true) + expect(useCan).toHaveBeenCalledWith({ + resource: "member", + action: "update", + params: { organizationId: "org-1" } + }) + }) + + it("preserves Better Auth permissions without an explicit stack can mapping", () => { + const useHasPermission = vi.fn(() => ({ + data: { error: null, success: false }, + isPending: false, + isRefetching: false + })) + vi.mocked(useStack).mockReturnValue({ + auth: { getIdentity: vi.fn() }, + basePath: "/p", + overrides: {}, + router: {} + }) + + const context = renderBridge({ hooks: { useHasPermission } }) + const permission = context.hooks.useHasPermission({ + organizationId: "org-1", + permissions: { member: ["update"] } + }) + + expect(permission.data?.success).toBe(false) + expect(useHasPermission).toHaveBeenCalledOnce() + expect(useCan).not.toHaveBeenCalled() + }) + + it("fails closed for permission batches that stack cannot represent", () => { + vi.mocked(useStack).mockReturnValue({ + auth: { can: vi.fn(), getIdentity: vi.fn() }, + basePath: "/p", + overrides: {}, + router: {} + }) + + const context = renderBridge() + const permission = context.hooks.useHasPermission({ + organizationId: "org-1", + permissions: { member: ["update", "delete"] } + }) + + expect(permission.data?.success).toBe(false) + expect(permission.isPending).toBe(false) + }) + + it("sources navigation and refreshes identity from top-level providers", async () => { + const navigate = vi.fn() + const refresh = vi.fn() + const refetch = vi.fn() + const StackLink = () => null + vi.mocked(useIdentity).mockReturnValue({ + identity: null, + isPending: false, + refetch + }) + vi.mocked(useStack).mockReturnValue({ + basePath: "/p", + overrides: {}, + router: { Link: StackLink, navigate, refresh } + }) + + const context = renderBridge({ + Link: () => null, + navigate: vi.fn(), + onSessionChange: vi.fn() + }) + + context.navigate("/account") + await context.onSessionChange?.() + expect(context.Link).toBe(StackLink) + expect(navigate).toHaveBeenCalledWith("/account") + expect(refetch).toHaveBeenCalledOnce() + expect(refresh).toHaveBeenCalledOnce() + }) + + it("routes toast rendering through the top-level notify provider", () => { + const notify = { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } + vi.mocked(useNotify).mockReturnValue(notify) + + const context = renderBridge({ toast: vi.fn() }) + context.toast({ variant: "success", message: "Saved" }) + context.toast({ message: "Heads up" }) + + expect(notify.success).toHaveBeenCalledWith("Saved") + expect(notify.info).toHaveBeenCalledWith("Heads up") + }) + + it("translates existing localization values through stack i18n", () => { + const translate = vi.fn((key: string, defaultValue: string) => + key === "better-auth-ui.SIGN_IN" ? "Entrar" : defaultValue + ) + vi.mocked(useTranslate).mockReturnValue(translate) + + const context = renderBridge() + + expect(context.localization.SIGN_IN).toBe("Entrar") + expect(translate).toHaveBeenCalledWith( + "better-auth-ui.SIGN_IN", + expect.any(String) + ) + }) +}) diff --git a/src/lib/better-auth-provider-shared.ts b/src/lib/better-auth-provider-shared.ts new file mode 100644 index 000000000..b90364a04 --- /dev/null +++ b/src/lib/better-auth-provider-shared.ts @@ -0,0 +1,58 @@ +import type { CanParams, StackIdentity } from "@btst/stack/context" + +export type BetterAuthPermissionProvider = "admin" | "organization" + +export interface BetterAuthUser { + id: string + name?: string | null + email?: string | null + image?: string | null + [key: string]: unknown +} + +export type BetterAuthPermissionResult = + | boolean + | { + data?: { success?: boolean } | null + success?: boolean + } + +export function toStackIdentity( + user: BetterAuthUser | null | undefined +): StackIdentity | null { + if (!user) return null + + const { id, name, email, image, ...fields } = user + + return { + ...fields, + id, + ...(typeof name === "string" ? { name } : {}), + ...(typeof email === "string" ? { email } : {}), + ...(typeof image === "string" ? { image } : {}) + } +} + +export function getPermissionBody( + { resource, action, params }: CanParams, + includeOrganizationId = true +): { + organizationId?: string + permissions: Record +} { + const organizationId = params?.organizationId + + return { + ...(includeOrganizationId && typeof organizationId === "string" + ? { organizationId } + : {}), + permissions: { [resource]: [action] } + } +} + +export function getPermissionDecision( + result: BetterAuthPermissionResult +): boolean { + if (typeof result === "boolean") return result + return result.data?.success ?? result.success ?? false +} diff --git a/src/lib/better-auth-provider.ts b/src/lib/better-auth-provider.ts new file mode 100644 index 000000000..57532c27a --- /dev/null +++ b/src/lib/better-auth-provider.ts @@ -0,0 +1,78 @@ +import type { StackAuthProvider } from "@btst/stack/context" +import { + type BetterAuthPermissionProvider, + type BetterAuthPermissionResult, + type BetterAuthUser, + getPermissionBody, + getPermissionDecision, + toStackIdentity +} from "./better-auth-provider-shared" + +type MaybePromise = Promise | T + +interface BetterAuthClientSessionResult { + data?: { user: BetterAuthUser } | null +} + +interface BetterAuthClientPermissionApi { + hasPermission: ( + input: ReturnType + ) => MaybePromise +} + +export interface BetterAuthStackClient { + getSession: () => MaybePromise + admin?: BetterAuthClientPermissionApi + organization?: BetterAuthClientPermissionApi +} + +export interface BetterAuthProviderOptions { + /** Path used by BTST when an unauthenticated user reaches a gated route. */ + loginPath?: string + /** + * Better Auth permission plugin to use for BTST resource/action checks. + * Leave unset when the client has no permission plugin configured. + */ + permissionProvider?: BetterAuthPermissionProvider + /** Override the default Better Auth permission mapping. */ + can?: NonNullable +} + +/** + * Adapt a Better Auth client to the auth contract consumed by StackProvider. + */ +export function createBetterAuthProvider( + authClient: BetterAuthStackClient, + options: BetterAuthProviderOptions = {} +): StackAuthProvider { + const permissionProvider = options.permissionProvider + const can = + options.can ?? + (permissionProvider + ? async ( + params: Parameters>[0] + ) => { + if (!params.identity) return false + + const permissionApi = authClient[permissionProvider] + if (!permissionApi?.hasPermission) return false + + const result = await permissionApi.hasPermission( + getPermissionBody( + params, + permissionProvider === "organization" + ) + ) + return getPermissionDecision(result) + } + : undefined) + + return { + getIdentity: async () => { + const session = await authClient.getSession() + return toStackIdentity(session.data?.user) + }, + ...(can ? { can } : {}), + loginPath: options.loginPath ?? "/auth/sign-in" + } +} diff --git a/src/lib/better-auth-server-provider.ts b/src/lib/better-auth-server-provider.ts new file mode 100644 index 000000000..9ce327a59 --- /dev/null +++ b/src/lib/better-auth-server-provider.ts @@ -0,0 +1,104 @@ +import type { StackServerAuthProvider } from "@btst/stack/api" +import { + type BetterAuthPermissionProvider, + type BetterAuthPermissionResult, + type BetterAuthUser, + getPermissionBody, + getPermissionDecision, + toStackIdentity +} from "./better-auth-provider-shared" + +type MaybePromise = Promise | T + +type BetterAuthServerSession = + | { data?: { user: BetterAuthUser } | null } + | { user: BetterAuthUser } + | null + +interface BetterAuthServerApi { + getSession: (input: { + headers: Headers + }) => MaybePromise + hasPermission?: (input: { + headers: Headers + body: ReturnType + }) => MaybePromise + userHasPermission?: (input: { + headers: Headers + body: ReturnType + }) => MaybePromise +} + +export interface BetterAuthStackServer { + api: BetterAuthServerApi +} + +export interface BetterAuthServerProviderOptions { + /** Better Auth permission plugin used by server-side BTST checks. */ + permissionProvider?: BetterAuthPermissionProvider + /** Override the default Better Auth permission mapping. */ + can?: NonNullable +} + +function getSessionUser( + session: BetterAuthServerSession +): BetterAuthUser | null { + if (!session) return null + if ("user" in session) return session.user + return session.data?.user ?? null +} + +/** + * Adapt a Better Auth server instance to the auth contract consumed by stack(). + */ +export function createBetterAuthServerProvider( + auth: BetterAuthStackServer, + options: BetterAuthServerProviderOptions = {} +): StackServerAuthProvider { + const identities = new WeakMap< + Request, + Promise> + >() + const permissionProvider = options.permissionProvider + const can = + options.can ?? + (permissionProvider + ? async ( + params: Parameters< + NonNullable + >[0] + ) => { + if (!params.identity) return false + + const permissionMethod = + permissionProvider === "organization" + ? auth.api.hasPermission + : auth.api.userHasPermission + if (!permissionMethod) return false + + const result = await permissionMethod({ + headers: params.headers, + body: getPermissionBody( + params, + permissionProvider === "organization" + ) + }) + return getPermissionDecision(result) + } + : undefined) + + return { + getIdentity: ({ headers, request }) => { + let identity = identities.get(request) + + if (!identity) { + identity = Promise.resolve( + auth.api.getSession({ headers }) + ).then((session) => toStackIdentity(getSessionUser(session))) + identities.set(request, identity) + } + return identity + }, + ...(can ? { can } : {}) + } +} diff --git a/src/lib/plugin-context-bridge.tsx b/src/lib/plugin-context-bridge.tsx index 6dbc6789f..c2891890a 100644 --- a/src/lib/plugin-context-bridge.tsx +++ b/src/lib/plugin-context-bridge.tsx @@ -1,11 +1,20 @@ "use client" -import { usePluginOverrides } from "@btst/stack/context" +import { + useCan, + useIdentity, + useNotify, + usePluginOverrides, + useStack, + useTranslate +} from "@btst/stack/context" import { type ReactNode, useMemo } from "react" -import { toast } from "sonner" import { RecaptchaV3 } from "../components/captcha/recaptcha-v3" import { useAuthData } from "../hooks/use-auth-data" -import { authLocalization } from "../localization/auth-localization" +import { + type AuthLocalization, + authLocalization +} from "../localization/auth-localization" import type { AccountPluginOverrides } from "../plugins/account-plugin" import type { AuthPluginOverrides } from "../plugins/auth-plugin" import type { OrganizationPluginOverrides } from "../plugins/organization-plugin" @@ -19,12 +28,12 @@ import type { DeleteUserOptions } from "../types/delete-user-options" import type { GenericOAuthOptions } from "../types/generic-oauth-options" import type { Link } from "../types/link" import type { OrganizationOptionsContext } from "../types/organization-options" -import type { RenderToast } from "../types/render-toast" import type { SignUpOptions } from "../types/sign-up-options" import type { SocialOptions } from "../types/social-options" import type { TeamOptionsContext } from "../types/team-options" import { AuthUIContext, type AuthUIContextType } from "./auth-ui-provider" import { OrganizationRefetcher } from "./organization-refetcher" +import { createRenderToast } from "./stack-adapters" import { accountViewPaths, authViewPaths, @@ -45,11 +54,32 @@ const defaultReplace = (href: string) => { window.location.replace(href) } -const defaultToast: RenderToast = ({ variant = "default", message }) => { - if (variant === "default") { - toast(message) - } else { - toast[variant](message) +function useStackHasPermission( + params: Parameters[0] +): ReturnType { + const permissions = ( + "permissions" in params ? params.permissions : params.permission + ) as Record + const permissionEntries = Object.entries(permissions) + const [resource, actions] = permissionEntries[0] ?? [] + const action = actions?.[0] + const isSinglePermission = + permissionEntries.length === 1 && actions?.length === 1 + const organizationId = + "organizationId" in params ? params.organizationId : undefined + const { can, isPending } = useCan({ + resource: resource ?? "", + action: action ?? "", + params: organizationId ? { organizationId } : undefined + }) + + return { + data: { + error: null, + success: Boolean(isSinglePermission && resource && action && can) + }, + isPending: isSinglePermission && isPending, + isRefetching: isSinglePermission && isPending } } @@ -63,6 +93,11 @@ export function BetterAuthPluginProvider({ }: { children: ReactNode }) { + const { auth, router } = useStack() + const { refetch: refetchIdentity } = useIdentity() + const notify = useNotify() + const translate = useTranslate() + // Read auth plugin overrides const authOverrides = usePluginOverrides< AuthPluginOverrides, @@ -73,11 +108,7 @@ export function BetterAuthPluginProvider({ redirectTo: "/", freshAge: 60 * 60 * 24, changeEmail: true, - nameRequired: true, - Link: DefaultLink, - navigate: defaultNavigate, - replace: defaultReplace, - toast: defaultToast + nameRequired: true }) // Read account plugin overrides (returns defaults if plugin not registered) @@ -444,12 +475,33 @@ export function BetterAuthPluginProvider({ }, [authOverrides.viewPaths]) const localization = useMemo(() => { - return { ...authLocalization, ...authOverrides.localization } - }, [authOverrides.localization]) + const merged = { ...authLocalization, ...authOverrides.localization } + + return Object.fromEntries( + Object.entries(merged).map(([key, defaultValue]) => [ + key, + translate(`better-auth-ui.${key}`, defaultValue) + ]) + ) as AuthLocalization + }, [authOverrides.localization, translate]) + + const renderToast = useMemo(() => createRenderToast(notify), [notify]) const hooks = useMemo(() => { - return { ...defaultHooks, ...authOverrides.hooks } - }, [defaultHooks, authOverrides.hooks]) + return { + ...defaultHooks, + ...authOverrides.hooks, + ...(auth?.can ? { useHasPermission: useStackHasPermission } : {}) + } + }, [auth, defaultHooks, authOverrides.hooks]) + + const onSessionChange = useMemo( + () => async () => { + await refetchIdentity() + await router?.refresh?.() + }, + [refetchIdentity, router?.refresh] + ) const mutators = useMemo(() => { return { ...defaultMutators, ...authOverrides.mutators } @@ -504,13 +556,11 @@ export function BetterAuthPluginProvider({ account, signUp, social, - // Shared navigation — account/org can legitimately override these via ...authConfig - toast: authOverrides.toast || defaultToast, - navigate: authOverrides.navigate || defaultNavigate, - replace: - authOverrides.replace || authOverrides.navigate || defaultReplace, + toast: renderToast, + navigate: router?.navigate || defaultNavigate, + replace: router?.navigate || defaultReplace, viewPaths, - Link: authOverrides.Link || DefaultLink, + Link: (router?.Link as typeof DefaultLink | undefined) || DefaultLink, apiKey: authOverrides.apiKey, gravatar: authOverrides.gravatar, additionalFields: authOverrides.additionalFields, @@ -524,7 +574,7 @@ export function BetterAuthPluginProvider({ localizeErrors: authOverrides.localizeErrors ?? true, persistClient: authOverrides.persistClient, optimistic: authOverrides.optimistic, - onSessionChange: authOverrides.onSessionChange + onSessionChange } return ( diff --git a/src/lib/stack-adapters.ts b/src/lib/stack-adapters.ts new file mode 100644 index 000000000..bae8296cf --- /dev/null +++ b/src/lib/stack-adapters.ts @@ -0,0 +1,15 @@ +import type { StackNotifyProvider } from "@btst/stack/context" +import type { RenderToast } from "../types/render-toast" + +export function createRenderToast( + notify: Required +): RenderToast { + return ({ variant = "default", message = "" }) => { + if (variant === "default") { + notify.info(message) + return + } + + notify[variant](message) + } +} diff --git a/src/plugins/auth-plugin.ts b/src/plugins/auth-plugin.ts index 239126f31..e3ad0cb04 100644 --- a/src/plugins/auth-plugin.ts +++ b/src/plugins/auth-plugin.ts @@ -18,8 +18,6 @@ import type { CaptchaOptions } from "../types/captcha-options" import type { CredentialsOptions } from "../types/credentials-options" import type { GenericOAuthOptions } from "../types/generic-oauth-options" import type { GravatarOptions } from "../types/gravatar-options" -import type { Link } from "../types/link" -import type { RenderToast } from "../types/render-toast" import type { SignUpOptions } from "../types/sign-up-options" import type { SocialOptions } from "../types/social-options" @@ -56,26 +54,6 @@ export interface AuthPluginOverrides { * @remarks AuthClient */ authClient: AnyAuthClient - /** - * Custom Link component for navigation - * @default - */ - Link?: Link - /** - * Navigate to a new URL - * @default window.location.href - */ - navigate?: (href: string) => void - /** - * Replace the current URL - * @default navigate - */ - replace?: (href: string) => void - /** - * Render custom Toasts - * @default Sonner - */ - toast?: RenderToast /** * Customize the Localization strings */ @@ -212,10 +190,6 @@ export interface AuthPluginOverrides { * ADVANCED: Custom mutators for updating auth data */ mutators?: Partial - /** - * Called whenever the Session changes - */ - onSessionChange?: () => void | Promise /** * Customize the paths for the auth views */ diff --git a/src/server.ts b/src/server.ts index 0279cfc79..454be45fb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,12 @@ export * from "./components/email/email-template" +export type { + BetterAuthServerProviderOptions, + BetterAuthStackServer +} from "./lib/better-auth-server-provider" +export { + createBetterAuthServerProvider, + createBetterAuthServerProvider as createBetterAuthProvider +} from "./lib/better-auth-server-provider" export { getViewByPath } from "./lib/utils" export * from "./lib/view-paths" export * from "./localization/auth-localization"