Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Sign-up looked dead.** On `/login`, "Create a DevStack account" relied on the browser's native validation (`required`, `type=email`, `minLength=10`). When the email or a password under 10 characters was invalid, the browser blocked the POST and only showed a native bubble, in English, which is easy to miss and missing in some in-app or mobile browsers. The form now uses `noValidate` and checks its own rules, matching the server (valid email, 10–200 characters). It always shows a visible TH/EN message and focuses the field. A double submit is ignored, and the pending state resets after back/forward navigation. Regression test: `tests/login-form.test.tsx`.
- `/api/connections`, `/api/projects`, `/api/deployments` and `/api/environment` reads were throttled by the fixed 4 / 60 s `NVX_CHAT_LIMITER` binding (deploy-status polling every 3 s returned 429). Reads now use the per-isolate limiter at each route's `perMinute`; writes keep the shared binding.
- Secrets Store `get()` result is wrapped in a native Promise (the dev platform proxy returns a non-native thenable).

Expand Down
46 changes: 45 additions & 1 deletion src/components/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import { buttonClasses, Field, Input, Spinner } from "@/components/ui";
import type { DictKey } from "@/lib/i18n";
import { useLang } from "./prefs";
Expand All @@ -25,11 +25,32 @@ function notice(auth: string, provider: string): { title: DictKey; body: DictKey
return null;
}

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

/** Client-side check mirroring the server (`normalizeEmail`, `acceptPassword`: 10–200 chars). */
export function loginFormProblem(email: string, password: string): { field: "email" | "password"; message: DictKey } | null {
const e = email.trim();
if (!e || e.length > 200 || !EMAIL_RE.test(e)) return { field: "email", message: "auth.emailInvalid" };
if (password.length < 10 || password.length > 200) return { field: "password", message: "auth.weak" };
return null;
}

/** Four sign-in methods: Cursor, Google, GitHub, and a DevStack email account. */
export function LoginForm({ next, auth, provider }: { next: string; auth: string; provider: string }) {
const { t } = useLang();
const alert = notice(auth, provider);
const [pending, setPending] = useState<string | null>(null);
const [problem, setProblem] = useState<DictKey | null>(null);

// Back/forward cache restores the page with the old "pending" state; reset it so the buttons work again.
useEffect(() => {
const reset = (e: PageTransitionEvent) => {
if (e.persisted) setPending(null);
};
window.addEventListener("pageshow", reset);
return () => window.removeEventListener("pageshow", reset);
}, []);

return (
<div className="mx-auto w-full max-w-md px-4 py-10 sm:py-16">
<h1 className="text-3xl font-semibold tracking-tight text-fg">{t("auth.title")}</h1>
Expand All @@ -55,12 +76,35 @@ export function LoginForm({ next, auth, provider }: { next: string; auth: string
action="/api/auth/password"
className="mt-8 space-y-4 border-t border-border pt-6"
aria-busy={pending !== null}
noValidate
onSubmit={(e) => {
// Native constraint bubbles are easy to miss (in-app browsers, mobile) and made "Create account" look dead.
// Validate here and always show a visible, translated message instead.
if (pending !== null) {
e.preventDefault();
return;
}
const form = e.currentTarget;
const issue = loginFormProblem(String(new FormData(form).get("email") ?? ""), String(new FormData(form).get("password") ?? ""));
if (issue) {
e.preventDefault();
setProblem(issue.message);
form.querySelector<HTMLInputElement>(`input[name="${issue.field}"]`)?.focus();
return;
}
setProblem(null);
const submitter = (e.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
setPending(submitter?.value ?? "login");
}}
>
<h2 className="text-base font-semibold text-fg">{t("auth.ownHeading")}</h2>
<div aria-live="assertive">
{problem && (
<p role="alert" data-testid="login-problem" className="rounded-lg border border-fg/40 px-3 py-2 text-sm text-fg">
{t(problem)}
</p>
)}
</div>
<input type="hidden" name="next" value={next} />
<Field label={t("auth.email")}>
{(props) => <Input {...props} name="email" type="email" autoComplete="username" autoCapitalize="none" spellCheck={false} required maxLength={200} />}
Expand Down
1 change: 1 addition & 0 deletions src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ const dict = {
"auth.takenTitle": { en: "That email already has a DevStack account", th: "อีเมลนี้มีบัญชี DevStack แล้ว" },
"auth.taken": { en: "Sign in with the password for this email. It is not linked to Google, GitHub or Cursor.", th: "เข้าสู่ระบบด้วยรหัสผ่านของอีเมลนี้ บัญชีนี้ไม่เชื่อมกับ Google, GitHub หรือ Cursor" },
"auth.weakTitle": { en: "Use a longer password", th: "ใช้รหัสผ่านที่ยาวกว่านี้" },
"auth.emailInvalid": { en: "Enter a valid email address.", th: "กรอกอีเมลให้ถูกต้อง" },
"auth.weak": { en: "Passwords need 10 to 200 characters, with no control characters.", th: "รหัสผ่านต้องมี 10 ถึง 200 ตัวอักษร และไม่มีอักขระควบคุม" },
"auth.historyEmpty": { en: "Chats on this account show up here.", th: "แชตของบัญชีนี้จะแสดงที่นี่" },
"auth.privacy": {
Expand Down
70 changes: 70 additions & 0 deletions tests/login-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { LoginForm, loginFormProblem } from "@/components/login-form";

/**
* Regression (2026-10-15): "Create a DevStack account" looked dead because the browser's native
* constraint bubble (minLength 10 / required / type=email) blocked the submit silently on some browsers.
* The form now validates itself and always shows a visible message.
*/
describe("LoginForm create-account feedback", () => {
function setup(email: string, password: string) {
const { container } = render(<LoginForm next="/chat" auth="" provider="" />);
const form = container.querySelector("form") as HTMLFormElement;
(container.querySelector('input[name="email"]') as HTMLInputElement).value = email;
(container.querySelector('input[name="password"]') as HTMLInputElement).value = password;
const register = container.querySelector('button[value="register"]') as HTMLButtonElement;
return { form, register };
}

it("does not rely on native validation", () => {
const { form } = setup("", "");
expect(form.noValidate).toBe(true);
expect(form.getAttribute("action")).toBe("/api/auth/password");
expect(form.getAttribute("method")).toBe("post");
});

it("shows a visible message for a short password instead of doing nothing", () => {
const { form, register } = setup("qa@example.test", "short");
const ev = new Event("submit", { bubbles: true, cancelable: true });
Object.defineProperty(ev, "submitter", { value: register });
act(() => {
form.dispatchEvent(ev);
});
expect(ev.defaultPrevented).toBe(true);
expect(screen.getByTestId("login-problem").textContent).toMatch(/10/);
});

it("shows a visible message for an invalid email", () => {
const { form, register } = setup("not-an-email", "Long-enough-pass-1");
const ev = new Event("submit", { bubbles: true, cancelable: true });
Object.defineProperty(ev, "submitter", { value: register });
act(() => {
form.dispatchEvent(ev);
});
expect(ev.defaultPrevented).toBe(true);
expect(screen.getByRole("alert").textContent).toBeTruthy();
});

it("lets a valid register submit through to the server (native POST)", () => {
const { form, register } = setup("qa@example.test", "Long-enough-pass-1");
const onSubmit = vi.fn();
const ev = new Event("submit", { bubbles: true, cancelable: true });
Object.defineProperty(ev, "submitter", { value: register });
form.addEventListener("submit", onSubmit);
act(() => {
form.dispatchEvent(ev);
});
expect(ev.defaultPrevented).toBe(false);
expect(screen.queryByTestId("login-problem")).toBeNull();
expect(register.getAttribute("aria-disabled")).toBe("true");
});

it("mirrors the server rules", () => {
expect(loginFormProblem("a@b.co", "x".repeat(10))).toBeNull();
expect(loginFormProblem("a@b.co", "x".repeat(9))?.field).toBe("password");
expect(loginFormProblem("a@b.co", "x".repeat(201))?.field).toBe("password");
expect(loginFormProblem("ab.co", "x".repeat(10))?.field).toBe("email");
});
});
Loading