diff --git a/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts b/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts index afcd8eae2f..1ed0650806 100644 --- a/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts +++ b/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts @@ -3,6 +3,7 @@ import { WASP_SERVER_URL } from "../playwright.config"; import { performEmailVerification, performLogin, performSignup } from "./auth"; import { generateRandomEmail, + generateRandomInternationalizedEmail, isRunningInDeployedMode, isRunningInDevMode, } from "./helpers"; @@ -45,7 +46,8 @@ test.describe("auth", () => { test("can sign up", async ({ page }) => { await performSignup(page, { - email, + // Padded to check that the form trims the address. + email: ` ${email} `, password, address: "Some at least 10 letter address", }); @@ -96,4 +98,72 @@ test.describe("auth", () => { await expect(page).toHaveURL("/"); }); }); + + test.describe("internationalized email address", () => { + test.skip( + isRunningInDeployedMode(), + "Skipped in deployed mode (no Mailcrab)", + ); + test.describe.configure({ mode: "serial" }); + + const email = generateRandomInternationalizedEmail(); + const password = "12345678"; + + test("can sign up", async ({ page }) => { + await performSignup(page, { + email, + password, + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + `You've signed up successfully! Check your email for the confirmation link.`, + ); + }); + + test("can verify email", async ({ page }) => { + if (isRunningInDevMode()) { + // Skip this test in dev mode, as email confirmation is not required. + test.skip(); + } + + await performEmailVerification(page, email); + }); + + test("can log in", async ({ page }) => { + await performLogin(page, { email, password }); + + await expect(page).toHaveURL("/"); + }); + }); + + test.describe("invalid email address", () => { + test("signing up with a malformed address results in an error message", async ({ + page, + }) => { + await performSignup(page, { + email: "not-an-email", + password: "12345678", + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + "Email must be a valid email", + ); + }); + + test("signing up with a leading combining mark results in an error message", async ({ + page, + }) => { + await performSignup(page, { + email: `\u0301${generateRandomInternationalizedEmail()}`, + password: "12345678", + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + "Email must be a valid email", + ); + }); + }); }); diff --git a/examples/kitchen-sink/e2e-tests/tests/auth.ts b/examples/kitchen-sink/e2e-tests/tests/auth.ts index 1c5af74064..cad6e89052 100644 --- a/examples/kitchen-sink/e2e-tests/tests/auth.ts +++ b/examples/kitchen-sink/e2e-tests/tests/auth.ts @@ -107,7 +107,7 @@ async function navigateToLoginPage(page: Page) { } async function submitLoginForm(page: Page, credentials: BaseEmailCredentials) { - await page.locator("input[type='email']").fill(credentials.email); + await page.locator("input[name='email']").fill(credentials.email); await page.locator("input[type='password']").fill(credentials.password); await page.getByRole("button", { name: "Log in" }).click(); } diff --git a/examples/kitchen-sink/e2e-tests/tests/helpers.ts b/examples/kitchen-sink/e2e-tests/tests/helpers.ts index 55f4b2bacc..4812c08b4a 100644 --- a/examples/kitchen-sink/e2e-tests/tests/helpers.ts +++ b/examples/kitchen-sink/e2e-tests/tests/helpers.ts @@ -12,3 +12,10 @@ export function isRunningInDeployedMode() { export function generateRandomEmail(): string { return `${randomUUID()}@test.com`; } + +/** + * Generates an address with non-ASCII characters on both sides of the `@`. + */ +export function generateRandomInternationalizedEmail(): string { + return `jürgen-${randomUUID()}@münchen.test`; +} diff --git a/examples/kitchen-sink/package-lock.json b/examples/kitchen-sink/package-lock.json index d1192870b7..fba633cb20 100644 --- a/examples/kitchen-sink/package-lock.json +++ b/examples/kitchen-sink/package-lock.json @@ -95,7 +95,7 @@ "dotenv": "^16.6.1", "express": "~5.1.0", "helmet": "^6.0.0", - "morgan": "~1.10.0", + "morgan": "~1.11.0", "socket.io": "^4.6.1", "superjson": "^2.2.1" }, @@ -3992,7 +3992,7 @@ "node_modules/@wasp.sh/lib-auth": { "version": "0.26.0", "resolved": "file:.wasp/out/libs/auth/wasp.sh-lib-auth-0.26.0.tgz", - "integrity": "sha512-zLl/sYSAjchwkfRKGsia/UhL37O96yoj+DO61P33EzLUzr047pWQYxzoJTOH6c2ugVbG4jbNZYp43QpONqN7qw==", + "integrity": "sha512-5Uv3Ousfv72qV94NhOOx3Vqt2rB2N9j8QQs/1bEVQwExtbkAP41ky8J9pA3ZS5gNjFJB4E+w50MQZ5gUqWyaRg==", "license": "MIT", "dependencies": { "@node-rs/argon2": "^2.0.2", @@ -7007,19 +7007,23 @@ "license": "MIT" }, "node_modules/morgan": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", + "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", - "on-finished": "~2.3.0", + "on-finished": "~2.4.1", "on-headers": "~1.1.0" }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/morgan/node_modules/debug": { @@ -7037,18 +7041,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", diff --git a/examples/kitchen-sink/src/features/auth/pages/CustomSignupPage.tsx b/examples/kitchen-sink/src/features/auth/pages/CustomSignupPage.tsx index ec02fd8c87..0fbe7eea48 100644 --- a/examples/kitchen-sink/src/features/auth/pages/CustomSignupPage.tsx +++ b/examples/kitchen-sink/src/features/auth/pages/CustomSignupPage.tsx @@ -68,7 +68,7 @@ export const CustomSignupPage = () => { )} E-mail - + {errors.email?.message} diff --git a/examples/kitchen-sink/src/features/auth/pages/ManualSignupPage.tsx b/examples/kitchen-sink/src/features/auth/pages/ManualSignupPage.tsx index e87f17e21f..55511b9aea 100644 --- a/examples/kitchen-sink/src/features/auth/pages/ManualSignupPage.tsx +++ b/examples/kitchen-sink/src/features/auth/pages/ManualSignupPage.tsx @@ -68,7 +68,7 @@ export const ManualSignupPage = () => { )} E-mail - + {errors.email?.message} diff --git a/waspc/ChangeLog.md b/waspc/ChangeLog.md index db064d6339..cea1f8e321 100644 --- a/waspc/ChangeLog.md +++ b/waspc/ChangeLog.md @@ -41,6 +41,8 @@ - Password reset now rejects an invalid or expired token before it looks at the new password, so someone without a valid reset link can no longer probe your app's password rules. ([#4657](https://github.com/wasp-lang/wasp/pull/4657)) - `onBeforeSignup` now runs before `userSignupFields` on every signup method: email, username and password, and OAuth. ([#4659](https://github.com/wasp-lang/wasp/pull/4659)) - Improved the wording of some CLI messages. ([#4717](https://github.com/wasp-lang/wasp/pull/4717)) +- Email validation now accepts internationalized addresses such as `jürgen@münchen.de`, accepts addresses typed in uppercase, and no longer accepts a string that merely contains an address somewhere inside it. ([#1392](https://github.com/wasp-lang/wasp/issues/1392)) +- The email verification email now goes to the address Wasp stores (emails are stored lowercased) instead of the address exactly as typed, so it matches where the password reset email is sent. ([#1392](https://github.com/wasp-lang/wasp/issues/1392)) ## 0.25.0 diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts new file mode 100644 index 0000000000..2cb2d3d2ae --- /dev/null +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -0,0 +1,63 @@ +// TODO: If we ever need a more quality email validator, its worth to +// look at https://github.com/JoshData/python-email-validator for inspiration. + +/** + * The syntax we accept is the HTML5 `input[type=email]` grammar, + * widened to also accept most unicode characters (HTML5 is ASCII only). + * + * We widen it to unicode letters, marks and decimal digits, leaving out + * the possibly dangerous format characters. What still gets through is + * handled separately later. + * + * We start from the HTML5 grammar because it is a good compromise + * between completeness and complexity. + * + * @see {@link https://github.com/whatwg/html/issues/4562 WHATWG international email addresses issue} + */ +const HTML5_EMAIL_WITH_UNICODE_REGEX = + /^[\p{L}\p{M}\p{Nd}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{Nd}](?:[\p{L}\p{M}\p{Nd}-]{0,61}[\p{L}\p{M}\p{Nd}])?(?:\.[\p{L}\p{Nd}](?:[\p{L}\p{M}\p{Nd}-]{0,61}[\p{L}\p{M}\p{Nd}])?)*$/u; + +/** + * Characters that render as nothing, so that two addresses spelled + * differently look identical on screen. + */ +const INVISIBLE_CHARACTER_REGEX = /\p{Default_Ignorable_Code_Point}/u; + +/** + * A combining mark at the start has no character of its own to attach to, + * so it lands on whatever text precedes the address when it is rendered. + */ +const LEADING_COMBINING_MARK_REGEX = /^\p{M}/u; + +export function isValidEmail(input: unknown): boolean { + if (typeof input !== "string") { + return false; + } + + return ( + HTML5_EMAIL_WITH_UNICODE_REGEX.test(input) && + !INVISIBLE_CHARACTER_REGEX.test(input) && + !LEADING_COMBINING_MARK_REGEX.test(input) && + isEmailOfValidLength(input) + ); +} + +// Upper bounds from RFC 5321. +const MAX_EMAIL_ADDRESS_LOCAL_PART_OCTETS = 64; +const MAX_EMAIL_ADDRESS_OCTETS = 254; + +function isEmailOfValidLength(email: string) { + return ( + countOctets(email) <= MAX_EMAIL_ADDRESS_OCTETS && + countOctets(getEmailLocalPart(email)) <= MAX_EMAIL_ADDRESS_LOCAL_PART_OCTETS + ); +} + +// Not punycoded for simplicity. +function countOctets(text: string): number { + return new TextEncoder().encode(text).length; +} + +function getEmailLocalPart(email: string): string { + return email.slice(0, email.lastIndexOf("@")); +} diff --git a/waspc/data/Generator/libs/auth/src/index.ts b/waspc/data/Generator/libs/auth/src/index.ts index abacf38d27..bbd6912019 100644 --- a/waspc/data/Generator/libs/auth/src/index.ts +++ b/waspc/data/Generator/libs/auth/src/index.ts @@ -1,3 +1 @@ -/** - * NOTE: this is a placeholder file for future code exported for both runtimes. - */ +export { isValidEmail } from "./email"; diff --git a/waspc/data/Generator/libs/auth/tests/email.test.ts b/waspc/data/Generator/libs/auth/tests/email.test.ts new file mode 100644 index 0000000000..fa94e4e5ba --- /dev/null +++ b/waspc/data/Generator/libs/auth/tests/email.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; +import { isValidEmail } from "../src/email"; + +describe("isValidEmail", () => { + it.each([ + "user@example.com", + "user.name+tag@example.co.uk", + "user_name@example.com", + "!#$%&'*+-/=?^_`{|}~@example.com", + "user@sub.domain.example.com", + "user@a.io", + // `input[type=email]` accepts a dotless domain, so we do too. + "user@localhost", + ])("accepts the ASCII address %j", (email) => { + expect(isValidEmail(email)).toBe(true); + }); + + it.each([ + "", + "plainaddress", + "@example.com", + "user@", + "user@@example.com", + "user@-example.com", + "user@example-.com", + "user@exam ple.com", + "user name@example.com", + "user@example..com", + ])("rejects the malformed address %j", (email) => { + expect(isValidEmail(email)).toBe(false); + }); + + it.each([null, undefined, 42, {}, ["user@example.com"]])( + "rejects the non-string input %j", + (input) => { + expect(isValidEmail(input)).toBe(false); + }, + ); + + describe("internationalized addresses", () => { + it.each([ + "jürgen@example.com", + "user@münchen.de", + "jürgen@münchen.de", + "用户@例子.广告", + "θσερ@εχαμπλε.ψομ", + "अजय@डाटा.भारत", + "квіточка@пошта.укр", + // Decomposed "ö", a combining mark following its base character. + "o\u0308ffentlich@example.com", + // Arabic-Indic digits. + "١٢@example.com", + ])("accepts %j", (email) => { + expect(isValidEmail(email)).toBe(true); + }); + + // RFC 6531 allows any non-ASCII character in the local part. + // We do not go that far. + it.each([ + // Emoji. + "😀@example.com", + // Numerals that are not decimal digits. + "user@examp⑪le.com", + "user@Ⅷ.com", + ])("rejects the unsupported character in %j", (email) => { + expect(isValidEmail(email)).toBe(false); + }); + + it("rejects an address starting with a combining mark", () => { + // The mark has no character of its own to attach to, so it lands on + // whatever text precedes the address when it is rendered. + expect(isValidEmail("\u0301user@example.com")).toBe(false); + }); + }); + + // These render as nothing, so they let two addresses that are spelled + // differently look identical. + describe("invisible characters", () => { + it.each([ + // Zero width joiner. + "us\u200Der@example.com", + // Zero width non-joiner. + "us\u200Cer@example.com", + // Right-to-left override. + "us\u202Eer@example.com", + // Soft hyphen. + "us\u00ADer@example.com", + // Variation selector 1. + "us\uFE00er@example.com", + // Variation selector 16. + "us\uFE0Fer@example.com", + // Variation selector supplement. + "us\u{E0100}er@example.com", + // Mongolian free variation selector. + "us\u180Ber@example.com", + // Hangul filler, the classic invisible username character. + "\u3164@example.com", + // Halfwidth Hangul filler. + "\uFFA0@example.com", + // Hangul choseong filler, in the domain this time. + "user@examp\u115Fle.com", + ])("rejects %j", (email) => { + expect(isValidEmail(email)).toBe(false); + }); + }); + + describe("case", () => { + // The email signup endpoint validates the raw request body and only + // lowercases the address afterwards, so the validator has to accept + // whatever casing the user typed. + it.each([ + "JOHN@EXAMPLE.COM", + "John@Example.com", + "jOhN@eXaMpLe.CoM", + // Uppercase confined to the domain. + "john@Example.com", + "john@example.COM", + // Uppercase confined to the local part, running right up to the "@". + "JOHN@example.com", + "john.DOE@example.com", + ])("accepts %j", (email) => { + expect(isValidEmail(email)).toBe(true); + }); + }); + + describe("anchoring", () => { + it.each([ + "user@example.com ", + "I am not an email, ask user@example.com", + "\nuser@example.com", + "user@example.com\n", + " user@example.com ", + ])("rejects %j, which merely contains an address", (input) => { + expect(isValidEmail(input)).toBe(false); + }); + }); + + describe("size limits (RFC 5321)", () => { + it("accepts a 64 octet local part", () => { + expect(isValidEmail(`${"a".repeat(64)}@example.com`)).toBe(true); + }); + + it("rejects a 65 octet local part", () => { + expect(isValidEmail(`${"a".repeat(65)}@example.com`)).toBe(false); + }); + + it("counts octets rather than characters in the local part", () => { + // "ä" is two octets in UTF-8, so 33 of them exceed the 64 octet limit + // while staying well under 64 characters. + expect(isValidEmail(`${"ä".repeat(32)}@example.com`)).toBe(true); + expect(isValidEmail(`${"ä".repeat(33)}@example.com`)).toBe(false); + }); + + it("rejects an address longer than 254 octets", () => { + const domain = `${"a".repeat(61)}.${"b".repeat(61)}.${"c".repeat(61)}.com`; + const localPart = "d".repeat(254 - domain.length - 1); + + expect(isValidEmail(`${localPart}@${domain}`)).toBe(true); + expect(isValidEmail(`${localPart}x@${domain}`)).toBe(false); + }); + }); +}); diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx index d5d05aa89b..21b4551bd5 100644 --- a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx @@ -6,6 +6,9 @@ import { config } from '../../../../client/index.js' import { clsx } from '../util' import { useAuthContext } from '@wasp.sh/lib-auth/browser' +{=# enabledProviders.isEmailAuthEnabled =} +import { emailFieldRules, emailInputProps } from '../emailField' +{=/ enabledProviders.isEmailAuthEnabled =} import { Form, FormInput, @@ -189,10 +192,8 @@ export const LoginSignupForm = ({ E-mail {errors.email && {errors.email.message}} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx index f367af40cf..967d86f6bd 100644 --- a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx @@ -3,6 +3,7 @@ import { useAuthContext } from '@wasp.sh/lib-auth/browser' import { requestPasswordReset } from '../../../email/actions/passwordReset.js' import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton, FormError } from '../Form' +import { emailFieldRules, emailInputProps } from '../emailField' // PRIVATE API @@ -34,10 +35,8 @@ export const ForgotPasswordForm = () => { E-mail {errors.email && {errors.email.message}} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts new file mode 100644 index 0000000000..12f2c0aa8b --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts @@ -0,0 +1,23 @@ +import { isValidEmail } from '@wasp.sh/lib-auth' + +/** + * Deliberately avoids setting `type="email"`. + * Browsers validate email input against the HTML5 grammar, which is ASCII-only + * and would reject internationalized addresses that the server accepts. + * `inputMode` keeps the email keyboard on mobile. + * + * @see {@link https://github.com/whatwg/html/issues/4562 WHATWG international email addresses issue} + */ +export const emailInputProps = { + type: 'text', + inputMode: 'email', + autoComplete: 'email', +} as const + +export const emailFieldRules = { + required: 'Email is required', + // `type="email"` used to strip surrounding whitespace for us, `type="text"` doesn't. + setValueAs: (email: string) => email.trim(), + validate: (email: string) => + isValidEmail(email) || 'Email must be a valid email', +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts b/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts index bbab0c26e1..5521f1f440 100644 --- a/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts +++ b/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts @@ -1,3 +1,4 @@ +import { isValidEmail } from '@wasp.sh/lib-auth'; import { HttpError } from '../server/index.js'; export const PASSWORD_FIELD = 'password'; @@ -55,17 +56,6 @@ function validate(args: object, validators: { validates: string, message: string } } -// NOTE(miho): it would be good to replace our custom validations with e.g. Zod - -const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/ -function isValidEmail(input: unknown): boolean { - if (typeof input !== 'string') { - return false - } - - return input.match(validEmailRegex) !== null -} - function isMinLength(input: unknown, minLength: number): boolean { if (typeof input !== 'string') { return false diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts index aa7ac2d2dd..e049b73de8 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts @@ -1,16 +1,5 @@ import { Request, Response } from 'express' import type { UserSignupFields } from 'wasp/auth/providers/types' -import { - createProviderId, - createUser, - deleteUserByAuthId, - doFakeWork, - findAuthIdentity, - getProviderDataWithPassword, - rethrowPossibleAuthError, - sanitizeAndSerializeProviderData, - validateAndGetUserFields, -} from 'wasp/server/auth/utils' import { ensurePasswordIsPresent, ensureValidEmail, @@ -23,6 +12,17 @@ import { isEmailResendAllowed, sendEmailVerificationEmail, } from 'wasp/server/auth/email/utils' +import { + createProviderId, + createUser, + deleteUserByAuthId, + doFakeWork, + findAuthIdentity, + getProviderDataWithPassword, + rethrowPossibleAuthError, + sanitizeAndSerializeProviderData, + validateAndGetUserFields, +} from 'wasp/server/auth/utils' import { EmailFromField } from 'wasp/server/email/core/types' import { onAfterSignupHook, onBeforeSignupHook } from '../../hooks.js' @@ -146,14 +146,18 @@ export function getSignupRoute({ return } + // We send the verification link to the normalized address that we stored. + // This way the address that proves ownership is the same one password reset later + // sends the reset link to. + const email = providerId.providerUserId const verificationLink = await createEmailVerificationLink( - fields.email, + email, clientRoute, ) try { - await sendEmailVerificationEmail(fields.email, { + await sendEmailVerificationEmail(email, { from: fromField, - to: fields.email, + to: email, ...getVerificationEmailContent({ verificationLink }), }) } catch (e: unknown) { diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/snapshot-file-list.manifest b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/snapshot-file-list.manifest index c6aa2a9e10..644d5f1f15 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/snapshot-file-list.manifest +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/snapshot-file-list.manifest @@ -48,6 +48,7 @@ wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ResetPasswordForm.tsx wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/VerifyEmailForm.tsx wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/useEmail.ts +wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/social/SocialButton.module.css wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/social/SocialButton.tsx wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/social/SocialIcons.module.css @@ -222,6 +223,10 @@ wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/email/useEmail.d.ts wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/email/useEmail.d.ts.map wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/email/useEmail.js wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/email/useEmail.js.map +wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/emailField.d.ts +wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/emailField.d.ts.map +wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/emailField.js +wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/emailField.js.map wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/social/SocialButton.d.ts wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/social/SocialButton.d.ts.map wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/social/SocialButton.jsx diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums index 6d97b5ca2f..a035fb68cf 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums @@ -32,7 +32,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "35480e2a814f4c24e9bb22f8e6c0a43fbca98c1cf0612108bcf0f4c1302262f0" + "b8b523100ff2dfeb74939afdd5c45883734ed8a7582f821b1c57ae05ff69c4d6" ], [ [ @@ -186,14 +186,14 @@ "file", "sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx" ], - "dcc63ceab024657653b082cd772b7ec50f53e47583a8f20974c605dfdb200a91" + "2accd8d7e5f95f9374f86f0bfd30c3576e7e55c7a544248fb528451a8dea45fa" ], [ [ "file", "sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx" ], - "0cffa908694b6eb863d094c1e0027fd92876fe58e244a2ee092ef2aa38f07c12" + "e5a766bcd839a37385201480dd30a93a36c0e9850aed4dc15b5dbb5fbf7079a8" ], [ [ @@ -216,6 +216,13 @@ ], "7751b34516bc55579c5f556ff4092a255a09b4ecb94ce3ab9cc08ffe324a4bf0" ], + [ + [ + "file", + "sdk/wasp/auth/forms/internal/emailField.ts" + ], + "f7e6f5601563191fc6d34140f8b273eef6bf26e101ff8ca0621225bf8319d9c8" + ], [ [ "file", @@ -368,7 +375,7 @@ "file", "sdk/wasp/auth/validation.ts" ], - "5a408544fade201c599d9c0d66a1a847ed4bfdd8f44a7f957fab74a97ecaa43c" + "7c1a41e0d3456da0cd98b52ab127e197895036a063c9c567898637c63083b840" ], [ [ @@ -1600,7 +1607,7 @@ "file", "server/src/auth/providers/email/signup.ts" ], - "36980d460729b3ae58c2bbee24144cf21909ea77e642f4ad65c75b107455fad8" + "398fbf1958fc0d272b80c977996fd598adc6266d08ac322faf4ce93d9ac306d7" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx index 01a822f481..7968433d91 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx @@ -5,6 +5,7 @@ import { config } from '../../../../client/index.js' import { clsx } from '../util' import { useAuthContext } from '@wasp.sh/lib-auth/browser' +import { emailFieldRules, emailInputProps } from '../emailField' import { Form, FormInput, @@ -110,10 +111,8 @@ export const LoginSignupForm = ({ E-mail {errors.email && {errors.email.message}} diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx index f367af40cf..967d86f6bd 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx @@ -3,6 +3,7 @@ import { useAuthContext } from '@wasp.sh/lib-auth/browser' import { requestPasswordReset } from '../../../email/actions/passwordReset.js' import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton, FormError } from '../Form' +import { emailFieldRules, emailInputProps } from '../emailField' // PRIVATE API @@ -34,10 +35,8 @@ export const ForgotPasswordForm = () => { E-mail {errors.email && {errors.email.message}} diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts new file mode 100644 index 0000000000..12f2c0aa8b --- /dev/null +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts @@ -0,0 +1,23 @@ +import { isValidEmail } from '@wasp.sh/lib-auth' + +/** + * Deliberately avoids setting `type="email"`. + * Browsers validate email input against the HTML5 grammar, which is ASCII-only + * and would reject internationalized addresses that the server accepts. + * `inputMode` keeps the email keyboard on mobile. + * + * @see {@link https://github.com/whatwg/html/issues/4562 WHATWG international email addresses issue} + */ +export const emailInputProps = { + type: 'text', + inputMode: 'email', + autoComplete: 'email', +} as const + +export const emailFieldRules = { + required: 'Email is required', + // `type="email"` used to strip surrounding whitespace for us, `type="text"` doesn't. + setValueAs: (email: string) => email.trim(), + validate: (email: string) => + isValidEmail(email) || 'Email must be a valid email', +} diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts index bbab0c26e1..5521f1f440 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts @@ -1,3 +1,4 @@ +import { isValidEmail } from '@wasp.sh/lib-auth'; import { HttpError } from '../server/index.js'; export const PASSWORD_FIELD = 'password'; @@ -55,17 +56,6 @@ function validate(args: object, validators: { validates: string, message: string } } -// NOTE(miho): it would be good to replace our custom validations with e.g. Zod - -const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/ -function isValidEmail(input: unknown): boolean { - if (typeof input !== 'string') { - return false - } - - return input.match(validEmailRegex) !== null -} - function isMinLength(input: unknown, minLength: number): boolean { if (typeof input !== 'string') { return false diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts index aa7ac2d2dd..e049b73de8 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts @@ -1,16 +1,5 @@ import { Request, Response } from 'express' import type { UserSignupFields } from 'wasp/auth/providers/types' -import { - createProviderId, - createUser, - deleteUserByAuthId, - doFakeWork, - findAuthIdentity, - getProviderDataWithPassword, - rethrowPossibleAuthError, - sanitizeAndSerializeProviderData, - validateAndGetUserFields, -} from 'wasp/server/auth/utils' import { ensurePasswordIsPresent, ensureValidEmail, @@ -23,6 +12,17 @@ import { isEmailResendAllowed, sendEmailVerificationEmail, } from 'wasp/server/auth/email/utils' +import { + createProviderId, + createUser, + deleteUserByAuthId, + doFakeWork, + findAuthIdentity, + getProviderDataWithPassword, + rethrowPossibleAuthError, + sanitizeAndSerializeProviderData, + validateAndGetUserFields, +} from 'wasp/server/auth/utils' import { EmailFromField } from 'wasp/server/email/core/types' import { onAfterSignupHook, onBeforeSignupHook } from '../../hooks.js' @@ -146,14 +146,18 @@ export function getSignupRoute({ return } + // We send the verification link to the normalized address that we stored. + // This way the address that proves ownership is the same one password reset later + // sends the reset link to. + const email = providerId.providerUserId const verificationLink = await createEmailVerificationLink( - fields.email, + email, clientRoute, ) try { - await sendEmailVerificationEmail(fields.email, { + await sendEmailVerificationEmail(email, { from: fromField, - to: fields.email, + to: email, ...getVerificationEmailContent({ verificationLink }), }) } catch (e: unknown) { diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts index afcd8eae2f..1ed0650806 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts @@ -3,6 +3,7 @@ import { WASP_SERVER_URL } from "../playwright.config"; import { performEmailVerification, performLogin, performSignup } from "./auth"; import { generateRandomEmail, + generateRandomInternationalizedEmail, isRunningInDeployedMode, isRunningInDevMode, } from "./helpers"; @@ -45,7 +46,8 @@ test.describe("auth", () => { test("can sign up", async ({ page }) => { await performSignup(page, { - email, + // Padded to check that the form trims the address. + email: ` ${email} `, password, address: "Some at least 10 letter address", }); @@ -96,4 +98,72 @@ test.describe("auth", () => { await expect(page).toHaveURL("/"); }); }); + + test.describe("internationalized email address", () => { + test.skip( + isRunningInDeployedMode(), + "Skipped in deployed mode (no Mailcrab)", + ); + test.describe.configure({ mode: "serial" }); + + const email = generateRandomInternationalizedEmail(); + const password = "12345678"; + + test("can sign up", async ({ page }) => { + await performSignup(page, { + email, + password, + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + `You've signed up successfully! Check your email for the confirmation link.`, + ); + }); + + test("can verify email", async ({ page }) => { + if (isRunningInDevMode()) { + // Skip this test in dev mode, as email confirmation is not required. + test.skip(); + } + + await performEmailVerification(page, email); + }); + + test("can log in", async ({ page }) => { + await performLogin(page, { email, password }); + + await expect(page).toHaveURL("/"); + }); + }); + + test.describe("invalid email address", () => { + test("signing up with a malformed address results in an error message", async ({ + page, + }) => { + await performSignup(page, { + email: "not-an-email", + password: "12345678", + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + "Email must be a valid email", + ); + }); + + test("signing up with a leading combining mark results in an error message", async ({ + page, + }) => { + await performSignup(page, { + email: `\u0301${generateRandomInternationalizedEmail()}`, + password: "12345678", + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + "Email must be a valid email", + ); + }); + }); }); diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.ts index 1c5af74064..cad6e89052 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.ts @@ -107,7 +107,7 @@ async function navigateToLoginPage(page: Page) { } async function submitLoginForm(page: Page, credentials: BaseEmailCredentials) { - await page.locator("input[type='email']").fill(credentials.email); + await page.locator("input[name='email']").fill(credentials.email); await page.locator("input[type='password']").fill(credentials.password); await page.getByRole("button", { name: "Log in" }).click(); } diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts index 55f4b2bacc..4812c08b4a 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts @@ -12,3 +12,10 @@ export function isRunningInDeployedMode() { export function generateRandomEmail(): string { return `${randomUUID()}@test.com`; } + +/** + * Generates an address with non-ASCII characters on both sides of the `@`. + */ +export function generateRandomInternationalizedEmail(): string { + return `jürgen-${randomUUID()}@münchen.test`; +} diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/CustomSignupPage.tsx b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/CustomSignupPage.tsx index ec02fd8c87..0fbe7eea48 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/CustomSignupPage.tsx +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/CustomSignupPage.tsx @@ -68,7 +68,7 @@ export const CustomSignupPage = () => { )} E-mail - + {errors.email?.message} diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/ManualSignupPage.tsx b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/ManualSignupPage.tsx index e87f17e21f..55511b9aea 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/ManualSignupPage.tsx +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/ManualSignupPage.tsx @@ -68,7 +68,7 @@ export const ManualSignupPage = () => { )} E-mail - + {errors.email?.message} diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums index d629bd7eba..4ed412b3d8 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "35480e2a814f4c24e9bb22f8e6c0a43fbca98c1cf0612108bcf0f4c1302262f0" + "b8b523100ff2dfeb74939afdd5c45883734ed8a7582f821b1c57ae05ff69c4d6" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js index 157b23ee84..a8309ccf5c 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js @@ -255,13 +255,12 @@ function getSessionIdFromAuthorizationHeader(header) { //#endregion //#region .wasp/out/sdk/wasp/dist/client/operations/queryClient.js var defaultQueryClientConfig = {}; -var queryClientConfig; var resolveQueryClientInitialized; var queryClientInitialized = new Promise((resolve) => { resolveQueryClientInitialized = resolve; }); function initializeQueryClient() { - const queryClient = new QueryClient(queryClientConfig ?? defaultQueryClientConfig); + const queryClient = new QueryClient(defaultQueryClientConfig); resolveQueryClientInitialized(queryClient); } //#endregion diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums index 78287722af..435eade551 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "35480e2a814f4c24e9bb22f8e6c0a43fbca98c1cf0612108bcf0f4c1302262f0" + "b8b523100ff2dfeb74939afdd5c45883734ed8a7582f821b1c57ae05ff69c4d6" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums index abfd42b654..f2300488de 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "35480e2a814f4c24e9bb22f8e6c0a43fbca98c1cf0612108bcf0f4c1302262f0" + "b8b523100ff2dfeb74939afdd5c45883734ed8a7582f821b1c57ae05ff69c4d6" ], [ [ diff --git a/waspc/src/Wasp/Generator/SdkGenerator/Auth/AuthFormsG.hs b/waspc/src/Wasp/Generator/SdkGenerator/Auth/AuthFormsG.hs index 42bce85f0a..486e69c846 100644 --- a/waspc/src/Wasp/Generator/SdkGenerator/Auth/AuthFormsG.hs +++ b/waspc/src/Wasp/Generator/SdkGenerator/Auth/AuthFormsG.hs @@ -96,7 +96,8 @@ genInternalAuthComponents auth = genEmailComponents = genConditionally isEmailAuthEnabled $ sequence - [ genFileCopyInAuthFormsInternal [relfile|email/VerifyEmailForm.tsx|], + [ genFileCopyInAuthFormsInternal [relfile|emailField.ts|], + genFileCopyInAuthFormsInternal [relfile|email/VerifyEmailForm.tsx|], genFileCopyInAuthFormsInternal [relfile|email/useEmail.ts|], genFileCopyInAuthFormsInternal [relfile|email/ForgotPasswordForm.tsx|], genFileCopyInAuthFormsInternal [relfile|email/ResetPasswordForm.tsx|] diff --git a/waspc/starters-e2e-tests/tests/auth.ts b/waspc/starters-e2e-tests/tests/auth.ts index 5a8cd6e1cb..5d76c30524 100644 --- a/waspc/starters-e2e-tests/tests/auth.ts +++ b/waspc/starters-e2e-tests/tests/auth.ts @@ -93,7 +93,7 @@ export async function performLogin(page: Page, credentials: Credentials) { } async function submitLoginForm(page: Page, credentials: Credentials) { - await page.locator("input[type='email']").fill(credentials.email); + await page.locator("input[name='email']").fill(credentials.email); await page.locator("input[type='password']").fill(credentials.password); await page.getByRole("button", { name: "Log in" }).click(); } diff --git a/web/docs/auth/email/create-your-own-ui.md b/web/docs/auth/email/create-your-own-ui.md index 7e34ea8ec1..d92d2e3d59 100644 --- a/web/docs/auth/email/create-your-own-ui.md +++ b/web/docs/auth/email/create-your-own-ui.md @@ -12,6 +12,10 @@ Read more about the default email and password validation rules in the [auth ove Even though Wasp offers premade [Auth UI](../ui.md) for your authentication flows, there are times when you might want more customization, so we also give you the option to create your own UI and call Wasp's auth actions from your own code, similar to how Auth UI does it under the hood. +:::note Handling the internationalized email input +We use `type="text"` with `inputMode=email` because it allows unicode/international characters, while `type=email` only allows ASCII / english letters. +::: + ## Example code Below you can find a starting point for making your own UI in the client code. This example has all the necessary components to handle login, signup, email verification, and the password reset flow. You can customize any of its look and behaviour, just make sure to call the functions imported from `wasp/client/auth`. @@ -41,7 +45,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await login({ email, password }) + await login({ email: email.trim(), password }) navigate('/') } catch (error) { setError(error) @@ -53,7 +57,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -80,7 +85,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await signup({ email, password }) + await signup({ email: email.trim(), password }) setNeedsConfirmation(true) } catch (error) { console.error('Error during signup:', error) @@ -102,7 +107,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -157,7 +163,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await requestPasswordReset({ email }) + await requestPasswordReset({ email: email.trim() }) setNeedsConfirmation(true) } catch (error) { console.error('Error during requesting reset:', error) @@ -179,7 +185,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -255,7 +262,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await login({ email, password }) + await login({ email: email.trim(), password }) navigate('/') } catch (error: unknown) { setError(error as Error) @@ -267,7 +274,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -294,7 +302,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await signup({ email, password }) + await signup({ email: email.trim(), password }) setNeedsConfirmation(true) } catch (error: unknown) { console.error('Error during signup:', error) @@ -316,7 +324,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -371,7 +380,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await requestPasswordReset({ email }) + await requestPasswordReset({ email: email.trim() }) setNeedsConfirmation(true) } catch (error: unknown) { console.error('Error during requesting reset:', error) @@ -393,7 +402,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" diff --git a/web/docs/auth/overview.md b/web/docs/auth/overview.md index fdcf913a45..b230883ac6 100644 --- a/web/docs/auth/overview.md +++ b/web/docs/auth/overview.md @@ -347,11 +347,15 @@ Note that `username`s are stored in a **case-insensitive** manner. If you use [Email](./email.md) authentication, the default validations are: -- The `email` must not be empty and a valid email address +- The `email` must not be empty and a valid email address (HTML5 format widened to support Unicode) - The `password` must not be empty, have at least 8 characters, and contain a number Note that `email`s are stored in a **case-insensitive** manner. +Because Wasp supports Unicode email addresses, browsers would reject their syntax with `input[type=email]`. +If you are building your own form, please use `type="text"` with `inputMode="email"` instead. +Keep in mind that `type="text"` doesn't strip surrounding whitespace the way `type="email"` does, so trim the address before you send it. + ## Customizing the Signup Process Sometimes you want to include **extra fields** in your signup process, like first name and last name and save them in the `User` entity. diff --git a/web/markdown-snapshots/docs/auth/overview.md b/web/markdown-snapshots/docs/auth/overview.md index 82bb434d83..c23cf330df 100644 --- a/web/markdown-snapshots/docs/auth/overview.md +++ b/web/markdown-snapshots/docs/auth/overview.md @@ -355,11 +355,13 @@ Note that `username`s are stored in a **case-insensitive** manner. If you use [Email](https://wasp.sh/docs/auth/email) authentication, the default validations are: -- The `email` must not be empty and a valid email address +- The `email` must not be empty and a valid email address (HTML5 format widened to support Unicode) - The `password` must not be empty, have at least 8 characters, and contain a number Note that `email`s are stored in a **case-insensitive** manner. +Because Wasp supports Unicode email addresses, browsers would reject their syntax with `input[type=email]`. If you are building your own form, please use `type="text"` with `inputMode="email"` instead. Keep in mind that `type="text"` doesn't strip surrounding whitespace the way `type="email"` does, so trim the address before you send it. + ## Customizing the Signup Process Sometimes you want to include **extra fields** in your signup process, like first name and last name and save them in the `User` entity. diff --git a/web/markdown-snapshots/llms-full.txt b/web/markdown-snapshots/llms-full.txt index 451a628c89..abbe5b98fc 100644 --- a/web/markdown-snapshots/llms-full.txt +++ b/web/markdown-snapshots/llms-full.txt @@ -3918,11 +3918,13 @@ Note that `username`s are stored in a **case-insensitive** manner. If you use [Email](https://wasp.sh/docs/auth/email) authentication, the default validations are: -- The `email` must not be empty and a valid email address +- The `email` must not be empty and a valid email address (HTML5 format widened to support Unicode) - The `password` must not be empty, have at least 8 characters, and contain a number Note that `email`s are stored in a **case-insensitive** manner. +Because Wasp supports Unicode email addresses, browsers would reject their syntax with `input[type=email]`. If you are building your own form, please use `type="text"` with `inputMode="email"` instead. Keep in mind that `type="text"` doesn't strip surrounding whitespace the way `type="email"` does, so trim the address before you send it. + ### Customizing the Signup Process Sometimes you want to include **extra fields** in your signup process, like first name and last name and save them in the `User` entity. @@ -5406,6 +5408,10 @@ Read more about the default email and password validation rules in the [auth ove Even though Wasp offers premade [Auth UI](https://wasp.sh/docs/auth/ui) for your authentication flows, there are times when you might want more customization, so we also give you the option to create your own UI and call Wasp's auth actions from your own code, similar to how Auth UI does it under the hood. +:::note[Handling the internationalized email input] +We use `type="text"` with `inputMode=email` because it allows unicode/international characters, while `type=email` only allows ASCII / english letters. +::: + ### Example code Below you can find a starting point for making your own UI in the client code. This example has all the necessary components to handle login, signup, email verification, and the password reset flow. You can customize any of its look and behaviour, just make sure to call the functions imported from `wasp/client/auth`. @@ -5433,7 +5439,7 @@ export function LoginPage() { event.preventDefault() setError(null) try { - await login({ email, password }) + await login({ email: email.trim(), password }) navigate('/') } catch (error: unknown) { setError(error as Error) @@ -5445,7 +5451,8 @@ export function LoginPage() { {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -5472,7 +5479,7 @@ export function SignupPage() { event.preventDefault() setError(null) try { - await signup({ email, password }) + await signup({ email: email.trim(), password }) setNeedsConfirmation(true) } catch (error: unknown) { console.error('Error during signup:', error) @@ -5494,7 +5501,8 @@ export function SignupPage() { {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -5549,7 +5557,7 @@ export function RequestPasswordResetPage() { event.preventDefault() setError(null) try { - await requestPasswordReset({ email }) + await requestPasswordReset({ email: email.trim() }) setNeedsConfirmation(true) } catch (error: unknown) { console.error('Error during requesting reset:', error) @@ -5571,7 +5579,8 @@ export function RequestPasswordResetPage() { {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email"