QA pass: layout, create-flow, and About books #68
@@ -9,6 +9,7 @@ import TextInput from "../../controls/TextInput";
|
|||||||
import ContentLockup from "../../type/ContentLockup";
|
import ContentLockup from "../../type/ContentLockup";
|
||||||
import Alert from "../Alert";
|
import Alert from "../Alert";
|
||||||
import { requestMagicLink } from "../../../../lib/create/api";
|
import { requestMagicLink } from "../../../../lib/create/api";
|
||||||
|
import { EMAIL_MAX_LEN } from "../../../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload";
|
import { buildCreateFlowDraftPayload } from "../../../../lib/create/buildCreateFlowDraftPayload";
|
||||||
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
import { safeInternalPath } from "../../../../lib/safeInternalPath";
|
||||||
import {
|
import {
|
||||||
@@ -232,6 +233,7 @@ export default function LoginForm({
|
|||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
error={Boolean(emailError)}
|
error={Boolean(emailError)}
|
||||||
showHelpIcon
|
showHelpIcon
|
||||||
|
maxLength={EMAIL_MAX_LEN}
|
||||||
/>
|
/>
|
||||||
{emailError ? (
|
{emailError ? (
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import Login from "../components/modals/Login";
|
import Login from "../components/modals/Login";
|
||||||
import LoginForm from "../components/modals/Login/LoginForm";
|
import LoginForm from "../components/modals/Login/LoginForm";
|
||||||
import { useTranslation } from "./MessagesContext";
|
|
||||||
|
|
||||||
export type AuthModalLoginVariant = "default" | "saveProgress";
|
export type AuthModalLoginVariant = "default" | "saveProgress";
|
||||||
|
|
||||||
@@ -34,7 +32,6 @@ const AuthModalContext = createContext<AuthModalContextValue | null>(null);
|
|||||||
export function AuthModalProvider({ children }: { children: ReactNode }) {
|
export function AuthModalProvider({ children }: { children: ReactNode }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [opts, setOpts] = useState<OpenLoginOptions>({});
|
const [opts, setOpts] = useState<OpenLoginOptions>({});
|
||||||
const t = useTranslation("pages.login");
|
|
||||||
|
|
||||||
const openLogin = useCallback((o?: OpenLoginOptions) => {
|
const openLogin = useCallback((o?: OpenLoginOptions) => {
|
||||||
setOpts(o ?? {});
|
setOpts(o ?? {});
|
||||||
@@ -62,15 +59,6 @@ export function AuthModalProvider({ children }: { children: ReactNode }) {
|
|||||||
backdropVariant={backdropVariant}
|
backdropVariant={backdropVariant}
|
||||||
usePortal
|
usePortal
|
||||||
ariaLabelledBy="login-modal-heading"
|
ariaLabelledBy="login-modal-heading"
|
||||||
belowCard={
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)] text-center hover:opacity-90"
|
|
||||||
onClick={() => closeLogin()}
|
|
||||||
>
|
|
||||||
{t("backToHome")}
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<LoginForm
|
<LoginForm
|
||||||
variant={opts.variant ?? "default"}
|
variant={opts.variant ?? "default"}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48;
|
export const CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS = 48;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Max length for the policy description. Wider than the title so Customize can
|
* Max length for persisted method-card `supportText`. Wider than the title so
|
||||||
* seed existing card support text without blocking Next.
|
* Customize can seed existing card copy and catalog blurbs without blocking Next.
|
||||||
*/
|
*/
|
||||||
export const CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS = 200;
|
export const CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS = 200;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const EMAIL_MAX_LEN = 254;
|
/** RFC 5321 max mailbox length (no angle brackets). */
|
||||||
|
export const EMAIL_MAX_LEN = 254;
|
||||||
|
|
||||||
/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */
|
/** Pragmatic check for the create-flow “save progress” email field (draft + footer enablement). */
|
||||||
export function isValidCreateFlowSaveEmail(value: unknown): boolean {
|
export function isValidCreateFlowSaveEmail(value: unknown): boolean {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
|
import { FLOW_STEP_ORDER } from "../../../app/(app)/create/utils/flowSteps";
|
||||||
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
|
import { customMethodCardFieldBlocksByIdSchema } from "../../../lib/create/customMethodCardFieldBlocks";
|
||||||
|
import {
|
||||||
|
CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS,
|
||||||
|
CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS,
|
||||||
|
} from "../../../lib/create/customMethodCardWizardConstants";
|
||||||
|
import { EMAIL_MAX_LEN } from "../../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
|
import { MAX_STAKEHOLDER_EMAILS } from "../../../lib/create/stakeholderLimits";
|
||||||
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
|
import { assertPlainJsonValue, DEFAULT_PLAIN_JSON_LIMITS } from "./plainJson";
|
||||||
|
|
||||||
@@ -62,8 +67,8 @@ const conflictManagementDetailEntrySchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const customMethodCardMetaEntrySchema = z.object({
|
const customMethodCardMetaEntrySchema = z.object({
|
||||||
label: z.string().max(48),
|
label: z.string().max(CUSTOM_METHOD_CARD_WIZARD_MAX_FIELD_CHARS),
|
||||||
supportText: z.string().max(48),
|
supportText: z.string().max(CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Normalized (trim + lowercase) stakeholder email for drafts + publish. */
|
/** Normalized (trim + lowercase) stakeholder email for drafts + publish. */
|
||||||
@@ -226,7 +231,7 @@ export const putDraftBodySchema = z.object({
|
|||||||
export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>;
|
export type CreateFlowStateValidated = z.infer<typeof createFlowStateSchema>;
|
||||||
|
|
||||||
export const magicLinkRequestBodySchema = z.object({
|
export const magicLinkRequestBodySchema = z.object({
|
||||||
email: z.string(),
|
email: z.string().max(EMAIL_MAX_LEN),
|
||||||
next: z.string().optional(),
|
next: z.string().optional(),
|
||||||
draft: createFlowStateSchema.optional(),
|
draft: createFlowStateSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const HeaderOverlayBlurred = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
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"
|
backdropVariant="blurredYellow"
|
||||||
usePortal
|
usePortal
|
||||||
ariaLabelledBy="login-modal-heading"
|
ariaLabelledBy="login-modal-heading"
|
||||||
belowCard={
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="text-center text-small-paragraph text-[var(--color-content-invert-tertiary)] hover:opacity-90"
|
|
||||||
>
|
|
||||||
{backToHome}
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Suspense fallback={<p className="p-6 text-small-paragraph">Loading…</p>}>
|
<Suspense fallback={<p className="p-6 text-small-paragraph">Loading…</p>}>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
|
|||||||
@@ -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 { requestMagicLink } from "../../lib/create/api";
|
||||||
import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage";
|
import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage";
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ describe("LoginForm", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("textbox", { name: /email address/i }),
|
screen.getByRole("textbox", { name: /email address/i }),
|
||||||
).toBeInTheDocument();
|
).toHaveAttribute("maxLength", String(EMAIL_MAX_LEN));
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -126,6 +127,24 @@ describe("LoginForm", () => {
|
|||||||
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
|
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 () => {
|
it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ describe("AuthModalProvider (header overlay)", () => {
|
|||||||
screen.getByRole("heading", { name: /log in to communityrule/i }),
|
screen.getByRole("heading", { name: /log in to communityrule/i }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /back to home/i }),
|
screen.queryByRole("link", { name: /back to home/i }),
|
||||||
).toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closes overlay when closeLogin is called", async () => {
|
it("closes overlay when closeLogin is called", async () => {
|
||||||
|
|||||||
@@ -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 () => {
|
it("returns 502 and rolls back the token when mail fails", async () => {
|
||||||
sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down"));
|
sendMagicLinkEmailMock.mockRejectedValueOnce(new Error("smtp down"));
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS } from "../../lib/create/customMethodCardWizardConstants";
|
||||||
import {
|
import {
|
||||||
assertPlainJsonValue,
|
assertPlainJsonValue,
|
||||||
DEFAULT_PLAIN_JSON_LIMITS,
|
DEFAULT_PLAIN_JSON_LIMITS,
|
||||||
@@ -87,6 +88,33 @@ describe("createFlowStateSchema", () => {
|
|||||||
expect(r.success).toBe(false);
|
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", () => {
|
it("accepts communityStructureChipSnapshots with custom chip rows", () => {
|
||||||
const r = createFlowStateSchema.safeParse({
|
const r = createFlowStateSchema.safeParse({
|
||||||
communityStructureChipSnapshots: {
|
communityStructureChipSnapshots: {
|
||||||
|
|||||||
Reference in New Issue
Block a user