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>