diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx index f354b08..3435ef8 100644 --- a/app/components/modals/Login/LoginForm.tsx +++ b/app/components/modals/Login/LoginForm.tsx @@ -9,6 +9,7 @@ import TextInput from "../../controls/TextInput"; import ContentLockup from "../../type/ContentLockup"; import Alert from "../Alert"; import { requestMagicLink } from "../../../../lib/create/api"; +import { EMAIL_MAX_LEN } from "../../../../lib/create/isValidCreateFlowSaveEmail"; import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload"; import { safeInternalPath } from "../../../../lib/safeInternalPath"; import { @@ -232,6 +233,7 @@ export default function LoginForm({ disabled={submitting} error={Boolean(emailError)} showHelpIcon + maxLength={EMAIL_MAX_LEN} /> {emailError ? (

(null); export function AuthModalProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); const [opts, setOpts] = useState({}); - const t = useTranslation("pages.login"); const openLogin = useCallback((o?: OpenLoginOptions) => { setOpts(o ?? {}); @@ -62,15 +59,6 @@ export function AuthModalProvider({ children }: { children: ReactNode }) { backdropVariant={backdropVariant} usePortal ariaLabelledBy="login-modal-heading" - belowCard={ - closeLogin()} - > - {t("backToHome")} - - } > ; export const magicLinkRequestBodySchema = z.object({ - email: z.string(), + email: z.string().max(EMAIL_MAX_LEN), next: z.string().optional(), draft: createFlowStateSchema.optional(), }); diff --git a/stories/modals/Login.stories.tsx b/stories/modals/Login.stories.tsx index c99ad88..0baade4 100644 --- a/stories/modals/Login.stories.tsx +++ b/stories/modals/Login.stories.tsx @@ -95,7 +95,7 @@ export const HeaderOverlayBlurred = { docs: { description: { story: - 'Same as **Log in** from the site header: `backdropVariant="blurredYellow"`, `usePortal`, card + “Back to home” below.', + 'Same as **Log in** from the site header: `backdropVariant="blurredYellow"`, `usePortal`, no “Back to home” under the card.', }, }, }, @@ -107,14 +107,6 @@ export const HeaderOverlayBlurred = { backdropVariant="blurredYellow" usePortal ariaLabelledBy="login-modal-heading" - belowCard={ - - {backToHome} - - } > Loading…

}> diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index aa8ccea..58e113e 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -43,6 +43,7 @@ vi.mock("../../app/(app)/create/utils/anonymousDraftStorage", async (importOrigi }; }); +import { EMAIL_MAX_LEN } from "../../lib/create/isValidCreateFlowSaveEmail"; import { requestMagicLink } from "../../lib/create/api"; import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage"; @@ -70,7 +71,7 @@ describe("LoginForm", () => { ).toBeInTheDocument(); expect( screen.getByRole("textbox", { name: /email address/i }), - ).toBeInTheDocument(); + ).toHaveAttribute("maxLength", String(EMAIL_MAX_LEN)); expect( screen.getByRole("button", { name: /send me a magic link/i }), ).toBeInTheDocument(); @@ -126,6 +127,24 @@ describe("LoginForm", () => { expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument(); }); + it("submits a long email without treating length as invalid", async () => { + const user = userEvent.setup(); + const email = + "very.long.local-part.for.testing@subdomain.example.communityrule.org"; + vi.mocked(requestMagicLink).mockResolvedValue({ ok: true }); + renderLoginForm(); + await user.type( + screen.getByRole("textbox", { name: /email address/i }), + email, + ); + await user.click( + screen.getByRole("button", { name: /send me a magic link/i }), + ); + await waitFor(() => { + expect(requestMagicLink).toHaveBeenCalledWith(email, "/", undefined); + }); + }); + it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => { const user = userEvent.setup(); vi.mocked(requestMagicLink).mockResolvedValue({ ok: true }); diff --git a/tests/contexts/AuthModalContext.test.tsx b/tests/contexts/AuthModalContext.test.tsx index 86e432d..c17bb78 100644 --- a/tests/contexts/AuthModalContext.test.tsx +++ b/tests/contexts/AuthModalContext.test.tsx @@ -96,8 +96,8 @@ describe("AuthModalProvider (header overlay)", () => { screen.getByRole("heading", { name: /log in to communityrule/i }), ).toBeInTheDocument(); expect( - screen.getByRole("link", { name: /back to home/i }), - ).toBeInTheDocument(); + screen.queryByRole("link", { name: /back to home/i }), + ).not.toBeInTheDocument(); }); it("closes overlay when closeLogin is called", async () => { diff --git a/tests/unit/authMagicLinkRequestRoute.test.ts b/tests/unit/authMagicLinkRequestRoute.test.ts index 0b13044..897e9a1 100644 --- a/tests/unit/authMagicLinkRequestRoute.test.ts +++ b/tests/unit/authMagicLinkRequestRoute.test.ts @@ -113,6 +113,63 @@ describe("POST /api/auth/magic-link/request", () => { ); }); + it("accepts an email longer than 48 characters", async () => { + const email = + "very.long.local-part.for.testing@subdomain.example.communityrule.org"; + expect(email.length).toBeGreaterThan(48); + const res = await POST( + new NextRequest("https://x.test/api/auth/magic-link/request", { + method: "POST", + body: JSON.stringify({ email }), + headers: { "content-type": "application/json" }, + }), + undefined, + ); + expect(res.status).toBe(200); + expect(sendMagicLinkEmailMock).toHaveBeenCalledWith( + email, + expect.stringContaining("/api/auth/magic-link/verify?token="), + ); + }); + + it("rejects an email longer than 254 characters", async () => { + const email = `${"a".repeat(243)}@example.com`; + expect(email.length).toBeGreaterThan(254); + const res = await POST( + new NextRequest("https://x.test/api/auth/magic-link/request", { + method: "POST", + body: JSON.stringify({ email }), + headers: { "content-type": "application/json" }, + }), + undefined, + ); + expect(res.status).toBe(400); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("accepts a draft whose method-card support text is longer than 48 characters", async () => { + const res = await POST( + new NextRequest("https://x.test/api/auth/magic-link/request", { + method: "POST", + body: JSON.stringify({ + email: "a@b.c", + draft: { + customMethodCardMetaById: { + "00000000-0000-4000-8000-000000000001": { + label: "Signal", + supportText: + "A decision is assumed approved unless objections are raised within a specified timeframe.", + }, + }, + }, + }), + headers: { "content-type": "application/json" }, + }), + undefined, + ); + expect(res.status).toBe(200); + }); + it("returns 502 and rolls back the token when mail fails", async () => { sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down")); const res = await POST( diff --git a/tests/unit/createFlowValidation.test.ts b/tests/unit/createFlowValidation.test.ts index 31fbe22..c3af55e 100644 --- a/tests/unit/createFlowValidation.test.ts +++ b/tests/unit/createFlowValidation.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS } from "../../lib/create/customMethodCardWizardConstants"; import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS, @@ -87,6 +88,33 @@ describe("createFlowStateSchema", () => { expect(r.success).toBe(false); }); + it("accepts custom method card support text longer than 48 characters", () => { + const r = createFlowStateSchema.safeParse({ + customMethodCardMetaById: { + "00000000-0000-4000-8000-000000000001": { + label: "Signal", + supportText: + "A decision is assumed approved unless objections are raised within a specified timeframe.", + }, + }, + }); + expect(r.success).toBe(true); + }); + + it("rejects custom method card support text over the description max", () => { + const r = createFlowStateSchema.safeParse({ + customMethodCardMetaById: { + "00000000-0000-4000-8000-000000000001": { + label: "Signal", + supportText: "x".repeat( + CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS + 1, + ), + }, + }, + }); + expect(r.success).toBe(false); + }); + it("accepts communityStructureChipSnapshots with custom chip rows", () => { const r = createFlowStateSchema.safeParse({ communityStructureChipSnapshots: {