[object Object]

← back to Govarbitrage

Sign-in accepts bare username (resolved via email local-part) or full email

1a64a1d5f4706b44738c714792babfede96cafc2 · 2026-07-22 19:08:15 -0700 · Steve Abrams

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files touched

Diff

commit 1a64a1d5f4706b44738c714792babfede96cafc2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 19:08:15 2026 -0700

    Sign-in accepts bare username (resolved via email local-part) or full email
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 src/app/api/auth/login/route.ts | 24 +++++++++++++++++++-----
 src/app/login/page.tsx          |  4 ++--
 2 files changed, 21 insertions(+), 7 deletions(-)

diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 8319c4a..982b43f 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -7,7 +7,21 @@ import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";
 
 export const dynamic = "force-dynamic";
 
-const Schema = z.object({ email: z.string().email(), password: z.string().min(1) });
+// `email` accepts a full email OR a bare username (resolved against the
+// email local-part, e.g. "admin" → admin@agentabrams.com). Field name kept
+// as `email` for wire compatibility with existing clients.
+const Schema = z.object({ email: z.string().trim().min(1), password: z.string().min(1) });
+
+async function resolveUser(identifier: string) {
+  const id = identifier.toLowerCase();
+  if (id.includes("@")) return prisma.user.findUnique({ where: { email: id } });
+  // Bare username: match the unique user whose email local-part equals it.
+  const matches = await prisma.user.findMany({
+    where: { email: { startsWith: `${id}@` } },
+    take: 2,
+  });
+  return matches.length === 1 ? matches[0] : null;
+}
 
 export async function POST(req: NextRequest) {
   // Brute-force protection: cap login attempts per IP.
@@ -17,14 +31,14 @@ export async function POST(req: NextRequest) {
 
   const parsed = Schema.safeParse(await req.json().catch(() => ({})));
   if (!parsed.success) {
-    return NextResponse.json({ error: "Email and password required" }, { status: 400 });
+    return NextResponse.json({ error: "Username and password required" }, { status: 400 });
   }
   const { email, password } = parsed.data;
 
-  // And per targeted email (credential-stuffing protection).
-  const emailLimit = rateLimit(`login:email:${email}`, 5, 15 * 60_000);
+  // And per targeted identifier (credential-stuffing protection).
+  const emailLimit = rateLimit(`login:email:${email.toLowerCase()}`, 5, 15 * 60_000);
   if (!emailLimit.allowed) return tooManyRequests(emailLimit);
-  const user = await prisma.user.findUnique({ where: { email } });
+  const user = await resolveUser(email);
 
   // Constant-ish response whether or not the user exists.
   const ok = user ? verifyPassword(password, user.passwordHash) : false;
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 4ea20f0..cf8b9f4 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -48,7 +48,7 @@ function LoginForm() {
       </CardHeader>
       <CardContent>
         <form onSubmit={submit} className="space-y-3">
-          <Input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
+          <Input type="text" autoCapitalize="none" autoCorrect="off" placeholder="Username or email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
           <Input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required />
           {error && <p className="text-sm text-[var(--negative)]">{error}</p>}
           <Button type="submit" className="w-full" disabled={loading}>
@@ -56,7 +56,7 @@ function LoginForm() {
           </Button>
         </form>
         <p className="mt-3 text-xs text-muted-foreground">
-          Demo: <code>admin@govarbitrage.local</code> / <code>changeme</code>
+          Demo: <code>admin</code> / <code>changeme</code>
         </p>
       </CardContent>
     </Card>

← 873b83f chore: v0.2.1 (session close — auth wall)  ·  back to Govarbitrage  ·  Username-only sign-in (email login retired); admin uses std 6c0f4d8 →