feat(admin-auth): support username login and add dashboard logout
This commit is contained in:
@ -2,8 +2,10 @@ import {
|
||||
authRouteHandlers,
|
||||
canUserSelfRegister,
|
||||
ensureSupportUserBootstrap,
|
||||
ensureUserUsername,
|
||||
hasOwnerUser,
|
||||
promoteFirstRegisteredUserToOwner,
|
||||
resolveEmailFromLoginIdentifier,
|
||||
} from "@/lib/auth/server"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
@ -12,6 +14,9 @@ type AuthPostResponse = {
|
||||
user?: {
|
||||
id?: string
|
||||
role?: string
|
||||
email?: string
|
||||
name?: string
|
||||
username?: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
@ -20,9 +25,54 @@ 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()
|
||||
|
||||
@ -35,7 +85,11 @@ async function handleSignUpPost(request: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
const response = await authRouteHandlers.POST(request)
|
||||
const response = await authRouteHandlers.POST(
|
||||
buildJsonRequest(request, {
|
||||
...signUpBodyWithoutUsername,
|
||||
}),
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return response
|
||||
@ -51,6 +105,12 @@ async function handleSignUpPost(request: Request): Promise<Response> {
|
||||
return response
|
||||
}
|
||||
|
||||
await ensureUserUsername(userId, {
|
||||
preferred: preferredUsername,
|
||||
fallbackEmail: payload?.user?.email,
|
||||
fallbackName: payload?.user?.name,
|
||||
})
|
||||
|
||||
if (hadOwnerBeforeSignUp || !payload?.user) {
|
||||
return response
|
||||
}
|
||||
@ -82,6 +142,10 @@ export async function GET(request: Request): Promise<Response> {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -31,6 +31,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
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)
|
||||
@ -50,7 +51,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
identifier: email,
|
||||
password,
|
||||
callbackURL: nextPath,
|
||||
}),
|
||||
@ -93,6 +94,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
callbackURL: nextPath,
|
||||
@ -152,11 +154,11 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium" htmlFor="email">
|
||||
Email
|
||||
Email or username
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
type="text"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
@ -228,6 +230,19 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
/>
|
||||
</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
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -5,6 +5,7 @@ import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { resolveRoleFromServerContext } from "@/lib/access-server"
|
||||
import { LogoutButton } from "./logout-button"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@ -28,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>
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ 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
|
||||
@ -88,6 +89,116 @@ function resolveBootstrapValue(
|
||||
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,
|
||||
@ -110,6 +221,11 @@ export const auth = betterAuth({
|
||||
defaultValue: "editor",
|
||||
input: false,
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
required: false,
|
||||
input: false,
|
||||
},
|
||||
isBanned: {
|
||||
type: "boolean",
|
||||
required: true,
|
||||
@ -146,6 +262,7 @@ let supportBootstrapPromise: Promise<void> | null = null
|
||||
|
||||
type BootstrapUserConfig = {
|
||||
email: string
|
||||
username: string
|
||||
name: string
|
||||
password: string
|
||||
role: Role
|
||||
@ -187,13 +304,21 @@ async function ensureCredentialUser(config: BootstrapUserConfig): Promise<void>
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
@ -220,6 +345,7 @@ async function bootstrapSystemUsers(): Promise<void> {
|
||||
|
||||
await ensureCredentialUser({
|
||||
email: supportEmail,
|
||||
username: supportUsername,
|
||||
name: supportName,
|
||||
password: supportPassword,
|
||||
role: "support",
|
||||
|
||||
@ -20,6 +20,12 @@ 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
|
||||
|
||||
@ -30,8 +30,9 @@
|
||||
"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:reset:dev": "bun --filter @cms/db db:reset:dev && bun run db:generate && bun run db:seed",
|
||||
"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 && bun --filter @cms/admin auth:seed:support",
|
||||
|
||||
@ -13,7 +13,8 @@
|
||||
"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:reset:dev": "bun --env-file=../../.env prisma migrate reset --force --skip-generate --skip-seed",
|
||||
"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"
|
||||
|
||||
@ -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");
|
||||
@ -22,6 +22,7 @@ model User {
|
||||
id String @id
|
||||
name String
|
||||
email String
|
||||
username String? @unique
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
Reference in New Issue
Block a user