Compare commits
9 Commits
todo/mvp0-
...
todo/mvp0-
| Author | SHA1 | Date | |
|---|---|---|---|
|
b96cd6d800
|
|||
|
7b665ae633
|
|||
|
411861419f
|
|||
|
df1280af4a
|
|||
|
670f7d3fb2
|
|||
|
2dcb8a80ba
|
|||
|
efb93f212b
|
|||
|
24eca3e740
|
|||
|
ba8abb3b1b
|
13
.env.example
13
.env.example
@ -1 +1,14 @@
|
||||
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_SELF_REGISTRATION_ENABLED="false"
|
||||
# Bootstrap system users (used only when creating missing users)
|
||||
CMS_SUPPORT_USERNAME="support"
|
||||
CMS_SUPPORT_EMAIL="support@cms.local"
|
||||
CMS_SUPPORT_PASSWORD="change-me-support-password"
|
||||
CMS_SUPPORT_NAME="Technical Support"
|
||||
CMS_SUPPORT_LOGIN_KEY="support-access-change-me"
|
||||
# Optional dev bypass role for admin middleware. Leave empty to require auth login.
|
||||
# CMS_DEV_ROLE="admin"
|
||||
|
||||
@ -1 +1,11 @@
|
||||
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_SELF_REGISTRATION_ENABLED="false"
|
||||
CMS_SUPPORT_USERNAME="support"
|
||||
CMS_SUPPORT_EMAIL="support@admin.example.com"
|
||||
CMS_SUPPORT_PASSWORD="replace-with-production-support-password"
|
||||
CMS_SUPPORT_NAME="Technical Support"
|
||||
CMS_SUPPORT_LOGIN_KEY="replace-with-production-support-login-key"
|
||||
|
||||
@ -1 +1,11 @@
|
||||
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_SELF_REGISTRATION_ENABLED="false"
|
||||
CMS_SUPPORT_USERNAME="support"
|
||||
CMS_SUPPORT_EMAIL="support@staging-admin.example.com"
|
||||
CMS_SUPPORT_PASSWORD="replace-with-staging-support-password"
|
||||
CMS_SUPPORT_NAME="Technical Support"
|
||||
CMS_SUPPORT_LOGIN_KEY="replace-with-staging-support-login-key"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,6 +27,7 @@ test-results
|
||||
|
||||
# prisma
|
||||
packages/db/prisma/dev.db*
|
||||
packages/db/prisma/generated/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@ -1,3 +1,13 @@
|
||||
## 0.1.0 (2026-02-10)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** add better-auth core wiring for admin and db ([ba8abb3](https://git.fellies.net/Citali/cms.fellies.org/commit/ba8abb3b1bc42f87bc19460107311f53b27799d8))
|
||||
* **rbac:** enforce admin access checks and document permission model ([947cb0a](https://git.fellies.net/Citali/cms.fellies.org/commit/947cb0a3d79104d82c4b97fb6584633b4c6a7c92))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **next:** migrate admin middleware to proxy convention ([efb93f2](https://git.fellies.net/Citali/cms.fellies.org/commit/efb93f212bc8d8976fc6b443e415be812d12961a))
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
@ -38,6 +38,8 @@ bun install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Set `BETTER_AUTH_SECRET` before production use.
|
||||
|
||||
3. Generate Prisma client and run migrations:
|
||||
|
||||
```bash
|
||||
@ -54,6 +56,7 @@ bun run dev
|
||||
|
||||
- Web: http://localhost:3000
|
||||
- Admin: http://localhost:3001
|
||||
- Admin login: http://localhost:3001/login
|
||||
|
||||
## Useful scripts
|
||||
|
||||
|
||||
23
TODO.md
23
TODO.md
@ -24,11 +24,14 @@ 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
|
||||
- [ ] [P1] Bootstrap first-run owner account creation when users table is empty
|
||||
- [x] [P1] Integrate Better Auth core configuration and session wiring
|
||||
- [x] [P1] Bootstrap first-run owner account creation via initial registration flow
|
||||
- [ ] [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)
|
||||
- [x] [P1] Create hidden technical support user by default (non-demotable, non-deletable)
|
||||
- [~] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
|
||||
- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`)
|
||||
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
|
||||
- [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
|
||||
- [ ] [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
|
||||
@ -39,8 +42,8 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [x] [P1] App Router + TypeScript + `src/` structure
|
||||
- [x] [P1] Shared DB access via `@cms/db`
|
||||
- [~] [P2] Base admin dashboard shell and roadmap page (`/todo`)
|
||||
- [~] [P1] Authentication and session model (`admin`, `editor`, `manager`)
|
||||
- [ ] [P1] Protected admin routes and session handling
|
||||
- [x] [P1] Authentication and session model (`admin`, `editor`, `manager`)
|
||||
- [x] [P1] Protected admin routes and session handling
|
||||
- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
|
||||
|
||||
### Public App
|
||||
@ -93,6 +96,12 @@ This file is the single source of truth for roadmap and delivery progress.
|
||||
- [ ] [P2] Define branch lifecycle for `todo/*`, `refactor/*`, and `code/*`
|
||||
- [x] [P2] Conventional commit schema documentation (`CONTRIBUTING.md`)
|
||||
- [x] [P2] Changelog scaffold and generation scripts (`CHANGELOG.md`, `bun run changelog:*`)
|
||||
- [ ] [P1] Versioning policy definition (SemVer strategy + when to bump major/minor/patch)
|
||||
- [ ] [P1] Source of truth for version (`package.json` root) and release tagging rules (`vX.Y.Z`)
|
||||
- [ ] [P1] Build metadata policy for git hash (`+sha.<short>`) in app runtime footer
|
||||
- [ ] [P1] App footer implementation plan for version + commit hash (admin + web)
|
||||
- [ ] [P2] Automated version injection in CI (stamping build from tag + commit hash)
|
||||
- [ ] [P2] Validation tests for displayed version/hash consistency per deployment
|
||||
- [ ] [P1] Release tagging and changelog publication policy in CI
|
||||
|
||||
## MVP 1: Core CMS Business Features
|
||||
@ -180,6 +189,8 @@ 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] Next.js 16 deprecates `middleware.ts` convention in favor of `proxy.ts`; admin route guard now lives at `apps/admin/src/proxy.ts`.
|
||||
- [2026-02-10] `server-only` imports break Bun CLI scripts; shared auth bootstrap code used by scripts must avoid Next-only runtime markers.
|
||||
|
||||
## How We Use This File
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
"dev": "bun --env-file=../../.env next dev --port 3001",
|
||||
"build": "bun --env-file=../../.env next build",
|
||||
"start": "bun --env-file=../../.env next start --port 3001",
|
||||
"auth:seed:support": "bun --env-file=../../.env ./scripts/seed-support-user.ts",
|
||||
"lint": "biome check src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
@ -14,23 +15,24 @@
|
||||
"@cms/content": "workspace:*",
|
||||
"@cms/db": "workspace:*",
|
||||
"@cms/ui": "workspace:*",
|
||||
"@tanstack/react-form": "latest",
|
||||
"@tanstack/react-query": "latest",
|
||||
"@tanstack/react-query-devtools": "latest",
|
||||
"@tanstack/react-table": "latest",
|
||||
"next": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"zustand": "latest"
|
||||
"@tanstack/react-form": "1.28.0",
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"better-auth": "1.4.18",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cms/config": "workspace:*",
|
||||
"@biomejs/biome": "latest",
|
||||
"@tailwindcss/postcss": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"typescript": "latest"
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@types/node": "25.2.2",
|
||||
"@types/react": "19.2.13",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/admin/scripts/seed-support-user.ts
Normal file
11
apps/admin/scripts/seed-support-user.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ensureSupportUserBootstrap } from "../src/lib/auth/server"
|
||||
|
||||
async function main() {
|
||||
await ensureSupportUserBootstrap()
|
||||
console.log("Support user bootstrap completed")
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
170
apps/admin/src/app/api/auth/[...all]/route.ts
Normal file
170
apps/admin/src/app/api/auth/[...all]/route.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import {
|
||||
authRouteHandlers,
|
||||
canUserSelfRegister,
|
||||
ensureSupportUserBootstrap,
|
||||
ensureUserUsername,
|
||||
hasOwnerUser,
|
||||
promoteFirstRegisteredUserToOwner,
|
||||
resolveEmailFromLoginIdentifier,
|
||||
} from "@/lib/auth/server"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
type AuthPostResponse = {
|
||||
user?: {
|
||||
id?: string
|
||||
role?: string
|
||||
email?: string
|
||||
name?: string
|
||||
username?: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, status: number): Response {
|
||||
return Response.json(payload, { status })
|
||||
}
|
||||
|
||||
async function parseJsonBody(request: Request): Promise<Record<string, unknown> | null> {
|
||||
return (await request.json().catch(() => null)) as Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function buildJsonRequest(request: Request, body: Record<string, unknown>): Request {
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set("content-type", "application/json")
|
||||
|
||||
return new Request(request.url, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSignInPost(request: Request): Promise<Response> {
|
||||
await ensureSupportUserBootstrap()
|
||||
|
||||
const body = await parseJsonBody(request)
|
||||
const identifier = typeof body?.identifier === "string" ? body.identifier : null
|
||||
const rawEmail = typeof body?.email === "string" ? body.email : null
|
||||
const resolvedEmail = await resolveEmailFromLoginIdentifier(identifier ?? rawEmail)
|
||||
|
||||
if (!resolvedEmail) {
|
||||
return jsonResponse(
|
||||
{
|
||||
message: "Invalid email or username.",
|
||||
},
|
||||
401,
|
||||
)
|
||||
}
|
||||
|
||||
const rewrittenBody = {
|
||||
...(body ?? {}),
|
||||
email: resolvedEmail,
|
||||
}
|
||||
|
||||
return authRouteHandlers.POST(buildJsonRequest(request, rewrittenBody))
|
||||
}
|
||||
|
||||
async function handleSignUpPost(request: Request): Promise<Response> {
|
||||
await ensureSupportUserBootstrap()
|
||||
|
||||
const signUpBody = await parseJsonBody(request)
|
||||
const preferredUsername =
|
||||
typeof signUpBody?.username === "string" ? signUpBody.username : undefined
|
||||
const { username: _ignoredUsername, ...signUpBodyWithoutUsername } = signUpBody ?? {}
|
||||
|
||||
const hadOwnerBeforeSignUp = await hasOwnerUser()
|
||||
const registrationEnabled = await canUserSelfRegister()
|
||||
|
||||
if (!registrationEnabled) {
|
||||
return jsonResponse(
|
||||
{
|
||||
message: "Registration is currently disabled.",
|
||||
},
|
||||
403,
|
||||
)
|
||||
}
|
||||
|
||||
const response = await authRouteHandlers.POST(
|
||||
buildJsonRequest(request, {
|
||||
...signUpBodyWithoutUsername,
|
||||
}),
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return response
|
||||
}
|
||||
|
||||
const payload = (await response
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null)) as AuthPostResponse | null
|
||||
const userId = payload?.user?.id
|
||||
|
||||
if (!userId) {
|
||||
return response
|
||||
}
|
||||
|
||||
await ensureUserUsername(userId, {
|
||||
preferred: preferredUsername,
|
||||
fallbackEmail: payload?.user?.email,
|
||||
fallbackName: payload?.user?.name,
|
||||
})
|
||||
|
||||
if (hadOwnerBeforeSignUp || !payload?.user) {
|
||||
return response
|
||||
}
|
||||
|
||||
const promoted = await promoteFirstRegisteredUserToOwner(userId)
|
||||
|
||||
if (!promoted) {
|
||||
return jsonResponse(
|
||||
{
|
||||
message: "Initial owner registration window has just closed. Please sign in instead.",
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
|
||||
payload.user.role = "owner"
|
||||
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
await ensureSupportUserBootstrap()
|
||||
return authRouteHandlers.GET(request)
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const pathname = new URL(request.url).pathname
|
||||
|
||||
if (pathname.endsWith("/sign-in/email")) {
|
||||
return handleSignInPost(request)
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/sign-up/email")) {
|
||||
return handleSignUpPost(request)
|
||||
}
|
||||
|
||||
await ensureSupportUserBootstrap()
|
||||
return authRouteHandlers.POST(request)
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request): Promise<Response> {
|
||||
await ensureSupportUserBootstrap()
|
||||
return authRouteHandlers.PATCH(request)
|
||||
}
|
||||
|
||||
export async function PUT(request: Request): Promise<Response> {
|
||||
await ensureSupportUserBootstrap()
|
||||
return authRouteHandlers.PUT(request)
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request): Promise<Response> {
|
||||
await ensureSupportUserBootstrap()
|
||||
return authRouteHandlers.DELETE(request)
|
||||
}
|
||||
286
apps/admin/src/app/login/login-form.tsx
Normal file
286
apps/admin/src/app/login/login-form.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { type FormEvent, useMemo, useState } from "react"
|
||||
|
||||
type LoginFormProps = {
|
||||
mode: "signin" | "signup-owner" | "signup-user"
|
||||
}
|
||||
|
||||
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({ mode }: LoginFormProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams])
|
||||
|
||||
const [name, setName] = useState("Admin User")
|
||||
const [username, setUsername] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
async function handleSignIn(event: FormEvent<HTMLFormElement>) {
|
||||
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({
|
||||
identifier: 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(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
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,
|
||||
username,
|
||||
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(
|
||||
mode === "signup-owner"
|
||||
? "Owner account created. Registration is now disabled."
|
||||
: "Account created.",
|
||||
)
|
||||
router.push(nextPath)
|
||||
router.refresh()
|
||||
} catch {
|
||||
setError("Network error while signing up")
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center px-6 py-16">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin Auth</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{mode === "signin"
|
||||
? "Sign in to CMS Admin"
|
||||
: mode === "signup-owner"
|
||||
? "Welcome to CMS Admin"
|
||||
: "Create an admin account"}
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{mode === "signin" ? (
|
||||
<>
|
||||
Better Auth is active on this app via <code>/api/auth</code>.
|
||||
</>
|
||||
) : mode === "signup-owner" ? (
|
||||
"Create the first owner account to initialize this admin instance."
|
||||
) : (
|
||||
"Self-registration is enabled for admin users."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === "signin" ? (
|
||||
<form
|
||||
onSubmit={handleSignIn}
|
||||
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="email">
|
||||
Email or username
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="text"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={8}
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isBusy ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-neutral-600">
|
||||
Need an account?{" "}
|
||||
<Link href={`/register?next=${encodeURIComponent(nextPath)}`} className="underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSignUp}
|
||||
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="username">
|
||||
Username (optional)
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="password">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
minLength={8}
|
||||
required
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{isBusy
|
||||
? "Creating account..."
|
||||
: mode === "signup-owner"
|
||||
? "Create owner account"
|
||||
: "Create account"}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-neutral-600">
|
||||
Already have an account?{" "}
|
||||
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="underline">
|
||||
Go to sign in
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{success ? <p className="text-sm text-green-700">{success}</p> : null}
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
36
apps/admin/src/app/login/page.tsx
Normal file
36
apps/admin/src/app/login/page.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
||||
import { hasOwnerUser } from "@/lib/auth/server"
|
||||
|
||||
import { LoginForm } from "./login-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>
|
||||
|
||||
function getSingleValue(input: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(input)) {
|
||||
return input[0]
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const params = await searchParams
|
||||
const nextPath = getSingleValue(params.next) ?? "/"
|
||||
const role = await resolveRoleFromServerContext()
|
||||
|
||||
if (role) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
const hasOwner = await hasOwnerUser()
|
||||
|
||||
if (!hasOwner) {
|
||||
redirect(`/welcome?next=${encodeURIComponent(nextPath)}`)
|
||||
}
|
||||
|
||||
return <LoginForm mode="signin" />
|
||||
}
|
||||
36
apps/admin/src/app/logout-button.tsx
Normal file
36
apps/admin/src/app/logout-button.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
|
||||
export function LogoutButton() {
|
||||
const router = useRouter()
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
|
||||
async function handleLogout() {
|
||||
setIsBusy(true)
|
||||
|
||||
try {
|
||||
await fetch("/api/auth/sign-out", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ callbackURL: "/login" }),
|
||||
})
|
||||
} finally {
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: Temporary cookie fallback until role resolution no longer needs this cookie.
|
||||
document.cookie = "cms_role=; Path=/; Max-Age=0; SameSite=Lax"
|
||||
router.push("/login")
|
||||
router.refresh()
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" onClick={() => void handleLogout()} disabled={isBusy} variant="secondary">
|
||||
{isBusy ? "Signing out..." : "Sign out"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@ -4,14 +4,19 @@ 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"
|
||||
import { LogoutButton } from "./logout-button"
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
@ -24,13 +29,14 @@ export default async function AdminHomePage() {
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Content Dashboard</h1>
|
||||
<p className="text-neutral-600">Manage posts from a dedicated admin surface.</p>
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Link
|
||||
href="/todo"
|
||||
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||
>
|
||||
Open roadmap and progress
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
40
apps/admin/src/app/register/page.tsx
Normal file
40
apps/admin/src/app/register/page.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { LoginForm } from "@/app/login/login-form"
|
||||
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
||||
import { hasOwnerUser, isSelfRegistrationEnabled } from "@/lib/auth/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>
|
||||
|
||||
function getSingleValue(input: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(input)) {
|
||||
return input[0]
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
export default async function RegisterPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const params = await searchParams
|
||||
const nextPath = getSingleValue(params.next) ?? "/"
|
||||
const role = await resolveRoleFromServerContext()
|
||||
|
||||
if (role) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
const hasOwner = await hasOwnerUser()
|
||||
|
||||
if (!hasOwner) {
|
||||
redirect(`/welcome?next=${encodeURIComponent(nextPath)}`)
|
||||
}
|
||||
|
||||
const enabled = await isSelfRegistrationEnabled()
|
||||
|
||||
if (!enabled) {
|
||||
redirect(`/login?next=${encodeURIComponent(nextPath)}`)
|
||||
}
|
||||
|
||||
return <LoginForm mode="signup-user" />
|
||||
}
|
||||
23
apps/admin/src/app/support/[key]/page.tsx
Normal file
23
apps/admin/src/app/support/[key]/page.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
import { LoginForm } from "@/app/login/login-form"
|
||||
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
||||
import { resolveSupportLoginKey } from "@/lib/auth/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type Params = Promise<{ key: string }>
|
||||
|
||||
export default async function SupportLoginPage({ params }: { params: Params }) {
|
||||
const { key } = await params
|
||||
const role = await resolveRoleFromServerContext()
|
||||
|
||||
if (role) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
if (key !== resolveSupportLoginKey()) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <LoginForm mode="signin" />
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
34
apps/admin/src/app/welcome/page.tsx
Normal file
34
apps/admin/src/app/welcome/page.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { LoginForm } from "@/app/login/login-form"
|
||||
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
||||
import { hasOwnerUser } from "@/lib/auth/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>
|
||||
|
||||
function getSingleValue(input: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(input)) {
|
||||
return input[0]
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
export default async function WelcomePage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const params = await searchParams
|
||||
const nextPath = getSingleValue(params.next) ?? "/"
|
||||
const role = await resolveRoleFromServerContext()
|
||||
|
||||
if (role) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
const hasOwner = await hasOwnerUser()
|
||||
|
||||
if (hasOwner) {
|
||||
redirect(`/login?next=${encodeURIComponent(nextPath)}`)
|
||||
}
|
||||
|
||||
return <LoginForm mode="signup-owner" />
|
||||
}
|
||||
42
apps/admin/src/lib/access-server.ts
Normal file
42
apps/admin/src/lib/access-server.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import "server-only"
|
||||
|
||||
import type { Role } from "@cms/content/rbac"
|
||||
import { cookies, headers } from "next/headers"
|
||||
|
||||
import { auth, resolveRoleFromAuthSession } from "@/lib/auth/server"
|
||||
import { resolveDefaultRole, resolveRoleFromRawValue } from "./access"
|
||||
|
||||
export async function resolveRoleFromServerContext(): Promise<Role | null> {
|
||||
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<Role | null> {
|
||||
try {
|
||||
const headerStore = await headers()
|
||||
const session = await auth.api.getSession({
|
||||
headers: headerStore,
|
||||
})
|
||||
|
||||
return resolveRoleFromAuthSession(session)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -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,26 @@ const guardRules: GuardRule[] = [
|
||||
route: /^\/unauthorized(?:\/|$)/,
|
||||
requirement: null,
|
||||
},
|
||||
{
|
||||
route: /^\/api\/auth(?:\/|$)/,
|
||||
requirement: null,
|
||||
},
|
||||
{
|
||||
route: /^\/login(?:\/|$)/,
|
||||
requirement: null,
|
||||
},
|
||||
{
|
||||
route: /^\/register(?:\/|$)/,
|
||||
requirement: null,
|
||||
},
|
||||
{
|
||||
route: /^\/welcome(?:\/|$)/,
|
||||
requirement: null,
|
||||
},
|
||||
{
|
||||
route: /^\/support\/[^/]+(?:\/|$)/,
|
||||
requirement: null,
|
||||
},
|
||||
{
|
||||
route: /^\/todo(?:\/|$)/,
|
||||
requirement: {
|
||||
@ -33,15 +52,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 +77,6 @@ export function resolveRoleFromRequest(request: NextRequest): Role | null {
|
||||
return resolveDefaultRole()
|
||||
}
|
||||
|
||||
export async function resolveRoleFromServerContext(): Promise<Role | null> {
|
||||
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 +106,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
|
||||
}
|
||||
|
||||
406
apps/admin/src/lib/auth/server.ts
Normal file
406
apps/admin/src/lib/auth/server.ts
Normal file
@ -0,0 +1,406 @@
|
||||
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"
|
||||
const DEFAULT_SUPPORT_USERNAME = "support"
|
||||
const DEFAULT_SUPPORT_PASSWORD = "change-me-support-password"
|
||||
const DEFAULT_SUPPORT_NAME = "Technical Support"
|
||||
const DEFAULT_SUPPORT_LOGIN_KEY = "support-access"
|
||||
const USERNAME_MAX_LENGTH = 32
|
||||
|
||||
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 async function hasOwnerUser(): Promise<boolean> {
|
||||
const ownerCount = await db.user.count({
|
||||
where: { role: "owner" },
|
||||
})
|
||||
|
||||
return ownerCount > 0
|
||||
}
|
||||
|
||||
export async function isInitialOwnerRegistrationOpen(): Promise<boolean> {
|
||||
return !(await hasOwnerUser())
|
||||
}
|
||||
|
||||
export async function isSelfRegistrationEnabled(): Promise<boolean> {
|
||||
// Temporary fallback until registration policy is managed from admin settings.
|
||||
return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
|
||||
}
|
||||
|
||||
export async function canUserSelfRegister(): Promise<boolean> {
|
||||
if (!(await hasOwnerUser())) {
|
||||
return true
|
||||
}
|
||||
|
||||
return isSelfRegistrationEnabled()
|
||||
}
|
||||
|
||||
export function resolveSupportLoginKey(): string {
|
||||
const value = process.env.CMS_SUPPORT_LOGIN_KEY
|
||||
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (isProduction) {
|
||||
throw new Error("CMS_SUPPORT_LOGIN_KEY is required in production")
|
||||
}
|
||||
|
||||
return DEFAULT_SUPPORT_LOGIN_KEY
|
||||
}
|
||||
|
||||
function resolveBootstrapValue(
|
||||
envKey: string,
|
||||
fallback: string,
|
||||
options: {
|
||||
requiredInProduction?: boolean
|
||||
} = {},
|
||||
): string {
|
||||
const value = process.env[envKey]
|
||||
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (isProduction && options.requiredInProduction) {
|
||||
throw new Error(`${envKey} is required in production`)
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeUsernameCandidate(input: string | null | undefined): string | null {
|
||||
if (!input) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalized = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^[._-]+|[._-]+$/g, "")
|
||||
.slice(0, USERNAME_MAX_LENGTH)
|
||||
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function extractEmailLocalPart(email: string): string {
|
||||
return email.split("@")[0] ?? email
|
||||
}
|
||||
|
||||
async function getAvailableUsername(base: string): Promise<string> {
|
||||
const normalizedBase = normalizeUsernameCandidate(base) ?? "user"
|
||||
|
||||
for (let suffix = 0; suffix < 1000; suffix += 1) {
|
||||
const candidate =
|
||||
suffix === 0 ? normalizedBase : `${normalizedBase}-${suffix}`.slice(0, USERNAME_MAX_LENGTH)
|
||||
const existing = await db.user.findUnique({
|
||||
where: { username: candidate },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to allocate unique username")
|
||||
}
|
||||
|
||||
export async function ensureUserUsername(
|
||||
userId: string,
|
||||
options: {
|
||||
preferred?: string | null | undefined
|
||||
fallbackEmail?: string | null | undefined
|
||||
fallbackName?: string | null | undefined
|
||||
} = {},
|
||||
): Promise<string | null> {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, username: true, email: true, name: true },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
return user.username
|
||||
}
|
||||
|
||||
const baseCandidate =
|
||||
normalizeUsernameCandidate(options.preferred) ??
|
||||
normalizeUsernameCandidate(
|
||||
options.fallbackEmail ? extractEmailLocalPart(options.fallbackEmail) : null,
|
||||
) ??
|
||||
normalizeUsernameCandidate(options.fallbackName) ??
|
||||
normalizeUsernameCandidate(extractEmailLocalPart(user.email)) ??
|
||||
normalizeUsernameCandidate(user.name) ??
|
||||
"user"
|
||||
|
||||
const username = await getAvailableUsername(baseCandidate)
|
||||
|
||||
await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: { username },
|
||||
})
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
export async function resolveEmailFromLoginIdentifier(
|
||||
identifier: string | null | undefined,
|
||||
): Promise<string | null> {
|
||||
const value = identifier?.trim()
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (value.includes("@")) {
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
const username = normalizeUsernameCandidate(value)
|
||||
|
||||
if (!username) {
|
||||
return null
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { username },
|
||||
select: { email: true },
|
||||
})
|
||||
|
||||
return user?.email ?? null
|
||||
}
|
||||
|
||||
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,
|
||||
// Sign-up gating is handled in route layer so we can close registration
|
||||
// automatically after the first owner account is created.
|
||||
disableSignUp: false,
|
||||
},
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
required: true,
|
||||
defaultValue: "editor",
|
||||
input: false,
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
isBanned: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
defaultValue: false,
|
||||
input: false,
|
||||
},
|
||||
isSystem: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
defaultValue: false,
|
||||
input: false,
|
||||
},
|
||||
isHidden: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
defaultValue: false,
|
||||
input: false,
|
||||
},
|
||||
isProtected: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
defaultValue: false,
|
||||
input: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const authRouteHandlers = toNextJsHandler(auth)
|
||||
|
||||
export type AuthSession = typeof auth.$Infer.Session
|
||||
|
||||
let supportBootstrapPromise: Promise<void> | null = null
|
||||
|
||||
type BootstrapUserConfig = {
|
||||
email: string
|
||||
username: string
|
||||
name: string
|
||||
password: string
|
||||
role: Role
|
||||
isHidden: boolean
|
||||
}
|
||||
|
||||
async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void> {
|
||||
const ctx = await auth.$context
|
||||
const normalizedEmail = config.email.toLowerCase()
|
||||
const existing = await ctx.internalAdapter.findUserByEmail(normalizedEmail, {
|
||||
includeAccounts: true,
|
||||
})
|
||||
|
||||
if (existing?.user) {
|
||||
await db.user.update({
|
||||
where: { id: existing.user.id },
|
||||
data: {
|
||||
name: config.name,
|
||||
role: config.role,
|
||||
isBanned: false,
|
||||
isSystem: true,
|
||||
isHidden: config.isHidden,
|
||||
isProtected: true,
|
||||
},
|
||||
})
|
||||
|
||||
const hasCredentialAccount = existing.accounts.some(
|
||||
(account) => account.providerId === "credential",
|
||||
)
|
||||
|
||||
if (!hasCredentialAccount) {
|
||||
const passwordHash = await ctx.password.hash(config.password)
|
||||
|
||||
await ctx.internalAdapter.linkAccount({
|
||||
userId: existing.user.id,
|
||||
providerId: "credential",
|
||||
accountId: existing.user.id,
|
||||
password: passwordHash,
|
||||
})
|
||||
}
|
||||
|
||||
await ensureUserUsername(existing.user.id, {
|
||||
preferred: config.username,
|
||||
fallbackEmail: existing.user.email,
|
||||
fallbackName: config.name,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const availableUsername = await getAvailableUsername(config.username)
|
||||
const passwordHash = await ctx.password.hash(config.password)
|
||||
const createdUser = await ctx.internalAdapter.createUser({
|
||||
name: config.name,
|
||||
email: normalizedEmail,
|
||||
username: availableUsername,
|
||||
emailVerified: true,
|
||||
role: config.role,
|
||||
isBanned: false,
|
||||
isSystem: true,
|
||||
isHidden: config.isHidden,
|
||||
isProtected: true,
|
||||
})
|
||||
|
||||
await ctx.internalAdapter.linkAccount({
|
||||
userId: createdUser.id,
|
||||
providerId: "credential",
|
||||
accountId: createdUser.id,
|
||||
password: passwordHash,
|
||||
})
|
||||
}
|
||||
|
||||
async function bootstrapSystemUsers(): Promise<void> {
|
||||
const supportUsername = resolveBootstrapValue("CMS_SUPPORT_USERNAME", DEFAULT_SUPPORT_USERNAME)
|
||||
const supportEmail = resolveBootstrapValue("CMS_SUPPORT_EMAIL", `${supportUsername}@cms.local`)
|
||||
const supportPassword = resolveBootstrapValue("CMS_SUPPORT_PASSWORD", DEFAULT_SUPPORT_PASSWORD, {
|
||||
requiredInProduction: true,
|
||||
})
|
||||
const supportName = resolveBootstrapValue("CMS_SUPPORT_NAME", DEFAULT_SUPPORT_NAME)
|
||||
|
||||
await ensureCredentialUser({
|
||||
email: supportEmail,
|
||||
username: supportUsername,
|
||||
name: supportName,
|
||||
password: supportPassword,
|
||||
role: "support",
|
||||
isHidden: true,
|
||||
})
|
||||
}
|
||||
|
||||
export async function ensureSupportUserBootstrap(): Promise<void> {
|
||||
if (supportBootstrapPromise) {
|
||||
await supportBootstrapPromise
|
||||
return
|
||||
}
|
||||
|
||||
supportBootstrapPromise = bootstrapSystemUsers()
|
||||
|
||||
try {
|
||||
await supportBootstrapPromise
|
||||
} catch (error) {
|
||||
supportBootstrapPromise = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function promoteFirstRegisteredUserToOwner(userId: string): Promise<boolean> {
|
||||
return db.$transaction(async (tx) => {
|
||||
const existingOwner = await tx.user.findFirst({
|
||||
where: { role: "owner" },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (existingOwner) {
|
||||
return false
|
||||
}
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
role: "owner",
|
||||
isSystem: false,
|
||||
isHidden: false,
|
||||
isProtected: true,
|
||||
isBanned: false,
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRoleFromAuthSession(session: AuthSession | null | undefined): Role | null {
|
||||
const sessionUserRole = session?.user?.role
|
||||
|
||||
if (typeof sessionUserRole !== "string") {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalizeRole(sessionUserRole)
|
||||
}
|
||||
@ -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) {
|
||||
export function proxy(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)) {
|
||||
@ -14,21 +14,21 @@
|
||||
"@cms/content": "workspace:*",
|
||||
"@cms/db": "workspace:*",
|
||||
"@cms/ui": "workspace:*",
|
||||
"@tanstack/react-query": "latest",
|
||||
"@tanstack/react-query-devtools": "latest",
|
||||
"next": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"zustand": "latest"
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zustand": "5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cms/config": "workspace:*",
|
||||
"@biomejs/biome": "latest",
|
||||
"@tailwindcss/postcss": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"typescript": "latest"
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@types/node": "25.2.2",
|
||||
"@types/react": "19.2.13",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"!**/coverage",
|
||||
"!**/playwright-report",
|
||||
"!**/test-results",
|
||||
"!**/prisma/generated",
|
||||
"!**/next-env.d.ts",
|
||||
"!**/.vitepress/cache",
|
||||
"!**/.vitepress/dist"
|
||||
|
||||
28
bun.lock
28
bun.lock
@ -35,6 +35,7 @@
|
||||
"@tanstack/react-query": "latest",
|
||||
"@tanstack/react-query-devtools": "latest",
|
||||
"@tanstack/react-table": "latest",
|
||||
"better-auth": "1.4.18",
|
||||
"next": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
@ -107,6 +108,7 @@
|
||||
"@cms/config": "workspace:*",
|
||||
"@types/node": "latest",
|
||||
"@types/pg": "latest",
|
||||
"better-auth": "1.4.18",
|
||||
"prisma": "latest",
|
||||
"typescript": "latest",
|
||||
},
|
||||
@ -221,6 +223,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=="],
|
||||
@ -477,6 +487,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 +781,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 +1053,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 +1069,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 +1165,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 +1295,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 +1309,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=="],
|
||||
|
||||
@ -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" },
|
||||
],
|
||||
|
||||
@ -20,6 +20,18 @@ bun run db:migrate
|
||||
bun run db:seed
|
||||
```
|
||||
|
||||
Create a named migration:
|
||||
|
||||
```bash
|
||||
bun run db:migrate:named -- --name your_migration_name
|
||||
```
|
||||
|
||||
Reset local dev DB:
|
||||
|
||||
```bash
|
||||
bun run db:reset:dev
|
||||
```
|
||||
|
||||
## Run apps
|
||||
|
||||
```bash
|
||||
@ -28,6 +40,9 @@ bun run dev
|
||||
|
||||
- Web: `http://localhost:3000`
|
||||
- Admin: `http://localhost:3001`
|
||||
- Admin welcome (first start): `http://localhost:3001/welcome`
|
||||
- Admin login: `http://localhost:3001/login`
|
||||
- Admin register (when enabled): `http://localhost:3001/register`
|
||||
|
||||
## Run docs
|
||||
|
||||
|
||||
42
docs/product-engineering/auth-baseline.md
Normal file
42
docs/product-engineering/auth-baseline.md
Normal file
@ -0,0 +1,42 @@
|
||||
# Better Auth Baseline
|
||||
|
||||
## Scope
|
||||
|
||||
This baseline activates Better Auth for the admin app with email/password login and Prisma-backed sessions.
|
||||
|
||||
Implemented in MVP0:
|
||||
|
||||
- Admin-local auth config: `apps/admin/src/lib/auth/server.ts`
|
||||
- Admin auth API routes: `apps/admin/src/app/api/auth/[...all]/route.ts`
|
||||
- Admin auth pages: `/welcome`, `/login`, `/register`
|
||||
- Support fallback sign-in page: `/support/<CMS_SUPPORT_LOGIN_KEY>`
|
||||
- Prisma auth models (`user`, `session`, `account`, `verification`)
|
||||
- First registration creates owner; subsequent registrations are disabled
|
||||
|
||||
## Environment
|
||||
|
||||
Required variables:
|
||||
|
||||
- `BETTER_AUTH_SECRET`
|
||||
- `BETTER_AUTH_URL`
|
||||
- `CMS_ADMIN_ORIGIN`
|
||||
- `CMS_WEB_ORIGIN`
|
||||
- `DATABASE_URL`
|
||||
|
||||
Optional:
|
||||
|
||||
- `CMS_ADMIN_SELF_REGISTRATION_ENABLED`
|
||||
- `CMS_SUPPORT_USERNAME`
|
||||
- `CMS_SUPPORT_EMAIL`
|
||||
- `CMS_SUPPORT_PASSWORD`
|
||||
- `CMS_SUPPORT_NAME`
|
||||
- `CMS_SUPPORT_LOGIN_KEY`
|
||||
- `CMS_DEV_ROLE` (development-only middleware bypass)
|
||||
|
||||
## Notes
|
||||
|
||||
- Support user bootstrap is available via `bun run auth:seed:support`.
|
||||
- Root `bun run db:seed` runs DB seed and support-user seed.
|
||||
- `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is temporary until admin settings UI manages this policy.
|
||||
- Owner invariant hardening for all future user-management mutations remains tracked in `TODO.md`.
|
||||
- Email verification and forgot/reset password pipelines are tracked for MVP2.
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ Scope hierarchy (higher includes lower):
|
||||
|
||||
## Enforcement Layers
|
||||
|
||||
- Route-level: `apps/admin/src/middleware.ts`
|
||||
- Route-level: `apps/admin/src/proxy.ts`
|
||||
- Action-level: server component checks in admin pages (`/` and `/todo`)
|
||||
- Shared model + checks: `packages/content/src/rbac.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()
|
||||
})
|
||||
|
||||
42
package.json
42
package.json
@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "cms-monorepo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.5",
|
||||
"workspaces": [
|
||||
@ -29,32 +30,35 @@
|
||||
"check": "biome check .",
|
||||
"db:generate": "bun --filter @cms/db db:generate",
|
||||
"db:migrate": "bun --filter @cms/db db:migrate",
|
||||
"db:migrate:named": "bun --filter @cms/db db:migrate:named",
|
||||
"db:migrate:named": "cd packages/db && bun --env-file=../../.env prisma migrate dev",
|
||||
"db:migrate:deploy": "bun --filter @cms/db db:migrate:deploy",
|
||||
"db:reset:dev": "bun --filter @cms/db db:reset:dev && bun run auth:seed:support",
|
||||
"db:push": "bun --filter @cms/db db:push",
|
||||
"db:studio": "bun --filter @cms/db db:studio",
|
||||
"db:seed": "bun --filter @cms/db db:seed",
|
||||
"db:seed": "bun --filter @cms/db db:seed && bun --filter @cms/admin auth:seed:support",
|
||||
"auth:seed:support": "bun --filter @cms/admin auth:seed:support",
|
||||
"docker:staging:up": "docker compose -f docker-compose.staging.yml up -d --build",
|
||||
"docker:staging:down": "docker compose -f docker-compose.staging.yml down",
|
||||
"docker:production:up": "docker compose -f docker-compose.production.yml up -d --build",
|
||||
"docker:production:down": "docker compose -f docker-compose.production.yml down"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "latest",
|
||||
"@commitlint/cli": "latest",
|
||||
"@commitlint/config-conventional": "latest",
|
||||
"@testing-library/jest-dom": "latest",
|
||||
"@testing-library/react": "latest",
|
||||
"@testing-library/user-event": "latest",
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"@vitest/coverage-istanbul": "latest",
|
||||
"@biomejs/biome": "latest",
|
||||
"jsdom": "latest",
|
||||
"msw": "latest",
|
||||
"conventional-changelog-cli": "latest",
|
||||
"turbo": "latest",
|
||||
"typescript": "latest",
|
||||
"vitepress": "latest",
|
||||
"vite-tsconfig-paths": "latest",
|
||||
"vitest": "latest"
|
||||
"@playwright/test": "1.58.2",
|
||||
"@commitlint/cli": "20.4.1",
|
||||
"@commitlint/config-conventional": "20.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@vitejs/plugin-react": "5.1.3",
|
||||
"@vitest/coverage-istanbul": "4.0.18",
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"jsdom": "28.0.0",
|
||||
"msw": "2.12.9",
|
||||
"conventional-changelog-cli": "5.0.0",
|
||||
"turbo": "2.8.3",
|
||||
"typescript": "5.9.3",
|
||||
"vitepress": "1.6.4",
|
||||
"vite-tsconfig-paths": "6.1.0",
|
||||
"vitest": "4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,11 +13,11 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "latest"
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cms/config": "workspace:*",
|
||||
"@biomejs/biome": "latest",
|
||||
"typescript": "latest"
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,12 +4,16 @@ import { hasPermission, normalizeRole, permissionMatrix } from "./rbac"
|
||||
|
||||
describe("rbac model", () => {
|
||||
it("normalizes valid roles", () => {
|
||||
expect(normalizeRole("OWNER")).toBe("owner")
|
||||
expect(normalizeRole("support")).toBe("support")
|
||||
expect(normalizeRole("ADMIN")).toBe("admin")
|
||||
expect(normalizeRole("manager")).toBe("manager")
|
||||
expect(normalizeRole("unknown")).toBeNull()
|
||||
})
|
||||
|
||||
it("grants admin full access", () => {
|
||||
expect(hasPermission("owner", "users:manage_roles", "global")).toBe(true)
|
||||
expect(hasPermission("support", "news:publish", "global")).toBe(true)
|
||||
expect(hasPermission("admin", "users:manage_roles", "global")).toBe(true)
|
||||
expect(hasPermission("admin", "news:publish", "global")).toBe(true)
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const roleSchema = z.enum(["admin", "editor", "manager"])
|
||||
export const roleSchema = z.enum(["owner", "support", "admin", "editor", "manager"])
|
||||
export const permissionScopeSchema = z.enum(["own", "team", "global"])
|
||||
|
||||
export const permissionSchema = z.enum([
|
||||
@ -44,6 +44,8 @@ const allGlobalGrants: PermissionGrant[] = allPermissions.map((permission) => ({
|
||||
}))
|
||||
|
||||
export const permissionMatrix: Record<Role, PermissionGrant[]> = {
|
||||
owner: allGlobalGrants,
|
||||
support: allGlobalGrants,
|
||||
admin: allGlobalGrants,
|
||||
manager: [
|
||||
{ permission: "dashboard:read", scopes: ["global"] },
|
||||
|
||||
@ -13,24 +13,26 @@
|
||||
"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",
|
||||
"db:migrate:deploy": "bun --env-file=../../.env prisma migrate deploy",
|
||||
"db:reset:dev": "bun --env-file=../../.env prisma migrate reset --force",
|
||||
"db:push": "bun --env-file=../../.env prisma db push",
|
||||
"db:studio": "bun --env-file=../../.env prisma studio",
|
||||
"db:seed": "bun --env-file=../../.env prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cms/content": "workspace:*",
|
||||
"@prisma/adapter-pg": "latest",
|
||||
"@prisma/client": "latest",
|
||||
"pg": "latest",
|
||||
"zod": "latest"
|
||||
"@prisma/adapter-pg": "7.3.0",
|
||||
"@prisma/client": "7.3.0",
|
||||
"pg": "8.18.0",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cms/config": "workspace:*",
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/pg": "latest",
|
||||
"prisma": "latest",
|
||||
"typescript": "latest"
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@types/node": "25.2.2",
|
||||
"@types/pg": "8.16.0",
|
||||
"prisma": "7.3.0",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "bun --env-file=../../.env prisma/seed.ts"
|
||||
|
||||
@ -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;
|
||||
@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "user" ADD COLUMN "username" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");
|
||||
@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "user"
|
||||
ADD COLUMN "isSystem" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "isHidden" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "isProtected" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_role_idx" ON "user"("role");
|
||||
@ -1,5 +1,6 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client"
|
||||
output = "./generated/client"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@ -16,3 +17,73 @@ model Post {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
email String
|
||||
username String? @unique
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
role String @default("editor")
|
||||
isBanned Boolean @default(false)
|
||||
isSystem Boolean @default(false)
|
||||
isHidden Boolean @default(false)
|
||||
isProtected Boolean @default(false)
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
|
||||
@@unique([email])
|
||||
@@index([role])
|
||||
@@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")
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg"
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
import { Pool } from "pg"
|
||||
import { PrismaClient } from "../prisma/generated/client/client"
|
||||
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
|
||||
|
||||
@ -14,19 +14,19 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "latest",
|
||||
"clsx": "latest",
|
||||
"tailwind-merge": "latest"
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"tailwind-merge": "3.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "latest",
|
||||
"react-dom": "latest"
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cms/config": "workspace:*",
|
||||
"@biomejs/biome": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"typescript": "latest"
|
||||
"@biomejs/biome": "2.3.14",
|
||||
"@types/react": "19.2.13",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user