Browse / github.com/storytold/artcraft / patch
fix (login-modal) : validate signup fields before submitting
Can't Sign Up I used a real username and email. XXXXX are placeholders. <img width="485" height="534" alt="Image" src="https://github.com/user-attachments/assets/a302d0c9-8911-4c4c-bbe7-dbc61bec43c0" />
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Ridzzz0Alam (imported from pull request #2195, open)
- Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373- Size
- +80 −11 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
7dae0cd1864d5fce9ea019fc
How it went
No other operator has reported using it yet.
Diff
raw · frontend/libs/components/login-modal/src/lib/artcraft-signup.tsx frontend/libs/components/login-modal/src/lib/login-modal.spec.tsx
@@ -18,6 +18,11 @@ interface ArtCraftSignUpProps { isLoading?: boolean; } +// Server signup rules; the messages match the desktop's server error mapping+// (password_auth_error_message.rs) so both read the same.+const MIN_PASSWORD_LENGTH = 6;+const USERNAME_PATTERN = /^[A-Za-z0-9_-]*$/;+ const FIELD_LABEL = "block font-mono text-[11px] font-semibold leading-4 uppercase tracking-[0.12em] text-white/70"; const FIELD_INPUT =@@ -47,26 +52,28 @@ export const ArtCraftSignUp = ({ const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault();+ setLocalError(undefined); const form = e.currentTarget; if (isSignUp) { const username = (form.elements.namedItem("username") as HTMLInputElement)- .value;+ .value.trim(); const email = (form.elements.namedItem("email") as HTMLInputElement)- .value;+ .value.trim(); const password = (form.elements.namedItem("password") as HTMLInputElement) .value; const confirmPassword = ( form.elements.namedItem("confirmPassword") as HTMLInputElement ).value;- if (password !== confirmPassword) {- setLocalError("Passwords do not match.");+ const validationError = validateSignup(username, password, confirmPassword);+ if (validationError) {+ setLocalError(validationError); return; } onSubmit(username, email, password, confirmPassword); } else { const usernameOrEmail = ( form.elements.namedItem("usernameOrEmail") as HTMLInputElement- ).value;+ ).value.trim(); const password = (form.elements.namedItem("password") as HTMLInputElement) .value; onSubmit(usernameOrEmail, "", password, "");@@ -129,7 +136,7 @@ export const ArtCraftSignUp = ({ <Input name="password" type={showPassword ? "text" : "password"}- placeholder="Min. 8 characters"+ placeholder={`Min. ${MIN_PASSWORD_LENGTH} characters`} required autoComplete="off" inputClassName={`${FIELD_INPUT} pr-12`}@@ -198,3 +205,21 @@ export const ArtCraftSignUp = ({ </form> ); };++/** Returns the first problem in form order, or undefined when the server should accept it. */+function validateSignup(+ username: string,+ password: string,+ confirmPassword: string,+): string | undefined {+ if (username.length < 3) return "Usernames must be at least 3 characters.";+ if (username.length > 16) return "Usernames can be at most 16 characters.";+ if (!USERNAME_PATTERN.test(username)) {+ return "Usernames can only contain letters, numbers, underscores (_), and hyphens (-).";+ }+ if (password.length < MIN_PASSWORD_LENGTH) {+ return `Passwords must be at least ${MIN_PASSWORD_LENGTH} characters.`;+ }+ if (password !== confirmPassword) return "Passwords do not match.";+ return undefined;+}@@ -61,12 +61,12 @@ describe("native login modal integration", () => { await act(async () => { fireEvent.click(screen.getByRole("button", { name: label })); }); expect(screen.getByRole("img", { name: "Login QR" })).toBeTruthy(); expect(screen.queryByPlaceholderText("you@example.com or username")).toBeNull();- expect(screen.queryByPlaceholderText("Min. 8 characters")).toBeNull();+ expect(screen.queryByPlaceholderText("Min. 6 characters")).toBeNull(); expect(screen.queryByRole("button", { name: "Sign up" })).toBeNull(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Back" })); }); expect(screen.getByPlaceholderText("you@example.com or username")).toBeTruthy();- expect(screen.getByPlaceholderText("Min. 8 characters")).toBeTruthy();+ expect(screen.getByPlaceholderText("Min. 6 characters")).toBeTruthy(); expect(screen.getByRole("button", { name: "Login with Website" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Scan to Login" })).toBeTruthy(); expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull();@@ -82,10 +82,10 @@ describe("native login modal integration", () => { native.mockImplementationOnce(() => new Promise((done) => { resolve = done; })); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Login with Website" })); }); expect(screen.getByText("Preparing login…")).toBeTruthy();- expect(screen.queryByPlaceholderText("Min. 8 characters")).toBeNull();+ expect(screen.queryByPlaceholderText("Min. 6 characters")).toBeNull(); await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Back" })); }); await act(async () => { resolve({ challenge_id: "late_handle" }); });- expect(screen.getByPlaceholderText("Min. 8 characters")).toBeTruthy();+ expect(screen.getByPlaceholderText("Min. 6 characters")).toBeTruthy(); expect(screen.queryByRole("img", { name: "Login QR" })).toBeNull(); expect(native).toHaveBeenCalledWith("storyteller_cancel_login_challenge_command", { challengeId: "late_handle" }); expect(native.mock.calls.filter(([command]) => command === "plugin:opener|open_url")).toHaveLength(0);@@ -124,7 +124,7 @@ describe("native login modal integration", () => { } else { fireEvent.change(screen.getByPlaceholderText("you@example.com or username"), { target: { value: "native_user" } }); }- fireEvent.change(screen.getByPlaceholderText("Min. 8 characters"), { target: { value: "password123" } });+ fireEvent.change(screen.getByPlaceholderText("Min. 6 characters"), { target: { value: "password123" } }); const submit = screen.getAllByRole("button", { name: signup ? "Sign up" : "Log in" }).find((button) => button.getAttribute("type") === "submit")!; await act(async () => { fireEvent.click(submit); }); expect(success).toHaveBeenCalledExactlyOnceWith(USER);@@ -180,3 +180,47 @@ describe("native login modal integration", () => { expect(screen.queryByRole("heading", { name: "Logged in as google_user" })).toBeNull(); }); });++describe("signup form validation", () => {+ it.each([+ ["Usernames must be at least 3 characters.", "ab", "password123", "password123"],+ ["Usernames can be at most 16 characters.", "a".repeat(17), "password123", "password123"],+ ["Usernames can only contain letters, numbers, underscores (_), and hyphens (-).", "bad name", "password123", "password123"],+ ["Passwords must be at least 6 characters.", "native_user", "12345", "12345"],+ ["Passwords do not match.", "native_user", "password123", "password124"],+ ])("shows %j without contacting the server", async (message, username, password, confirmation) => {+ await act(async () => { render(<LoginModal />); });+ await submitSignup(username, "user@example.com", password, confirmation);+ expect(screen.getByText(message)).toBeTruthy();+ expect(native).not.toHaveBeenCalledWith("storyteller_password_signup_command", expect.anything());+ });++ it("trims the username and email before signing up", async () => {+ await act(async () => { render(<LoginModal />); });+ await submitSignup(" native_user ", " user@example.com ", "password123", "password123");+ expect(native).toHaveBeenCalledWith("storyteller_password_signup_command", {+ request: { username: "native_user", email_address: "user@example.com", password: "password123", password_confirmation: "password123", signup_source: "artcraft" },+ });+ });++ it("clears the previous error when the form is submitted again", async () => {+ native.mockImplementation(async (command: string) => {+ if (command === "storyteller_password_signup_command") return new Promise(() => {});+ return null;+ });+ await act(async () => { render(<LoginModal />); });+ await submitSignup("native_user", "user@example.com", "password123", "password124");+ expect(screen.getByText("Passwords do not match.")).toBeTruthy();+ await submitSignup("native_user", "user@example.com", "password123", "password123");+ expect(screen.queryByText("Passwords do not match.")).toBeNull();+ });+});++async function submitSignup(username: string, email: string, password: string, confirmation: string) {+ fireEvent.change(screen.getByPlaceholderText("Username"), { target: { value: username } });+ fireEvent.change(screen.getByPlaceholderText("you@example.com"), { target: { value: email } });+ fireEvent.change(screen.getByPlaceholderText("Min. 6 characters"), { target: { value: password } });+ fireEvent.change(screen.getByPlaceholderText("Re-enter password"), { target: { value: confirmation } });+ const submit = screen.getAllByRole("button", { name: "Sign up" }).find((button) => button.getAttribute("type") === "submit")!;+ await act(async () => { fireEvent.click(submit); });+}