From b96cd6d8005aea50c49a70540e0fd2afff94c682 Mon Sep 17 00:00:00 2001 From: Citali Date: Tue, 10 Feb 2026 18:35:19 +0100 Subject: [PATCH] feat(admin-auth): support username login and add dashboard logout --- apps/admin/src/app/api/auth/[...all]/route.ts | 66 ++++++++- apps/admin/src/app/login/login-form.tsx | 21 ++- apps/admin/src/app/logout-button.tsx | 36 +++++ apps/admin/src/app/page.tsx | 4 +- apps/admin/src/lib/auth/server.ts | 126 ++++++++++++++++++ docs/getting-started.md | 6 + package.json | 5 +- packages/db/package.json | 3 +- .../migration.sql | 11 ++ packages/db/prisma/schema.prisma | 1 + 10 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 apps/admin/src/app/logout-button.tsx create mode 100644 packages/db/prisma/migrations/20260210173127_username_auth/migration.sql diff --git a/apps/admin/src/app/api/auth/[...all]/route.ts b/apps/admin/src/app/api/auth/[...all]/route.ts index c42bdee..4063db7 100644 --- a/apps/admin/src/app/api/auth/[...all]/route.ts +++ b/apps/admin/src/app/api/auth/[...all]/route.ts @@ -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 | null> { + return (await request.json().catch(() => null)) as Record | null +} + +function buildJsonRequest(request: Request, body: Record): 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 { + 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 { 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 { ) } - 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 { 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 { export async function POST(request: Request): Promise { 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) } diff --git a/apps/admin/src/app/login/login-form.tsx b/apps/admin/src/app/login/login-form.tsx index 7289cd6..16679be 100644 --- a/apps/admin/src/app/login/login-form.tsx +++ b/apps/admin/src/app/login/login-form.tsx @@ -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) { >
setEmail(event.target.value)} @@ -228,6 +230,19 @@ export function LoginForm({ mode }: LoginFormProps) { />
+
+ + setUsername(event.target.value)} + className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm" + /> +
+