From ba8abb3b1bc42f87bc19460107311f53b27799d8 Mon Sep 17 00:00:00 2001 From: Citali Date: Tue, 10 Feb 2026 12:42:49 +0100 Subject: [PATCH] feat(auth): add better-auth core wiring for admin and db --- .env.example | 7 + .env.production.example | 5 + .env.staging.example | 5 + README.md | 5 + TODO.md | 5 +- apps/admin/package.json | 1 + apps/admin/src/app/api/auth/[...all]/route.ts | 3 + apps/admin/src/app/login/login-form.tsx | 204 ++++++++++++++++++ apps/admin/src/app/login/page.tsx | 18 ++ apps/admin/src/app/page.tsx | 8 +- apps/admin/src/app/todo/page.tsx | 8 +- apps/admin/src/lib/access-server.ts | 40 ++++ apps/admin/src/lib/access.ts | 37 ++-- apps/admin/src/middleware.ts | 19 +- bun.lock | 45 ++++ docs/.vitepress/config.mts | 1 + docs/getting-started.md | 2 + docs/product-engineering/auth-baseline.md | 33 +++ docs/product-engineering/index.md | 1 + e2e/smoke.pw.ts | 9 +- package.json | 1 + packages/auth/package.json | 25 +++ packages/auth/src/index.ts | 7 + packages/auth/src/server.ts | 84 ++++++++ packages/auth/tsconfig.json | 8 + packages/db/package.json | 2 + packages/db/prisma/better-auth.config.ts | 37 ++++ .../db/prisma/generated/better-auth.prisma | 74 +++++++ .../migration.sql | 80 +++++++ packages/db/prisma/schema.prisma | 65 ++++++ 30 files changed, 807 insertions(+), 32 deletions(-) create mode 100644 apps/admin/src/app/api/auth/[...all]/route.ts create mode 100644 apps/admin/src/app/login/login-form.tsx create mode 100644 apps/admin/src/app/login/page.tsx create mode 100644 apps/admin/src/lib/access-server.ts create mode 100644 docs/product-engineering/auth-baseline.md create mode 100644 packages/auth/package.json create mode 100644 packages/auth/src/index.ts create mode 100644 packages/auth/src/server.ts create mode 100644 packages/auth/tsconfig.json create mode 100644 packages/db/prisma/better-auth.config.ts create mode 100644 packages/db/prisma/generated/better-auth.prisma create mode 100644 packages/db/prisma/migrations/20260210123700_better_auth_core/migration.sql diff --git a/.env.example b/.env.example index 1bdb9bd..68e2ecb 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,8 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/cms?schema=public" +BETTER_AUTH_SECRET="replace-with-long-random-secret" +BETTER_AUTH_URL="http://localhost:3001" +CMS_ADMIN_ORIGIN="http://localhost:3001" +CMS_WEB_ORIGIN="http://localhost:3000" +CMS_ADMIN_REGISTRATION_ENABLED="true" +# Optional dev bypass role for admin middleware. Leave empty to require auth login. +# CMS_DEV_ROLE="admin" diff --git a/.env.production.example b/.env.production.example index 69fd016..f637751 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1 +1,6 @@ DATABASE_URL="postgresql://cms:cms_production_password@localhost:65432/cms_production?schema=public" +BETTER_AUTH_SECRET="replace-with-production-secret" +BETTER_AUTH_URL="https://admin.example.com" +CMS_ADMIN_ORIGIN="https://admin.example.com" +CMS_WEB_ORIGIN="https://www.example.com" +CMS_ADMIN_REGISTRATION_ENABLED="false" diff --git a/.env.staging.example b/.env.staging.example index ac1e2f6..de13780 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -1 +1,6 @@ DATABASE_URL="postgresql://cms:cms_staging_password@localhost:55432/cms_staging?schema=public" +BETTER_AUTH_SECRET="replace-with-staging-secret" +BETTER_AUTH_URL="https://staging-admin.example.com" +CMS_ADMIN_ORIGIN="https://staging-admin.example.com" +CMS_WEB_ORIGIN="https://staging-web.example.com" +CMS_ADMIN_REGISTRATION_ENABLED="false" diff --git a/README.md b/README.md index 3cce847..49ffec7 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,12 @@ bun install cp .env.example .env ``` +Set `BETTER_AUTH_SECRET` before production use. + 3. Generate Prisma client and run migrations: ```bash +bun run db:auth:generate bun run db:generate bun run db:migrate bun run db:seed @@ -54,6 +57,7 @@ bun run dev - Web: http://localhost:3000 - Admin: http://localhost:3001 +- Admin login: http://localhost:3001/login ## Useful scripts @@ -72,6 +76,7 @@ bun run dev - `bun run check` - `bun run format` - `bun run db:generate` +- `bun run db:auth:generate` - `bun run db:migrate` - `bun run db:push` - `bun run db:studio` diff --git a/TODO.md b/TODO.md index 19cb6b3..cb4dd0e 100644 --- a/TODO.md +++ b/TODO.md @@ -24,11 +24,11 @@ This file is the single source of truth for roadmap and delivery progress. - [ ] [P1] i18n baseline architecture (default locale, supported locales, routing strategy) - [ ] [P1] i18n runtime integration baseline for both apps (locale provider + message loading) - [ ] [P1] Locale persistence and switcher base component (cookie/header + UI) -- [ ] [P1] Integrate Better Auth core configuration and session wiring +- [x] [P1] Integrate Better Auth core configuration and session wiring - [ ] [P1] Bootstrap first-run owner account creation when users table is empty - [ ] [P1] Enforce invariant: exactly one owner user must always exist - [ ] [P1] Create hidden technical support user by default (non-demotable, non-deletable) -- [ ] [P1] Admin registration policy control (allow/deny self-registration for admin panel) +- [~] [P1] Admin registration policy control (allow/deny self-registration for admin panel) - [ ] [P1] Reusable CRUD base patterns (list/detail/editor/service/repository) - [ ] [P1] Shared CRUD validation strategy (Zod + server-side enforcement) - [ ] [P1] Shared error and audit hooks for CRUD mutations @@ -180,6 +180,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-10] Prisma client must be generated before app/e2e startup to avoid runtime module errors. - [2026-02-10] `bun test` conflicts with Playwright-style test files; keep e2e files on `*.pw.ts` and run e2e via Playwright. - [2026-02-10] Linux Playwright runtime depends on host packages; browser setup may require `playwright install --with-deps`. +- [2026-02-10] Better Auth Prisma schema generation is stable via dedicated config (`packages/db/prisma/better-auth.config.ts`) and generated schema snapshot (`packages/db/prisma/generated/better-auth.prisma`). ## How We Use This File diff --git a/apps/admin/package.json b/apps/admin/package.json index 98aa031..afbf199 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@cms/auth": "workspace:*", "@cms/content": "workspace:*", "@cms/db": "workspace:*", "@cms/ui": "workspace:*", diff --git a/apps/admin/src/app/api/auth/[...all]/route.ts b/apps/admin/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..e381bed --- /dev/null +++ b/apps/admin/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,3 @@ +import { authRouteHandlers } from "@cms/auth/server" + +export const { GET, POST, PATCH, PUT, DELETE } = authRouteHandlers diff --git a/apps/admin/src/app/login/login-form.tsx b/apps/admin/src/app/login/login-form.tsx new file mode 100644 index 0000000..fde44f3 --- /dev/null +++ b/apps/admin/src/app/login/login-form.tsx @@ -0,0 +1,204 @@ +"use client" + +import { useRouter, useSearchParams } from "next/navigation" +import { type FormEvent, useMemo, useState } from "react" + +type LoginFormProps = { + allowRegistration: boolean +} + +type AuthResponse = { + user?: { + role?: string + } + message?: string +} + +function persistRoleCookie(role: unknown) { + if (typeof role !== "string") { + return + } + + // biome-ignore lint/suspicious/noDocumentCookie: Temporary fallback for middleware role resolution. + document.cookie = `cms_role=${encodeURIComponent(role)}; Path=/; SameSite=Lax` +} + +export function LoginForm({ allowRegistration }: LoginFormProps) { + const router = useRouter() + const searchParams = useSearchParams() + + const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams]) + + const [name, setName] = useState("Admin User") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [isBusy, setIsBusy] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + async function handleSignIn(event: FormEvent) { + event.preventDefault() + setIsBusy(true) + setError(null) + setSuccess(null) + + try { + const response = await fetch("/api/auth/sign-in/email", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + email, + password, + callbackURL: nextPath, + }), + }) + + const payload = (await response.json().catch(() => null)) as AuthResponse | null + + if (!response.ok) { + setError(payload?.message ?? "Sign in failed") + return + } + + persistRoleCookie(payload?.user?.role) + router.push(nextPath) + router.refresh() + } catch { + setError("Network error while signing in") + } finally { + setIsBusy(false) + } + } + + async function handleSignUp() { + if (!name.trim()) { + setError("Name is required for account creation") + return + } + + setIsBusy(true) + setError(null) + setSuccess(null) + + try { + const response = await fetch("/api/auth/sign-up/email", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + name, + email, + password, + callbackURL: nextPath, + }), + }) + + const payload = (await response.json().catch(() => null)) as AuthResponse | null + + if (!response.ok) { + setError(payload?.message ?? "Sign up failed") + return + } + + persistRoleCookie(payload?.user?.role) + setSuccess("Account created. You can continue to the dashboard.") + router.push(nextPath) + router.refresh() + } catch { + setError("Network error while signing up") + } finally { + setIsBusy(false) + } + } + + return ( +
+
+

Admin Auth

+

Sign in to CMS Admin

+

+ Better Auth is active on this app via /api/auth. +

+
+ +
+
+ + setEmail(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ +
+ + setPassword(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ + + + {allowRegistration ? ( + <> +
+ + setName(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+ + + ) : ( +

+ Registration is disabled. Ask an owner or support user to create your account. +

+ )} + + {error ?

{error}

: null} + {success ?

{success}

: null} +
+
+ ) +} diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx new file mode 100644 index 0000000..12c3f0e --- /dev/null +++ b/apps/admin/src/app/login/page.tsx @@ -0,0 +1,18 @@ +import { isAdminRegistrationEnabled } from "@cms/auth/server" +import { redirect } from "next/navigation" + +import { resolveRoleFromServerContext } from "@/lib/access-server" + +import { LoginForm } from "./login-form" + +export const dynamic = "force-dynamic" + +export default async function LoginPage() { + const role = await resolveRoleFromServerContext() + + if (role) { + redirect("/") + } + + return +} diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx index fd076aa..0487add 100644 --- a/apps/admin/src/app/page.tsx +++ b/apps/admin/src/app/page.tsx @@ -4,14 +4,18 @@ import { Button } from "@cms/ui/button" import Link from "next/link" import { redirect } from "next/navigation" -import { resolveRoleFromServerContext } from "@/lib/access" +import { resolveRoleFromServerContext } from "@/lib/access-server" export const dynamic = "force-dynamic" export default async function AdminHomePage() { const role = await resolveRoleFromServerContext() - if (!role || !hasPermission(role, "news:read", "team")) { + if (!role) { + redirect("/login?next=/") + } + + if (!hasPermission(role, "news:read", "team")) { redirect("/unauthorized?required=news:read&scope=team") } diff --git a/apps/admin/src/app/todo/page.tsx b/apps/admin/src/app/todo/page.tsx index 76a54ca..34878ff 100644 --- a/apps/admin/src/app/todo/page.tsx +++ b/apps/admin/src/app/todo/page.tsx @@ -4,7 +4,7 @@ import { hasPermission } from "@cms/content/rbac" import Link from "next/link" import { redirect } from "next/navigation" -import { resolveRoleFromServerContext } from "@/lib/access" +import { resolveRoleFromServerContext } from "@/lib/access-server" export const dynamic = "force-dynamic" @@ -407,7 +407,11 @@ export default async function AdminTodoPage(props: { }) { const role = await resolveRoleFromServerContext() - if (!role || !hasPermission(role, "roadmap:read", "global")) { + if (!role) { + redirect("/login?next=/todo") + } + + if (!hasPermission(role, "roadmap:read", "global")) { redirect("/unauthorized?required=roadmap:read&scope=global") } diff --git a/apps/admin/src/lib/access-server.ts b/apps/admin/src/lib/access-server.ts new file mode 100644 index 0000000..bdfd218 --- /dev/null +++ b/apps/admin/src/lib/access-server.ts @@ -0,0 +1,40 @@ +import { auth, resolveRoleFromAuthSession } from "@cms/auth/server" +import type { Role } from "@cms/content/rbac" +import { cookies, headers } from "next/headers" + +import { resolveDefaultRole, resolveRoleFromRawValue } from "./access" + +export async function resolveRoleFromServerContext(): Promise { + const roleFromAuthSession = await resolveRoleFromAuthSessionInServerContext() + + if (roleFromAuthSession) { + return roleFromAuthSession + } + + const cookieStore = await cookies() + const headerStore = await headers() + + const roleFromCookie = cookieStore.get("cms_role")?.value + const roleFromHeader = headerStore.get("x-cms-role") + + const resolved = resolveRoleFromRawValue(roleFromCookie ?? roleFromHeader) + + if (resolved) { + return resolved + } + + return resolveDefaultRole() +} + +async function resolveRoleFromAuthSessionInServerContext(): Promise { + try { + const headerStore = await headers() + const session = await auth.api.getSession({ + headers: headerStore, + }) + + return resolveRoleFromAuthSession(session) + } catch { + return null + } +} diff --git a/apps/admin/src/lib/access.ts b/apps/admin/src/lib/access.ts index c239790..262254f 100644 --- a/apps/admin/src/lib/access.ts +++ b/apps/admin/src/lib/access.ts @@ -1,5 +1,4 @@ import { hasPermission, normalizeRole, type PermissionScope, type Role } from "@cms/content/rbac" -import { cookies, headers } from "next/headers" import type { NextRequest } from "next/server" type RoutePermission = { @@ -17,6 +16,14 @@ const guardRules: GuardRule[] = [ route: /^\/unauthorized(?:\/|$)/, requirement: null, }, + { + route: /^\/api\/auth(?:\/|$)/, + requirement: null, + }, + { + route: /^\/login(?:\/|$)/, + requirement: null, + }, { route: /^\/todo(?:\/|$)/, requirement: { @@ -33,15 +40,15 @@ const guardRules: GuardRule[] = [ }, ] -function resolveDefaultRole(): Role | null { +export function resolveDefaultRole(): Role | null { if (process.env.NODE_ENV === "production") { return null } - return normalizeRole(process.env.CMS_DEV_ROLE ?? "admin") + return normalizeRole(process.env.CMS_DEV_ROLE) } -function resolveRoleFromRawValue(raw: string | null | undefined): Role | null { +export function resolveRoleFromRawValue(raw: string | null | undefined): Role | null { return normalizeRole(raw) } @@ -58,22 +65,6 @@ export function resolveRoleFromRequest(request: NextRequest): Role | null { return resolveDefaultRole() } -export async function resolveRoleFromServerContext(): Promise { - const cookieStore = await cookies() - const headerStore = await headers() - - const roleFromCookie = cookieStore.get("cms_role")?.value - const roleFromHeader = headerStore.get("x-cms-role") - - const resolved = resolveRoleFromRawValue(roleFromCookie ?? roleFromHeader) - - if (resolved) { - return resolved - } - - return resolveDefaultRole() -} - export function getRequiredPermission(pathname: string): RoutePermission { for (const rule of guardRules) { if (rule.route.test(pathname)) { @@ -103,3 +94,9 @@ export function canAccessRoute(role: Role, pathname: string): boolean { return hasPermission(role, requirement.permission, requirement.scope) } + +export function isPublicRoute(pathname: string): boolean { + const rule = guardRules.find((item) => item.route.test(pathname)) + + return rule?.requirement === null +} diff --git a/apps/admin/src/middleware.ts b/apps/admin/src/middleware.ts index 245aaaa..a7b12f4 100644 --- a/apps/admin/src/middleware.ts +++ b/apps/admin/src/middleware.ts @@ -1,18 +1,27 @@ import { type NextRequest, NextResponse } from "next/server" -import { canAccessRoute, getRequiredPermission, resolveRoleFromRequest } from "@/lib/access" +import { + canAccessRoute, + getRequiredPermission, + isPublicRoute, + resolveRoleFromRequest, +} from "@/lib/access" export function middleware(request: NextRequest) { const { pathname } = request.nextUrl + if (isPublicRoute(pathname)) { + return NextResponse.next() + } + const role = resolveRoleFromRequest(request) if (!role) { - const unauthorizedUrl = request.nextUrl.clone() - unauthorizedUrl.pathname = "/unauthorized" - unauthorizedUrl.searchParams.set("reason", "missing-role") + const loginUrl = request.nextUrl.clone() + loginUrl.pathname = "/login" + loginUrl.searchParams.set("next", pathname) - return NextResponse.redirect(unauthorizedUrl) + return NextResponse.redirect(loginUrl) } if (!canAccessRoute(role, pathname)) { diff --git a/bun.lock b/bun.lock index 7fec593..a44969d 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "name": "@cms/admin", "version": "0.0.1", "dependencies": { + "@cms/auth": "workspace:*", "@cms/content": "workspace:*", "@cms/db": "workspace:*", "@cms/ui": "workspace:*", @@ -76,6 +77,21 @@ "typescript": "latest", }, }, + "packages/auth": { + "name": "@cms/auth", + "version": "0.0.1", + "dependencies": { + "@cms/content": "workspace:*", + "@cms/db": "workspace:*", + "better-auth": "^1.4.18", + }, + "devDependencies": { + "@biomejs/biome": "latest", + "@cms/config": "workspace:*", + "@types/node": "latest", + "typescript": "latest", + }, + }, "packages/config": { "name": "@cms/config", "version": "0.0.1", @@ -107,6 +123,7 @@ "@cms/config": "workspace:*", "@types/node": "latest", "@types/pg": "latest", + "better-auth": "^1.4.18", "prisma": "latest", "typescript": "latest", }, @@ -221,6 +238,14 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@better-auth/core": ["@better-auth/core@1.4.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg=="], + + "@better-auth/telemetry": ["@better-auth/telemetry@1.4.18", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.18" } }, "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ=="], + + "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], + + "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@biomejs/biome": ["@biomejs/biome@2.3.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.14", "@biomejs/cli-darwin-x64": "2.3.14", "@biomejs/cli-linux-arm64": "2.3.14", "@biomejs/cli-linux-arm64-musl": "2.3.14", "@biomejs/cli-linux-x64": "2.3.14", "@biomejs/cli-linux-x64-musl": "2.3.14", "@biomejs/cli-win32-arm64": "2.3.14", "@biomejs/cli-win32-x64": "2.3.14" }, "bin": { "biome": "bin/biome" } }, "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A=="], @@ -249,6 +274,8 @@ "@cms/admin": ["@cms/admin@workspace:apps/admin"], + "@cms/auth": ["@cms/auth@workspace:packages/auth"], + "@cms/config": ["@cms/config@workspace:packages/config"], "@cms/content": ["@cms/content@workspace:packages/content"], @@ -477,6 +504,10 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], + "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], + + "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], @@ -767,6 +798,10 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + "better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="], + + "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], @@ -1035,6 +1070,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1049,6 +1086,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -1143,6 +1182,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], @@ -1271,6 +1312,8 @@ "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], + "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], @@ -1283,6 +1326,8 @@ "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a1fc9f0..c2115b5 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -19,6 +19,7 @@ export default defineConfig({ { text: "Section Overview", link: "/product-engineering/" }, { text: "Getting Started", link: "/getting-started" }, { text: "Architecture", link: "/architecture" }, + { text: "Better Auth Baseline", link: "/product-engineering/auth-baseline" }, { text: "RBAC And Permissions", link: "/product-engineering/rbac-permission-model" }, { text: "Workflow", link: "/workflow" }, ], diff --git a/docs/getting-started.md b/docs/getting-started.md index daccf2c..4d7f041 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -15,6 +15,7 @@ cp .env.example .env ## Database ```bash +bun run db:auth:generate bun run db:generate bun run db:migrate bun run db:seed @@ -28,6 +29,7 @@ bun run dev - Web: `http://localhost:3000` - Admin: `http://localhost:3001` +- Admin login: `http://localhost:3001/login` ## Run docs diff --git a/docs/product-engineering/auth-baseline.md b/docs/product-engineering/auth-baseline.md new file mode 100644 index 0000000..53fc8e0 --- /dev/null +++ b/docs/product-engineering/auth-baseline.md @@ -0,0 +1,33 @@ +# Better Auth Baseline + +## Scope + +This baseline activates Better Auth for the admin app with email/password login and Prisma-backed sessions. + +Implemented in MVP0: + +- Shared auth package: `@cms/auth` +- Admin auth API routes: `apps/admin/src/app/api/auth/[...all]/route.ts` +- Admin login page: `/login` +- Prisma auth models (`user`, `session`, `account`, `verification`) +- Registration toggle via `CMS_ADMIN_REGISTRATION_ENABLED` + +## Environment + +Required variables: + +- `BETTER_AUTH_SECRET` +- `BETTER_AUTH_URL` +- `CMS_ADMIN_ORIGIN` +- `CMS_WEB_ORIGIN` +- `DATABASE_URL` + +Optional: + +- `CMS_ADMIN_REGISTRATION_ENABLED` +- `CMS_DEV_ROLE` (development-only middleware bypass) + +## Notes + +- Owner bootstrap, hidden support user, and owner invariant are tracked as upcoming MVP0 tasks in `TODO.md`. +- Email verification and forgot/reset password pipelines are tracked for MVP2. diff --git a/docs/product-engineering/index.md b/docs/product-engineering/index.md index f0df482..2d5fd11 100644 --- a/docs/product-engineering/index.md +++ b/docs/product-engineering/index.md @@ -6,6 +6,7 @@ This section covers platform and implementation documentation for engineers and - [Getting Started](/getting-started) - [Architecture](/architecture) +- [Better Auth Baseline](/product-engineering/auth-baseline) - [RBAC And Permissions](/product-engineering/rbac-permission-model) - [Workflow](/workflow) diff --git a/e2e/smoke.pw.ts b/e2e/smoke.pw.ts index b975ff7..7db0cb0 100644 --- a/e2e/smoke.pw.ts +++ b/e2e/smoke.pw.ts @@ -8,5 +8,12 @@ test("smoke", async ({ page }, testInfo) => { return } - await expect(page.getByRole("heading", { name: /content dashboard/i })).toBeVisible() + const dashboardHeading = page.getByRole("heading", { name: /content dashboard/i }) + + if (await dashboardHeading.isVisible({ timeout: 2000 })) { + await expect(dashboardHeading).toBeVisible() + return + } + + await expect(page.getByRole("heading", { name: /sign in to cms admin/i })).toBeVisible() }) diff --git a/package.json b/package.json index c9b550f..9f1522b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "format": "biome format --write .", "check": "biome check .", "db:generate": "bun --filter @cms/db db:generate", + "db:auth:generate": "bun --filter @cms/db db:auth:generate", "db:migrate": "bun --filter @cms/db db:migrate", "db:migrate:named": "bun --filter @cms/db db:migrate:named", "db:push": "bun --filter @cms/db db:push", diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..850c3ff --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,25 @@ +{ + "name": "@cms/auth", + "version": "0.0.1", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server.ts" + }, + "scripts": { + "lint": "biome check src", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@cms/content": "workspace:*", + "@cms/db": "workspace:*", + "better-auth": "^1.4.18" + }, + "devDependencies": { + "@cms/config": "workspace:*", + "@biomejs/biome": "latest", + "@types/node": "latest", + "typescript": "latest" + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 0000000..8c13bb6 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,7 @@ +export { + type AuthSession, + auth, + authRouteHandlers, + isAdminRegistrationEnabled, + resolveRoleFromAuthSession, +} from "./server" diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts new file mode 100644 index 0000000..1cd1457 --- /dev/null +++ b/packages/auth/src/server.ts @@ -0,0 +1,84 @@ +import { normalizeRole, type Role } from "@cms/content/rbac" +import { db } from "@cms/db" +import { betterAuth } from "better-auth" +import { prismaAdapter } from "better-auth/adapters/prisma" +import { toNextJsHandler } from "better-auth/next-js" + +const FALLBACK_DEV_SECRET = "dev-only-change-me-for-production" + +const isProduction = process.env.NODE_ENV === "production" + +const adminOrigin = process.env.CMS_ADMIN_ORIGIN ?? "http://localhost:3001" +const webOrigin = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000" + +function resolveAuthSecret(): string { + const value = process.env.BETTER_AUTH_SECRET + + if (value) { + return value + } + + if (isProduction) { + throw new Error("BETTER_AUTH_SECRET is required in production") + } + + return FALLBACK_DEV_SECRET +} + +export function isAdminRegistrationEnabled(): boolean { + const value = process.env.CMS_ADMIN_REGISTRATION_ENABLED + + if (value === "true") { + return true + } + + if (value === "false") { + return false + } + + return !isProduction +} + +export const auth = betterAuth({ + appName: "CMS Admin", + baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin, + secret: resolveAuthSecret(), + trustedOrigins: [adminOrigin, webOrigin], + database: prismaAdapter(db, { + provider: "postgresql", + }), + emailAndPassword: { + enabled: true, + disableSignUp: !isAdminRegistrationEnabled(), + }, + user: { + additionalFields: { + role: { + type: "string", + required: true, + defaultValue: "editor", + input: false, + }, + isBanned: { + type: "boolean", + required: true, + defaultValue: false, + input: false, + }, + }, + }, +}) + +export const authRouteHandlers = toNextJsHandler(auth) + +export type AuthSession = typeof auth.$Infer.Session + +export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null { + const sessionUserRole = session?.user?.role + + if (typeof sessionUserRole !== "string") { + return null + } + + return normalizeRole(sessionUserRole) +} diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 0000000..a094fe2 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cms/config/tsconfig/base", + "compilerOptions": { + "noEmit": false, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/db/package.json b/packages/db/package.json index 65c5720..3c4c585 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -10,6 +10,7 @@ "build": "tsc -p tsconfig.json", "lint": "biome check src prisma/seed.ts", "typecheck": "tsc -p tsconfig.json --noEmit", + "db:auth:generate": "mkdir -p prisma/generated && set -a && . ../../.env && set +a && bunx @better-auth/cli@latest generate --config prisma/better-auth.config.ts --output prisma/generated/better-auth.prisma --yes", "db:generate": "bun --env-file=../../.env prisma generate", "db:migrate": "bun --env-file=../../.env prisma migrate dev --name init", "db:migrate:named": "bun --env-file=../../.env prisma migrate dev", @@ -29,6 +30,7 @@ "@biomejs/biome": "latest", "@types/node": "latest", "@types/pg": "latest", + "better-auth": "^1.4.18", "prisma": "latest", "typescript": "latest" }, diff --git a/packages/db/prisma/better-auth.config.ts b/packages/db/prisma/better-auth.config.ts new file mode 100644 index 0000000..8922d84 --- /dev/null +++ b/packages/db/prisma/better-auth.config.ts @@ -0,0 +1,37 @@ +import { betterAuth } from "better-auth" +import { prismaAdapter } from "better-auth/adapters/prisma" +import { db } from "../src/client" + +const adminOrigin = process.env.CMS_ADMIN_ORIGIN ?? "http://localhost:3001" +const webOrigin = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000" +const registrationFlag = process.env.CMS_ADMIN_REGISTRATION_ENABLED + +export const auth = betterAuth({ + appName: "CMS Admin", + baseURL: process.env.BETTER_AUTH_URL ?? adminOrigin, + secret: process.env.BETTER_AUTH_SECRET ?? "dev-only-change-me-for-production", + trustedOrigins: [adminOrigin, webOrigin], + database: prismaAdapter(db, { + provider: "postgresql", + }), + emailAndPassword: { + enabled: true, + disableSignUp: registrationFlag === "false", + }, + user: { + additionalFields: { + role: { + type: "string", + required: true, + defaultValue: "editor", + input: false, + }, + isBanned: { + type: "boolean", + required: true, + defaultValue: false, + input: false, + }, + }, + }, +}) diff --git a/packages/db/prisma/generated/better-auth.prisma b/packages/db/prisma/generated/better-auth.prisma new file mode 100644 index 0000000..70ae20f --- /dev/null +++ b/packages/db/prisma/generated/better-auth.prisma @@ -0,0 +1,74 @@ + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id + name String + email String + emailVerified Boolean @default(false) + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + role String @default("editor") + isBanned Boolean @default(false) + sessions Session[] + accounts Account[] + + @@unique([email]) + @@map("user") +} + +model Session { + id String @id + expiresAt DateTime + token String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ipAddress String? + userAgent String? + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token]) + @@index([userId]) + @@map("session") +} + +model Account { + id String @id + accountId String + providerId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@map("account") +} + +model Verification { + id String @id + identifier String + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([identifier]) + @@map("verification") +} diff --git a/packages/db/prisma/migrations/20260210123700_better_auth_core/migration.sql b/packages/db/prisma/migrations/20260210123700_better_auth_core/migration.sql new file mode 100644 index 0000000..da1e3d4 --- /dev/null +++ b/packages/db/prisma/migrations/20260210123700_better_auth_core/migration.sql @@ -0,0 +1,80 @@ +-- CreateTable +CREATE TABLE "user" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "role" TEXT NOT NULL DEFAULT 'editor', + "isBanned" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "user_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "session" ( + "id" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" TEXT NOT NULL, + + CONSTRAINT "session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "account" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "idToken" TEXT, + "accessTokenExpiresAt" TIMESTAMP(3), + "refreshTokenExpiresAt" TIMESTAMP(3), + "scope" TEXT, + "password" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "verification" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "verification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); + +-- CreateIndex +CREATE INDEX "session_userId_idx" ON "session"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); + +-- CreateIndex +CREATE INDEX "account_userId_idx" ON "account"("userId"); + +-- CreateIndex +CREATE INDEX "verification_identifier_idx" ON "verification"("identifier"); + +-- AddForeignKey +ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 31c7374..923ca82 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -16,3 +16,68 @@ model Post { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model User { + id String @id + name String + email String + emailVerified Boolean @default(false) + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + role String @default("editor") + isBanned Boolean @default(false) + sessions Session[] + accounts Account[] + + @@unique([email]) + @@map("user") +} + +model Session { + id String @id + expiresAt DateTime + token String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ipAddress String? + userAgent String? + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token]) + @@index([userId]) + @@map("session") +} + +model Account { + id String @id + accountId String + providerId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + password String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@map("account") +} + +model Verification { + id String @id + identifier String + value String + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([identifier]) + @@map("verification") +}