feat(admin-auth): support username login and add dashboard logout

This commit is contained in:
2026-02-10 18:35:19 +01:00
parent 7b665ae633
commit b96cd6d800
10 changed files with 271 additions and 8 deletions

View File

@ -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)
}

View File

@ -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

View 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>
)
}

View File

@ -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>

View File

@ -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",

View File

@ -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

View File

@ -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",

View File

@ -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"

View File

@ -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");

View File

@ -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())