← 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
M src/app/api/auth/login/route.tsM src/app/login/page.tsx
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 →