diff --git a/CloudronManifest.json b/CloudronManifest.json index 61f8c86..ec2c29b 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,7 +4,7 @@ "title": "Community Rule", "author": "MEDLab", "description": "Community governance and rule-building app", - "version": "0.1.11", + "version": "0.1.12", "httpPort": 3000, "healthCheckPath": "/api/health", "memoryLimit": 805306368, diff --git a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx index 4c5f8bd..f412c9d 100644 --- a/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx +++ b/app/(app)/create/components/CustomMethodCardWizard/CustomMethodCardWizard.container.tsx @@ -6,6 +6,7 @@ import { useTranslation, } from "../../../../contexts/MessagesContext"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import type { CustomMethodCardFieldBlock } from "../../../../../lib/create/customMethodCardFieldBlocks"; import { CUSTOM_METHOD_CARD_WIZARD_MAX_DESCRIPTION_CHARS, @@ -213,6 +214,8 @@ const CustomMethodCardWizardContainer = memo( policyTitle, ]); + useBeforeUnloadGuard(isOpen && isWizardSessionDirty()); + const confirmAbandonWizardEdits = useCallback(async () => { if (!isWizardSessionDirty()) { return true; diff --git a/app/(app)/create/components/FinalReviewChipEditModal.tsx b/app/(app)/create/components/FinalReviewChipEditModal.tsx index 266f739..99d3d90 100644 --- a/app/(app)/create/components/FinalReviewChipEditModal.tsx +++ b/app/(app)/create/components/FinalReviewChipEditModal.tsx @@ -28,6 +28,7 @@ import { import CustomMethodCardModalBody from "./CustomMethodCardModalBody"; import { buildCustomRuleModalKebabMenu } from "./customRuleModalKebabMenu"; import { useDiscardCustomizeConfirm } from "../hooks/useDiscardCustomizeConfirm"; +import { useBeforeUnloadGuard } from "../../../hooks/useBeforeUnloadGuard"; import { communicationPresetFor, conflictManagementPresetFor, @@ -370,6 +371,12 @@ export function FinalReviewChipEditModal({ draftFieldBlocks, ]); + useBeforeUnloadGuard( + isOpen && + !addCustomWizardOpen && + (!coreCustomizeSaveDisabled || !methodCustomizeSaveDisabled), + ); + const modalUsesWizardFieldBlocksBody = Boolean( target && (usesWizardFieldBlocksModalBody({ @@ -1071,6 +1078,7 @@ export function FinalReviewChipEditModal({ void; - /** Disable meaning/signals. Create-flow core-values omits this; final-review locks until Customize. */ + /** Disable meaning/signals. Facet and final-review callers omit this so fields stay editable on open. */ readOnly?: boolean; } diff --git a/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx b/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx index 9c1de98..326aa64 100644 --- a/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx +++ b/app/(app)/create/components/methodEditFields/DecisionApproachEditFields.tsx @@ -80,19 +80,21 @@ function DecisionApproachEditFieldsComponent({ onChange={(v) => patch("stepByStepInstructions", v)} disabled={readOnly} /> - patch("consensusLevel", next)} - formatValue={(v) => `${v}%`} - decrementAriaLabel="Decrease consensus level" - incrementAriaLabel="Increase consensus level" - disabled={readOnly} - /> + {value.consensusLevel !== undefined ? ( + patch("consensusLevel", next)} + formatValue={(v) => `${v}%`} + decrementAriaLabel="Decrease consensus level" + incrementAriaLabel="Increase consensus level" + disabled={readOnly} + /> + ) : null} (null); + useBeforeUnloadGuard( + isMethodCardCustomizeUnloadBlocked( + createModalOpen, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeSnapshotRef.current?.headerDraft ?? null, + ), + ); + const selectedIds = state.selectedCommunicationMethodIds ?? []; const mergedMethods = useMemo( @@ -216,7 +228,7 @@ export function CommunicationMethodsScreen() { methodId: pendingCardId, meta: state.customMethodCardMetaById, fieldBlocksById: state.customMethodCardFieldBlocksById, - modalEditUnlocked: false, + modalEditUnlocked: true, draftFieldBlocks, customFacetDetailsMatchPreset, }), diff --git a/app/(app)/create/screens/card/ConflictManagementScreen.tsx b/app/(app)/create/screens/card/ConflictManagementScreen.tsx index 0aa9337..7917e3b 100644 --- a/app/(app)/create/screens/card/ConflictManagementScreen.tsx +++ b/app/(app)/create/screens/card/ConflictManagementScreen.tsx @@ -16,6 +16,7 @@ import { useState, useCallback, useMemo, useRef } from "react"; import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; @@ -54,6 +55,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, + isMethodCardCustomizeUnloadBlocked, type MethodCardCustomizeSnapshot, type MethodCardHeaderDraft, } from "../../../../../lib/create/methodCardCustomizeSession"; @@ -85,6 +87,16 @@ export function ConflictManagementScreen() { CustomMethodCardFieldBlock[] | null >(null); + useBeforeUnloadGuard( + isMethodCardCustomizeUnloadBlocked( + createModalOpen, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeSnapshotRef.current?.headerDraft ?? null, + ), + ); + const selectedIds = state.selectedConflictManagementIds ?? []; const mergedMethods = useMemo( @@ -217,7 +229,7 @@ export function ConflictManagementScreen() { methodId: pendingCardId, meta: state.customMethodCardMetaById, fieldBlocksById: state.customMethodCardFieldBlocksById, - modalEditUnlocked: false, + modalEditUnlocked: true, draftFieldBlocks, customFacetDetailsMatchPreset, }), diff --git a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx index 78532c8..55a3cc1 100644 --- a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx +++ b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx @@ -17,6 +17,7 @@ import { useState, useCallback, useMemo, useRef } from "react"; import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup"; @@ -55,6 +56,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, + isMethodCardCustomizeUnloadBlocked, type MethodCardCustomizeSnapshot, type MethodCardHeaderDraft, } from "../../../../../lib/create/methodCardCustomizeSession"; @@ -86,6 +88,16 @@ export function MembershipMethodsScreen() { CustomMethodCardFieldBlock[] | null >(null); + useBeforeUnloadGuard( + isMethodCardCustomizeUnloadBlocked( + createModalOpen, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeSnapshotRef.current?.headerDraft ?? null, + ), + ); + const selectedIds = state.selectedMembershipMethodIds ?? []; const mergedMethods = useMemo( @@ -214,7 +226,7 @@ export function MembershipMethodsScreen() { methodId: pendingCardId, meta: state.customMethodCardMetaById, fieldBlocksById: state.customMethodCardFieldBlocksById, - modalEditUnlocked: false, + modalEditUnlocked: true, draftFieldBlocks, customFacetDetailsMatchPreset, }), diff --git a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx index adb23da..3dbbd8c 100644 --- a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx +++ b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx @@ -26,6 +26,7 @@ import type { InfoMessageBoxItem } from "../../../../components/controls/InfoMes import { useMessages } from "../../../../contexts/MessagesContext"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import { useDiscardCustomizeConfirm } from "../../hooks/useDiscardCustomizeConfirm"; import { useMethodCardDeckOrdering } from "../../hooks/useMethodCardDeckOrdering"; import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell"; @@ -60,6 +61,7 @@ import { buildMethodCardWizardInitialValues } from "../../../../../lib/create/me import type { MethodCardWizardInitialValues } from "../../../../../lib/create/methodCardWizardPrefill"; import { captureMethodCardCustomizeSnapshot, + isMethodCardCustomizeUnloadBlocked, type MethodCardCustomizeSnapshot, type MethodCardHeaderDraft, } from "../../../../../lib/create/methodCardCustomizeSession"; @@ -91,6 +93,16 @@ export function DecisionApproachesScreen() { CustomMethodCardFieldBlock[] | null >(null); + useBeforeUnloadGuard( + isMethodCardCustomizeUnloadBlocked( + createModalOpen, + customizeSnapshotRef.current, + pendingDraft, + draftFieldBlocks, + customizeSnapshotRef.current?.headerDraft ?? null, + ), + ); + const selectedIds = state.selectedDecisionApproachIds ?? []; const messageBoxCheckedIds = decisionApproachKeyResourceCheckboxIds({ detailsById: state.decisionApproachDetailsById, @@ -250,7 +262,7 @@ export function DecisionApproachesScreen() { methodId: pendingCardId, meta: state.customMethodCardMetaById, fieldBlocksById: state.customMethodCardFieldBlocksById, - modalEditUnlocked: false, + modalEditUnlocked: true, draftFieldBlocks, customFacetDetailsMatchPreset, }), diff --git a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx index 743c044..4ca6a8a 100644 --- a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx +++ b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx @@ -9,6 +9,7 @@ import { useMessages } from "../../../../contexts/MessagesContext"; import { buildCoreValueChipOptionsFromDraft } from "../../../../../lib/create/coreValueChipOptionsFromDraft"; import { useCreateFlow } from "../../context/CreateFlowContext"; import { useAsyncConfirm } from "../../../../hooks/useAsyncConfirm"; +import { useBeforeUnloadGuard } from "../../../../hooks/useBeforeUnloadGuard"; import type { CommunityStructureChipSnapshotRow, CoreValueDetailEntry, @@ -257,6 +258,14 @@ export function CoreValuesSelectScreen() { }); }, [cv.detailModal, draft, modalSession, requestConfirm]); + useBeforeUnloadGuard( + activeModalChipId != null && + !addCustomWizardOpen && + initialDraftRef.current != null && + (draft.meaning !== initialDraftRef.current.meaning || + draft.signals !== initialDraftRef.current.signals), + ); + const handleDuplicateCoreChip = useCallback(() => { if (!activeModalChipId || !modalSession) return; markCreateFlowInteraction(); diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index 630ece7..69a9fbe 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -85,7 +85,11 @@ export type DecisionApproachDetailEntry = { applicableScope: string[]; selectedApplicableScope: string[]; stepByStepInstructions: string; - consensusLevel: number; + /** + * Catalog presets always set this. User-authored custom cards omit it until + * the author adds a proportion field or edits consensus in the facet form. + */ + consensusLevel?: number; objectionsDeadlocks: string; }; diff --git a/app/components/controls/TextArea/TextArea.container.tsx b/app/components/controls/TextArea/TextArea.container.tsx index 3c6f59f..044ee1a 100644 --- a/app/components/controls/TextArea/TextArea.container.tsx +++ b/app/components/controls/TextArea/TextArea.container.tsx @@ -80,7 +80,8 @@ const TextAreaContainer = forwardRef( }, }; - // State styles (embedded: Figma 20736-12668 – borderless, darker grey block, white text) + // Embedded (Figma 20736-12668): borderless grey block; default copy is + // tertiary, primary on focus so seeded modal fields stay muted until edit. const getStateStyles = (): { textarea: string; label: string; @@ -89,13 +90,13 @@ const TextAreaContainer = forwardRef( if (disabled) { return { textarea: - "border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] cursor-not-allowed opacity-60", + "border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-tertiary,#b4b4b4)] cursor-not-allowed opacity-60", label: "text-[var(--color-content-default-secondary)]", }; } return { textarea: - "border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-border-default-tertiary)] focus:ring-inset", + "border-0 bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-tertiary,#b4b4b4)] placeholder:text-[var(--color-content-default-tertiary,#b4b4b4)] focus:text-[var(--color-content-default-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-border-default-tertiary)] focus:ring-inset", label: "text-[var(--color-content-default-secondary)]", }; } diff --git a/app/components/controls/TextArea/TextArea.types.ts b/app/components/controls/TextArea/TextArea.types.ts index 5b8d98a..812a35a 100644 --- a/app/components/controls/TextArea/TextArea.types.ts +++ b/app/components/controls/TextArea/TextArea.types.ts @@ -48,7 +48,8 @@ export interface TextAreaProps extends Omit< showHelpIcon?: boolean; /** * Visual appearance. "embedded" matches Create modal sections (Figma 20736-12668): - * borderless, darker grey background, white text. "default" is standard bordered input. + * borderless, darker grey background, tertiary text in default (primary on + * focus). "default" is the standard bordered input. * @default "default" */ appearance?: TextAreaAppearanceValue; diff --git a/app/components/type/CommunityRule/CommunityRule.types.ts b/app/components/type/CommunityRule/CommunityRule.types.ts index ba08c11..2c44bdb 100644 --- a/app/components/type/CommunityRule/CommunityRule.types.ts +++ b/app/components/type/CommunityRule/CommunityRule.types.ts @@ -14,8 +14,8 @@ export interface CommunityRuleEntry { /** Plain text; split on blank lines into paragraphs when rendering. */ body: string; /** - * When set, rendered as Figma-style label + body stacks. If non-empty, takes - * precedence over {@link body} for main content (body may be empty). + * Figma-style label + body stacks (facet sections, wizard fields). Shown + * after {@link body} when both are present. */ blocks?: CommunityRuleLabeledBlock[]; } diff --git a/app/components/type/CommunityRule/CommunityRule.view.tsx b/app/components/type/CommunityRule/CommunityRule.view.tsx index 74d75fd..7e253a1 100644 --- a/app/components/type/CommunityRule/CommunityRule.view.tsx +++ b/app/components/type/CommunityRule/CommunityRule.view.tsx @@ -45,7 +45,7 @@ function CommunityRuleView({ ); diff --git a/app/components/type/TextBlock/TextBlock.view.tsx b/app/components/type/TextBlock/TextBlock.view.tsx index dbbe4a3..f213787 100644 --- a/app/components/type/TextBlock/TextBlock.view.tsx +++ b/app/components/type/TextBlock/TextBlock.view.tsx @@ -94,9 +94,10 @@ function TextBlockView({ >

{title}

+ {body.trim().length > 0 ? : null} {hasRows ? rows!.map((row, i) => ) - : body.trim().length > 0 && } + : null}
); diff --git a/app/hooks/index.ts b/app/hooks/index.ts index 690f43e..256749d 100644 --- a/app/hooks/index.ts +++ b/app/hooks/index.ts @@ -18,6 +18,7 @@ export { useMediaQuery, } from "./useMediaQuery"; export { useAsyncConfirm } from "./useAsyncConfirm"; export type { AsyncConfirmOptions } from "./useAsyncConfirm"; +export { useBeforeUnloadGuard } from "./useBeforeUnloadGuard"; export type { SchemaOrganization, SchemaWebSite, diff --git a/app/hooks/useBeforeUnloadGuard.ts b/app/hooks/useBeforeUnloadGuard.ts new file mode 100644 index 0000000..27215b0 --- /dev/null +++ b/app/hooks/useBeforeUnloadGuard.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Attach the browser’s native leave-site prompt while `enabled` is true. + * + * Modern browsers ignore custom copy; this only blocks accidental tab or + * window close. No-ops during SSR and when `enabled` is false so clean + * sessions never register a listener. + * + * @param enabled Whether unsaved work would be lost on unload. + * + * @example + * useBeforeUnloadGuard(isWizardSessionDirty()); + */ +export function useBeforeUnloadGuard(enabled: boolean): void { + useEffect(() => { + if (!enabled || typeof window === "undefined") { + return; + } + + const onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }; + + window.addEventListener("beforeunload", onBeforeUnload); + return () => { + window.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [enabled]); +} diff --git a/docs/guides/backend-roadmap.md b/docs/guides/backend-roadmap.md index dfc5004..0b8e528 100644 --- a/docs/guides/backend-roadmap.md +++ b/docs/guides/backend-roadmap.md @@ -227,7 +227,7 @@ npm run dev 1. TLS certificates and hostnames. _On Cloudron: handled by the platform per chosen subdomain._ 2. PostgreSQL backups and restore drill. _On Cloudron: daily snapshots; configure retention in admin UI._ -3. SMTP DNS (SPF, DKIM). _On Cloudron: handled for the platform-managed domain._ +3. SMTP DNS (SPF, DKIM). _TLS for the app hostname is Cloudron/Let's Encrypt. Mail is SES-relayed: publish SES DKIM (and SPF `include:amazonses.com`) via Cloudron Domains → Namecheap. Cloudron skips SPF/DKIM checks when a relay is configured. See [`ops-runbook.md`](ops-runbook.md) §8.1._ 4. Health check URL for reverse proxy (`/api/health`). _On Cloudron: set `healthCheckPath` in `CloudronManifest.json`._ 5. Log retention and alerts for 5xx errors. _On Cloudron: app log viewer; export off-platform if longer retention is needed._ diff --git a/docs/guides/ops-backend-deploy.md b/docs/guides/ops-backend-deploy.md index 5d01268..6074ed9 100644 --- a/docs/guides/ops-backend-deploy.md +++ b/docs/guides/ops-backend-deploy.md @@ -101,8 +101,16 @@ per-app in the manifest and provisioned at install time. - Backups: Cloudron's automatic backups are already on for the host (legacy app shows weekly snapshots ~451 MB each). Same default applies to new apps. -- TLS / DNS / SPF / DKIM: handled by Cloudron for any subdomain of - `communityrule.info`. +- TLS for Cloudron app hostnames: handled by Cloudron (Let's Encrypt). +- **Mail DNS (SPF/DKIM):** *not* automatic for this domain. Cloudron's + DNS provider for `communityrule.info` is Namecheap, but outbound mail + is **Amazon SES relay**. Cloudron's own mail-status check **skips** + SPF and DKIM and says to configure them on the relay. Add the SES + identity's **DKIM CNAME** records (and optionally + `include:amazonses.com` on SPF) in Cloudron → *Domains* → + `communityrule.info` → DNS so they publish to Namecheap. See + [`ops-runbook.md`](ops-runbook.md) §8.1. DMARC on the domain is + currently `p=reject`. ## 5. Cutover plan (side-by-side, never in-place) @@ -476,7 +484,7 @@ steps below are still required. | ------- | ------------ | ----- | | Image pull error on install | Repo still private, or wrong tag in manifest | §6.3; `docker pull --platform linux/amd64 …` from laptop | | Health `503` / `database: disconnected` | Postgres addon not provisioned or URL missing | Cloudron app → Environment; expect `CLOUDRON_POSTGRESQL_URL` | -| Magic link not sent | Mail addon or `SMTP_FROM` | Cloudron mail logs; `CLOUDRON_MAIL_SMTP_*` vars | +| Magic link not sent | Mail addon, `SMTP_FROM`, or SES DNS | Cloudron mail logs; `CLOUDRON_MAIL_SMTP_*`; [ops-runbook §8.1](ops-runbook.md#81-mail-dns-when-ses-is-the-relay) | | Upload `server_misconfigured` | `UPLOAD_ROOT` unset | Set to `/app/data/uploads` (§3) | | Container crash on start | Migration failure | App logs around `prisma migrate deploy` | | No "Recommended" on method cards | `MethodFacet` not seeded | §10 step 6; API should return `matches.score > 0` for some methods when `facet.*` set | diff --git a/docs/guides/ops-runbook.md b/docs/guides/ops-runbook.md index 5a66797..48962f6 100644 --- a/docs/guides/ops-runbook.md +++ b/docs/guides/ops-runbook.md @@ -256,13 +256,29 @@ Full detail: [`ops-backend-deploy.md` §3](ops-backend-deploy.md#3-environment-v | Image pull error on update | Private repo, wrong tag, or amd64 manifest missing | Confirm repo is public; verify pull with `--platform linux/amd64` (§3.1) | | Health `503` / `database: disconnected` | Postgres addon or `CLOUDRON_POSTGRESQL_URL` missing | Cloudron app → Environment | | Container crash on start | Migration failure | App logs around `prisma migrate deploy`; fix forward with new migration | -| Magic link not sent | Mail addon or `SMTP_FROM` | Cloudron mail logs; `CLOUDRON_MAIL_SMTP_*` vars | +| Magic link not sent | Mail addon, `SMTP_FROM`, or SES DNS | Cloudron mail logs (`CLOUDRON_MAIL_SMTP_*`); inbox/spam; SPF/DKIM for SES (§8.1) | | Upload `server_misconfigured` | `UPLOAD_ROOT` unset | `cloudron env set --app UPLOAD_ROOT=/app/data/uploads` | | No “Recommended” on method cards | Seed not run | §3.4 — `node prisma/seed.bundle.cjs` | | Rate limit too aggressive after deploy | Expected per §6.1 | Single instance only; limits reset on container restart | App logs: Cloudron dashboard → *Logs* tab, or `cloudron logs --app -f`. +### 8.1 Mail DNS when SES is the relay + +`communityrule.info` outbound mail is **Amazon SES SMTP** (`email-smtp.us-east-2.amazonaws.com:587`), not Cloudron's own MTA. Cloudron Mail → domain status therefore **skips SPF and DKIM** ("configure the relay provider") and only checks MX, DMARC (`v=DMARC1; p=reject; pct=100`), and that the SES connection works. + +That is expected. Recipients still authenticate the visible `From:` (`staging.app@communityrule.info` on staging) against **SES DKIM/SPF**, not `a:my.medlab.host`. + +**Operator steps (AWS + Cloudron DNS, not app code):** + +1. In **AWS SES** (us-east-2), open the verified identity for `communityrule.info` (create one if missing). Copy the **DKIM CNAME** records SES shows (three `*._domainkey.communityrule.info` names). +2. In **Cloudron** → *Domains* → `communityrule.info` → DNS, add those CNAMEs. Cloudron's Namecheap provider publishes them to the registrar. Confirm with `dig +short CNAME ._domainkey.communityrule.info`. +3. Optional but recommended for SPF alignment: add `include:amazonses.com` to the existing TXT SPF, e.g. `v=spf1 include:amazonses.com a:my.medlab.host ~all`. Do not remove `a:my.medlab.host` until you know nothing still sends directly from the box. +4. Leave DMARC at `p=reject` once DKIM verifies in SES; if a provider still quarantines after DKIM is live, inspect that provider's headers before relaxing DMARC. +5. Retest: request a magic link to Gmail **and** a non-Gmail inbox (May First / university). Check spam. Staging From is `Community Rule `. + +`SMTP_FROM` should stay the Cloudron mailbox (`staging.app@communityrule.info` on staging, `hello@communityrule.info` on the apex app). The app falls back to `CLOUDRON_MAIL_FROM` if `SMTP_FROM` is unset. + ## 9. Related docs - [`ops-backend-deploy.md`](ops-backend-deploy.md) — first install, cutover diff --git a/lib/create/api.ts b/lib/create/api.ts index 97fe039..5815226 100644 --- a/lib/create/api.ts +++ b/lib/create/api.ts @@ -27,6 +27,26 @@ function readApiErrorMessage(data: unknown): string { return "Request failed"; } +function retryAfterFromResponse( + res: Response, + data: unknown, +): number | undefined { + if (res.status !== 429) return undefined; + if (data && typeof data === "object" && "details" in data) { + const d = (data as { details?: unknown }).details; + if (d && typeof d === "object" && "retryAfterMs" in d) { + const ms = (d as { retryAfterMs?: unknown }).retryAfterMs; + if (typeof ms === "number" && ms > 0) return ms; + } + } + const h = res.headers.get("retry-after"); + if (h) { + const sec = Number.parseInt(h, 10); + if (!Number.isNaN(sec)) return sec * 1000; + } + return undefined; +} + export async function fetchAuthSession(): Promise<{ user: { id: string; email: string } | null; }> { @@ -54,13 +74,12 @@ export async function requestMagicLink( ...(draft && Object.keys(draft).length > 0 ? { draft } : {}), }), }); - const data = await parseJson<{ error?: string; retryAfterMs?: number }>(res); + const data: unknown = await parseJson(res); if (!res.ok) { return { ok: false, error: readApiErrorMessage(data), - retryAfterMs: - typeof data.retryAfterMs === "number" ? data.retryAfterMs : undefined, + retryAfterMs: retryAfterFromResponse(res, data), }; } return { ok: true }; @@ -85,22 +104,10 @@ export async function requestEmailChange( }); const data: unknown = await res.json().catch(() => ({})); if (!res.ok) { - let retryAfterMs: number | undefined; - if ( - res.status === 429 && - data && - typeof data === "object" && - "details" in data - ) { - const d = (data as { details?: { retryAfterMs?: unknown } }).details; - if (d && typeof d.retryAfterMs === "number") { - retryAfterMs = d.retryAfterMs; - } - } return { ok: false, error: readApiErrorMessage(data), - retryAfterMs, + retryAfterMs: retryAfterFromResponse(res, data), }; } return { ok: true }; @@ -438,26 +445,6 @@ export type RuleStakeholderMutationResult = | { ok: true } | { ok: false; error: string; status: number; retryAfterMs?: number }; -function retryAfterFromResponse( - res: Response, - data: unknown, -): number | undefined { - if (res.status !== 429) return undefined; - if (data && typeof data === "object" && "details" in data) { - const d = (data as { details?: unknown }).details; - if (d && typeof d === "object" && "retryAfterMs" in d) { - const ms = (d as { retryAfterMs?: unknown }).retryAfterMs; - if (typeof ms === "number" && ms > 0) return ms; - } - } - const h = res.headers.get("retry-after"); - if (h) { - const sec = Number.parseInt(h, 10); - if (!Number.isNaN(sec)) return sec * 1000; - } - return undefined; -} - export async function addRuleStakeholder( ruleId: string, email: string, diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts index 9417974..42a93a9 100644 --- a/lib/create/buildPublishPayload.ts +++ b/lib/create/buildPublishPayload.ts @@ -16,7 +16,10 @@ import { publishedMethodDisplayLabel, } from "./finalReviewChipPresets"; import { isDocumentEntry } from "./documentEntryGuards"; -import { replaceMethodSectionsWithMethodSelections } from "./ruleSectionsFromMethodSelections"; +import { + replaceMethodSectionsWithMethodSelections, + withoutUnpublishedDecisionConsensus, +} from "./ruleSectionsFromMethodSelections"; import { templateCategoryToGroupKey } from "./templateReviewMapping"; export { isDocumentEntry } from "./documentEntryGuards"; @@ -78,21 +81,25 @@ export type PublishedMethodSelections = { id: string; label: string; sections: CommunicationMethodDetailEntry; + supportText?: string; }>; membership?: Array<{ id: string; label: string; sections: MembershipMethodDetailEntry; + supportText?: string; }>; decisionApproaches?: Array<{ id: string; label: string; sections: DecisionApproachDetailEntry; + supportText?: string; }>; conflictManagement?: Array<{ id: string; label: string; sections: ConflictManagementDetailEntry; + supportText?: string; }>; }; @@ -247,6 +254,14 @@ function pickMethodIds( return derived; } +function publishedRowSupportText( + id: string, + meta: CreateFlowState["customMethodCardMetaById"], +): string | undefined { + const t = meta?.[id]?.supportText?.trim(); + return t && t.length > 0 ? t : undefined; +} + /** * Merge `selected*MethodIds` with any saved `{group}MethodDetailsById` * overrides authored on the final-review screen. Preset defaults from the @@ -270,6 +285,10 @@ export function buildMethodSelectionsForDocument( out.communication = commIds.map((id) => { const preset = communicationPresetFor(id); const override = state.communicationMethodDetailsById?.[id]; + const supportText = publishedRowSupportText( + id, + state.customMethodCardMetaById, + ); return { id, label: publishedMethodDisplayLabel( @@ -278,6 +297,7 @@ export function buildMethodSelectionsForDocument( state.customMethodCardMetaById, ), sections: override ? { ...preset, ...override } : preset, + ...(supportText ? { supportText } : {}), }; }); } @@ -290,6 +310,10 @@ export function buildMethodSelectionsForDocument( out.membership = memIds.map((id) => { const preset = membershipPresetFor(id); const override = state.membershipMethodDetailsById?.[id]; + const supportText = publishedRowSupportText( + id, + state.customMethodCardMetaById, + ); return { id, label: publishedMethodDisplayLabel( @@ -298,6 +322,7 @@ export function buildMethodSelectionsForDocument( state.customMethodCardMetaById, ), sections: override ? { ...preset, ...override } : preset, + ...(supportText ? { supportText } : {}), }; }); } @@ -310,6 +335,11 @@ export function buildMethodSelectionsForDocument( out.decisionApproaches = daIds.map((id) => { const preset = decisionApproachPresetFor(id); const override = state.decisionApproachDetailsById?.[id]; + const supportText = publishedRowSupportText( + id, + state.customMethodCardMetaById, + ); + const merged = override ? { ...preset, ...override } : preset; return { id, label: publishedMethodDisplayLabel( @@ -317,7 +347,11 @@ export function buildMethodSelectionsForDocument( id, state.customMethodCardMetaById, ), - sections: override ? { ...preset, ...override } : preset, + sections: withoutUnpublishedDecisionConsensus( + { ...merged }, + state.customMethodCardFieldBlocksById?.[id], + ) as DecisionApproachDetailEntry, + ...(supportText ? { supportText } : {}), }; }); } @@ -330,6 +364,10 @@ export function buildMethodSelectionsForDocument( out.conflictManagement = cmIds.map((id) => { const preset = conflictManagementPresetFor(id); const override = state.conflictManagementDetailsById?.[id]; + const supportText = publishedRowSupportText( + id, + state.customMethodCardMetaById, + ); return { id, label: publishedMethodDisplayLabel( @@ -338,6 +376,7 @@ export function buildMethodSelectionsForDocument( state.customMethodCardMetaById, ), sections: override ? { ...preset, ...override } : preset, + ...(supportText ? { supportText } : {}), }; }); } diff --git a/lib/create/finalReviewChipPresets.ts b/lib/create/finalReviewChipPresets.ts index ab1bd8f..48c90f3 100644 --- a/lib/create/finalReviewChipPresets.ts +++ b/lib/create/finalReviewChipPresets.ts @@ -106,7 +106,7 @@ export function membershipPresetFor(id: string): MembershipMethodDetailEntry { }; } -/** Default consensus level used when presets omit a value (see DecisionApproachesScreen). */ +/** Default consensus level used when a **catalog** preset omits a value. */ export const DECISION_CONSENSUS_LEVEL_DEFAULT = 75; export function decisionApproachPresetFor( @@ -114,19 +114,22 @@ export function decisionApproachPresetFor( ): DecisionApproachDetailEntry { const method = findMethod(decisionApproachesMessages, id); const s = method?.sections ?? {}; - return { + const entry: DecisionApproachDetailEntry = { corePrinciple: asString(s.corePrinciple), applicableScope: asStringArray(s.applicableScope), selectedApplicableScope: [], stepByStepInstructions: asString(s.stepByStepInstructions), - consensusLevel: asNumberClamped( + objectionsDeadlocks: asString(s.objectionsDeadlocks), + }; + if (method) { + entry.consensusLevel = asNumberClamped( s.consensusLevel, 0, 100, DECISION_CONSENSUS_LEVEL_DEFAULT, - ), - objectionsDeadlocks: asString(s.objectionsDeadlocks), - }; + ); + } + return entry; } export function conflictManagementPresetFor( diff --git a/lib/create/methodCardCustomizeSession.ts b/lib/create/methodCardCustomizeSession.ts index 67b8727..636921e 100644 --- a/lib/create/methodCardCustomizeSession.ts +++ b/lib/create/methodCardCustomizeSession.ts @@ -25,6 +25,28 @@ export function captureMethodCardCustomizeSnapshot( }; } +/** + * True when a method-card create/customize modal is open with edits that + * are not yet persisted — the condition for a tab/window `beforeunload` guard. + */ +export function isMethodCardCustomizeUnloadBlocked( + modalOpen: boolean, + snapshot: MethodCardCustomizeSnapshot | null, + pendingDraft: TDraft | null, + draftFieldBlocks: CustomMethodCardFieldBlock[] | null, + headerDraft: MethodCardHeaderDraft | null, +): boolean { + if (!modalOpen || snapshot === null) { + return false; + } + return isMethodCardCustomizeSessionDirty( + snapshot, + pendingDraft, + draftFieldBlocks, + headerDraft, + ); +} + export function isMethodCardCustomizeSessionDirty( snapshot: MethodCardCustomizeSnapshot, pendingDraft: TDraft | null, diff --git a/lib/create/methodCardWizardPrefill.ts b/lib/create/methodCardWizardPrefill.ts index d81cf4f..278e881 100644 --- a/lib/create/methodCardWizardPrefill.ts +++ b/lib/create/methodCardWizardPrefill.ts @@ -195,12 +195,19 @@ function mapFacetPrefillToWizardFieldBlocks( prefill.headings.stepByStepInstructions, prefill.draft.stepByStepInstructions, ), - { + ); + if ( + typeof prefill.draft.consensusLevel === "number" && + facetPrefillHasContent(prefill) + ) { + blocks.push({ kind: "proportion", id: "facet-consensusLevel", blockTitle: prefill.headings.consensusLevel, defaultPercent: clampPercent(prefill.draft.consensusLevel), - }, + }); + } + blocks.push( textBlock( "facet-objectionsDeadlocks", prefill.headings.objectionsDeadlocks, diff --git a/lib/create/publishedDocumentToCreateFlowState.ts b/lib/create/publishedDocumentToCreateFlowState.ts index d2d6ea9..35cbb4e 100644 --- a/lib/create/publishedDocumentToCreateFlowState.ts +++ b/lib/create/publishedDocumentToCreateFlowState.ts @@ -22,6 +22,7 @@ function customMethodCardMetaFromPublishedSelections( | Array<{ id: string; label: string; + supportText?: string; }> | undefined, ) => { @@ -32,7 +33,9 @@ function customMethodCardMetaFromPublishedSelections( if (methodLabelFor(groupKey, id).length > 0) continue; const label = typeof row.label === "string" ? row.label.trim() : ""; if (!label) continue; - meta[id] = { label, supportText: "" }; + const supportText = + typeof row.supportText === "string" ? row.supportText : ""; + meta[id] = { label, supportText }; } }; absorb("communication", ms.communication); diff --git a/lib/create/ruleSectionsFromMethodSelections.ts b/lib/create/ruleSectionsFromMethodSelections.ts index a0d2a33..8c2ddf5 100644 --- a/lib/create/ruleSectionsFromMethodSelections.ts +++ b/lib/create/ruleSectionsFromMethodSelections.ts @@ -78,6 +78,8 @@ export function labeledBlocksFromCustomMethodCardFieldBlocks( export type CommunityRuleEntryFromChipOptions = { consensusLevelKey?: string; customFieldBlocks?: CustomMethodCardFieldBlock[]; + /** Wizard step-2 policy description (`customMethodCardMetaById.supportText`). */ + supportText?: string; }; /** Canonical `categoryName` strings for method groups in published documents. */ @@ -195,8 +197,64 @@ export function communityRuleEntryFromMethodChip( ? labeledBlocksFromCustomMethodCardFieldBlocks(options.customFieldBlocks) : []; const blocks = [...presetBlocks, ...wizardBlocks]; - if (blocks.length === 0) return null; - return { title, body: "", blocks }; + const description = nonEmptyTrimmed(options?.supportText); + if (blocks.length === 0) { + if (!description) return null; + return { title, body: description }; + } + return { + title, + body: description ?? "", + blocks, + }; +} + +function decisionApproachHasPublishableFacetCopy( + sections: Record, +): boolean { + return Boolean( + nonEmptyTrimmed(sections.corePrinciple) || + nonEmptyTrimmed(sections.stepByStepInstructions) || + nonEmptyTrimmed(sections.objectionsDeadlocks) || + formatScopePayload(sections.applicableScope) || + formatScopePayload(sections.selectedApplicableScope), + ); +} + +/** + * Catalog methods publish their consensus figure. User-authored custom cards + * often seed `75` with empty facet copy — skip that unless the author actually + * filled decision sections. Wizard field blocks (including proportion) are the + * source of truth when present. + */ +function shouldPublishDecisionConsensusLevel( + sections: Record, + customFieldBlocks?: CustomMethodCardFieldBlock[], +): boolean { + if ( + typeof sections.consensusLevel !== "number" || + Number.isNaN(sections.consensusLevel) + ) { + return false; + } + if (customFieldBlocks && customFieldBlocks.length > 0) { + return false; + } + return decisionApproachHasPublishableFacetCopy(sections); +} + +/** Drop seeded / wizard-superseded `consensusLevel` before publish or hydrate. */ +export function withoutUnpublishedDecisionConsensus( + sections: Record, + customFieldBlocks?: CustomMethodCardFieldBlock[], +): Record { + if (shouldPublishDecisionConsensusLevel(sections, customFieldBlocks)) { + return sections; + } + if (!("consensusLevel" in sections)) return sections; + const next = { ...sections }; + delete next.consensusLevel; + return next; } export function sectionFromCommunication( @@ -209,6 +267,7 @@ export function sectionFromCommunication( const sec = m.sections as unknown as Record; const e = communityRuleEntryFromMethodChip(m.label, sec, COMM_LABELS, { customFieldBlocks: customFieldBlocksById?.[m.id], + supportText: m.supportText, }); if (e) entries.push(e); } @@ -227,6 +286,7 @@ export function sectionFromMembership( const sec = m.sections as unknown as Record; const e = communityRuleEntryFromMethodChip(m.label, sec, MEM_LABELS, { customFieldBlocks: customFieldBlocksById?.[m.id], + supportText: m.supportText, }); if (e) entries.push(e); } @@ -254,10 +314,19 @@ export function sectionFromDecision( ); if (scope) merged.applicableScope = scope; delete merged.selectedApplicableScope; - const e = communityRuleEntryFromMethodChip(m.label, merged, DEC_LABELS, { - consensusLevelKey: "consensusLevel", - customFieldBlocks: customFieldBlocksById?.[m.id], - }); + const e = communityRuleEntryFromMethodChip( + m.label, + withoutUnpublishedDecisionConsensus( + merged, + customFieldBlocksById?.[m.id], + ), + DEC_LABELS, + { + consensusLevelKey: "consensusLevel", + customFieldBlocks: customFieldBlocksById?.[m.id], + supportText: m.supportText, + }, + ); if (e) entries.push(e); } return entries.length > 0 @@ -281,6 +350,7 @@ export function sectionFromConflict( delete merged.selectedApplicableScope; const e = communityRuleEntryFromMethodChip(m.label, merged, CM_LABELS, { customFieldBlocks: customFieldBlocksById?.[m.id], + supportText: m.supportText, }); if (e) entries.push(e); } diff --git a/lib/create/usesWizardFieldBlocksModalBody.ts b/lib/create/usesWizardFieldBlocksModalBody.ts index 4afc432..ea6ac57 100644 --- a/lib/create/usesWizardFieldBlocksModalBody.ts +++ b/lib/create/usesWizardFieldBlocksModalBody.ts @@ -29,6 +29,9 @@ import { isCustomMethodCardId } from "./isCustomMethodCardId"; * so meta-only wizard cards show policy copy instead of empty section editors. * Pass `customFacetDetailsMatchPreset: false` when the caller knows facet details * were edited or cloned from a filled preset. + * + * Create-flow facet screens and final-review pass `modalEditUnlocked: true` so + * section fields stay editable on open (Customize is the kebab wizard). */ export function usesWizardFieldBlocksModalBody(args: { methodId: string; diff --git a/lib/server/mail.ts b/lib/server/mail.ts index 29e29fe..1b904cf 100644 --- a/lib/server/mail.ts +++ b/lib/server/mail.ts @@ -2,62 +2,108 @@ import nodemailer from "nodemailer"; import { logger } from "../logger"; import { getSmtpUrl } from "./env"; -export async function sendMagicLinkEmail( - to: string, - verifyUrl: string, -): Promise { - const url = getSmtpUrl(); +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} - if (!url) { +export function resolveMailFrom(): string { + return ( + process.env.SMTP_FROM?.trim() || + process.env.CLOUDRON_MAIL_FROM?.trim() || + "noreply@localhost" + ); +} + +/** Plaintext + HTML for one-time verify URLs. HTML `href` survives quoted-printable wrapping. */ +export function buildVerifyLinkParts( + verifyUrl: string, + intro: string, + outro: string, + linkLabel: string, +): { text: string; html: string } { + const text = `${intro}\n\n${verifyUrl}\n\n${outro}`; + const html = + `

${escapeHtml(intro).replace(/\n/g, "
")}

` + + `

${escapeHtml(linkLabel)}

` + + `

${escapeHtml(outro)}

`; + return { text, html }; +} + +async function sendHtmlMail(opts: { + to: string; + subject: string; + text: string; + html: string; + from?: string; + replyTo?: string; + devLog: string; +}): Promise { + const smtpUrl = getSmtpUrl(); + + if (!smtpUrl) { if (process.env.NODE_ENV === "development") { - logger.info(`[dev] Magic link for ${to}: ${verifyUrl}`); + logger.info(opts.devLog); return; } throw new Error("CLOUDRON_MAIL_SMTP_* is not configured"); } - const transporter = nodemailer.createTransport(url); - const from = process.env.SMTP_FROM ?? "noreply@localhost"; - + const transporter = nodemailer.createTransport(smtpUrl); await transporter.sendMail({ - from, - to, - subject: "Sign in to Community Rule", - text: `Open this link to sign in (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this, you can ignore this email.`, + from: opts.from ?? resolveMailFrom(), + to: opts.to, + subject: opts.subject, + text: opts.text, + html: opts.html, + replyTo: opts.replyTo, + }); +} + +export async function sendMagicLinkEmail( + to: string, + verifyUrl: string, +): Promise { + const { text, html } = buildVerifyLinkParts( + verifyUrl, + "Open this link to sign in (it expires in 15 minutes):", + "If you did not request this, you can ignore this email.", + "Sign in", + ); + await sendHtmlMail({ + to, + subject: "Sign in to Community Rule", + text, + html, + devLog: `[dev] Magic link for ${to}: ${verifyUrl}`, }); } -/** CR-103: confirm control of the new inbox before `User.email` is updated. */ /** Stakeholder invite after rule publish (one-time link, same dev/Mailhog pattern as magic link). */ export async function sendRuleStakeholderInviteEmail( to: string, verifyUrl: string, ruleTitle: string, ): Promise { - const url = getSmtpUrl(); - - if (!url) { - if (process.env.NODE_ENV === "development") { - logger.info( - `[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`, - ); - return; - } - throw new Error("CLOUDRON_MAIL_SMTP_* is not configured"); - } - - const transporter = nodemailer.createTransport(url); - const from = process.env.SMTP_FROM ?? "noreply@localhost"; - - await transporter.sendMail({ - from, + const { text, html } = buildVerifyLinkParts( + verifyUrl, + `You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:`, + "If you did not expect this, you can ignore this email.", + "Open the rule", + ); + await sendHtmlMail({ to, subject: `You're invited to view a Community Rule: ${ruleTitle}`, - text: `You've been invited to view "${ruleTitle}" on Community Rule.\n\nOpen this link to create your account (or sign in) and open the rule. The link expires in 15 minutes and works once:\n\n${verifyUrl}\n\nIf you did not expect this, you can ignore this email.`, + text, + html, + devLog: `[dev] Rule stakeholder invite (${ruleTitle}) for ${to}: ${verifyUrl}`, }); } -/** CR-107: notify support/organizers when a visitor submits the Ask an organizer form. */ +/** Notify support/organizers when a visitor submits the Ask an organizer form. */ export async function sendOrganizerInquiryNotification(params: { /** Destination inbox (e.g. from ORGANIZER_INQUIRY_TO). */ to: string; @@ -67,26 +113,18 @@ export async function sendOrganizerInquiryNotification(params: { requestId: string; }): Promise { const { to, fromEmail, visitorEmail, message, requestId } = params; - const url = getSmtpUrl(); - - if (!url) { - if (process.env.NODE_ENV === "development") { - logger.info( - `[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`, - ); - return; - } - throw new Error("CLOUDRON_MAIL_SMTP_* is not configured"); - } - - const transporter = nodemailer.createTransport(url); - - await transporter.sendMail({ - from: fromEmail, + const text = `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`; + const html = + `

Request ID: ${escapeHtml(requestId)}
From: ${escapeHtml(visitorEmail)}

` + + `
${escapeHtml(message)}
`; + await sendHtmlMail({ to, + from: fromEmail, replyTo: visitorEmail, subject: `Ask an organizer inquiry from ${visitorEmail}`, - text: `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`, + text, + html, + devLog: `[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`, }); } @@ -94,24 +132,17 @@ export async function sendEmailChangeEmail( to: string, verifyUrl: string, ): Promise { - const url = getSmtpUrl(); - - if (!url) { - if (process.env.NODE_ENV === "development") { - logger.info(`[dev] Email change verify for ${to}: ${verifyUrl}`); - return; - } - throw new Error("CLOUDRON_MAIL_SMTP_* is not configured"); - } - - const transporter = nodemailer.createTransport(url); - const from = process.env.SMTP_FROM ?? "noreply@localhost"; - - await transporter.sendMail({ - from, + const { text, html } = buildVerifyLinkParts( + verifyUrl, + "You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):", + "If you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.", + "Confirm email", + ); + await sendHtmlMail({ to, subject: "Confirm your new Community Rule email", - text: `You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 15 minutes):\n\n${verifyUrl}\n\nIf you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.`, + text, + html, + devLog: `[dev] Email change verify for ${to}: ${verifyUrl}`, }); } - diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts index 42d8ef6..0284b8e 100644 --- a/lib/server/validation/createFlowSchemas.ts +++ b/lib/server/validation/createFlowSchemas.ts @@ -54,7 +54,7 @@ const decisionApproachDetailEntrySchema = z.object({ applicableScope: z.array(z.string().max(2000)).max(50), selectedApplicableScope: z.array(z.string().max(2000)).max(50), stepByStepInstructions: z.string().max(8000), - consensusLevel: z.number().int().min(0).max(100), + consensusLevel: z.number().int().min(0).max(100).optional(), objectionsDeadlocks: z.string().max(8000), }); diff --git a/messages/en/create/community/communitySave.json b/messages/en/create/community/communitySave.json index 2e14367..a80a72a 100644 --- a/messages/en/create/community/communitySave.json +++ b/messages/en/create/community/communitySave.json @@ -4,6 +4,6 @@ "placeholder": "email@domain.com", "characterCountTemplate": "{current}/{max}", "magicLinkSuccessTitle": "Check your email to log in", - "magicLinkSuccessDescription": "Your account has been created. A login link has been emailed to you.", + "magicLinkSuccessDescription": "We emailed a sign-in link. Open it on this device to continue — check spam or promotions if you don't see it.", "magicLinkErrorTitle": "Could not send link" } diff --git a/messages/en/pages/login.json b/messages/en/pages/login.json index d7819ad..7391ab9 100644 --- a/messages/en/pages/login.json +++ b/messages/en/pages/login.json @@ -7,7 +7,7 @@ "emailPlaceholder": "you@example.com", "sendMagicLink": "Send me a magic link", "successTitle": "Check your email", - "successBody": "We sent a sign-in link. Open it on this device to continue.", + "successBody": "We sent a sign-in link. Open it on this device to continue. If you don't see it, check spam or promotions.", "legalPrefix": "By continuing, you agree to our ", "legalAnd": " and ", "legalSuffix": ".", diff --git a/tests/components/CommunicationMethodsScreenPersistence.test.tsx b/tests/components/CommunicationMethodsScreenPersistence.test.tsx index 9ec30d7..bd32324 100644 --- a/tests/components/CommunicationMethodsScreenPersistence.test.tsx +++ b/tests/components/CommunicationMethodsScreenPersistence.test.tsx @@ -6,6 +6,7 @@ import { cleanup, within, waitFor, + dispatchBeforeUnload, } from "../utils/test-utils"; import { fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; @@ -576,4 +577,62 @@ describe("CommunicationMethodsScreen — Add Platform persistence", () => { expect(labels[1]).toMatch(/Code of Conduct/); expect(labels[2]).toMatch(/Core Principle/); }); + + it("does not block tab close when the create modal is unchanged", async () => { + render( + { + /* noop */ + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + await screen.findByRole("dialog"); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("blocks tab close while the create modal has unsaved field edits", async () => { + render( + { + /* noop */ + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + const textboxes = within(dialog).getAllByRole( + "textbox", + ) as HTMLTextAreaElement[]; + fireEvent.change(textboxes[0], { target: { value: "Unsaved principle" } }); + expect(dispatchBeforeUnload()).toBe(true); + }); + + it("blocks tab close while the custom-policy wizard has unsaved edits", async () => { + render( + { + /* noop */ + }} + />, + ); + + fireEvent.click( + screen.getAllByRole("button", { name: /Signal: Encrypted messaging/ })[0], + ); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "More options" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Customize" })); + + const nameInput = await screen.findByPlaceholderText("Policy name"); + expect(dispatchBeforeUnload()).toBe(false); + fireEvent.change(nameInput, { target: { value: "Renamed in wizard" } }); + expect(dispatchBeforeUnload()).toBe(true); + }); }); diff --git a/tests/components/CommunityRule.test.tsx b/tests/components/CommunityRule.test.tsx index 422dbf7..b6e9c26 100644 --- a/tests/components/CommunityRule.test.tsx +++ b/tests/components/CommunityRule.test.tsx @@ -50,4 +50,26 @@ describe("CommunityRule", () => { ); expect(screen.getByText("How proposals pass")).toBeInTheDocument(); }); + + it("renders entry body together with labeled blocks", () => { + render( + , + ); + expect(screen.getByText("Anyone can start a thread.")).toBeInTheDocument(); + expect(screen.getByText("Quorum")).toBeInTheDocument(); + expect(screen.getByText("60%")).toBeInTheDocument(); + }); }); diff --git a/tests/components/ConflictManagementScreen.test.tsx b/tests/components/ConflictManagementScreen.test.tsx new file mode 100644 index 0000000..2d2a68b --- /dev/null +++ b/tests/components/ConflictManagementScreen.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + renderWithProviders as render, + screen, + cleanup, + within, +} from "../utils/test-utils"; +import { fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { ConflictManagementScreen } from "../../app/(app)/create/screens/card/ConflictManagementScreen"; + +afterEach(() => { + cleanup(); +}); + +describe("ConflictManagementScreen", () => { + it("opens section fields editable without Customize", async () => { + render(); + fireEvent.click( + screen.getAllByRole("button", { + name: /Peer Mediation: Trained members/, + })[0], + ); + const dialog = await screen.findByRole("dialog"); + const fields = within(dialog).getAllByRole("textbox"); + expect(fields.length).toBeGreaterThan(0); + for (const field of fields) { + expect(field).toBeEnabled(); + } + fireEvent.click( + within(dialog).getByRole("button", { name: "More options" }), + ); + expect( + screen.getByRole("menuitem", { name: "Customize" }), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/components/CoreValuesSelectScreen.test.tsx b/tests/components/CoreValuesSelectScreen.test.tsx index 1861485..05b409e 100644 --- a/tests/components/CoreValuesSelectScreen.test.tsx +++ b/tests/components/CoreValuesSelectScreen.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, fireEvent, waitFor, within } from "@testing-library/react"; import "@testing-library/jest-dom/vitest"; -import { renderWithProviders } from "../utils/test-utils"; +import { renderWithProviders, dispatchBeforeUnload } from "../utils/test-utils"; import { CoreValuesSelectScreen } from "../../app/(app)/create/screens/select/CoreValuesSelectScreen"; describe("CoreValuesSelectScreen", () => { @@ -65,6 +65,20 @@ describe("CoreValuesSelectScreen", () => { }); }); + it("does not block tab close when a pending value is unchanged", async () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Accessibility")); + await screen.findByRole("dialog"); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("blocks tab close after editing a pending value", async () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Accessibility")); + await editMeaningInOpenDialog("Changed meaning"); + expect(dispatchBeforeUnload()).toBe(true); + }); + it("keeps the pending value modal open when Keep editing is chosen", async () => { renderWithProviders(); fireEvent.click(screen.getByText("Accessibility")); diff --git a/tests/components/CustomMethodCardWizardUnload.test.tsx b/tests/components/CustomMethodCardWizardUnload.test.tsx new file mode 100644 index 0000000..9737412 --- /dev/null +++ b/tests/components/CustomMethodCardWizardUnload.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { + renderWithProviders as render, + screen, + fireEvent, + dispatchBeforeUnload, +} from "../utils/test-utils"; +import "@testing-library/jest-dom/vitest"; +import CustomMethodCardWizard from "../../app/(app)/create/components/CustomMethodCardWizard"; + +describe("CustomMethodCardWizard — tab close guard", () => { + it("does not block unload when the wizard is open but unchanged", async () => { + render( + { + /* noop */ + }} + onFinalize={() => { + /* noop */ + }} + />, + ); + await screen.findByPlaceholderText("Policy name"); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("blocks unload after the user types a policy name", async () => { + render( + { + /* noop */ + }} + onFinalize={() => { + /* noop */ + }} + />, + ); + const name = await screen.findByPlaceholderText("Policy name"); + fireEvent.change(name, { target: { value: "Garden hours" } }); + expect(dispatchBeforeUnload()).toBe(true); + }); +}); diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx index 8ba5ac3..77d818f 100644 --- a/tests/components/FinalReviewPage.test.tsx +++ b/tests/components/FinalReviewPage.test.tsx @@ -5,6 +5,7 @@ import { renderWithProviders as render, screen, waitFor, + dispatchBeforeUnload, } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen"; @@ -518,6 +519,25 @@ describe("FinalReviewScreen — chip detail modal", () => { ).not.toBeInTheDocument(); }); + it("closes the chip edit modal when Back is pressed", async () => { + render( + {}} + initial={{ + title: "Oak Park Commons", + selectedCommunicationMethodIds: ["signal"], + }} + />, + ); + + fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "Back" })); + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + }); /** @@ -623,6 +643,28 @@ describe("FinalReviewScreen — chip edit modal save semantics", () => { expect(latest.communicationMethodDetailsById).toBeUndefined(); }); + it("blocks tab close while chip edits are unsaved", async () => { + render( + { + /* noop */ + }} + initial={baseSelections} + />, + ); + + fireEvent.click(await screen.findByRole("button", { name: "Signal" })); + const dialog = await screen.findByRole("dialog"); + expect(dispatchBeforeUnload()).toBe(false); + const principleField = within(dialog).getByRole("textbox", { + name: /core principle/i, + }); + fireEvent.change(principleField, { + target: { value: "Unsaved on tab close" }, + }); + expect(dispatchBeforeUnload()).toBe(true); + }); + it("shows consolidated placeholder for user-authored communication chips", async () => { const customId = "550e8400-e29b-41d4-a716-446655440000"; render( diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx index 58e113e..aeeb6b6 100644 --- a/tests/components/LoginForm.test.tsx +++ b/tests/components/LoginForm.test.tsx @@ -125,6 +125,7 @@ describe("LoginForm", () => { await screen.findByRole("heading", { name: /check your email/i }), ).toBeInTheDocument(); expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument(); + expect(screen.getByText(/check spam or promotions/i)).toBeInTheDocument(); }); it("submits a long email without treating length as invalid", async () => { diff --git a/tests/components/MembershipMethodsScreen.test.tsx b/tests/components/MembershipMethodsScreen.test.tsx new file mode 100644 index 0000000..c10e7c7 --- /dev/null +++ b/tests/components/MembershipMethodsScreen.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + renderWithProviders as render, + screen, + cleanup, + within, +} from "../utils/test-utils"; +import { fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { MembershipMethodsScreen } from "../../app/(app)/create/screens/card/MembershipMethodsScreen"; + +afterEach(() => { + cleanup(); +}); + +describe("MembershipMethodsScreen", () => { + it("opens section fields editable without Customize", async () => { + render(); + fireEvent.click( + screen.getAllByRole("button", { + name: /Open Access: Maximum inclusion/, + })[0], + ); + const dialog = await screen.findByRole("dialog"); + const fields = within(dialog).getAllByRole("textbox"); + expect(fields.length).toBeGreaterThan(0); + for (const field of fields) { + expect(field).toBeEnabled(); + } + fireEvent.click( + within(dialog).getByRole("button", { name: "More options" }), + ); + expect( + screen.getByRole("menuitem", { name: "Customize" }), + ).toBeInTheDocument(); + }); +}); diff --git a/tests/components/TextArea.test.tsx b/tests/components/TextArea.test.tsx index 61588b2..96bce2b 100644 --- a/tests/components/TextArea.test.tsx +++ b/tests/components/TextArea.test.tsx @@ -42,4 +42,17 @@ describe("TextArea appearance", () => { expect(textarea).toBeInTheDocument(); expect(textarea).toHaveClass("border-0"); }); + + it("uses tertiary text in the embedded default state and primary on focus", () => { + renderWithProviders( +