@murumets-ee/forms-ui 0.0.0-preview-20261014110621

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,94 @@
1
+ Elastic License 2.0 (ELv2)
2
+
3
+ URL: https://www.elastic.co/licensing/elastic-license
4
+
5
+ ## Acceptance
6
+
7
+ By using the software, you agree to all of the terms and conditions below.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
12
+ non-sublicensable, non-transferable license to use, copy, distribute, make
13
+ available, and prepare derivative works of the software, in each case subject
14
+ to the limitations and conditions below.
15
+
16
+ ## Limitations
17
+
18
+ You may not provide the software to third parties as a hosted or managed
19
+ service, where the service provides users with access to any substantial set
20
+ of the features or functionality of the software.
21
+
22
+ You may not move, change, disable, or circumvent the license key functionality
23
+ in the software, and you may not remove or obscure any functionality in the
24
+ software that is protected by the license key.
25
+
26
+ You may not alter, remove, or obscure any licensing, copyright, or other
27
+ notices of the licensor in the software. Any use of the licensor's trademarks
28
+ is subject to applicable law.
29
+
30
+ ## Patents
31
+
32
+ The licensor grants you a license, under any patent claims the licensor can
33
+ license, or becomes able to license, to make, have made, use, sell, offer for
34
+ sale, import and have imported the software, in each case subject to the
35
+ limitations and conditions in this license. This license does not cover any
36
+ patent claims that you cause to be infringed by modifications or additions to
37
+ the software. If you or your company make any written claim that the software
38
+ infringes or contributes to infringement of any patent, your patent license
39
+ for the software granted under these terms ends immediately. If your company
40
+ makes such a claim, your patent license ends immediately for work on behalf
41
+ of your company.
42
+
43
+ ## Notices
44
+
45
+ You must ensure that anyone who gets a copy of any part of the software from
46
+ you also gets a copy of these terms.
47
+
48
+ If you modify the software, you must include in any modified copies of the
49
+ software prominent notices stating that you have modified the software.
50
+
51
+ ## No Other Rights
52
+
53
+ These terms do not imply any licenses other than those expressly granted in
54
+ these terms.
55
+
56
+ ## Termination
57
+
58
+ If you use the software in violation of these terms, such use is not licensed,
59
+ and your licenses will automatically terminate. If the licensor provides you
60
+ with a notice of your violation, and you cease all violation of this license
61
+ no later than 30 days after you receive that notice, your licenses will be
62
+ reinstated retroactively. However, if you violate these terms after such
63
+ reinstatement, any additional violation of these terms will cause your
64
+ licenses to terminate automatically and permanently.
65
+
66
+ ## No Liability
67
+
68
+ As far as the law allows, the software comes as is, without any warranty or
69
+ condition, and the licensor will not be liable to you for any damages arising
70
+ out of these terms or the use or nature of the software, under any kind of
71
+ legal claim.
72
+
73
+ ## Definitions
74
+
75
+ The **licensor** is the entity offering these terms, and the **software** is
76
+ the software the licensor makes available under these terms, including any
77
+ portion of it.
78
+
79
+ **you** refers to the individual or entity agreeing to these terms.
80
+
81
+ **your company** is any legal entity, sole proprietorship, or other kind of
82
+ organization that you work for, plus all organizations that have control over,
83
+ are under the control of, or are under common control with that organization.
84
+ **control** means ownership of substantially all the assets of an entity, or
85
+ the power to direct the management and policies of an entity (for example, by
86
+ voting right, contract, or otherwise). Control can be direct or indirect.
87
+
88
+ **your licenses** are all the licenses granted to you for the software under
89
+ these terms.
90
+
91
+ **use** means anything you do with the software requiring one of your
92
+ licenses.
93
+
94
+ **trademark** means trademarks, service marks, and similar rights.
@@ -0,0 +1,15 @@
1
+
2
+ import { CanvasHeaderActionProps } from "@murumets-ee/admin-ui/content-editor";
3
+ import { ReactElement } from "react";
4
+
5
+ //#region src/canvas-header-action.d.ts
6
+ /**
7
+ * The words come from the editor's translator, so this entry imports no i18n
8
+ * library: `lumi.config.ts` reaches it under jiti, where `next-intl` cannot load.
9
+ */
10
+ declare function FormSubmissionsHeaderAction({
11
+ t
12
+ }: CanvasHeaderActionProps): ReactElement | null;
13
+ //#endregion
14
+ export { FormSubmissionsHeaderAction };
15
+ //# sourceMappingURL=canvas-header-action.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canvas-header-action.d.mts","names":[],"sources":["../src/canvas-header-action.tsx"],"mappings":";;;;;;;;;iBAuBgB,2BAAA;EAA8B;AAAA,GAAK,uBAAA,GAA0B,YAAA"}
@@ -0,0 +1,3 @@
1
+ "use client";
2
+ "use client";import{submissionsHeaderFallback as e,useFormCanvasFacts as t}from"@murumets-ee/forms/canvas-facts";import{jsx as n}from"react/jsx-runtime";function r({t:r}){let i=t()?.submissions;if(i===void 0)return null;let a=r(`Forms.header.submissions`,e(i.fresh),{count:i.fresh});return n(`a`,{href:i.href,"data-form-submissions-link":``,className:`inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 font-medium text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground`,children:a})}export{r as FormSubmissionsHeaderAction};
3
+ //# sourceMappingURL=canvas-header-action.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canvas-header-action.mjs","names":[],"sources":["../src/canvas-header-action.tsx"],"sourcesContent":["'use client'\n\n/**\n * \"Submissions (N new)\" in the form canvas HEADER, beside Publish — the form's\n * contribution to `Plugin.adminUi.canvasHeaderActions` (forms PR08, D011).\n *\n * A component REFERENCE shipped from its own `'use client'` subpath, because\n * the canvas is a client component and a closure cannot cross to it. The link\n * and the count come from the facts the canvas page provides, and the page\n * provides them ONLY to a person who holds `form_submissions:view` — so a role\n * without it gets no button, rather than one that answers \"not found\".\n */\n\nimport type { CanvasHeaderActionProps } from '@murumets-ee/admin-ui/content-editor'\nimport { submissionsHeaderFallback, useFormCanvasFacts } from '@murumets-ee/forms/canvas-facts'\nimport type { ReactElement } from 'react'\n\nconst KEY = 'Forms.header.submissions'\n\n/**\n * The words come from the editor's translator, so this entry imports no i18n\n * library: `lumi.config.ts` reaches it under jiti, where `next-intl` cannot load.\n */\nexport function FormSubmissionsHeaderAction({ t }: CanvasHeaderActionProps): ReactElement | null {\n const submissions = useFormCanvasFacts()?.submissions\n if (submissions === undefined) return null\n const label = t(KEY, submissionsHeaderFallback(submissions.fresh), { count: submissions.fresh })\n return (\n <a\n href={submissions.href}\n data-form-submissions-link=\"\"\n className=\"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 font-medium text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n >\n {label}\n </a>\n )\n}\n"],"mappings":";yJAuBA,SAAgB,EAA4B,CAAE,KAAmD,CAC/F,IAAM,EAAc,EAAmB,CAAC,EAAE,YAC1C,GAAI,IAAgB,IAAA,GAAW,OAAO,KACtC,IAAM,EAAQ,EAAE,2BAAK,EAA0B,EAAY,KAAK,EAAG,CAAE,MAAO,EAAY,KAAM,CAAC,EAC/F,OACE,EAAC,IAAD,CACE,KAAM,EAAY,KAClB,6BAA2B,GAC3B,UAAU,+KAET,CACA,CAAA,CAEP"}
@@ -0,0 +1,94 @@
1
+
2
+ import { ReactElement } from "react";
3
+ import { DeliveryRow, SubmissionColumn, SubmissionRow } from "@murumets-ee/forms";
4
+
5
+ //#region src/delete-form-dialog.d.ts
6
+ interface DeleteFormButtonProps {
7
+ readonly apiBasePath: string;
8
+ readonly formId: string;
9
+ readonly formName: string;
10
+ /** Stored submissions, or `null` when this person may not see the count. */
11
+ readonly submissions: number | null;
12
+ /** Published pages showing the form, or `null` when the count could not be read. */
13
+ readonly pages: number | null;
14
+ }
15
+ declare function DeleteFormButton({
16
+ apiBasePath,
17
+ formId,
18
+ formName,
19
+ submissions,
20
+ pages
21
+ }: DeleteFormButtonProps): ReactElement;
22
+ //#endregion
23
+ //#region src/new-form-dialog.d.ts
24
+ interface NewFormDialogProps {
25
+ readonly apiBasePath: string;
26
+ /** Where the form canvas lives — `<admin base>/forms`. */
27
+ readonly formsBasePath: string;
28
+ }
29
+ declare function NewFormDialog({
30
+ apiBasePath,
31
+ formsBasePath
32
+ }: NewFormDialogProps): ReactElement;
33
+ //#endregion
34
+ //#region src/privacy-view.d.ts
35
+ interface PrivacyRequestsViewProps {
36
+ readonly apiBasePath: string;
37
+ /** `<admin base>/forms` — a listed submission links to its form's Submissions. */
38
+ readonly formsBasePath: string;
39
+ /** The interface language, for the export's column words and delimiter. */
40
+ readonly locale: string;
41
+ /** May erase (`form_submissions:manage`). */
42
+ readonly canErase: boolean;
43
+ }
44
+ declare function PrivacyRequestsView({
45
+ apiBasePath,
46
+ formsBasePath,
47
+ locale,
48
+ canErase
49
+ }: PrivacyRequestsViewProps): ReactElement;
50
+ //#endregion
51
+ //#region src/register.d.ts
52
+ declare function registerFormBlocks(): void;
53
+ /**
54
+ * Render it, do not import it for effect: a component in the layout is what
55
+ * guarantees the registration runs before any canvas resolves its vocabulary
56
+ * (`resolveBlockVocabulary` runs during render). Same shape as menus-ui's.
57
+ */
58
+ declare function FormBlocksRegistration(): null;
59
+ //#endregion
60
+ //#region src/submissions-view.d.ts
61
+ interface SubmissionsViewProps {
62
+ readonly formId: string;
63
+ readonly rows: readonly SubmissionRow[];
64
+ readonly columns: readonly SubmissionColumn[];
65
+ /** May mark read / archive / delete (`form_submissions:manage`). */
66
+ readonly canManage: boolean;
67
+ /** Each listed submission's emails (notification / auto-reply), by submission id. */
68
+ readonly deliveries: Readonly<Record<string, readonly DeliveryRow[]>>;
69
+ readonly apiBasePath: string;
70
+ /** What to say when no submission is listed — the page knows whether a filter is why. */
71
+ readonly emptyText: string;
72
+ }
73
+ declare function SubmissionsView({
74
+ formId,
75
+ rows,
76
+ columns,
77
+ canManage,
78
+ deliveries,
79
+ apiBasePath,
80
+ emptyText
81
+ }: SubmissionsViewProps): ReactElement;
82
+ /**
83
+ * "Export CSV" — a plain download link to the export of the filters on screen.
84
+ * A link cannot carry the site header the admin's own requests send, so the
85
+ * selected site is added as `_site` when it is clicked, from the page URL.
86
+ */
87
+ declare function SubmissionsExportLink({
88
+ href
89
+ }: {
90
+ readonly href: string;
91
+ }): ReactElement;
92
+ //#endregion
93
+ export { DeleteFormButton, type DeleteFormButtonProps, FormBlocksRegistration, NewFormDialog, type NewFormDialogProps, PrivacyRequestsView, type PrivacyRequestsViewProps, SubmissionsExportLink, SubmissionsView, type SubmissionsViewProps, registerFormBlocks };
94
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/delete-form-dialog.tsx","../src/new-form-dialog.tsx","../src/privacy-view.tsx","../src/register.tsx","../src/submissions-view.tsx"],"mappings":";;;;;UAwBiB,qBAAA;EAAA,SACN,WAAA;EAAA,SACA,MAAA;EAAA,SACA,QAAA;EAH2B;EAAA,SAK3B,WAAA;EAL2B;EAAA,SAO3B,KAAA;AAAA;AAAA,iBAGK,gBAAA;EACd,WAAA;EACA,MAAA;EACA,QAAA;EACA,WAAA;EACA;AAAA,GACC,qBAAA,GAAwB,YAAA;;;UCVV,kBAAA;EAAA,SACN,WAAA;;WAEA,aAAa;AAAA;AAAA,iBAGR,aAAA;EAAgB,WAAA;EAAa;AAAA,GAAiB,kBAAA,GAAqB,YAAA;;;UCDlE,wBAAA;EAAA,SACN,WAAA;;WAEA,aAAA;EFd2B;EAAA,SEgB3B,MAAA;EFhB2B;EAAA,SEkB3B,QAAA;AAAA;AAAA,iBAsCK,mBAAA;EACd,WAAA;EACA,aAAA;EACA,MAAA;EACA;AAAA,GACC,wBAAA,GAA2B,YAAA;;;iBCtDd,kBAAA;;;;AHPhB;;iBGwCgB,sBAAA;;;UClBC,oBAAA;EAAA,SACN,MAAA;EAAA,SACA,IAAA,WAAe,aAAA;EAAA,SACf,OAAA,WAAkB,gBAAA;;WAElB,SAAA;EJ1BA;EAAA,SI4BA,UAAA,EAAY,QAAA,CAAS,MAAA,kBAAwB,WAAA;EAAA,SAC7C,WAAA;EJzBA;EAAA,SI2BA,SAAA;AAAA;AAAA,iBAwDK,eAAA;EACd,MAAA;EACA,IAAA;EACA,OAAA;EACA,SAAA;EACA,UAAA;EACA,WAAA;EACA;AAAA,GACC,oBAAA,GAAuB,YAAA;AJtF1B;;;;;AAAA,iBIqiBgB,qBAAA;EAAwB;AAAA;EAAA,SAAmB,IAAA;AAAA,IAAiB,YAAY"}
package/dist/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ "use client";
2
+ import{Badge as e,Button as t,Checkbox as n,Dialog as r,DialogContent as i,DialogDescription as a,DialogHeader as o,DialogTitle as ee,Input as s,Label as c,Table as l,TableBody as u,TableCell as d,TableHead as f,TableHeader as p,TableRow as m}from"@murumets-ee/ui";import{useRouter as h}from"next/navigation";import{useFormatter as g,useTranslations as _}from"next-intl";import{useEffect as v,useId as y,useRef as b,useState as x,useTransition as S}from"react";import{Fragment as C,jsx as w,jsxs as T}from"react/jsx-runtime";import{FORMS_SOURCE as E,FORM_ENVIRONMENT as D,FORM_NAME_MAX as O,PAGE_ENVIRONMENT as k,formEnvironmentBlocks as A,formEnvironmentTraits as j,formSiteBlocks as M}from"@murumets-ee/forms/schema";import{registerBlockEnvironment as N,registerBlockEnvironmentBase as P,registerBlockTheme as F,widenFieldType as I}from"@murumets-ee/blocks/core";import{formFieldKeyType as L,formPickerType as R,formSettingsTabsType as z}from"@murumets-ee/forms/editor";import{FORMS_THEME as B,formsTheme as V}from"@murumets-ee/forms/render";import{withAdminSiteParam as H}from"@murumets-ee/admin-ui";function U({apiBasePath:e,formId:n,formName:s,submissions:c,pages:l}){let u=_(`Forms.remove`),d=_(`Forms.list`),f=h(),[,p]=S(),[m,g]=x(!1),[v,y]=x(!1),[b,E]=x(null),[D,O]=x(!1),k=l!==null&&l>0,A=()=>{g(!1),D&&p(()=>f.refresh())},j=async()=>{y(!0),E(null);try{let t=await fetch(`${e}/form-remove`,{method:`DELETE`,headers:{"Content-Type":`application/json`},body:JSON.stringify({id:n})});if(t.status===202){E(u(`awaitingApproval`));return}if(t.status===409){E(u(`stillInUse`));return}if(t.status===403){E(u(`forbidden`));return}let r=await t.json().catch(()=>null),i=typeof r?.submissions==`number`?r.submissions:0;if(t.status===500&&r?.deleted===!0){O(!0),E(u(`lateFailed`));return}if(p(()=>f.refresh()),t.status===500&&r?.more===!0){E(u(`partial`,{count:i}));return}if(!t.ok){E(u(`failed`,{status:t.status}));return}if(r?.deleted!==!0){E(u(`more`,{count:i}));return}g(!1)}catch{E(u(`failed`,{status:0}))}finally{y(!1)}};return T(C,{children:[w(t,{type:`button`,variant:`ghost`,size:`sm`,className:`text-destructive hover:text-destructive`,"aria-label":d(`deleteForm`,{form:s}),onClick:()=>{E(null),g(!0)},children:d(`delete`)}),w(r,{open:m,onOpenChange:e=>e?g(!0):A(),children:T(i,{className:`max-w-md`,children:[w(o,{children:w(ee,{children:u(`title`,{form:s})})}),w(a,{asChild:!0,children:w(`div`,{className:`space-y-2 text-sm text-muted-foreground`,children:k?w(`p`,{"data-forms-remove-in-use":``,children:u(`inUse`,{count:l})}):T(C,{children:[w(`p`,{"data-forms-remove-count":c??``,children:c===null?u(`submissionsUnknown`):u(`submissions`,{count:c})}),w(`p`,{children:u(`trash`)})]})})}),b!==null&&w(`p`,{role:`alert`,className:`text-sm text-destructive`,children:b}),T(`div`,{className:`flex justify-end gap-2 pt-2`,children:[w(t,{type:`button`,variant:`outline`,onClick:A,children:u(`cancel`)}),!k&&!D&&w(t,{variant:`destructive`,disabled:v,onClick:()=>void j(),children:u(c===0?`confirmEmpty`:`confirm`)})]})]})})]})}function W({apiBasePath:e,formsBasePath:n}){let a=_(`Forms.newDialog`),l=h(),[u,d]=x(!1),[f,p]=x(``),[m,g]=x(!1),[v,y]=x(null),b=f.trim(),S=b!==``&&b.length<=O&&!m;return T(C,{children:[w(t,{type:`button`,size:`sm`,onClick:()=>d(!0),children:a(`title`)}),w(r,{open:u,onOpenChange:d,children:T(i,{className:`max-w-md`,children:[w(o,{children:w(ee,{children:a(`title`)})}),T(`form`,{onSubmit:async t=>{if(t.preventDefault(),S){g(!0),y(null);try{let t=await fetch(`${e}/form`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({name:b})});if(!t.ok)throw Error(a(`failed`,{status:t.status}));let r=await t.json();if(typeof r.id!=`string`)throw Error(a(`noId`));d(!1),g(!1),l.push(`${n}/${r.id}`)}catch(e){y(e instanceof Error?e.message:a(`failed`,{status:0})),g(!1)}}},className:`flex flex-col gap-3`,children:[T(`div`,{className:`flex flex-col gap-1.5`,children:[w(c,{htmlFor:`new-form-name`,children:a(`name`)}),w(s,{id:`new-form-name`,value:f,maxLength:O,onChange:e=>p(e.target.value),placeholder:a(`namePlaceholder`),"aria-describedby":`new-form-name-help`}),w(`p`,{id:`new-form-name-help`,className:`text-xs text-muted-foreground`,children:a(`help`)})]}),v!==null&&w(`p`,{role:`alert`,className:`text-sm text-destructive`,children:v}),T(`div`,{className:`flex justify-end gap-2 pt-1`,children:[w(t,{type:`button`,variant:`outline`,onClick:()=>d(!1),children:a(`cancel`)}),w(t,{type:`submit`,disabled:!S,loading:m,children:a(`create`)})]})]})]})})]})}function G(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function K(e){return G(e)&&typeof e.id==`string`&&typeof e.formId==`string`&&(typeof e.formName==`string`||e.formName===null)&&typeof e.createdAt==`string`&&(typeof e.pagePath==`string`||e.pagePath===null)}function te(e,t){return/filename="([^"]+)"/.exec(e.headers.get(`Content-Disposition`)??``)?.[1]??t}function q({apiBasePath:e,formsBasePath:n,locale:h,canErase:v}){let b=_(`Forms.privacy`),S=g(),E=y(),[D,O]=x(``),[k,A]=x(null),[j,M]=x(!1),[N,P]=x(!1),[F,I]=x(null),[L,R]=x(null),z=`${e}/form-privacy`,B=(e,t,n)=>fetch(`${z}${e}`,{method:t,headers:{"Content-Type":`application/json`},body:JSON.stringify(n)}),V=e=>e.ok?!0:(e.status===400?I(b(`invalid`)):e.status===403?I(b(`forbidden`)):e.status===503?I(b(`busy`)):I(b(`failed`,{status:e.status})),!1),H=async e=>{M(!0),I(null);try{let t=await B(`/search`,`POST`,{email:e});if(!V(t)){A(null);return}let n=await t.json().catch(()=>null);if(!G(n)||typeof n.total!=`number`||!Array.isArray(n.items)){I(b(`failed`,{status:t.status})),A(null);return}let r=n.items;A({email:e,total:n.total,items:r.filter(K),unindexed:typeof n.unindexed==`number`?n.unindexed:0,exportMax:typeof n.exportMax==`number`?n.exportMax:5e3})}catch{I(b(`failed`,{status:0})),A(null)}finally{M(!1)}},U=e=>{e.preventDefault();let t=D.trim();if(R(null),t===``){I(b(`invalid`)),A(null);return}H(t)},W=async e=>{if(k!==null){M(!0),I(null),R(null);try{let t=await B(`/export`,`POST`,{email:k.email,format:e,uiLocale:h});if(!V(t))return;let n=URL.createObjectURL(await t.blob()),r=document.createElement(`a`);r.href=n,r.download=te(t,`privacy-request.${e}`),document.body.append(r),r.click(),r.remove(),setTimeout(()=>URL.revokeObjectURL(n),6e4),t.headers.get(`X-Lumi-Export-Truncated`)===`true`&&R(b(`exportCapped`,{max:k.exportMax}))}catch{I(b(`failed`,{status:0}))}finally{M(!1)}}},q=async()=>{if(k!==null){P(!1),M(!0),I(null),R(null);try{let e=await B(``,`DELETE`,{email:k.email}),t=await e.json().catch(()=>null),n=G(t)&&typeof t.erased==`number`?t.erased:null;if(e.status===500&&n!==null&&G(t)&&t.more===!0){await H(k.email),I(b(`eraseFailed`,{count:n}));return}if(!V(e))return;if(n===null){I(b(`failed`,{status:e.status}));return}let r=G(t)&&t.more===!0;await H(k.email),R(b(r?`erasedMore`:`erased`,{count:n}))}catch{I(b(`failed`,{status:0}))}finally{M(!1)}}},J=e=>S.dateTime(new Date(e),{dateStyle:`short`,timeStyle:`short`});return T(`div`,{className:`space-y-4`,children:[T(`form`,{onSubmit:U,className:`flex flex-wrap items-end gap-3 rounded-md border p-3`,autoComplete:`off`,children:[T(`div`,{className:`flex min-w-[16rem] flex-1 flex-col gap-1.5`,children:[w(c,{htmlFor:E,children:b(`email`)}),w(s,{id:E,type:`email`,inputMode:`email`,autoComplete:`off`,spellCheck:!1,maxLength:320,value:D,onChange:e=>O(e.target.value),placeholder:b(`placeholder`)})]}),w(t,{type:`submit`,disabled:j,children:b(j&&k===null?`finding`:`find`)})]}),F!==null&&w(`p`,{role:`alert`,className:`rounded-md border border-destructive/40 p-3 text-sm`,children:F}),L!==null&&w(`p`,{role:`status`,className:`rounded-md border p-3 text-sm`,"data-forms-privacy-notice":``,children:L}),k!==null&&k.unindexed>0&&w(`p`,{role:`status`,className:`rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm`,"data-forms-privacy-unindexed":k.unindexed,children:b(`unindexed`,{count:k.unindexed})}),k!==null&&k.total===0&&T(`div`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:[w(`p`,{children:b(`none`)}),w(`p`,{className:`mt-1`,children:b(`noneHint`)})]}),k!==null&&k.total>0&&T(C,{children:[w(`div`,{className:`overflow-x-auto rounded-md border`,children:T(l,{children:[w(p,{children:T(m,{children:[w(f,{children:b(`form`)}),w(f,{children:b(`received`)}),w(f,{children:b(`page`)})]})}),w(u,{children:k.items.map(e=>T(m,{"data-forms-privacy-row":e.id,children:[w(d,{className:`font-medium`,children:e.formName===null?w(`span`,{className:`text-muted-foreground`,children:b(`deletedForm`)}):w(`a`,{href:`${n}/${e.formId}/submissions`,className:`text-primary hover:underline`,children:e.formName})}),w(d,{className:`whitespace-nowrap tabular-nums`,children:J(e.createdAt)}),w(d,{className:`text-muted-foreground`,children:e.pagePath??`—`})]},e.id))})]})}),T(`div`,{className:`flex flex-wrap items-center gap-2 text-sm`,children:[w(`span`,{"data-forms-privacy-total":k.total,children:b(`summary`,{count:k.total})}),w(t,{variant:`outline`,size:`sm`,disabled:j,onClick:()=>void W(`csv`),children:b(`exportCsv`)}),w(t,{variant:`outline`,size:`sm`,disabled:j,onClick:()=>void W(`json`),children:b(`exportJson`)}),v&&w(t,{variant:`outline`,size:`sm`,className:`border-destructive/50 text-destructive hover:bg-destructive/10`,disabled:j,onClick:()=>P(!0),children:b(`eraseAll`,{count:k.total})})]}),k.total>k.items.length&&w(`p`,{className:`text-xs text-muted-foreground`,children:b(`listCapped`,{shown:k.items.length,max:k.exportMax})}),w(`p`,{className:`text-xs text-muted-foreground`,children:b(`eraseNote`)})]}),w(r,{open:N,onOpenChange:P,children:T(i,{className:`max-w-md`,children:[w(o,{children:w(ee,{children:b(`confirmTitle`,{count:Math.max(k?.total??1,1)})})}),w(a,{children:b(`confirmBody`)}),T(`div`,{className:`flex justify-end gap-2 pt-2`,children:[w(t,{variant:`outline`,onClick:()=>P(!1),children:b(`cancel`)}),w(t,{variant:`destructive`,disabled:j,onClick:()=>void q(),children:b(`confirmErase`)})]})]})})]})}function J(){F(V),P({name:D,blocks:A,traits:j,fieldTypes:[I(L),I(z)],defaultTheme:B}),N({source:E,name:k,blocks:M,fieldTypes:[I(R),I(L),I(z)]})}function ne(){return J(),null}const re={new:`default`,read:`secondary`,archived:`outline`},Y=[`no_recipients`,`disabled`],ie=(e,t)=>e.status===`failed`&&!(e.kind===`autoReply`&&e.errorClass===`timeout`&&t-new Date(e.updatedAt).getTime()<36e5)||e.status===`queued`&&t-new Date(e.updatedAt).getTime()>3e5||e.status===`skipped`&&e.errorClass!==null&&Y.includes(e.errorClass),ae=e=>e===`failed`?`destructive`:e===`sent`?`secondary`:`outline`;function X(e,t){return Object.hasOwn(e,t)?e[t]:void 0}function oe(e,t){let n=t.map(e=>e.key).filter(t=>Object.hasOwn(e.data,t));return[...n,...Object.keys(e.data).filter(e=>!n.includes(e))]}function Z({formId:s,rows:c,columns:y,canManage:C,deliveries:E,apiBasePath:D,emptyText:O}){let k=_(`Forms.submissions`),A=_(`Forms.submissions.emails`),j=_(`Forms.submissionStatus`),M=g(),N=h(),[P,F]=S(),[I,L]=x(null),R=I?.id??null,z=b(null),B=b(new Set),[V,H]=x(new Set),[U,W]=x(null),[G,K]=x(!1),[te,q]=x(null),[J,ne]=x(null),Y=c.find(e=>e.id===R)??I,Z=c.filter(e=>V.has(e.id)).map(e=>e.id),se=c.length>0&&Z.length===c.length,ce=e=>M.dateTime(new Date(e),{dateStyle:`short`,timeStyle:`short`}),le=e=>e===void 0||e===``?`—`:typeof e==`boolean`?k(e?`yes`:`no`):String(e),ue=async(e,t)=>{K(!0),q(null);try{let n=await fetch(`${D}/form-submissions`,{method:e,headers:{"Content-Type":`application/json`},body:JSON.stringify({form:s,...t})});if(n.status===202)return q(k(`awaitingApproval`)),!1;if(!n.ok)return q(k(`failed`,{status:n.status})),!1;let r=await n.json().catch(()=>null),i=e===`DELETE`?r?.deleted:r?.changed;if(F(()=>N.refresh()),typeof i!=`number`||i<=0){q(k(`nothingChanged`));let e=t.ids;return Array.isArray(e)&&L(t=>t&&e.includes(t.id)?null:t),!1}return!0}catch{return q(k(`failed`,{status:0})),!1}finally{K(!1)}},de=async(e,t)=>{K(!0),q(null);try{let n=await fetch(`${D}/form-emails/retry`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({form:s,submission:e,kind:t})});if(n.status===202){q(k(`awaitingApproval`));return}if(!n.ok){q(k(`failed`,{status:n.status}));return}if((await n.json().catch(()=>null))?.queued!==!0){q(A(`notQueued`));return}ne(A(`retrying`)),F(()=>N.refresh())}catch{q(k(`failed`,{status:0}))}finally{K(!1)}},Q=async(e,t)=>{let n=await ue(`PATCH`,{ids:e,status:t});if(!n||t===`new`)for(let t of e)B.current.delete(t);return n&&L(n=>n&&e.includes(n.id)?{...n,status:t}:n),n},fe=e=>{L(e),C&&e.status===`new`&&!B.current.has(e.id)&&(B.current.add(e.id),Q([e.id],`read`))};v(()=>{R!==null&&z.current?.focus()},[R]);let pe=async()=>{if(U===null)return;let e=U;W(null),await ue(`DELETE`,{ids:e})&&(H(t=>new Set([...t].filter(t=>!e.includes(t)))),L(t=>t&&e.includes(t.id)?null:t))},me=(e,t)=>H(n=>{let r=new Set(n);return t?r.add(e):r.delete(e),r}),$=G||P,he=Date.now(),ge=e=>A.has(`reason.${e}`)?A(`reason.${e}`):e,_e=t=>{if(t===void 0||t.length===0)return w(`span`,{className:`text-muted-foreground`,children:`—`});let n=t.find(e=>e.status===`failed`)??t.find(e=>e.status===`queued`)??t.find(e=>e.status===`sent`)??t[0];return n===void 0?w(`span`,{className:`text-muted-foreground`,children:`—`}):w(e,{variant:ae(n.status),children:A(`delivery.${n.status}`)})};return T(`div`,{className:`space-y-3`,children:[J!==null&&w(`p`,{role:`status`,className:`rounded-md border p-3 text-sm`,children:J}),te!==null&&w(`p`,{role:`alert`,className:`rounded-md border border-destructive/40 p-3 text-sm`,children:te}),T(`div`,{className:`flex flex-col gap-4 lg:flex-row lg:items-start`,children:[T(`div`,{className:`min-w-0 flex-1 space-y-3`,children:[c.length===0&&w(`p`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:O}),w(`div`,{className:`overflow-x-auto rounded-md border`,hidden:c.length===0,children:T(l,{children:[w(p,{children:T(m,{children:[C&&w(f,{className:`w-10`,children:w(n,{"aria-label":k(`selectAll`),checked:se,onCheckedChange:e=>H(e===!0?new Set(c.map(e=>e.id)):new Set)})}),w(f,{className:`whitespace-nowrap`,children:k(`received`)}),w(f,{className:`whitespace-nowrap`,children:k(`status`)}),w(f,{className:`whitespace-nowrap`,children:A(`emailsColumn`)}),w(f,{className:`whitespace-nowrap`,children:k(`page`)}),y.map(e=>T(f,{"data-forms-column":e.key,title:e.label,className:`max-w-[14rem] truncate whitespace-nowrap`,children:[e.label,e.retired&&T(`span`,{className:`ml-1 text-xs font-normal text-muted-foreground`,children:[`(`,k(`removedField`),`)`]})]},e.key))]})}),w(u,{children:c.map(t=>T(m,{"data-state":t.id===R?`selected`:void 0,className:t.status===`new`?`font-medium`:void 0,children:[C&&w(d,{children:w(n,{"aria-label":k(`selectRow`,{when:ce(t.createdAt)}),checked:V.has(t.id),onCheckedChange:e=>me(t.id,e===!0)})}),w(d,{className:`whitespace-nowrap`,children:w(`button`,{type:`button`,className:`text-primary hover:underline`,"aria-expanded":t.id===R,onClick:()=>fe(t),children:ce(t.createdAt)})}),w(d,{children:w(e,{variant:re[t.status],children:j(t.status)})}),w(d,{"data-forms-emails-cell":``,children:_e(X(E,t.id))}),w(d,{className:`text-muted-foreground`,children:t.pagePath??`—`}),y.map(e=>{let n=le(X(t.data,e.key));return w(d,{className:`max-w-[16rem] truncate`,children:n.length>60?`${n.slice(0,60)}…`:n},e.key)})]},t.id))})]})}),C&&c.length>0&&w(t,{variant:`outline`,size:`sm`,disabled:$||Z.length===0,onClick:()=>W(Z),children:k(`deleteSelected`,{count:Z.length})})]}),Y!==null&&T(`aside`,{ref:z,tabIndex:-1,"aria-label":k(`detail`),className:`w-full shrink-0 space-y-4 rounded-md border p-4 lg:w-96`,children:[T(`div`,{className:`flex items-start justify-between gap-2`,children:[T(`div`,{children:[w(`p`,{className:`font-medium`,children:ce(Y.createdAt)}),w(`p`,{className:`text-sm text-muted-foreground`,children:Y.pagePath===null?k(`noPage`):k(`fromPage`,{page:Y.pagePath})})]}),w(t,{variant:`ghost`,size:`sm`,onClick:()=>L(null),children:k(`close`)})]}),w(`dl`,{className:`space-y-3 text-sm`,children:oe(Y,y).map(e=>{let t=X(Y.data,e),n=X(Y.consent,e);return T(`div`,{children:[w(`dt`,{className:`text-xs text-muted-foreground`,children:X(Y.fields,e)?.label??e}),w(`dd`,{className:`whitespace-pre-wrap break-words`,children:le(t)}),n!==void 0&&w(`dd`,{className:`mt-1 text-xs text-muted-foreground`,children:k(`consentWording`,{wording:n})})]},e)})}),T(`section`,{className:`space-y-2 border-t pt-3 text-sm`,"aria-label":A(`emailsTitle`),children:[w(`h3`,{className:`text-xs font-medium text-muted-foreground`,children:A(`emailsTitle`)}),(X(E,Y.id)??[]).length===0?w(`p`,{className:`text-muted-foreground`,children:A(`noEmails`)}):w(`ul`,{className:`space-y-2`,children:(X(E,Y.id)??[]).map(n=>T(`li`,{"data-forms-delivery":`${n.kind}:${n.status}`,children:[T(`div`,{className:`flex flex-wrap items-center gap-2`,children:[w(`span`,{className:`font-medium`,children:A(`kind.${n.kind}`)}),w(e,{variant:ae(n.status),children:A(`delivery.${n.status}`)}),C&&ie(n,he)&&w(t,{variant:`outline`,size:`sm`,disabled:$,onClick:()=>void de(Y.id,n.kind),children:A(`retry`)})]}),n.errorClass!==null&&w(`p`,{className:`mt-1 text-xs text-muted-foreground`,children:ge(n.errorClass)})]},n.kind))})]}),C&&T(`div`,{className:`flex flex-wrap gap-2 border-t pt-3`,children:[Y.status===`new`?w(t,{variant:`outline`,size:`sm`,disabled:$,onClick:()=>void Q([Y.id],`read`),children:k(`markRead`)}):w(t,{variant:`outline`,size:`sm`,disabled:$,onClick:()=>void Q([Y.id],`new`),children:k(`markNew`)}),Y.status===`archived`?w(t,{variant:`outline`,size:`sm`,disabled:$,onClick:()=>void Q([Y.id],`read`),children:k(`unarchive`)}):w(t,{variant:`outline`,size:`sm`,disabled:$,onClick:()=>void Q([Y.id],`archived`),children:k(`archive`)}),w(t,{variant:`destructive`,size:`sm`,disabled:$,onClick:()=>W([Y.id]),children:k(`delete`)})]})]})]}),w(r,{open:U!==null,onOpenChange:e=>!e&&W(null),children:T(i,{children:[w(o,{children:w(ee,{children:k(`confirmDeleteTitle`,{count:U?.length??0})})}),w(a,{children:k(`confirmDeleteBody`)}),T(`div`,{className:`flex justify-end gap-2`,children:[w(t,{variant:`outline`,onClick:()=>W(null),children:k(`cancel`)}),w(t,{variant:`destructive`,disabled:G,onClick:()=>void pe(),children:k(`confirmDelete`)})]})]})})]})}function se({href:e}){return w(t,{variant:`outline`,size:`sm`,asChild:!0,children:w(`a`,{href:e,download:!0,onPointerDown:t=>{t.currentTarget.href=H(e)},onFocus:t=>{t.currentTarget.href=H(e)},onClick:t=>{t.currentTarget.href=H(e)},children:_(`Forms.submissions`)(`exportCsv`)})})}export{U as DeleteFormButton,ne as FormBlocksRegistration,W as NewFormDialog,q as PrivacyRequestsView,se as SubmissionsExportLink,Z as SubmissionsView,J as registerFormBlocks};
3
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/delete-form-dialog.tsx","../src/new-form-dialog.tsx","../src/privacy-view.tsx","../src/register.tsx","../src/submissions-view.tsx"],"sourcesContent":["'use client'\n\n/**\n * \"Delete…\" on a row of the Forms list (W1): say what goes with the form —\n * how many submissions, for good — and delete both on confirm (forms D007).\n *\n * It calls `DELETE /api/admin/form-remove`, not the generic entity delete:\n * that one would move the form to the Trash and leave its visitors' answers\n * stored where nobody can see them. The numbers shown are the list's own, read\n * on the server; the route checks everything again and is what decides.\n */\n\nimport {\n Button,\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from '@murumets-ee/ui'\nimport { useRouter } from 'next/navigation'\nimport { useTranslations } from 'next-intl'\nimport { type ReactElement, useState, useTransition } from 'react'\n\nexport interface DeleteFormButtonProps {\n readonly apiBasePath: string\n readonly formId: string\n readonly formName: string\n /** Stored submissions, or `null` when this person may not see the count. */\n readonly submissions: number | null\n /** Published pages showing the form, or `null` when the count could not be read. */\n readonly pages: number | null\n}\n\nexport function DeleteFormButton({\n apiBasePath,\n formId,\n formName,\n submissions,\n pages,\n}: DeleteFormButtonProps): ReactElement {\n const t = useTranslations('Forms.remove')\n const tList = useTranslations('Forms.list')\n const router = useRouter()\n const [, startRefresh] = useTransition()\n const [open, setOpen] = useState(false)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [reloadOnClose, setReloadOnClose] = useState(false)\n const inUse = pages !== null && pages > 0\n\n /** Close the dialog — and reload the list if an answer was held back for it. */\n const close = (): void => {\n setOpen(false)\n if (reloadOnClose) startRefresh(() => router.refresh())\n }\n\n const remove = async (): Promise<void> => {\n setBusy(true)\n setError(null)\n try {\n const res = await fetch(`${apiBasePath}/form-remove`, {\n method: 'DELETE',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ id: formId }),\n })\n if (res.status === 202) {\n // An agent's delete was parked for a person to approve.\n setError(t('awaitingApproval'))\n return\n }\n if (res.status === 409) {\n setError(t('stillInUse'))\n return\n }\n if (res.status === 403) {\n setError(t('forbidden'))\n return\n }\n const answer = (await res.json().catch(() => null)) as {\n deleted?: unknown\n more?: unknown\n submissions?: unknown\n } | null\n const gone = typeof answer?.submissions === 'number' ? answer.submissions : 0\n if (res.status === 500 && answer?.deleted === true) {\n // The form went, but a submission sent while it was going could not\n // be removed. This dialog lives in the form's row, which a reload\n // takes away — so the list is reloaded when the person closes it.\n setReloadOnClose(true)\n setError(t('lateFailed'))\n return\n }\n // Whatever happened, the list's numbers are stale now.\n startRefresh(() => router.refresh())\n if (res.status === 500 && answer?.more === true) {\n // A batch failed part-way. The form was kept, so this row — and this\n // dialog — are still here to ask again from.\n setError(t('partial', { count: gone }))\n return\n }\n if (!res.ok) {\n setError(t('failed', { status: res.status }))\n return\n }\n if (answer?.deleted !== true) {\n // More submissions than one request removes: the form was kept.\n setError(t('more', { count: gone }))\n return\n }\n setOpen(false)\n } catch {\n setError(t('failed', { status: 0 }))\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"text-destructive hover:text-destructive\"\n aria-label={tList('deleteForm', { form: formName })}\n onClick={() => {\n setError(null)\n setOpen(true)\n }}\n >\n {tList('delete')}\n </Button>\n <Dialog open={open} onOpenChange={(next) => (next ? setOpen(true) : close())}>\n <DialogContent className=\"max-w-md\">\n <DialogHeader>\n <DialogTitle>{t('title', { form: formName })}</DialogTitle>\n </DialogHeader>\n <DialogDescription asChild>\n <div className=\"space-y-2 text-sm text-muted-foreground\">\n {inUse ? (\n <p data-forms-remove-in-use=\"\">{t('inUse', { count: pages })}</p>\n ) : (\n <>\n <p data-forms-remove-count={submissions ?? ''}>\n {submissions === null\n ? t('submissionsUnknown')\n : t('submissions', { count: submissions })}\n </p>\n <p>{t('trash')}</p>\n </>\n )}\n </div>\n </DialogDescription>\n {error !== null && (\n <p role=\"alert\" className=\"text-sm text-destructive\">\n {error}\n </p>\n )}\n <div className=\"flex justify-end gap-2 pt-2\">\n <Button type=\"button\" variant=\"outline\" onClick={close}>\n {t('cancel')}\n </Button>\n {!inUse && !reloadOnClose && (\n <Button variant=\"destructive\" disabled={busy} onClick={() => void remove()}>\n {submissions === 0 ? t('confirmEmpty') : t('confirm')}\n </Button>\n )}\n </div>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n","'use client'\n\n/**\n * \"New form\" on the Forms list (W1): ask for a name, create the form, and land\n * on its canvas (W2).\n *\n * It sends the NAME and nothing else. The server gives a form created without\n * a tree its starting contact form, in the request's content language\n * (`formStarterTree` in `@murumets-ee/forms`) — so the browser shapes no part\n * of the tree, and an agent creating a form gets the same one.\n *\n * It posts to the generic entity API (`POST /api/admin/form`), where the\n * firewall sits: `form:create`, CSRF, and the site of the request (the\n * admin's patched `fetch` adds it).\n */\n\nimport { FORM_NAME_MAX } from '@murumets-ee/forms/schema'\nimport {\n Button,\n Dialog,\n DialogContent,\n DialogHeader,\n DialogTitle,\n Input,\n Label,\n} from '@murumets-ee/ui'\nimport { useRouter } from 'next/navigation'\nimport { useTranslations } from 'next-intl'\nimport { type FormEvent, type ReactElement, useState } from 'react'\n\nexport interface NewFormDialogProps {\n readonly apiBasePath: string\n /** Where the form canvas lives — `<admin base>/forms`. */\n readonly formsBasePath: string\n}\n\nexport function NewFormDialog({ apiBasePath, formsBasePath }: NewFormDialogProps): ReactElement {\n const t = useTranslations('Forms.newDialog')\n const router = useRouter()\n const [open, setOpen] = useState(false)\n const [name, setName] = useState('')\n const [submitting, setSubmitting] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n const trimmed = name.trim()\n const canSubmit = trimmed !== '' && trimmed.length <= FORM_NAME_MAX && !submitting\n\n const onSubmit = async (event: FormEvent) => {\n event.preventDefault()\n if (!canSubmit) return\n setSubmitting(true)\n setError(null)\n try {\n const res = await fetch(`${apiBasePath}/form`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ name: trimmed }),\n })\n if (!res.ok) throw new Error(t('failed', { status: res.status }))\n const created = (await res.json()) as { id?: unknown }\n if (typeof created.id !== 'string') throw new Error(t('noId'))\n setOpen(false)\n setSubmitting(false)\n router.push(`${formsBasePath}/${created.id}`)\n } catch (err) {\n setError(err instanceof Error ? err.message : t('failed', { status: 0 }))\n setSubmitting(false)\n }\n }\n\n return (\n <>\n <Button type=\"button\" size=\"sm\" onClick={() => setOpen(true)}>\n {t('title')}\n </Button>\n <Dialog open={open} onOpenChange={setOpen}>\n <DialogContent className=\"max-w-md\">\n <DialogHeader>\n <DialogTitle>{t('title')}</DialogTitle>\n </DialogHeader>\n <form onSubmit={onSubmit} className=\"flex flex-col gap-3\">\n <div className=\"flex flex-col gap-1.5\">\n <Label htmlFor=\"new-form-name\">{t('name')}</Label>\n <Input\n id=\"new-form-name\"\n value={name}\n maxLength={FORM_NAME_MAX}\n onChange={(e) => setName(e.target.value)}\n placeholder={t('namePlaceholder')}\n aria-describedby=\"new-form-name-help\"\n />\n <p id=\"new-form-name-help\" className=\"text-xs text-muted-foreground\">\n {t('help')}\n </p>\n </div>\n {error !== null && (\n <p role=\"alert\" className=\"text-sm text-destructive\">\n {error}\n </p>\n )}\n <div className=\"flex justify-end gap-2 pt-1\">\n <Button type=\"button\" variant=\"outline\" onClick={() => setOpen(false)}>\n {t('cancel')}\n </Button>\n <Button type=\"submit\" disabled={!canSubmit} loading={submitting}>\n {t('create')}\n </Button>\n </div>\n </form>\n </DialogContent>\n </Dialog>\n </>\n )\n}\n","'use client'\n\n/**\n * Privacy requests (W5) — the interactive half: enter an address, see which\n * submissions carry it, export them, erase them.\n *\n * The address is kept in this component's state and sent in request BODIES\n * only (`privacy-routes.ts` says why): it is never put in the URL, so it does\n * not reach browser history or a server log, and reloading the page starts\n * empty. Nothing is fetched until a person presses Find.\n *\n * What is listed is where and when — form, time, page — not what was said;\n * the answers are in the export, and on each form's own Submissions screen.\n */\n\nimport {\n Button,\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n Input,\n Label,\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@murumets-ee/ui'\nimport { useFormatter, useTranslations } from 'next-intl'\nimport { type FormEvent, type ReactElement, useId, useState } from 'react'\nimport type { PrivacyMatch } from './privacy-match.js'\n\nexport interface PrivacyRequestsViewProps {\n readonly apiBasePath: string\n /** `<admin base>/forms` — a listed submission links to its form's Submissions. */\n readonly formsBasePath: string\n /** The interface language, for the export's column words and delimiter. */\n readonly locale: string\n /** May erase (`form_submissions:manage`). */\n readonly canErase: boolean\n}\n\ninterface Found {\n /** The address the list is FOR — what export and erase name, whatever the box says now. */\n readonly email: string\n readonly total: number\n readonly items: readonly PrivacyMatch[]\n /** Submissions of this site the search could NOT look through yet. */\n readonly unindexed: number\n /** Most submissions one export holds. */\n readonly exportMax: number\n}\n\n/** What the screen says an export holds when the server did not say — the server's own cap. */\nconst EXPORT_MAX_FALLBACK = 5000\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction isMatch(value: unknown): value is PrivacyMatch {\n return (\n isRecord(value) &&\n typeof value.id === 'string' &&\n typeof value.formId === 'string' &&\n (typeof value.formName === 'string' || value.formName === null) &&\n typeof value.createdAt === 'string' &&\n (typeof value.pagePath === 'string' || value.pagePath === null)\n )\n}\n\n/** The file name a download answers with, or `fallback`. */\nfunction downloadName(res: Response, fallback: string): string {\n const match = /filename=\"([^\"]+)\"/.exec(res.headers.get('Content-Disposition') ?? '')\n return match?.[1] ?? fallback\n}\n\nexport function PrivacyRequestsView({\n apiBasePath,\n formsBasePath,\n locale,\n canErase,\n}: PrivacyRequestsViewProps): ReactElement {\n const t = useTranslations('Forms.privacy')\n const format = useFormatter()\n const inputId = useId()\n const [email, setEmail] = useState('')\n const [found, setFound] = useState<Found | null>(null)\n const [busy, setBusy] = useState(false)\n const [confirming, setConfirming] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [notice, setNotice] = useState<string | null>(null)\n\n const endpoint = `${apiBasePath}/form-privacy`\n\n const send = (path: string, method: 'POST' | 'DELETE', body: Record<string, unknown>) =>\n fetch(`${endpoint}${path}`, {\n method,\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n })\n\n /** Words for a refusal; `true` when the answer was a success. */\n const explain = (res: Response): boolean => {\n if (res.ok) return true\n if (res.status === 400) setError(t('invalid'))\n else if (res.status === 403) setError(t('forbidden'))\n else if (res.status === 503) setError(t('busy'))\n else setError(t('failed', { status: res.status }))\n return false\n }\n\n const find = async (address: string): Promise<void> => {\n setBusy(true)\n setError(null)\n try {\n const res = await send('/search', 'POST', { email: address })\n if (!explain(res)) {\n setFound(null)\n return\n }\n const answer: unknown = await res.json().catch(() => null)\n if (!isRecord(answer) || typeof answer.total !== 'number' || !Array.isArray(answer.items)) {\n setError(t('failed', { status: res.status }))\n setFound(null)\n return\n }\n const items: unknown[] = answer.items\n setFound({\n email: address,\n total: answer.total,\n items: items.filter(isMatch),\n unindexed: typeof answer.unindexed === 'number' ? answer.unindexed : 0,\n exportMax: typeof answer.exportMax === 'number' ? answer.exportMax : EXPORT_MAX_FALLBACK,\n })\n } catch {\n setError(t('failed', { status: 0 }))\n setFound(null)\n } finally {\n setBusy(false)\n }\n }\n\n const onSubmit = (event: FormEvent) => {\n event.preventDefault()\n const address = email.trim()\n setNotice(null)\n // The server judges the address; this only saves a round trip for an empty box.\n if (address === '') {\n setError(t('invalid'))\n setFound(null)\n return\n }\n void find(address)\n }\n\n const download = async (kind: 'csv' | 'json'): Promise<void> => {\n if (found === null) return\n setBusy(true)\n setError(null)\n setNotice(null)\n try {\n const res = await send('/export', 'POST', {\n email: found.email,\n format: kind,\n uiLocale: locale,\n })\n if (!explain(res)) return\n const url = URL.createObjectURL(await res.blob())\n const link = document.createElement('a')\n link.href = url\n link.download = downloadName(res, `privacy-request.${kind}`)\n document.body.append(link)\n link.click()\n link.remove()\n // Not at once: some browsers cancel a download whose URL is revoked in\n // the same tick it was clicked.\n setTimeout(() => URL.revokeObjectURL(url), 60_000)\n if (res.headers.get('X-Lumi-Export-Truncated') === 'true') {\n setNotice(t('exportCapped', { max: found.exportMax }))\n }\n } catch {\n setError(t('failed', { status: 0 }))\n } finally {\n setBusy(false)\n }\n }\n\n const erase = async (): Promise<void> => {\n if (found === null) return\n setConfirming(false)\n setBusy(true)\n setError(null)\n setNotice(null)\n try {\n const res = await send('', 'DELETE', { email: found.email })\n const answer: unknown = await res.json().catch(() => null)\n const erased = isRecord(answer) && typeof answer.erased === 'number' ? answer.erased : null\n // A batch failed part-way and submissions remain: the answer still says\n // how many went.\n if (res.status === 500 && erased !== null && isRecord(answer) && answer.more === true) {\n await find(found.email)\n setError(t('eraseFailed', { count: erased }))\n return\n }\n if (!explain(res)) return\n if (erased === null) {\n setError(t('failed', { status: res.status }))\n return\n }\n const more = isRecord(answer) && answer.more === true\n // Show what is left as the server now finds it — nothing, normally.\n await find(found.email)\n setNotice(more ? t('erasedMore', { count: erased }) : t('erased', { count: erased }))\n } catch {\n setError(t('failed', { status: 0 }))\n } finally {\n setBusy(false)\n }\n }\n\n const when = (iso: string) =>\n format.dateTime(new Date(iso), { dateStyle: 'short', timeStyle: 'short' })\n\n return (\n <div className=\"space-y-4\">\n <form\n onSubmit={onSubmit}\n className=\"flex flex-wrap items-end gap-3 rounded-md border p-3\"\n // The address must not be offered back by the browser on the next visit.\n autoComplete=\"off\"\n >\n <div className=\"flex min-w-[16rem] flex-1 flex-col gap-1.5\">\n <Label htmlFor={inputId}>{t('email')}</Label>\n <Input\n id={inputId}\n type=\"email\"\n inputMode=\"email\"\n autoComplete=\"off\"\n spellCheck={false}\n maxLength={320}\n value={email}\n onChange={(event) => setEmail(event.target.value)}\n placeholder={t('placeholder')}\n />\n </div>\n <Button type=\"submit\" disabled={busy}>\n {busy && found === null ? t('finding') : t('find')}\n </Button>\n </form>\n\n {error !== null && (\n <p role=\"alert\" className=\"rounded-md border border-destructive/40 p-3 text-sm\">\n {error}\n </p>\n )}\n {notice !== null && (\n <p role=\"status\" className=\"rounded-md border p-3 text-sm\" data-forms-privacy-notice=\"\">\n {notice}\n </p>\n )}\n\n {found !== null && found.unindexed > 0 && (\n <p\n role=\"status\"\n className=\"rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm\"\n data-forms-privacy-unindexed={found.unindexed}\n >\n {t('unindexed', { count: found.unindexed })}\n </p>\n )}\n\n {found !== null && found.total === 0 && (\n <div className=\"rounded-md border border-dashed p-6 text-sm text-muted-foreground\">\n <p>{t('none')}</p>\n <p className=\"mt-1\">{t('noneHint')}</p>\n </div>\n )}\n\n {found !== null && found.total > 0 && (\n <>\n <div className=\"overflow-x-auto rounded-md border\">\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>{t('form')}</TableHead>\n <TableHead>{t('received')}</TableHead>\n <TableHead>{t('page')}</TableHead>\n </TableRow>\n </TableHeader>\n <TableBody>\n {found.items.map((item) => (\n <TableRow key={item.id} data-forms-privacy-row={item.id}>\n <TableCell className=\"font-medium\">\n {item.formName === null ? (\n <span className=\"text-muted-foreground\">{t('deletedForm')}</span>\n ) : (\n <a\n href={`${formsBasePath}/${item.formId}/submissions`}\n className=\"text-primary hover:underline\"\n >\n {item.formName}\n </a>\n )}\n </TableCell>\n <TableCell className=\"whitespace-nowrap tabular-nums\">\n {when(item.createdAt)}\n </TableCell>\n <TableCell className=\"text-muted-foreground\">{item.pagePath ?? '—'}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n\n <div className=\"flex flex-wrap items-center gap-2 text-sm\">\n <span data-forms-privacy-total={found.total}>\n {t('summary', { count: found.total })}\n </span>\n <Button\n variant=\"outline\"\n size=\"sm\"\n disabled={busy}\n onClick={() => void download('csv')}\n >\n {t('exportCsv')}\n </Button>\n <Button\n variant=\"outline\"\n size=\"sm\"\n disabled={busy}\n onClick={() => void download('json')}\n >\n {t('exportJson')}\n </Button>\n {canErase && (\n <Button\n variant=\"outline\"\n size=\"sm\"\n className=\"border-destructive/50 text-destructive hover:bg-destructive/10\"\n disabled={busy}\n onClick={() => setConfirming(true)}\n >\n {t('eraseAll', { count: found.total })}\n </Button>\n )}\n </div>\n {found.total > found.items.length && (\n <p className=\"text-xs text-muted-foreground\">\n {t('listCapped', { shown: found.items.length, max: found.exportMax })}\n </p>\n )}\n <p className=\"text-xs text-muted-foreground\">{t('eraseNote')}</p>\n </>\n )}\n\n <Dialog open={confirming} onOpenChange={setConfirming}>\n <DialogContent className=\"max-w-md\">\n <DialogHeader>\n <DialogTitle>\n {t('confirmTitle', { count: Math.max(found?.total ?? 1, 1) })}\n </DialogTitle>\n </DialogHeader>\n <DialogDescription>{t('confirmBody')}</DialogDescription>\n <div className=\"flex justify-end gap-2 pt-2\">\n <Button variant=\"outline\" onClick={() => setConfirming(false)}>\n {t('cancel')}\n </Button>\n <Button variant=\"destructive\" disabled={busy} onClick={() => void erase()}>\n {t('confirmErase')}\n </Button>\n </div>\n </DialogContent>\n </Dialog>\n </div>\n )\n}\n","'use client'\n\n/**\n * Puts the `form` environment into the browser's block registry — the one\n * client-side step a canvas needs, MOUNTED by the admin layout beside\n * `<MenuBlocksRegistration />` (see CLAUDE.md's standing exception for why the\n * browser half is a registry call and not a `Plugin.contributes` slot).\n *\n * The server learns the same vocabulary declaratively from the plugin\n * (`blocks.blocks` + `blocks.environmentBases`), so the two agree on every\n * block name; this half adds what only the browser can hold — the renders and\n * the panel's two field editors.\n */\n\nimport {\n registerBlockEnvironment,\n registerBlockEnvironmentBase,\n registerBlockTheme,\n widenFieldType,\n} from '@murumets-ee/blocks/core'\nimport { formFieldKeyType, formPickerType, formSettingsTabsType } from '@murumets-ee/forms/editor'\nimport { FORMS_THEME, formsTheme } from '@murumets-ee/forms/render'\nimport {\n FORM_ENVIRONMENT,\n FORMS_SOURCE,\n formEnvironmentBlocks,\n formEnvironmentTraits,\n formSiteBlocks,\n PAGE_ENVIRONMENT,\n} from '@murumets-ee/forms/schema'\n\nexport function registerFormBlocks(): void {\n registerBlockTheme(formsTheme)\n registerBlockEnvironmentBase({\n name: FORM_ENVIRONMENT,\n blocks: formEnvironmentBlocks,\n traits: formEnvironmentTraits,\n fieldTypes: [widenFieldType(formFieldKeyType), widenFieldType(formSettingsTabsType)],\n defaultTheme: FORMS_THEME,\n })\n // The page canvas (D002): the Form block in the palette, and — because the\n // saved form it shows is drawn and edited IN PLACE on the page — every block\n // a form is made of, with their panel editors. A field is still offered only\n // inside a form (`ancestor`), so the page itself gains one entry: \"Form\".\n // The server's `page` environment holds the same blocks\n // (`formPageEnvironmentExtension`); the fields are edited in the form's own\n // document, judged against the `form` environment.\n registerBlockEnvironment({\n source: FORMS_SOURCE,\n name: PAGE_ENVIRONMENT,\n blocks: formSiteBlocks,\n fieldTypes: [\n widenFieldType(formPickerType),\n widenFieldType(formFieldKeyType),\n widenFieldType(formSettingsTabsType),\n ],\n })\n}\n\n/**\n * Render it, do not import it for effect: a component in the layout is what\n * guarantees the registration runs before any canvas resolves its vocabulary\n * (`resolveBlockVocabulary` runs during render). Same shape as menus-ui's.\n */\nexport function FormBlocksRegistration(): null {\n registerFormBlocks()\n return null\n}\n","'use client'\n\n/**\n * The interactive half of the Submissions screen (W4): the table with a\n * column per field, the detail panel beside it, and the actions — mark read,\n * archive, delete, delete selected, export.\n *\n * The rows come from the server page as props (no fetch on mount). An action\n * calls the `form-submissions` routes and then asks the server for the page\n * again (`router.refresh()`), so what is on screen is always what is stored.\n *\n * Opening a new submission marks it read — that is what \"new\" means — but only\n * for someone who may change submissions; a read-only viewer leaves no trace.\n *\n * The detail panel shows each answer under the label the field had WHEN IT WAS\n * SENT (the row's own snapshot), while a column header carries the field's\n * current label: a renamed field keeps old answers readable both ways.\n */\n\nimport { withAdminSiteParam } from '@murumets-ee/admin-ui'\nimport type {\n DeliveryRow,\n SubmissionColumn,\n SubmissionRow,\n SubmissionStatus,\n} from '@murumets-ee/forms'\nimport {\n Badge,\n Button,\n Checkbox,\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@murumets-ee/ui'\nimport { useRouter } from 'next/navigation'\nimport { useFormatter, useTranslations } from 'next-intl'\nimport { type ReactElement, useEffect, useRef, useState, useTransition } from 'react'\n\nexport interface SubmissionsViewProps {\n readonly formId: string\n readonly rows: readonly SubmissionRow[]\n readonly columns: readonly SubmissionColumn[]\n /** May mark read / archive / delete (`form_submissions:manage`). */\n readonly canManage: boolean\n /** Each listed submission's emails (notification / auto-reply), by submission id. */\n readonly deliveries: Readonly<Record<string, readonly DeliveryRow[]>>\n readonly apiBasePath: string\n /** What to say when no submission is listed — the page knows whether a filter is why. */\n readonly emptyText: string\n}\n\n/** Longest answer shown in a table cell; the panel shows it whole. */\nconst CELL_MAX = 60\n\nconst STATUS_VARIANT: Record<SubmissionStatus, 'default' | 'secondary' | 'outline'> = {\n new: 'default',\n read: 'secondary',\n archived: 'outline',\n}\n\n/**\n * A Retry only where trying again can change the answer: a failed send, or an\n * email skipped because a setting that has since been changed was missing.\n * Not one refused for the hourly limit, a bad address or a vanished form —\n * those would be refused again.\n */\nconst RETRYABLE_SKIPS: readonly string[] = ['no_recipients', 'disabled']\n/** A `queued` email this old has lost its job (worker down, process died) — it can be queued again. */\nconst STUCK_AFTER_MS = 5 * 60 * 1000\n/**\n * A timed-out auto-reply keeps its hourly slot on purpose (the provider may\n * still deliver it), so a Retry inside that hour would only be refused.\n */\nconst GUARD_WINDOW_MS = 60 * 60 * 1000\nconst canRetry = (row: DeliveryRow, now: number): boolean =>\n (row.status === 'failed' &&\n !(\n row.kind === 'autoReply' &&\n row.errorClass === 'timeout' &&\n now - new Date(row.updatedAt).getTime() < GUARD_WINDOW_MS\n )) ||\n (row.status === 'queued' && now - new Date(row.updatedAt).getTime() > STUCK_AFTER_MS) ||\n (row.status === 'skipped' && row.errorClass !== null && RETRYABLE_SKIPS.includes(row.errorClass))\n\nconst deliveryVariant = (\n status: DeliveryRow['status'],\n): 'default' | 'secondary' | 'outline' | 'destructive' =>\n status === 'failed' ? 'destructive' : status === 'sent' ? 'secondary' : 'outline'\n\n/** `record[key]` when it is the record's OWN — a field keyed `constructor` must not read the prototype. */\nfunction own<T>(record: Readonly<Record<string, T>>, key: string): T | undefined {\n return Object.hasOwn(record, key) ? record[key] : undefined\n}\n\n/**\n * The keys of one submission's answers in the order the form asks them: the\n * list's columns first (current fields, then retired ones), then anything the\n * row holds that no column names. Stored JSON keeps no order of its own.\n */\nfunction answerKeys(row: SubmissionRow, columns: readonly SubmissionColumn[]): string[] {\n const ordered = columns.map((column) => column.key).filter((key) => Object.hasOwn(row.data, key))\n return [...ordered, ...Object.keys(row.data).filter((key) => !ordered.includes(key))]\n}\n\nexport function SubmissionsView({\n formId,\n rows,\n columns,\n canManage,\n deliveries,\n apiBasePath,\n emptyText,\n}: SubmissionsViewProps): ReactElement {\n const t = useTranslations('Forms.submissions')\n const tEmails = useTranslations('Forms.submissions.emails')\n const tStatus = useTranslations('Forms.submissionStatus')\n const format = useFormatter()\n const router = useRouter()\n const [refreshing, startRefresh] = useTransition()\n // The submission in the panel, as it was when opened. Kept as a row rather\n // than an id: marking it read (or archiving it) can take it OUT of the\n // filtered list, and the person is still reading it.\n const [opened, setOpened] = useState<SubmissionRow | null>(null)\n const openId = opened?.id ?? null\n const panel = useRef<HTMLElement>(null)\n /** New rows already asked to be marked read — one request each, however fast the clicks. */\n const marked = useRef(new Set<string>())\n const [selected, setSelected] = useState<ReadonlySet<string>>(new Set())\n const [confirming, setConfirming] = useState<readonly string[] | null>(null)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n const [notice, setNotice] = useState<string | null>(null)\n\n // The list's own copy when the row is still listed (its status is current),\n // else the copy taken when it was opened. Selection is derived the same way:\n // a row that has left the list is simply no longer selected.\n const open = rows.find((row) => row.id === openId) ?? opened\n const selectedIds = rows.filter((row) => selected.has(row.id)).map((row) => row.id)\n const allSelected = rows.length > 0 && selectedIds.length === rows.length\n\n const when = (iso: string) =>\n format.dateTime(new Date(iso), { dateStyle: 'short', timeStyle: 'short' })\n\n const answer = (value: SubmissionRow['data'][string] | undefined): string => {\n if (value === undefined || value === '') return '—'\n if (typeof value === 'boolean') return value ? t('yes') : t('no')\n return String(value)\n }\n\n const call = async (method: 'PATCH' | 'DELETE', body: Record<string, unknown>) => {\n setBusy(true)\n setError(null)\n try {\n const res = await fetch(`${apiBasePath}/form-submissions`, {\n method,\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ form: formId, ...body }),\n })\n if (res.status === 202) {\n // An agent's delete was parked for a person to approve.\n setError(t('awaitingApproval'))\n return false\n }\n if (!res.ok) {\n setError(t('failed', { status: res.status }))\n return false\n }\n // The route answers how many rows it touched. None means the rows are\n // gone or were never this form's — say so rather than look successful.\n const answer = (await res.json().catch(() => null)) as {\n changed?: unknown\n deleted?: unknown\n } | null\n const touched = method === 'DELETE' ? answer?.deleted : answer?.changed\n startRefresh(() => router.refresh())\n // Success is a positive count. Anything else — zero, or an answer that\n // carries no count at all — is told to the person, never assumed.\n if (typeof touched !== 'number' || touched <= 0) {\n setError(t('nothingChanged'))\n // Whatever was named is not there any more: do not keep it open.\n const named: unknown = body.ids\n if (Array.isArray(named)) {\n setOpened((current) => (current && named.includes(current.id) ? null : current))\n }\n return false\n }\n return true\n } catch {\n setError(t('failed', { status: 0 }))\n return false\n } finally {\n setBusy(false)\n }\n }\n\n const retryEmail = async (submission: string, kind: DeliveryRow['kind']) => {\n setBusy(true)\n setError(null)\n try {\n const res = await fetch(`${apiBasePath}/form-emails/retry`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ form: formId, submission, kind }),\n })\n if (res.status === 202) {\n // An agent's request, parked for a person to approve.\n setError(t('awaitingApproval'))\n return\n }\n if (!res.ok) {\n setError(t('failed', { status: res.status }))\n return\n }\n // The route answers 200 even when the queue could not take it.\n const answer = (await res.json().catch(() => null)) as { queued?: unknown } | null\n if (answer?.queued !== true) {\n setError(tEmails('notQueued'))\n return\n }\n setNotice(tEmails('retrying'))\n startRefresh(() => router.refresh())\n } catch {\n setError(t('failed', { status: 0 }))\n } finally {\n setBusy(false)\n }\n }\n\n const setStatus = async (ids: readonly string[], status: SubmissionStatus) => {\n const done = await call('PATCH', { ids, status })\n // A row that is new again, or whose mark-read did not happen, is marked\n // read the next time it is opened.\n if (!done || status === 'new') for (const id of ids) marked.current.delete(id)\n // Keep the panel's copy in step when its row is no longer in the list.\n if (done)\n setOpened((current) =>\n current && ids.includes(current.id) ? { ...current, status } : current,\n )\n return done\n }\n\n const openRow = (row: SubmissionRow) => {\n setOpened(row)\n if (canManage && row.status === 'new' && !marked.current.has(row.id)) {\n marked.current.add(row.id)\n void setStatus([row.id], 'read')\n }\n }\n\n // Moving focus is the one thing here React cannot express: when a panel\n // opens, a keyboard or screen-reader user is taken to it.\n useEffect(() => {\n if (openId !== null) panel.current?.focus()\n }, [openId])\n\n const confirmDelete = async () => {\n if (confirming === null) return\n const ids = confirming\n setConfirming(null)\n if (await call('DELETE', { ids })) {\n setSelected((current) => new Set([...current].filter((id) => !ids.includes(id))))\n // A deleted submission is not left open.\n setOpened((current) => (current && ids.includes(current.id) ? null : current))\n }\n }\n\n const toggle = (id: string, on: boolean) =>\n setSelected((current) => {\n const next = new Set(current)\n if (on) next.add(id)\n else next.delete(id)\n return next\n })\n\n const disabled = busy || refreshing\n // Read once per render: only the \"stuck\" test of a queued email needs it.\n const nowMs = Date.now()\n\n /** Why an email failed or was not sent, in words — an unknown class is shown as it is. */\n const reasonText = (errorClass: string): string =>\n tEmails.has(`reason.${errorClass}`) ? tEmails(`reason.${errorClass}`) : errorClass\n\n /** One chip per submission: the worst thing that happened to its emails. */\n const emailsBadge = (rows: readonly DeliveryRow[] | undefined): ReactElement => {\n if (rows === undefined || rows.length === 0)\n return <span className=\"text-muted-foreground\">—</span>\n const worst =\n rows.find((r) => r.status === 'failed') ??\n rows.find((r) => r.status === 'queued') ??\n rows.find((r) => r.status === 'sent') ??\n rows[0]\n if (worst === undefined) return <span className=\"text-muted-foreground\">—</span>\n return (\n <Badge variant={deliveryVariant(worst.status)}>{tEmails(`delivery.${worst.status}`)}</Badge>\n )\n }\n\n return (\n <div className=\"space-y-3\">\n {notice !== null && (\n <p role=\"status\" className=\"rounded-md border p-3 text-sm\">\n {notice}\n </p>\n )}\n {error !== null && (\n <p role=\"alert\" className=\"rounded-md border border-destructive/40 p-3 text-sm\">\n {error}\n </p>\n )}\n\n <div className=\"flex flex-col gap-4 lg:flex-row lg:items-start\">\n <div className=\"min-w-0 flex-1 space-y-3\">\n {rows.length === 0 && (\n <p className=\"rounded-md border border-dashed p-6 text-sm text-muted-foreground\">\n {emptyText}\n </p>\n )}\n <div className=\"overflow-x-auto rounded-md border\" hidden={rows.length === 0}>\n <Table>\n <TableHeader>\n <TableRow>\n {canManage && (\n <TableHead className=\"w-10\">\n <Checkbox\n aria-label={t('selectAll')}\n checked={allSelected}\n onCheckedChange={(value) =>\n setSelected(\n value === true ? new Set(rows.map((row) => row.id)) : new Set(),\n )\n }\n />\n </TableHead>\n )}\n <TableHead className=\"whitespace-nowrap\">{t('received')}</TableHead>\n <TableHead className=\"whitespace-nowrap\">{t('status')}</TableHead>\n <TableHead className=\"whitespace-nowrap\">{tEmails('emailsColumn')}</TableHead>\n <TableHead className=\"whitespace-nowrap\">{t('page')}</TableHead>\n {columns.map((column) => (\n // One line, however long the label (a consent's is a\n // sentence): the whole of it is the cell's title.\n <TableHead\n key={column.key}\n data-forms-column={column.key}\n title={column.label}\n className=\"max-w-[14rem] truncate whitespace-nowrap\"\n >\n {column.label}\n {column.retired && (\n <span className=\"ml-1 text-xs font-normal text-muted-foreground\">\n ({t('removedField')})\n </span>\n )}\n </TableHead>\n ))}\n </TableRow>\n </TableHeader>\n <TableBody>\n {rows.map((row) => (\n <TableRow\n key={row.id}\n data-state={row.id === openId ? 'selected' : undefined}\n className={row.status === 'new' ? 'font-medium' : undefined}\n >\n {canManage && (\n <TableCell>\n <Checkbox\n aria-label={t('selectRow', { when: when(row.createdAt) })}\n checked={selected.has(row.id)}\n onCheckedChange={(value) => toggle(row.id, value === true)}\n />\n </TableCell>\n )}\n <TableCell className=\"whitespace-nowrap\">\n <button\n type=\"button\"\n className=\"text-primary hover:underline\"\n aria-expanded={row.id === openId}\n onClick={() => openRow(row)}\n >\n {when(row.createdAt)}\n </button>\n </TableCell>\n <TableCell>\n <Badge variant={STATUS_VARIANT[row.status]}>{tStatus(row.status)}</Badge>\n </TableCell>\n <TableCell data-forms-emails-cell=\"\">\n {emailsBadge(own(deliveries, row.id))}\n </TableCell>\n <TableCell className=\"text-muted-foreground\">{row.pagePath ?? '—'}</TableCell>\n {columns.map((column) => {\n const text = answer(own(row.data, column.key))\n return (\n <TableCell key={column.key} className=\"max-w-[16rem] truncate\">\n {text.length > CELL_MAX ? `${text.slice(0, CELL_MAX)}…` : text}\n </TableCell>\n )\n })}\n </TableRow>\n ))}\n </TableBody>\n </Table>\n </div>\n\n {canManage && rows.length > 0 && (\n <Button\n variant=\"outline\"\n size=\"sm\"\n disabled={disabled || selectedIds.length === 0}\n onClick={() => setConfirming(selectedIds)}\n >\n {t('deleteSelected', { count: selectedIds.length })}\n </Button>\n )}\n </div>\n\n {open !== null && (\n <aside\n ref={panel}\n tabIndex={-1}\n aria-label={t('detail')}\n className=\"w-full shrink-0 space-y-4 rounded-md border p-4 lg:w-96\"\n >\n <div className=\"flex items-start justify-between gap-2\">\n <div>\n <p className=\"font-medium\">{when(open.createdAt)}</p>\n <p className=\"text-sm text-muted-foreground\">\n {open.pagePath === null ? t('noPage') : t('fromPage', { page: open.pagePath })}\n </p>\n </div>\n <Button variant=\"ghost\" size=\"sm\" onClick={() => setOpened(null)}>\n {t('close')}\n </Button>\n </div>\n\n <dl className=\"space-y-3 text-sm\">\n {answerKeys(open, columns).map((key) => {\n const value = own(open.data, key)\n const wording = own(open.consent, key)\n return (\n <div key={key}>\n {/* The label the field had when this was sent — the row's own snapshot. */}\n <dt className=\"text-xs text-muted-foreground\">\n {own(open.fields, key)?.label ?? key}\n </dt>\n <dd className=\"whitespace-pre-wrap break-words\">{answer(value)}</dd>\n {wording !== undefined && (\n <dd className=\"mt-1 text-xs text-muted-foreground\">\n {t('consentWording', { wording })}\n </dd>\n )}\n </div>\n )\n })}\n </dl>\n\n <section\n className=\"space-y-2 border-t pt-3 text-sm\"\n aria-label={tEmails('emailsTitle')}\n >\n <h3 className=\"text-xs font-medium text-muted-foreground\">\n {tEmails('emailsTitle')}\n </h3>\n {(own(deliveries, open.id) ?? []).length === 0 ? (\n <p className=\"text-muted-foreground\">{tEmails('noEmails')}</p>\n ) : (\n <ul className=\"space-y-2\">\n {(own(deliveries, open.id) ?? []).map((row) => (\n <li key={row.kind} data-forms-delivery={`${row.kind}:${row.status}`}>\n <div className=\"flex flex-wrap items-center gap-2\">\n <span className=\"font-medium\">{tEmails(`kind.${row.kind}`)}</span>\n <Badge variant={deliveryVariant(row.status)}>\n {tEmails(`delivery.${row.status}`)}\n </Badge>\n {canManage && canRetry(row, nowMs) && (\n <Button\n variant=\"outline\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => void retryEmail(open.id, row.kind)}\n >\n {tEmails('retry')}\n </Button>\n )}\n </div>\n {row.errorClass !== null && (\n <p className=\"mt-1 text-xs text-muted-foreground\">\n {reasonText(row.errorClass)}\n </p>\n )}\n </li>\n ))}\n </ul>\n )}\n </section>\n\n {canManage && (\n <div className=\"flex flex-wrap gap-2 border-t pt-3\">\n {open.status === 'new' ? (\n <Button\n variant=\"outline\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => void setStatus([open.id], 'read')}\n >\n {t('markRead')}\n </Button>\n ) : (\n <Button\n variant=\"outline\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => void setStatus([open.id], 'new')}\n >\n {t('markNew')}\n </Button>\n )}\n {open.status === 'archived' ? (\n <Button\n variant=\"outline\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => void setStatus([open.id], 'read')}\n >\n {t('unarchive')}\n </Button>\n ) : (\n <Button\n variant=\"outline\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => void setStatus([open.id], 'archived')}\n >\n {t('archive')}\n </Button>\n )}\n <Button\n variant=\"destructive\"\n size=\"sm\"\n disabled={disabled}\n onClick={() => setConfirming([open.id])}\n >\n {t('delete')}\n </Button>\n </div>\n )}\n </aside>\n )}\n </div>\n\n <Dialog open={confirming !== null} onOpenChange={(next) => !next && setConfirming(null)}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{t('confirmDeleteTitle', { count: confirming?.length ?? 0 })}</DialogTitle>\n </DialogHeader>\n <DialogDescription>{t('confirmDeleteBody')}</DialogDescription>\n <div className=\"flex justify-end gap-2\">\n <Button variant=\"outline\" onClick={() => setConfirming(null)}>\n {t('cancel')}\n </Button>\n <Button variant=\"destructive\" disabled={busy} onClick={() => void confirmDelete()}>\n {t('confirmDelete')}\n </Button>\n </div>\n </DialogContent>\n </Dialog>\n </div>\n )\n}\n\n/**\n * \"Export CSV\" — a plain download link to the export of the filters on screen.\n * A link cannot carry the site header the admin's own requests send, so the\n * selected site is added as `_site` when it is clicked, from the page URL.\n */\nexport function SubmissionsExportLink({ href }: { readonly href: string }): ReactElement {\n const t = useTranslations('Forms.submissions')\n return (\n <Button variant=\"outline\" size=\"sm\" asChild>\n <a\n href={href}\n download\n // Before the link can be followed by any means — click, middle-click,\n // the context menu's \"Save link as\", Enter.\n onPointerDown={(event) => {\n event.currentTarget.href = withAdminSiteParam(href)\n }}\n onFocus={(event) => {\n event.currentTarget.href = withAdminSiteParam(href)\n }}\n onClick={(event) => {\n event.currentTarget.href = withAdminSiteParam(href)\n }}\n >\n {t('exportCsv')}\n </a>\n </Button>\n )\n}\n"],"mappings":"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"}
@@ -0,0 +1,37 @@
1
+ import { PageProps } from "@murumets-ee/core";
2
+ import { ReactNode } from "react";
3
+
4
+ //#region src/pages/form-canvas-page.d.ts
5
+ declare function FormCanvasPage({
6
+ params,
7
+ searchParams
8
+ }: PageProps<{
9
+ locale: string;
10
+ id: string;
11
+ }>): Promise<ReactNode>;
12
+ //#endregion
13
+ //#region src/pages/forms-list-page.d.ts
14
+ declare function FormsListPage({
15
+ params
16
+ }: PageProps<{
17
+ locale: string;
18
+ }>): Promise<ReactNode>;
19
+ //#endregion
20
+ //#region src/pages/privacy-page.d.ts
21
+ declare function PrivacyRequestsPage({
22
+ params
23
+ }: PageProps<{
24
+ locale: string;
25
+ }>): Promise<ReactNode>;
26
+ //#endregion
27
+ //#region src/pages/submissions-page.d.ts
28
+ declare function SubmissionsPage({
29
+ params,
30
+ searchParams
31
+ }: PageProps<{
32
+ locale: string;
33
+ id: string;
34
+ }>): Promise<ReactNode>;
35
+ //#endregion
36
+ export { FormCanvasPage, FormsListPage, PrivacyRequestsPage, SubmissionsPage };
37
+ //# sourceMappingURL=pages.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pages.d.mts","names":[],"sources":["../src/pages/form-canvas-page.tsx","../src/pages/forms-list-page.tsx","../src/pages/privacy-page.tsx","../src/pages/submissions-page.tsx"],"mappings":";;;;iBA4BsB,cAAA;EACpB,MAAA;EACA;AAAA,GACC,SAAA;EAAY,MAAA;EAAgB,EAAA;AAAA,KAAgB,OAAA,CAAQ,SAAA;;;iBCgCjC,aAAA;EAAgB;AAAA,GAAU,SAAA;EAAY,MAAA;AAAA,KAAoB,OAAA,CAAQ,SAAA;;;iBCnClE,mBAAA;EACpB;AAAA,GACC,SAAA;EAAY,MAAA;AAAA,KAAoB,OAAA,CAAQ,SAAA;;;iBCuBrB,eAAA;EACpB,MAAA;EACA;AAAA,GACC,SAAA;EAAY,MAAA;EAAgB,EAAA;AAAA,KAAgB,OAAA,CAAQ,SAAA"}
package/dist/pages.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import{getRegisteredAdminConfig as e}from"@murumets-ee/admin-ui/config-registry";import{hasEntityPermission as t,hasResourcePermission as n,renderEntityEditFallback as r}from"@murumets-ee/admin-ui/pages";import{getApp as i,getContext as a,isTokenDerivedDoor as o,requireRequestSite as s}from"@murumets-ee/core";import{FORMS_AUTO_REPLY_TYPE as c,FORMS_NOTIFICATION_TYPE as l,FORMS_RESOURCE as u,FORMS_SETTINGS_NAMESPACE as d,FORM_ENTITY as f,FORM_SUBMISSIONS_RESOURCE as p,Form as m,STATUS_FILTERS as h,SUBMISSIONS_PAGE_SIZE as g,collectRetiredColumns as _,compileForm as v,currentFormFields as y,ensureFormEmailDefaults as b,formSpamCounts as x,formsSettings as S,lastRetentionRun as C,listSubmissions as w,parseSubmissionFilters as T,readDeliveries as ee,readEmailSettings as E,submissionColumns as te,submissionCounts as D,submissionFilterParams as ne,submissionPages as re}from"@murumets-ee/forms";import{FormCanvasFactsProvider as O}from"@murumets-ee/forms/editor";import{notFound as k,redirect as A}from"next/navigation";import{challengeSecretFor as j}from"@murumets-ee/forms/server";import{instanceTypeId as M}from"@murumets-ee/notifications/define";import{SettingsClient as N}from"@murumets-ee/settings";import{jsx as P,jsxs as F}from"react/jsx-runtime";import{fetchEntityList as I}from"@murumets-ee/admin-ui/server";import{WorkflowStatusBadge as L}from"@murumets-ee/admin-ui/workflow";import{countEmbedUsages as R}from"@murumets-ee/blocks/server";import{DeleteFormButton as z,NewFormDialog as B,PrivacyRequestsView as V,SubmissionsExportLink as ie,SubmissionsView as ae}from"@murumets-ee/forms-ui";import{Button as H,Table as U,TableBody as W,TableCell as G,TableHead as K,TableHeader as q,TableRow as J}from"@murumets-ee/ui";import Y from"next/link";import{getFormatter as oe,getTimeZone as se,getTranslations as X,setRequestLocale as Z}from"next-intl/server";async function Q(e){let t=new Map;if(e.length===0)return t;let n=await x.makeClient(i().db.readWrite).findMany({where:{formId:{in:[...e]}},limit:e.length});for(let e of n)t.set(e.formId,e.discarded);return t}async function ce(){let e=i(),t=await s(d,e),n=new N(S,{app:e,site:t}),[r,a]=await Promise.all([n.get(`turnstileSiteKey`),n.get(`recaptchaSiteKey`)]),o=e=>typeof e==`string`&&e!==``;return{turnstile:o(r)&&j(`turnstile`,t,process.env)!==void 0,recaptcha:o(a)&&j(`recaptcha`,t,process.env)!==void 0}}async function le(e){let t=i(),n=await t.getSystemClient(m).findById(e.formId);if(n==null)return null;await b(t,e.formId);let r=await E(t,e.formId),a=(v(n.body)?.fields??[]).filter(e=>e.kind===`email`).map(e=>({key:e.key,label:e.label===``?e.key:e.label})),o=t=>e.canEditTemplates?`${e.adminBase}/notifications/templates/${encodeURIComponent(M(t,e.formId))}/email`:null;return{formId:e.formId,recipients:r.recipients,autoReplyEnabled:r.autoReplyEnabled,autoReplyField:r.autoReplyField,emailFields:a,notificationHref:o(l),autoReplyHref:o(c),apiBasePath:e.apiBasePath}}const ue=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;async function de({params:t,searchParams:a}){let{id:o,locale:s}=await t;ue.test(o)||k();let c=e(),l=await r(m,c,{params:t,searchParams:a}),u=await c.withAdminContext(s,async()=>{let e=c.adminBasePath.replace(`:locale`,s),[t,r,a,l]=await Promise.all([Q([o]).catch(()=>(console.warn(`forms: the blocked count for form ${o} could not be read`),null)),ce(),n(p,`view`,c).then(async e=>e?(await D(i(),[o])).get(o)?.fresh??0:null).catch(()=>(console.warn(`forms: the new-submissions count for form ${o} could not be read`),null)),n(`message_template`,`update`,c).then(t=>le({formId:o,adminBase:e,apiBasePath:c.apiBasePath??`/api/admin`,canEditTemplates:t})).catch(()=>(console.warn(`forms: the email settings of form ${o} could not be read`),null))]);return{discarded:t===null?null:t.get(o)??0,ready:r,settingsHref:`${e}/settings/${d}`,...a!==null&&{submissions:{href:`${e}/forms/${o}/submissions`,fresh:a}},...l!==null&&{emails:l}}}).catch(()=>(console.warn(`forms: the spam facts for form ${o} could not be read`),null));return u===null?l:P(O,{facts:u,children:l})}async function fe({params:r}){let a=e(),{locale:o}=await r;Z(o);let s=await X(`Forms.list`),c=`${a.adminBasePath.replace(`:locale`,o)}/forms`;return a.withAdminContext(o,async()=>{await t(m,`view`,a)||k();let[{items:e,total:r},o,l,d,h]=await Promise.all([I(m,{sortField:`name`,sortDirection:`asc`,limit:200}),n(`form`,`create`,a),n(`form`,`delete`,a),n(u,`manage`,a),n(p,`view`,a)]),g=a.apiBasePath??`/api/admin`,_=await R(f,e.map(e=>e.id)).catch(()=>null),v=d||h?await Q(e.map(e=>String(e.id))).catch(()=>(console.warn(`forms: the blocked counts for the Forms list could not be read`),null)):null,y=h?await D(i(),e.map(e=>String(e.id))).catch(()=>(console.warn(`forms: the submission counts for the Forms list could not be read`),null)):null,b=e=>`${c}/${String(e)}/submissions`,x=h?await C(i()).catch(()=>{console.warn(`forms: the last clean-up run for the Forms list could not be read`)}):void 0,S=await oe();return F(`div`,{className:`space-y-4 p-6`,children:[F(`div`,{className:`flex items-start justify-between gap-4`,children:[F(`div`,{children:[P(`h1`,{className:`text-2xl font-bold`,children:s(`title`)}),P(`p`,{className:`text-sm text-muted-foreground`,children:s(`description`)})]}),F(`div`,{className:`flex items-center gap-2`,children:[h&&P(H,{variant:`outline`,size:`sm`,asChild:!0,children:P(Y,{href:`${c}/privacy`,"data-forms-privacy-link":``,children:s(`privacy`)})}),o&&d&&P(B,{apiBasePath:g,formsBasePath:c})]})]}),e.length===0?P(`p`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:s(`empty`)}):F(U,{children:[P(q,{children:F(J,{children:[P(K,{children:s(`name`)}),P(K,{children:s(`usedOn`)}),P(K,{className:`text-right`,children:s(`submissions`)}),P(K,{className:`text-right`,children:s(`new`)}),P(K,{className:`text-right`,children:s(`blocked`)}),P(K,{children:s(`status`)}),l&&d&&P(K,{className:`w-0`})]})}),P(W,{children:e.map(e=>F(J,{children:[P(G,{className:`font-medium`,children:d||h?P(Y,{href:d?`${c}/${e.id}`:b(e.id),className:`text-primary hover:underline`,children:e.name}):e.name}),P(G,{className:`text-muted-foreground`,"data-forms-used-on":e.id,children:_===null?s(`notYet`):s(`usedOnPages`,{count:_.get(e.id)??0})}),P(G,{className:`text-right tabular-nums`,"data-forms-submissions":e.id,children:y===null?P(`span`,{className:`text-muted-foreground`,children:s(`notYet`)}):P(Y,{href:b(e.id),className:`text-primary hover:underline`,"aria-label":s(`submissionsOf`,{form:String(e.name),count:y.get(String(e.id))?.total??0}),children:y.get(String(e.id))?.total??0})}),P(G,{className:`text-right tabular-nums`,"data-forms-new":e.id,children:y===null?P(`span`,{className:`text-muted-foreground`,children:s(`notYet`)}):y.get(String(e.id))?.fresh??0}),P(G,{className:`text-right tabular-nums`,children:v===null?s(`notYet`):v.get(String(e.id))??0}),P(G,{children:P(L,{status:e.status===`published`?`published`:`draft`})}),l&&d&&P(G,{className:`text-right`,children:P(z,{apiBasePath:g,formId:String(e.id),formName:String(e.name),submissions:y===null?null:y.get(String(e.id))?.total??0,pages:_===null?null:_.get(e.id)??0})})]},e.id))})]}),r>e.length&&P(`p`,{className:`text-xs text-muted-foreground`,children:s(`truncated`,{count:e.length})}),x!==void 0&&P(`p`,{className:`text-xs text-muted-foreground`,"data-forms-retention-run":``,children:x===null?s(`retentionNever`):`${s(`retentionRun`,{when:S.dateTime(new Date(x.lastRunAt),{dateStyle:`medium`,timeStyle:`short`}),count:x.removed})}${x.unfinished?` ${s(`retentionUnfinished`)}`:``}`})]})})}async function pe({params:t}){let r=e(),{locale:i}=await t;Z(i);let s=await X(`Forms.privacy`),c=`${r.adminBasePath.replace(`:locale`,i)}/forms`;return r.withAdminContext(i,async()=>{await n(p,`view`,r)||k();let e=!o(a()?.door)&&await n(p,`manage`,r);return F(`div`,{className:`space-y-4 p-6`,children:[F(`div`,{children:[P(`p`,{className:`text-sm text-muted-foreground`,children:F(Y,{href:c,className:`hover:underline`,children:[`← `,s(`backToForms`)]})}),P(`h1`,{className:`text-2xl font-bold`,children:s(`title`)}),P(`p`,{className:`max-w-prose text-sm text-muted-foreground`,children:s(`intro`)})]}),P(V,{apiBasePath:r.apiBasePath??`/api/admin`,formsBasePath:c,locale:i,canErase:e})]})})}const me=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,$=e=>Array.isArray(e)?e[0]:e;async function he({params:t,searchParams:r}){let{id:a,locale:o}=await t;me.test(a)||k();let s=await r,c=e();Z(o);let l=await X(`Forms.submissions`),d=`${c.adminBasePath.replace(`:locale`,o)}/forms`,f=c.apiBasePath??`/api/admin`;return c.withAdminContext(o,async()=>{await n(p,`view`,c)||k();let e=i(),t=await e.getSystemClient(m).findById(a);t??k();let r=T(e=>$(s[e]),{timeZone:await se()}),v=Number($(s.p)),b=Number.isFinite(v)?Math.min(Math.max(Math.trunc(v),1),1e5):1,[{items:x,total:S},C,E,D,O]=await Promise.all([w(e,a,r,{limit:g,offset:(b-1)*g}),re(e,a),n(p,`manage`,c),n(u,`manage`,c),Q([a]).catch(()=>(console.warn(`forms: the blocked count for form ${a} could not be read`),null))]),j=await ee(e,a,x.map(e=>e.id)).then(e=>Object.fromEntries(e)).catch(()=>(console.warn(`forms: the email state of form ${a}'s submissions could not be read`),{})),M=y(t.body),N=te(M,_(M,x)),I=ne(r),L=new URLSearchParams(I);L.set(`form`,a),L.set(`uiLocale`,o),L.set(`tz`,r.timeZone);let R=e=>{let t=new URLSearchParams(I);e>1&&t.set(`p`,String(e));let n=t.toString();return`${d}/${a}/submissions${n===``?``:`?${n}`}`},z=Math.max(Math.ceil(S/g),1);x.length===0&&S>0&&b>z&&A(R(z));let B=I.toString()!==``,V=typeof t.name==`string`?t.name:``,U=O===null?null:O.get(a)??0,W=r.page!==void 0&&!C.includes(r.page)?[r.page,...C]:C;return F(`div`,{className:`space-y-4 p-6`,children:[F(`div`,{className:`flex flex-wrap items-start justify-between gap-4`,children:[F(`div`,{children:[P(`p`,{className:`text-sm text-muted-foreground`,children:F(Y,{href:d,className:`hover:underline`,children:[`← `,l(`backToForms`)]})}),P(`h1`,{className:`text-2xl font-bold`,children:l(`title`,{form:V})})]}),F(`div`,{className:`flex items-center gap-2`,children:[D&&P(H,{variant:`outline`,size:`sm`,asChild:!0,children:P(Y,{href:`${d}/${a}`,children:l(`openForm`)})}),P(ie,{href:`${f}/form-submissions/export?${L.toString()}`})]})]}),F(`form`,{method:`get`,action:`${d}/${a}/submissions`,className:`flex flex-wrap items-end gap-3 rounded-md border p-3 text-sm`,children:[F(`label`,{className:`flex flex-col gap-1`,children:[P(`span`,{className:`text-xs text-muted-foreground`,children:l(`filters.from`)}),P(`input`,{type:`date`,name:`from`,defaultValue:r.from??``,className:`h-8 rounded-md border bg-background px-2`})]}),F(`label`,{className:`flex flex-col gap-1`,children:[P(`span`,{className:`text-xs text-muted-foreground`,children:l(`filters.to`)}),P(`input`,{type:`date`,name:`to`,defaultValue:r.to??``,className:`h-8 rounded-md border bg-background px-2`})]}),F(`label`,{className:`flex flex-col gap-1`,children:[P(`span`,{className:`text-xs text-muted-foreground`,children:l(`filters.page`)}),F(`select`,{name:`page`,defaultValue:r.page??``,className:`h-8 rounded-md border bg-background px-2`,children:[P(`option`,{value:``,children:l(`filters.allPages`)}),W.map(e=>P(`option`,{value:e,children:e},e))]})]}),F(`label`,{className:`flex flex-col gap-1`,children:[P(`span`,{className:`text-xs text-muted-foreground`,children:l(`filters.status`)}),P(`select`,{name:`status`,defaultValue:r.status,className:`h-8 rounded-md border bg-background px-2`,children:h.map(e=>P(`option`,{value:e,children:l(`filters.statuses.${e}`)},e))})]}),P(H,{type:`submit`,size:`sm`,variant:`secondary`,children:l(`filters.apply`)}),B&&P(H,{size:`sm`,variant:`ghost`,asChild:!0,children:P(Y,{href:`${d}/${a}/submissions`,children:l(`filters.clear`)})}),U!==null&&U>0&&P(`span`,{className:`ml-auto text-xs text-muted-foreground`,"data-forms-blocked":!0,children:l(`blocked`,{count:U})})]}),P(ae,{formId:a,rows:x,columns:N,canManage:E,deliveries:j,apiBasePath:f,emptyText:l(B?`emptyFiltered`:`empty`)},`${I.toString()}#${b}`),F(`div`,{className:`flex items-center justify-between text-sm text-muted-foreground`,children:[P(`span`,{children:l(`showing`,{shown:x.length,total:S})}),z>1&&F(`span`,{className:`flex items-center gap-2`,children:[b>1&&F(Y,{href:R(b-1),className:`hover:underline`,children:[`← `,l(`previous`)]}),P(`span`,{children:l(`pageOf`,{page:Math.min(b,z),pages:z})}),b<z&&F(Y,{href:R(b+1),className:`hover:underline`,children:[l(`next`),` →`]})]})]})]})})}export{de as FormCanvasPage,fe as FormsListPage,pe as PrivacyRequestsPage,he as SubmissionsPage};
2
+ //# sourceMappingURL=pages.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pages.mjs","names":["UUID_RE"],"sources":["../src/pages/form-facts.ts","../src/pages/form-canvas-page.tsx","../src/pages/forms-list-page.tsx","../src/pages/privacy-page.tsx","../src/pages/submissions-page.tsx"],"sourcesContent":["/**\n * What the Forms list (W1) and the form canvas (W2) show about the spam\n * protection (forms D004): how many submissions each form has dropped, and\n * whether the site can run each extra check.\n *\n * Read on the SERVER, inside the page's admin context, behind the page's own\n * gate (`forms:manage`). Counts only — nothing a visitor sent.\n */\n\nimport { getApp, requireRequestSite } from '@murumets-ee/core'\nimport {\n compileForm,\n ensureFormEmailDefaults,\n FORMS_AUTO_REPLY_TYPE,\n FORMS_NOTIFICATION_TYPE,\n FORMS_SETTINGS_NAMESPACE,\n Form,\n formSpamCounts,\n formsSettings,\n readEmailSettings,\n} from '@murumets-ee/forms'\nimport type { FormEmailFacts } from '@murumets-ee/forms/editor'\nimport { challengeSecretFor } from '@murumets-ee/forms/server'\nimport { instanceTypeId } from '@murumets-ee/notifications/define'\nimport { SettingsClient } from '@murumets-ee/settings'\n\n/** Dropped-as-spam counts for `formIds` (at most a page's worth), by id; absent = 0. */\nexport async function discardedCounts(formIds: readonly string[]): Promise<Map<string, number>> {\n const counts = new Map<string, number>()\n if (formIds.length === 0) return counts\n const rows = await formSpamCounts.makeClient(getApp().db.readWrite).findMany({\n where: { formId: { in: [...formIds] } },\n limit: formIds.length,\n })\n for (const row of rows) counts.set(row.formId, row.discarded)\n return counts\n}\n\n/** Whether this site can run each extra check: a site key in settings AND a secret on the server. */\nexport async function challengeReadiness(): Promise<{ turnstile: boolean; recaptcha: boolean }> {\n const app = getApp()\n const site = await requireRequestSite(FORMS_SETTINGS_NAMESPACE, app)\n const settings = new SettingsClient(formsSettings, { app, site })\n const [turnstileKey, recaptchaKey] = await Promise.all([\n settings.get('turnstileSiteKey'),\n settings.get('recaptchaSiteKey'),\n ])\n const has = (value: unknown) => typeof value === 'string' && value !== ''\n return {\n turnstile:\n has(turnstileKey) && challengeSecretFor('turnstile', site, process.env) !== undefined,\n recaptcha:\n has(recaptchaKey) && challengeSecretFor('recaptcha', site, process.env) !== undefined,\n }\n}\n\n/**\n * The Emails tab's facts for form `formId` (forms D006): its recipients and\n * auto-reply, its email fields, and where its two emails are edited.\n *\n * A form made before emails existed has no defaults yet; they are added here —\n * idempotently, behind the page's `forms:manage` gate — so the editor link\n * always opens a real template.\n *\n * `canEditTemplates` is the viewer's own `message_template:update`: the\n * template editor is the administrator's, so an editor without it is told so\n * rather than shown a link that 404s.\n */\nexport async function emailFacts(args: {\n formId: string\n adminBase: string\n apiBasePath: string\n canEditTemplates: boolean\n}): Promise<FormEmailFacts | null> {\n const app = getApp()\n const row: Record<string, unknown> | null | undefined = await app\n .getSystemClient(Form)\n .findById(args.formId)\n if (row === null || row === undefined) return null\n await ensureFormEmailDefaults(app, args.formId)\n const settings = await readEmailSettings(app, args.formId)\n const emailFields = (compileForm(row.body)?.fields ?? [])\n .filter((field) => field.kind === 'email')\n .map((field) => ({ key: field.key, label: field.label !== '' ? field.label : field.key }))\n const editor = (base: string): string | null =>\n args.canEditTemplates\n ? `${args.adminBase}/notifications/templates/${encodeURIComponent(instanceTypeId(base, args.formId))}/email`\n : null\n return {\n formId: args.formId,\n recipients: settings.recipients,\n autoReplyEnabled: settings.autoReplyEnabled,\n autoReplyField: settings.autoReplyField,\n emailFields,\n notificationHref: editor(FORMS_NOTIFICATION_TYPE),\n autoReplyHref: editor(FORMS_AUTO_REPLY_TYPE),\n apiBasePath: args.apiBasePath,\n }\n}\n","/**\n * The form canvas (W2) — a form open on its own canvas, in the `form`\n * environment.\n *\n * It owns `/admin/forms/:id` (and the singular `/admin/form/:id`), the URLs\n * the generic entity edit page would have answered, so a form has one editor and it is gated by the\n * job permission: the route's `forms:manage`, then — inside\n * `renderEntityEditFallback` — the entity's own `form:update`, which is also\n * what every write from the canvas is checked against. The editor itself is\n * the toolkit's: drafts, publish, locks, undo — nothing re-implemented.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasResourcePermission, renderEntityEditFallback } from '@murumets-ee/admin-ui/pages'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport {\n FORM_SUBMISSIONS_RESOURCE,\n FORMS_SETTINGS_NAMESPACE,\n Form,\n submissionCounts,\n} from '@murumets-ee/forms'\nimport { FormCanvasFactsProvider } from '@murumets-ee/forms/editor'\nimport { notFound } from 'next/navigation'\nimport type { ReactNode } from 'react'\nimport { challengeReadiness, discardedCounts, emailFacts } from './form-facts.js'\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nexport async function FormCanvasPage({\n params,\n searchParams,\n}: PageProps<{ locale: string; id: string }>): Promise<ReactNode> {\n const { id, locale } = await params\n // `forms/new` lands here too (this route shadows the entity family); a form\n // is only ever created from the Forms list, so anything but an id is a 404.\n if (!UUID_RE.test(id)) notFound()\n const config = getRegisteredAdminConfig()\n // The editor FIRST: it is what refuses another site's id or a role without\n // `form:update` (a no-leak 404), so nothing below runs for a form the\n // person may not open.\n const editor = await renderEntityEditFallback(Form, config, { params, searchParams })\n // What only the server knows, for the Form panel's Spam tab (D004): the\n // blocked count and which extra checks this site can run. Supplementary:\n // a failed read never costs the editor. Without the count the panel leaves\n // that line out; without the readiness it gets no facts at all, as on a\n // canvas that does not provide them.\n const facts = await config\n .withAdminContext(locale, async () => {\n const adminBase = config.adminBasePath.replace(':locale', locale)\n const [counts, ready, fresh, emails] = await Promise.all([\n discardedCounts([id]).catch(() => {\n // Never the error object: only the fact, and which form.\n console.warn(`forms: the blocked count for form ${id} could not be read`)\n return null\n }),\n challengeReadiness(),\n // The way to this form's Submissions (W4), with how many are new —\n // only for a person who may open them, and never at the editor's cost.\n hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config)\n .then(async (allowed) =>\n allowed ? ((await submissionCounts(getApp(), [id])).get(id)?.fresh ?? 0) : null,\n )\n .catch(() => {\n console.warn(`forms: the new-submissions count for form ${id} could not be read`)\n return null\n }),\n // The Emails tab (D006). Without it the tab points at the Forms list.\n hasResourcePermission('message_template', 'update', config)\n .then((canEditTemplates) =>\n emailFacts({\n formId: id,\n adminBase,\n apiBasePath: config.apiBasePath ?? '/api/admin',\n canEditTemplates,\n }),\n )\n .catch(() => {\n console.warn(`forms: the email settings of form ${id} could not be read`)\n return null\n }),\n ])\n return {\n discarded: counts === null ? null : (counts.get(id) ?? 0),\n ready,\n settingsHref: `${adminBase}/settings/${FORMS_SETTINGS_NAMESPACE}`,\n ...(fresh !== null && {\n submissions: { href: `${adminBase}/forms/${id}/submissions`, fresh },\n }),\n ...(emails !== null && { emails }),\n }\n })\n .catch(() => {\n console.warn(`forms: the spam facts for form ${id} could not be read`)\n return null\n })\n if (facts === null) return editor\n return <FormCanvasFactsProvider facts={facts}>{editor}</FormCanvasFactsProvider>\n}\n","/**\n * Forms (W1) — the selected site's forms, and the way into each one's canvas\n * and its submissions.\n *\n * Gated by `form:view` on its route (the plugin's `defaultRoutes`), which is\n * also what the sidebar link inherits: seeing the list is seeing this site's\n * forms. What a person may DO from it follows their job permissions, each\n * asked here and each only deciding what is drawn —\n *\n * - `forms:manage` (build forms): the name opens the canvas, and \"New form\".\n * - `form_submissions:view`: the Submissions and New counts, each a link to\n * the form's Submissions screen (W4). For a person who holds this and not\n * `forms:manage` — the read-only role of D005 — the name opens Submissions\n * too, so the list is their way in. Also the \"Privacy requests\" link (W5)\n * and the line saying when the daily clean-up last ran and what it removed\n * (D007).\n * - `form:delete` together with `forms:manage`: \"Delete…\" on a row — the form\n * AND its submissions, after a confirm that names the count (the route\n * decides; see `handleRemove`).\n *\n * Lists THIS site's forms only: `form` is site-bound, so the entity read is\n * scoped by the request's site like every other bound entity (C2).\n *\n * The columns are the approved wireframe's. \"Used on\" counts the PUBLISHED\n * pages that show the form (forms D002 — from `entity_refs`, which a page's\n * Form block feeds when the page is published); \"Blocked\" counts what the spam\n * checks dropped (D004). A number that could not be read, or that this person\n * may not see, is a dash — never a zero that claims a count nobody took.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasEntityPermission, hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { fetchEntityList } from '@murumets-ee/admin-ui/server'\nimport { WorkflowStatusBadge } from '@murumets-ee/admin-ui/workflow'\nimport { countEmbedUsages } from '@murumets-ee/blocks/server'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport {\n FORM_ENTITY,\n FORM_SUBMISSIONS_RESOURCE,\n FORMS_RESOURCE,\n Form,\n lastRetentionRun,\n submissionCounts,\n} from '@murumets-ee/forms'\nimport { DeleteFormButton, NewFormDialog } from '@murumets-ee/forms-ui'\nimport {\n Button,\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@murumets-ee/ui'\nimport Link from 'next/link'\nimport { notFound } from 'next/navigation'\nimport { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\nimport { discardedCounts } from './form-facts.js'\n\n/** A site's forms are a handful; the bound keeps one page load one query. */\nconst MAX_FORMS = 200\n\nexport async function FormsListPage({ params }: PageProps<{ locale: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale } = await params\n setRequestLocale(locale)\n const t = await getTranslations('Forms.list')\n const formsBasePath = `${config.adminBasePath.replace(':locale', locale)}/forms`\n\n return config.withAdminContext(locale, async () => {\n // The route's gate is `form:view`, asked again here because this is the\n // read it guards: a page rendered past its gate must still not list.\n if (!(await hasEntityPermission(Form, 'view', config))) notFound()\n const [{ items, total }, canCreate, canDelete, canBuild, canSeeSubmissions] = await Promise.all(\n [\n fetchEntityList(Form, { sortField: 'name', sortDirection: 'asc', limit: MAX_FORMS }),\n hasResourcePermission('form', 'create', config),\n hasResourcePermission('form', 'delete', config),\n hasResourcePermission(FORMS_RESOURCE, 'manage', config),\n hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config),\n ],\n )\n const apiBasePath = config.apiBasePath ?? '/api/admin'\n // One grouped query for every listed form. A failure costs the column its\n // numbers (a dash), never the list.\n const usedOn = await countEmbedUsages(\n FORM_ENTITY,\n items.map((form) => form.id),\n ).catch(() => null)\n // Dropped-as-spam per form (D004) — one bounded read for the listed forms.\n // A failure costs the column its numbers (a dash), never the list.\n // Shown to people who build forms or read submissions — not to a role that\n // can only see that the forms exist.\n const blocked = !(canBuild || canSeeSubmissions)\n ? null\n : await discardedCounts(items.map((form) => String(form.id))).catch(() => {\n console.warn('forms: the blocked counts for the Forms list could not be read')\n return null\n })\n\n // Stored and new submissions per form — one grouped query for the list,\n // and only for a person who may see submissions at all.\n const received = canSeeSubmissions\n ? await submissionCounts(\n getApp(),\n items.map((form) => String(form.id)),\n ).catch(() => {\n console.warn('forms: the submission counts for the Forms list could not be read')\n return null\n })\n : null\n const submissionsHref = (id: unknown) => `${formsBasePath}/${String(id)}/submissions`\n // When the daily clean-up last ran here and what it removed (D007) — for\n // the people whose submissions it removes. `undefined`: could not be read\n // (the line is left out); `null`: it has never run on this site.\n const lastRun = canSeeSubmissions\n ? await lastRetentionRun(getApp()).catch(() => {\n console.warn('forms: the last clean-up run for the Forms list could not be read')\n return undefined\n })\n : undefined\n const format = await getFormatter()\n\n return (\n <div className=\"space-y-4 p-6\">\n <div className=\"flex items-start justify-between gap-4\">\n <div>\n <h1 className=\"text-2xl font-bold\">{t('title')}</h1>\n <p className=\"text-sm text-muted-foreground\">{t('description')}</p>\n </div>\n <div className=\"flex items-center gap-2\">\n {canSeeSubmissions && (\n <Button variant=\"outline\" size=\"sm\" asChild>\n <Link href={`${formsBasePath}/privacy`} data-forms-privacy-link=\"\">\n {t('privacy')}\n </Link>\n </Button>\n )}\n {canCreate && canBuild && (\n <NewFormDialog apiBasePath={apiBasePath} formsBasePath={formsBasePath} />\n )}\n </div>\n </div>\n\n {items.length === 0 ? (\n <p className=\"rounded-md border border-dashed p-6 text-sm text-muted-foreground\">\n {t('empty')}\n </p>\n ) : (\n <Table>\n <TableHeader>\n <TableRow>\n <TableHead>{t('name')}</TableHead>\n <TableHead>{t('usedOn')}</TableHead>\n <TableHead className=\"text-right\">{t('submissions')}</TableHead>\n <TableHead className=\"text-right\">{t('new')}</TableHead>\n <TableHead className=\"text-right\">{t('blocked')}</TableHead>\n <TableHead>{t('status')}</TableHead>\n {canDelete && canBuild && <TableHead className=\"w-0\" />}\n </TableRow>\n </TableHeader>\n <TableBody>\n {items.map((form) => (\n <TableRow key={form.id}>\n <TableCell className=\"font-medium\">\n {canBuild || canSeeSubmissions ? (\n <Link\n href={canBuild ? `${formsBasePath}/${form.id}` : submissionsHref(form.id)}\n className=\"text-primary hover:underline\"\n >\n {form.name}\n </Link>\n ) : (\n form.name\n )}\n </TableCell>\n <TableCell className=\"text-muted-foreground\" data-forms-used-on={form.id}>\n {usedOn === null\n ? t('notYet')\n : t('usedOnPages', { count: usedOn.get(form.id) ?? 0 })}\n </TableCell>\n <TableCell className=\"text-right tabular-nums\" data-forms-submissions={form.id}>\n {received === null ? (\n <span className=\"text-muted-foreground\">{t('notYet')}</span>\n ) : (\n <Link\n href={submissionsHref(form.id)}\n className=\"text-primary hover:underline\"\n aria-label={t('submissionsOf', {\n form: String(form.name),\n count: received.get(String(form.id))?.total ?? 0,\n })}\n >\n {received.get(String(form.id))?.total ?? 0}\n </Link>\n )}\n </TableCell>\n <TableCell className=\"text-right tabular-nums\" data-forms-new={form.id}>\n {received === null ? (\n <span className=\"text-muted-foreground\">{t('notYet')}</span>\n ) : (\n (received.get(String(form.id))?.fresh ?? 0)\n )}\n </TableCell>\n <TableCell className=\"text-right tabular-nums\">\n {blocked === null ? t('notYet') : (blocked.get(String(form.id)) ?? 0)}\n </TableCell>\n <TableCell>\n <WorkflowStatusBadge\n status={form.status === 'published' ? 'published' : 'draft'}\n />\n </TableCell>\n {canDelete && canBuild && (\n <TableCell className=\"text-right\">\n <DeleteFormButton\n apiBasePath={apiBasePath}\n formId={String(form.id)}\n formName={String(form.name)}\n submissions={\n received === null ? null : (received.get(String(form.id))?.total ?? 0)\n }\n pages={usedOn === null ? null : (usedOn.get(form.id) ?? 0)}\n />\n </TableCell>\n )}\n </TableRow>\n ))}\n </TableBody>\n </Table>\n )}\n {total > items.length && (\n <p className=\"text-xs text-muted-foreground\">{t('truncated', { count: items.length })}</p>\n )}\n {lastRun !== undefined && (\n <p className=\"text-xs text-muted-foreground\" data-forms-retention-run=\"\">\n {lastRun === null\n ? t('retentionNever')\n : `${t('retentionRun', {\n when: format.dateTime(new Date(lastRun.lastRunAt), {\n dateStyle: 'medium',\n timeStyle: 'short',\n }),\n count: lastRun.removed,\n })}${lastRun.unfinished ? ` ${t('retentionUnfinished')}` : ''}`}\n </p>\n )}\n </div>\n )\n })\n}\n","/**\n * Privacy requests (W5) — find one person's submissions on this site by email\n * address, export them, erase them (forms D007).\n *\n * `/admin/forms/privacy`, reached from the Forms list's header; never a menu\n * entry of its own. Gated on `form_submissions:view` by its route (the\n * plugin's `defaultRoutes`, forms D011) — the same permission that opens a\n * form's Submissions, because what this screen reaches is the same answers.\n * Erasing additionally needs `form_submissions:manage` AND a person's own\n * session — the erase route is closed to every agent door (`privacy-routes.ts`\n * says why); without both the button is not drawn, and the route refuses in\n * any case.\n *\n * The page itself reads NOTHING about anyone: it draws the search box. The\n * address is typed into the client view and sent in a request body\n * (`privacy-routes.ts`), so it never appears in a URL.\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { getContext, isTokenDerivedDoor, type PageProps } from '@murumets-ee/core'\nimport { FORM_SUBMISSIONS_RESOURCE } from '@murumets-ee/forms'\nimport { PrivacyRequestsView } from '@murumets-ee/forms-ui'\nimport Link from 'next/link'\nimport { notFound } from 'next/navigation'\nimport { getTranslations, setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\n\nexport async function PrivacyRequestsPage({\n params,\n}: PageProps<{ locale: string }>): Promise<ReactNode> {\n const config = getRegisteredAdminConfig()\n const { locale } = await params\n setRequestLocale(locale)\n const t = await getTranslations('Forms.privacy')\n const formsBasePath = `${config.adminBasePath.replace(':locale', locale)}/forms`\n\n return config.withAdminContext(locale, async () => {\n // The route's gate, asked again: a page rendered past its gate must still\n // offer nothing.\n if (!(await hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config))) notFound()\n // An agent's browser session finds and exports; it is never offered an\n // erase its route would refuse.\n // The door is read from the request context the app's `withAdminContext`\n // establishes (the in-repo admin and the scaffold both carry it). An app\n // whose own copy does not would draw the button for an agent session —\n // and the route would still refuse it, with the reason shown.\n const canErase =\n !isTokenDerivedDoor(getContext()?.door) &&\n (await hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'manage', config))\n return (\n <div className=\"space-y-4 p-6\">\n <div>\n <p className=\"text-sm text-muted-foreground\">\n <Link href={formsBasePath} className=\"hover:underline\">\n ← {t('backToForms')}\n </Link>\n </p>\n <h1 className=\"text-2xl font-bold\">{t('title')}</h1>\n <p className=\"max-w-prose text-sm text-muted-foreground\">{t('intro')}</p>\n </div>\n <PrivacyRequestsView\n apiBasePath={config.apiBasePath ?? '/api/admin'}\n formsBasePath={formsBasePath}\n locale={locale}\n canErase={canErase}\n />\n </div>\n )\n })\n}\n","/**\n * Submissions (W4) — what visitors sent through one form.\n *\n * `/admin/forms/:id/submissions`, gated on `form_submissions:view` by its\n * route (the plugin's `defaultRoutes`). The rows are read HERE, on the server,\n * through `@murumets-ee/forms`' submission functions — an elevated read the\n * page's gate has authorized, confined to the request's site (see\n * `submissions.ts`): another site's form id is a 404, and so is a form of\n * this site for a role without the permission.\n *\n * The filters are a plain GET form — status, page, day range — so the URL is\n * the state: it can be bookmarked, it works without JavaScript, and \"Export\n * CSV\" exports exactly what the URL names.\n *\n * Columns: one per field of the form as it is saved now, then one per retired\n * key that an answer on this page still holds (D005).\n */\n\nimport { getRegisteredAdminConfig } from '@murumets-ee/admin-ui/config-registry'\nimport { hasResourcePermission } from '@murumets-ee/admin-ui/pages'\nimport { getApp, type PageProps } from '@murumets-ee/core'\nimport {\n collectRetiredColumns,\n currentFormFields,\n FORM_SUBMISSIONS_RESOURCE,\n FORMS_RESOURCE,\n Form,\n listSubmissions,\n parseSubmissionFilters,\n readDeliveries,\n STATUS_FILTERS,\n SUBMISSIONS_PAGE_SIZE,\n submissionColumns,\n submissionFilterParams,\n submissionPages,\n} from '@murumets-ee/forms'\nimport { SubmissionsExportLink, SubmissionsView } from '@murumets-ee/forms-ui'\nimport { Button } from '@murumets-ee/ui'\nimport Link from 'next/link'\nimport { notFound, redirect } from 'next/navigation'\nimport { getTimeZone, getTranslations, setRequestLocale } from 'next-intl/server'\nimport type { ReactNode } from 'react'\nimport { discardedCounts } from './form-facts.js'\n\n/** Far past any real list; keeps a hand-typed `?p=` from becoming an absurd offset. */\nconst MAX_PAGE = 100_000\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/** The first value of a query parameter that may repeat. */\nconst first = (value: string | string[] | undefined): string | undefined =>\n Array.isArray(value) ? value[0] : value\n\nexport async function SubmissionsPage({\n params,\n searchParams,\n}: PageProps<{ locale: string; id: string }>): Promise<ReactNode> {\n const { id, locale } = await params\n if (!UUID_RE.test(id)) notFound()\n const query = await searchParams\n const config = getRegisteredAdminConfig()\n setRequestLocale(locale)\n const t = await getTranslations('Forms.submissions')\n const formsBasePath = `${config.adminBasePath.replace(':locale', locale)}/forms`\n const apiBasePath = config.apiBasePath ?? '/api/admin'\n\n return config.withAdminContext(locale, async () => {\n // The route's gate is `form_submissions:view`, asked again here because\n // everything below reads through an elevated client: a page rendered past\n // its gate must still show nothing.\n if (!(await hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'view', config))) notFound()\n const app = getApp()\n // The form of THIS site, or nothing: the elevated read keeps the request's\n // site, so another site's id is not found.\n const form: Record<string, unknown> | null | undefined = await app\n .getSystemClient(Form)\n .findById(id)\n if (form === null || form === undefined) notFound()\n\n // Days are days of the zone the rows' times are shown in (next-intl's),\n // so \"from the 9th\" matches the clock a person reads beside each row.\n const timeZone = await getTimeZone()\n const filters = parseSubmissionFilters((name) => first(query[name]), { timeZone })\n // A whole page number within reach — `?p=1e400` is not one.\n const asked = Number(first(query.p))\n const pageNumber = Number.isFinite(asked)\n ? Math.min(Math.max(Math.trunc(asked), 1), MAX_PAGE)\n : 1\n const [{ items, total }, pages, canManage, canBuild, blocked] = await Promise.all([\n listSubmissions(app, id, filters, {\n limit: SUBMISSIONS_PAGE_SIZE,\n offset: (pageNumber - 1) * SUBMISSIONS_PAGE_SIZE,\n }),\n submissionPages(app, id),\n hasResourcePermission(FORM_SUBMISSIONS_RESOURCE, 'manage', config),\n hasResourcePermission(FORMS_RESOURCE, 'manage', config),\n // Supplementary: a failed read costs the line, never the screen.\n discardedCounts([id]).catch(() => {\n console.warn(`forms: the blocked count for form ${id} could not be read`)\n return null\n }),\n ])\n\n // Each shown submission's email state (D006) — supplementary: a failed read\n // costs the column, never the screen.\n const deliveries = await readDeliveries(\n app,\n id,\n items.map((item) => item.id),\n )\n .then((byId) => Object.fromEntries(byId))\n .catch(() => {\n console.warn(`forms: the email state of form ${id}'s submissions could not be read`)\n return {}\n })\n\n const current = currentFormFields(form.body)\n const columns = submissionColumns(current, collectRetiredColumns(current, items))\n const filterQuery = submissionFilterParams(filters)\n const exportQuery = new URLSearchParams(filterQuery)\n exportQuery.set('form', id)\n exportQuery.set('uiLocale', locale)\n exportQuery.set('tz', filters.timeZone)\n const pageHref = (page: number): string => {\n const next = new URLSearchParams(filterQuery)\n if (page > 1) next.set('p', String(page))\n const text = next.toString()\n return `${formsBasePath}/${id}/submissions${text === '' ? '' : `?${text}`}`\n }\n const lastPage = Math.max(Math.ceil(total / SUBMISSIONS_PAGE_SIZE), 1)\n // Past the end — the last rows of the page were deleted or archived, or\n // the URL was typed: show the last page that has rows, never an empty one\n // that reads as \"no submissions\".\n if (items.length === 0 && total > 0 && pageNumber > lastPage) redirect(pageHref(lastPage))\n const filtered = filterQuery.toString() !== ''\n const name = typeof form.name === 'string' ? form.name : ''\n const discarded = blocked === null ? null : (blocked.get(id) ?? 0)\n // The page filter keeps a value that is no longer among the options (its\n // last submission was deleted), so the select never shows a blank.\n const pageOptions =\n filters.page !== undefined && !pages.includes(filters.page) ? [filters.page, ...pages] : pages\n\n return (\n <div className=\"space-y-4 p-6\">\n <div className=\"flex flex-wrap items-start justify-between gap-4\">\n <div>\n <p className=\"text-sm text-muted-foreground\">\n <Link href={formsBasePath} className=\"hover:underline\">\n ← {t('backToForms')}\n </Link>\n </p>\n <h1 className=\"text-2xl font-bold\">{t('title', { form: name })}</h1>\n </div>\n <div className=\"flex items-center gap-2\">\n {canBuild && (\n <Button variant=\"outline\" size=\"sm\" asChild>\n <Link href={`${formsBasePath}/${id}`}>{t('openForm')}</Link>\n </Button>\n )}\n <SubmissionsExportLink\n href={`${apiBasePath}/form-submissions/export?${exportQuery.toString()}`}\n />\n </div>\n </div>\n\n <form\n method=\"get\"\n action={`${formsBasePath}/${id}/submissions`}\n className=\"flex flex-wrap items-end gap-3 rounded-md border p-3 text-sm\"\n >\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.from')}</span>\n <input\n type=\"date\"\n name=\"from\"\n defaultValue={filters.from ?? ''}\n className=\"h-8 rounded-md border bg-background px-2\"\n />\n </label>\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.to')}</span>\n <input\n type=\"date\"\n name=\"to\"\n defaultValue={filters.to ?? ''}\n className=\"h-8 rounded-md border bg-background px-2\"\n />\n </label>\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.page')}</span>\n <select\n name=\"page\"\n defaultValue={filters.page ?? ''}\n className=\"h-8 rounded-md border bg-background px-2\"\n >\n <option value=\"\">{t('filters.allPages')}</option>\n {pageOptions.map((path) => (\n <option key={path} value={path}>\n {path}\n </option>\n ))}\n </select>\n </label>\n <label className=\"flex flex-col gap-1\">\n <span className=\"text-xs text-muted-foreground\">{t('filters.status')}</span>\n <select\n name=\"status\"\n defaultValue={filters.status}\n className=\"h-8 rounded-md border bg-background px-2\"\n >\n {STATUS_FILTERS.map((status) => (\n <option key={status} value={status}>\n {t(`filters.statuses.${status}`)}\n </option>\n ))}\n </select>\n </label>\n <Button type=\"submit\" size=\"sm\" variant=\"secondary\">\n {t('filters.apply')}\n </Button>\n {filtered && (\n <Button size=\"sm\" variant=\"ghost\" asChild>\n <Link href={`${formsBasePath}/${id}/submissions`}>{t('filters.clear')}</Link>\n </Button>\n )}\n {discarded !== null && discarded > 0 && (\n <span className=\"ml-auto text-xs text-muted-foreground\" data-forms-blocked>\n {t('blocked', { count: discarded })}\n </span>\n )}\n </form>\n\n {/* Always mounted: the panel holds the submission being read, which may\n be the last one a filter listed. Keyed by what is listed, so moving\n to another page or filter starts with nothing open. */}\n <SubmissionsView\n key={`${filterQuery.toString()}#${pageNumber}`}\n formId={id}\n rows={items}\n columns={columns}\n canManage={canManage}\n deliveries={deliveries}\n apiBasePath={apiBasePath}\n emptyText={filtered ? t('emptyFiltered') : t('empty')}\n />\n\n <div className=\"flex items-center justify-between text-sm text-muted-foreground\">\n <span>{t('showing', { shown: items.length, total })}</span>\n {lastPage > 1 && (\n <span className=\"flex items-center gap-2\">\n {pageNumber > 1 && (\n <Link href={pageHref(pageNumber - 1)} className=\"hover:underline\">\n ← {t('previous')}\n </Link>\n )}\n <span>{t('pageOf', { page: Math.min(pageNumber, lastPage), pages: lastPage })}</span>\n {pageNumber < lastPage && (\n <Link href={pageHref(pageNumber + 1)} className=\"hover:underline\">\n {t('next')} →\n </Link>\n )}\n </span>\n )}\n </div>\n </div>\n )\n })\n}\n"],"mappings":"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"}
@@ -0,0 +1,206 @@
1
+ import { PluginNotificationTypeDefinition } from "@murumets-ee/core";
2
+ import { getFormsMessages } from "@murumets-ee/forms/i18n";
3
+
4
+ //#region ../admin-route/dist/index.d.mts
5
+ /**
6
+ * Spec passed to {@link defineFeature}.
7
+ *
8
+ * Plugin-level NON-CRUD resources whose permission grants aren't tied to a
9
+ * single route or entity — e.g. `ticketing.bulk-edit` with actions
10
+ * `['view', 'execute']`, or `commerce.imports` with `['view', 'export',
11
+ * 'cancel']`. Use `defineAdminRoute` for routes and `defineAdminPage` for
12
+ * pages; `defineFeature` is the catalog hook for everything that's neither.
13
+ *
14
+ * The resource string is the same shape as a `defineAdminRoute` permission's
15
+ * left side. Actions are the right side — one catalog entry is emitted per
16
+ * `(resource, action)` pair, exactly as if `defineAdminRoute` had been
17
+ * called once per action with a no-op handler.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * defineFeature({
22
+ * resource: 'ticketing.bulk-edit',
23
+ * actions: ['view', 'execute'],
24
+ * defaultRoles: ['admin', 'agent'],
25
+ * description: 'Operate ticketing bulk-edit tools',
26
+ * })
27
+ * ```
28
+ */
29
+ interface DefineFeatureSpec {
30
+ /**
31
+ * Resource string — left side of the permission. Must be non-empty and
32
+ * not contain `:` (the action separator). Convention: dot-notation for
33
+ * plugin-prefixed names (`'ticketing.bulk-edit'`, `'commerce.imports'`).
34
+ */
35
+ resource: string;
36
+ /**
37
+ * Actions on this resource. Each becomes a catalog entry
38
+ * `<resource>:<action>`. Must be non-empty; each action must be a
39
+ * non-empty string without `:`. Duplicates within the same call are
40
+ * rejected (would be silently deduped by `registerPermission` but the
41
+ * call-site shape suggests programmer error).
42
+ */
43
+ actions: readonly string[];
44
+ /**
45
+ * Built-in roles granted EVERY action on this resource when the role row is
46
+ * first written, and granted the RESOURCE on a later boot if the saved row
47
+ * does not carry it yet.
48
+ *
49
+ * ⚠️ Not "on every `upsertBuiltInRoles`": its step 2 merges at RESOURCE
50
+ * granularity, so adding a new ACTION to a resource a role already holds
51
+ * reaches fresh installs and NOT existing ones. That asymmetry is deliberate
52
+ * — it is what stops an upgrade from undoing an operator's per-action edits —
53
+ * and it means a new action needs a migration or an operator, not a redeploy.
54
+ *
55
+ * ✅ **This is the field that actually seeds** — it is the only one. A
56
+ * `defineAdminRoute`/`defineAdminPage` `defaultRoles` carrying the same role
57
+ * names grants nothing (see their docblocks), so a permission that should be
58
+ * held by a non-admin role needs an entry HERE, reached declaratively via
59
+ * `Plugin.shared.features[]`. Read off the plugin objects rather than the
60
+ * catalog, because `registerPermission` keeps the first registrant's
61
+ * `source` and a permission registered by a route first would look like a
62
+ * route contribution afterwards (editor plan F111/R046).
63
+ *
64
+ * ⚠️ **Exactly three role names are honoured: `authenticated`, `editor`,
65
+ * `agent`** — `PLUGIN_SEEDABLE_ROLES` in `@murumets-ee/auth`. Anything else
66
+ * here is silently ignored, which is worth knowing BEFORE you write it:
67
+ *
68
+ * - `admin` is a bypass flag in `buildPermissionChecker`, not a row, and
69
+ * `PATCH /permissions` refuses to modify one — a grant would be redundant
70
+ * and uneditable. Most declarations name it and lose nothing by doing so.
71
+ * - `customer` must never reach `/api/admin/*` at all, per
72
+ * `buildInitialRoleDefinitions`' own docblock.
73
+ * - `public` is the anonymous visitor; changing what a site publishes is not
74
+ * a decision a plugin makes on an operator's behalf.
75
+ * - `viewer` is named in 20+ `defaultRoles` arrays repo-wide and is not in
76
+ * `BUILT_IN_ROLES`. It does not exist (editor plan F113).
77
+ *
78
+ * A permission that genuinely needs one of those roles wants a review, not an
79
+ * array entry. Ask for it.
80
+ *
81
+ * Defaults to `[]`. The same default-roles set applies to every action — if
82
+ * different actions need different default grants, register separate
83
+ * `defineFeature` calls.
84
+ */
85
+ defaultRoles?: readonly string[];
86
+ /**
87
+ * Optional description for the Permission Matrix UI (PR-D) and audit
88
+ * logs. Plain-text, no markdown. Applied to every catalog entry the
89
+ * call produces; per-action descriptions need separate `defineFeature`
90
+ * calls (one per action).
91
+ */
92
+ description?: string;
93
+ }
94
+ /**
95
+ * Register a non-CRUD plugin resource in the permission catalog.
96
+ *
97
+ * **Plugin authors should NOT call this directly.** Contribute features
98
+ * declaratively via `Plugin.shared.features: DefineFeatureSpec[]` — the
99
+ * framework's merge step at boot iterates that array and calls this
100
+ * factory for each spec. The declarative path is the supported plugin-
101
+ * authoring API; direct invocation is reserved for tests / scripts /
102
+ * non-plugin call sites that need to populate the catalog explicitly.
103
+ *
104
+ * The returned `PermissionCatalogEntry[]` contains ONE entry per `(resource,
105
+ * action)` pair — the same shape every other catalog consumer (Permission
106
+ * Matrix UI, `buildResourceCatalog`) already understands. `upsertBuiltInRoles`
107
+ * is NOT among them: it reads `Plugin.shared.features[]` off the plugin
108
+ * objects, never the catalog, because `registerPermission` keeps the FIRST
109
+ * registrant's `source` and unions `defaultRoles` across all of them — so a
110
+ * feature registered by a route first is indistinguishable here from a route
111
+ * (editor plan R046). Each returned entry reflects the AUTHORITATIVE
112
+ * post-merge catalog state: on a fresh registration it equals the input
113
+ * spec's fields; on a repeat registration (something else already
114
+ * contributed the same `(resource, action)` pair) the entry carries the
115
+ * first registrant's `source` + `description` and the union of every
116
+ * registrant's `defaultRoles`. Callers consume the return value as a
117
+ * truthful snapshot, not the draft this call would have registered.
118
+ *
119
+ * Unlike {@link defineAdminRoute}, `defineFeature` does NOT produce a
120
+ * handler — its only contribution is the catalog registration. Use this
121
+ * for non-route, non-entity-action permissions that the toolkit's auth
122
+ * layer should know about: feature flags an admin can grant per-role,
123
+ * bulk operations that aren't a discrete HTTP endpoint, scheduled-job
124
+ * "run-now" capabilities, etc.
125
+ *
126
+ * **Why a wrapper exists when `registerPermission` already does the job:**
127
+ * the wrapper validates input shape (non-empty resource, non-empty
128
+ * actions, no `:` in either, no duplicate actions in the same call),
129
+ * splits a multi-action declaration into the right number of catalog
130
+ * entries, and sets `source: 'feature'` so the Matrix UI can group by
131
+ * declaration site. `registerPermission` is the lower-level primitive
132
+ * shared with `defineAdminRoute`; plugin code should reach for
133
+ * `defineFeature` instead.
134
+ *
135
+ * **Idempotency:** repeat calls for the same `(resource, action)` pair
136
+ * union their `defaultRoles` (same behavior as `defineAdminRoute`
137
+ * registering the same permission twice). Two plugins both declaring
138
+ * `'ticketing.bulk-edit:execute'` produce one catalog entry with the
139
+ * combined defaults.
140
+ *
141
+ * @example Plugin authoring (declarative — preferred):
142
+ * ```ts
143
+ * import type { Plugin } from '@murumets-ee/core'
144
+ *
145
+ * export function ticketingPlugin(): Plugin {
146
+ * return {
147
+ * name: '@app/ticketing',
148
+ * shared: {
149
+ * features: [
150
+ * {
151
+ * resource: 'ticketing.bulk-edit',
152
+ * actions: ['view', 'execute'],
153
+ * defaultRoles: ['admin', 'agent'],
154
+ * description: 'Operate ticketing bulk-edit tools',
155
+ * },
156
+ * ],
157
+ * },
158
+ * }
159
+ * }
160
+ * ```
161
+ *
162
+ * @example Tests / scripts (direct invocation):
163
+ * ```ts
164
+ * import { defineFeature } from '@murumets-ee/core'
165
+ *
166
+ * const entries = defineFeature({
167
+ * resource: 'ticketing.bulk-edit',
168
+ * actions: ['view', 'execute'],
169
+ * defaultRoles: ['admin', 'agent'],
170
+ * })
171
+ * // entries[0].source === 'feature'
172
+ * ```
173
+ */
174
+ //#endregion
175
+ //#region src/plugin.d.ts
176
+ /** The daily clean-up of expired submissions (forms D007) — see `runFormsRetention`. */
177
+ declare const FORMS_RETENTION_JOB = "forms:retention";
178
+ /** Nightly, off the hour so it does not start with every other 03:00 job. */
179
+ declare const FORMS_RETENTION_SCHEDULE = "23 3 * * *";
180
+ declare function formsPlugin(): Pick<import("@murumets-ee/core").Plugin, "server" | "adminUi" | "postInstall" | "requires" | "contributes"> & {
181
+ readonly name: "@murumets-ee/forms";
182
+ readonly shared?: {
183
+ readonly features: readonly DefineFeatureSpec[];
184
+ readonly notifications: PluginNotificationTypeDefinition[];
185
+ readonly settings: readonly [import("@murumets-ee/settings").SettingsDefinition<{
186
+ readonly turnstileSiteKey: import("@murumets-ee/settings").TextSettingConfig & {
187
+ readonly default: "";
188
+ readonly maxLength: 100;
189
+ readonly pattern: RegExp;
190
+ readonly label: "Cloudflare Turnstile site key";
191
+ readonly description: "From the Turnstile widget in your Cloudflare dashboard. The secret key is set on the server as FORMS_TURNSTILE_SECRET, never here.";
192
+ };
193
+ readonly recaptchaSiteKey: import("@murumets-ee/settings").TextSettingConfig & {
194
+ readonly default: "";
195
+ readonly maxLength: 100;
196
+ readonly pattern: RegExp;
197
+ readonly label: "Google reCAPTCHA v3 site key";
198
+ readonly description: "From the reCAPTCHA admin console (v3). The secret key is set on the server as FORMS_RECAPTCHA_SECRET, never here. reCAPTCHA sends visitor data to Google \u2014 mention it in your privacy notice.";
199
+ };
200
+ }, "forms">];
201
+ readonly messages: typeof getFormsMessages;
202
+ };
203
+ };
204
+ //#endregion
205
+ export { FORMS_RETENTION_JOB, FORMS_RETENTION_SCHEDULE, formsPlugin };
206
+ //# sourceMappingURL=plugin.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.mts","names":["P","TApp","prefix","path","SegmentPath","method","AdminRouteMethod","permission","PermissionString","defaultRoles","description","descriptionKey","handler","AdminRouteHandler","matchAnyPath","tokens","RouteTokenChoice","mcp","AdminRouteMcpSpec","confirm","JsonValue","key","type","properties","required","additionalProperties","inputSchema","McpInputSchema","ENTRY_GATED","resource","action","guardedHandler","acceptsTokens","tokenRefusal","RouteTokenRefusalReason","requiresConfirmation","source","PermissionCatalogEntry","entry","Map","Parameters","ctx","NonNullable","entries","Promise","extraMetadata","Record","PermissionDenialContext","spec","role","Response","DefineAdminRouteSpec","AdminRouteEntry","actions","DefineFeatureSpec","AdminRoute","ReadonlySet","AuthDoor","door","Readonly","id","narrowing","CredentialNarrowing","rateLimitPerMinute","raw","U","name","email","user","credential","CallerCredential","confirmation","ConfirmationPolicy","base","entityType","entityId","userId","userName","changes","metadata","Request","req","segments","AuthUser","locale","defaultLocale","audit","AuditLogFn","auditMany","AuditLogManyFn","checkPermission","app","GATED","handlers","GET","POST","PATCH","DELETE","tokenPolicy","TokenPolicyEntry","ROUTE_TOKEN_REFUSAL_REASONS","refuse","refusal","Pick","route","decision","RouteTokenDecision","RouteTokenDecisionSource","routes","AdminRouteTokenDecision","value","APPROVAL_STATUSES","ApprovalStatus","RegExp","kind","entity","ids","snapshot","principalId","credentialId","query","body","preview","ApprovalPreview","status","requestedAt","Date","expiresAt","decidedBy","decidedAt","resultStatus","result","ParkedOperation","ok","reason","park","ParkInput","input","now","ParkOutcome","get","list","limit","offset","options","items","total","countPending","claim","reject","finish","outcome","resumeCaller","op","AdminApiCaller","sweep","staleExecutingMs","expired","interrupted","suffix","behavior","requires","needsContentClient","BuiltInEntityAction","segment","site","pathname","href"],"sources":["../../admin-route/dist/index.d.mts","../src/plugin.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;UAmwBU,iBAAA;;;;;;EAMR6B,QAAAA;;;;;;;;EAQAwB,OAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0CA5C,YAAAA;;;;;;;EAOAC,WAAAA;AAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cC7uBW,mBAAA;;cAGA,wBAAA;AAAA,iBAEG,WAAA,IAAW,IAAA,6BAAA,MAAA;EAAA;;gCAAA,iBAAA;IAAA,wBAiG4B,gCAAA;IAAA"}
@@ -0,0 +1,2 @@
1
+ import{combineAdminRoutes as e,defineAdminRoute as t,definePlugin as n,requireRequestSite as r}from"@murumets-ee/core";import{FORMS_MANAGE as i,FORMS_SETTINGS_NAMESPACE as a,FORM_SUBMISSIONS_MANAGE as o,FORM_SUBMISSIONS_VIEW as s,FORM_SUBMISSION_ENTITY as c,Form as l,FormSubmission as u,MAX_SUBMISSION_BULK as d,PRIVACY_EXPORT_MAX as f,PRIVACY_LIST_MAX as p,SUBMISSIONS_READ_MAX as m,collectRetiredColumns as h,compileForm as g,countSubmissionsByEmail as ee,countUnindexedSubmissions as _,currentFormFields as v,deleteSubmissions as te,enqueueFormEmail as ne,eraseSubmissionsByEmail as re,findSubmissionsByEmail as y,formEmailTypes as ie,formNames as b,formsFeatures as x,formsRoutes as S,formsSettings as ae,formsTables as oe,isEmailKind as se,isSubmissionStatus as ce,listSubmissionRows as le,parsePrivacyEmail as ue,parseRecipients as de,parseSubmissionFilters as fe,readCappedBody as C,readDeliveries as pe,setSubmissionStatus as me,submissionColumns as he,submissionCounts as ge,submissionFilterParams as _e,writeEmailSettings as ve}from"@murumets-ee/forms";import{getFormsMessages as w}from"@murumets-ee/forms/i18n";import{FormEmbedBlock as ye,formEnvironment as be,formPageEnvironmentExtension as xe,formSchemaBlocks as Se}from"@murumets-ee/forms/schema";import{FormSubmissionsHeaderAction as Ce}from"@murumets-ee/forms-ui/canvas-header-action";import{csvDelimiterFor as T,csvFilename as E,pagedExportRows as D,runCsvExport as O}from"@murumets-ee/admin-ui/csv-export";const k=`form-emails`,A=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function j(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`}})}function we(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}async function M(e){let t=await C(e,8192);if(`tooLarge`in t)return{refusal:j({error:`body_too_large`},413)};try{return{value:JSON.parse(t.text)}}catch{return{refusal:j({error:`invalid_json`},400)}}}function N(e,t){if(!we(e))return null;let n=new Set(t);return Object.keys(e).every(e=>n.has(e))?e:null}async function P(e,t){let n=await e.getSystemClient(l).findById(t);return n==null?null:{body:n.body}}const Te=async(e,t)=>{let n=await M(e);if(`refusal`in n)return n.refusal;let r=N(n.value,[`form`,`recipients`,`autoReplyEnabled`,`autoReplyField`]),{form:i,recipients:a,autoReplyEnabled:o,autoReplyField:s}=r??{};if(r===null||typeof i!=`string`||!A.test(i)||typeof o!=`boolean`||!(s===null||typeof s==`string`))return j({error:`Expected { form: uuid, recipients: string[], autoReplyEnabled: boolean, autoReplyField: string|null }`},400);let c=de(a);if(!c.ok)return j({error:`invalid_recipients`,invalid:c.invalid,tooMany:c.tooMany},400);let l=await P(t.app,i);if(l===null)return j({error:`not_found`},404);let u=(g(l.body)?.fields??[]).filter(e=>e.kind===`email`).map(e=>e.key);return s!==null&&!u.includes(s)?j({error:`invalid_auto_reply_field`},400):o&&s===null?j({error:`auto_reply_needs_field`},400):(await ve(t.app,i,{recipients:c.recipients,autoReplyEnabled:o,autoReplyField:s}),t.audit?.({action:`forms.emails.update`,entityType:`form`,entityId:i,metadata:{recipients:c.recipients.length,autoReply:o}}),j({recipients:c.recipients,autoReplyEnabled:o,autoReplyField:s}))},Ee=[`no_recipients`,`disabled`];function De(e,t){return e.status===`failed`?!(e.kind===`autoReply`&&e.errorClass===`timeout`&&t-new Date(e.updatedAt).getTime()<36e5):e.status===`queued`?t-new Date(e.updatedAt).getTime()>3e5:e.status===`skipped`&&e.errorClass!==null&&Ee.includes(e.errorClass)}const Oe=async(e,t)=>{let n=await M(e);if(`refusal`in n)return n.refusal;let i=N(n.value,[`form`,`submission`,`kind`]),{form:o,submission:s,kind:l}=i??{};if(i===null||typeof o!=`string`||!A.test(o)||typeof s!=`string`||!A.test(s)||!se(l))return j({error:`Expected { form: uuid, submission: uuid, kind: notification|autoReply }`},400);if(await P(t.app,o)===null)return j({error:`not_found`},404);let d=await t.app.getSystemClient(u).findById(s);if(d==null||String(d.form)!==o)return j({error:`not_found`},404);let f=((await pe(t.app,o,[s])).get(s)??[]).find(e=>e.kind===l);if(f!==void 0&&!De(f,Date.now()))return j({error:`not_retryable`,status:f.status},409);let p=await r(a,t.app),m=await ne(t.app,{site:p,formId:o,submissionId:s,kind:l});return t.audit?.({action:`forms.emails.retry`,entityType:c,entityId:s,metadata:{kind:l,queued:m}}),j({queued:m})},ke=t({prefix:k,path:``,method:`PATCH`,permission:i,defaultRoles:[`admin`,`editor`],description:`Set a form’s email recipients and auto-reply: { form, recipients, … }.`,descriptionKey:`Forms.permissions.routeEmails`,tokens:`accept`,confirm:!0,handler:Te}),Ae=t({prefix:k,path:`retry`,method:`POST`,permission:o,defaultRoles:[`admin`,`editor`],description:`Queue a failed form email again: { form, submission, kind }.`,descriptionKey:`Forms.permissions.routeEmailRetry`,tokens:`accept`,handler:Oe});function je(){return e([ke,Ae])}const F=`form-privacy`,I=`X-Lumi-Export-Truncated`;let L=0;function Me(){return new Response(JSON.stringify({error:`export_busy`,code:`export_busy`}),{status:503,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`,"Retry-After":`10`}})}function R(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`}})}function z(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}async function B(e,t){let n=await C(e,2048);if(`tooLarge`in n)return{refusal:R({error:`body_too_large`},413)};let r;try{r=JSON.parse(n.text)}catch{return{refusal:R({error:`invalid_json`},400)}}let i=new Set([`email`,...t]);if(!z(r)||Object.keys(r).some(e=>!i.has(e)))return{refusal:R({error:`invalid_body`},400)};let a=ue(r.email);return a===null?{refusal:R({error:`invalid_email`,code:`invalid_email`},400)}:{email:a,rest:r}}const Ne=async(e,t)=>{let n=await B(e,[]);if(`refusal`in n)return n.refusal;let{email:r}=n,[i,a,o]=await Promise.all([ee(t.app,r),y(t.app,r,{limit:p,offset:0}),_(t.app)]),s=await b(t.app,a.map(e=>e.formId)),c=a.map(e=>({id:e.id,formId:e.formId,formName:s.get(e.formId)??null,createdAt:e.createdAt,pagePath:e.pagePath}));return t.audit?.({action:`forms.privacy.search`,entityType:`form_submission`,metadata:{count:i,forms:[...new Set(a.map(e=>e.formId))],unindexed:o}}),R({total:i,items:c,unindexed:o,exportMax:f})},Pe=[`csv`,`json`];function Fe(e,t){let n=e.Forms;for(let e of t){if(!z(n))return;n=n[e]}return typeof n==`string`&&n!==``?n:void 0}async function Ie(e){let t=await w(e??`en`),n=(e,n)=>Fe(t,e)??n;return{form:n([`privacy`,`csv`,`form`],`Form`),received:n([`privacy`,`csv`,`received`],`Received (UTC)`),page:n([`privacy`,`csv`,`page`],`Page`),status:n([`privacy`,`csv`,`status`],`Status`),yes:n([`submissions`,`yes`],`yes`),no:n([`submissions`,`no`],`no`),deletedForm:n([`privacy`,`deletedForm`],`deleted`),statuses:{new:n([`submissionStatus`,`new`],`New`),read:n([`submissionStatus`,`read`],`Read`),archived:n([`submissionStatus`,`archived`],`Archived`)}}}function V(e,t){let n=e.fields[t]?.label;return n!==void 0&&n!==``?n:t}function Le(e){let t=new Map;for(let[n,r]of Object.entries(e.data)){let i=V(e,n);t.set(i,[...t.get(i)??[],r])}return t}function Re(e,t){if(e===void 0||e.length===0)return``;let n=e.map(e=>typeof e==`boolean`?e?t.yes:t.no:e),r=n[0];return n.length===1&&r!==void 0?r:n.join(` | `)}function ze(e){return e.slice(0,16).replace(`T`,` `)}function Be(e,t,n,r){return D((r,i)=>y(e,t,{limit:i,offset:r,until:n}),{pageSize:m,maxRows:f,...r!==void 0&&{onTruncated:r}})}const Ve=async(e,t)=>{let n=await B(e,[`format`,`uiLocale`]);if(`refusal`in n)return n.refusal;let{email:r,rest:i}=n,a=Pe.find(e=>e===i.format);if(a===void 0)return R({error:`invalid_format`},400);let o=typeof i.uiLocale==`string`&&i.uiLocale.length<=35?i.uiLocale:t.locale,s=t.app;if(L>=2)return Me();L+=1;let c=new Date,l=[],u=!1,d;try{for await(let e of Be(s,r,c,()=>{u=!0}))l.push(e);d=await b(s,[...new Set(l.map(e=>e.formId))])}finally{--L}u&&s.logger.warn({written:l.length},`forms: a privacy export reached the row cap — the file holds the newest rows only`);let f=()=>{t.audit?.({action:`forms.privacy.export`,entityType:`form_submission`,metadata:{count:l.length,forms:[...new Set(l.map(e=>e.formId))],format:a,truncated:u}})};if(a===`json`){let e=JSON.stringify({exportedAt:c.toISOString(),truncated:u,submissions:l.map(e=>({form:d.get(e.formId)??null,receivedAt:e.createdAt,page:e.pagePath,language:e.locale,status:e.status,answers:Object.entries(e.data).map(([t,n])=>({field:V(e,t),key:t,answer:n})),consent:Object.values(e.consent)}))},null,2);return f(),new Response(e,{status:200,headers:{"Content-Type":`application/json; charset=utf-8`,"Content-Disposition":`attachment; filename="${E(`privacy-request`).replace(/\.csv$/,`.json`)}"`,"Cache-Control":`private, no-store`,"X-Content-Type-Options":`nosniff`,[I]:String(u)}})}let p=await Ie(o),m=[],h=new Set;for(let e of l)for(let t of Object.keys(e.data)){let n=V(e,t);h.has(n)||(h.add(n),m.push(n))}let g=await O(()=>({filename:E(`privacy-request`),delimiter:T(o),header:[p.form,p.received,p.page,p.status,...m],rows:l.map(e=>{let t=Le(e);return[d.get(e.formId)??p.deletedForm,ze(e.createdAt),e.pagePath??``,p.statuses[e.status]??e.status,...m.map(e=>Re(t.get(e),p))]})}));return g.ok&&(f(),g.headers.set(I,String(u))),g},He=async(e,t)=>{let n=await B(e,[]);if(`refusal`in n)return n.refusal;let r=await re(t.app,n.email),i=await _(t.app).catch(()=>null);return t.audit?.({action:`forms.privacy.erase`,entityType:`form_submission`,metadata:{count:r.erased,forms:[...r.formIds],unfinished:r.more,failed:r.failed,unindexed:i}}),R({erased:r.erased,forms:r.formIds.length,more:r.more,failed:r.failed,unindexed:i},r.failed?500:200)},Ue=t({prefix:F,path:`search`,method:`POST`,permission:s,defaultRoles:[`admin`,`editor`],description:`Find this site’s submissions by one whole email address: { email }.`,descriptionKey:`Forms.permissions.routePrivacySearch`,tokens:`accept`,handler:Ne}),We=t({prefix:F,path:`export`,method:`POST`,permission:s,defaultRoles:[`admin`,`editor`],description:`Download one person’s submissions as CSV or JSON: { email, format }.`,descriptionKey:`Forms.permissions.routePrivacyExport`,tokens:`accept`,handler:Ve}),Ge=t({prefix:F,path:``,method:`DELETE`,permission:o,defaultRoles:[`admin`,`editor`],description:`Erase one person’s submissions for good: { email }. People only, no agents.`,descriptionKey:`Forms.permissions.routePrivacyErase`,tokens:{refuse:`personal-data`},handler:He});function Ke(){return e([Ue,We,Ge])}const H=`form-submissions`;function U(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`}})}const W=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function G(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function qe(e){return Array.isArray(e)}function K(e,t){if(!G(e))return null;let n=new Set([`form`,`ids`,...t]);if(Object.keys(e).some(e=>!n.has(e)))return null;let{form:r,ids:i}=e;if(typeof r!=`string`||!W.test(r)||!qe(i)||i.length===0||i.length>d)return null;let a=i.filter(e=>typeof e==`string`&&W.test(e));return a.length===i.length?{form:r,ids:a,rest:e}:null}async function q(e){let t=await C(e,8192);if(`tooLarge`in t)return{refusal:U({error:`body_too_large`},413)};let n=t.text;try{return{value:JSON.parse(n)}}catch{return{refusal:U({error:`invalid_json`},400)}}}async function J(e,t){let n=await e.getSystemClient(l).findById(t);return n==null?null:{name:typeof n.name==`string`?n.name:`form`,body:n.body}}const Je=async(e,t)=>{let n=new URL(e.url).searchParams.get(`form`)??``;if(!W.test(n)||await J(t.app,n)===null)return U({error:`not_found`},404);let r=(await ge(t.app,[n])).get(n);return U({total:r?.total??0,fresh:r?.fresh??0})},Ye=async(e,t)=>{let n=await q(e);if(`refusal`in n)return n.refusal;let r=K(n.value,[`status`]),i=r?.rest.status;if(r===null||!ce(i))return U({error:`Expected { form: uuid, ids: uuid[], status: new|read|archived }`},400);let{form:a}=r;if(await J(t.app,a)===null)return U({error:`not_found`},404);let o=await me(t.app,a,r.ids,i);return t.audit?.({action:`forms.submissions.status`,entityType:`form`,entityId:a,metadata:{status:i,count:o}}),U({changed:o})},Xe=async(e,t)=>{let n=await q(e);if(`refusal`in n)return n.refusal;let r=K(n.value,[]);if(r===null)return U({error:`Expected { form: uuid, ids: uuid[] }`},400);let{form:i}=r;if(await J(t.app,i)===null)return U({error:`not_found`},404);let a=await te(t.app,i,r.ids);return t.audit?.({action:`forms.submissions.delete`,entityType:`form`,entityId:i,metadata:{count:a}}),U({deleted:a})},Y={received:`Received (UTC)`,page:`Page`,status:`Status`,yes:`yes`,no:`no`,removed:`removed field`,statuses:{new:`New`,read:`Read`,archived:`Archived`}};function Ze(e,t){let n=e.Forms;for(let e of t){if(!G(n))return;n=n[e]}return typeof n==`string`&&n!==``?n:void 0}async function X(e){let t=await w(e??`en`),n=(e,n)=>Ze(t,e)??n;return{received:n([`submissions`,`csv`,`received`],Y.received),page:n([`submissions`,`csv`,`page`],Y.page),status:n([`submissions`,`csv`,`status`],Y.status),yes:n([`submissions`,`yes`],Y.yes),no:n([`submissions`,`no`],Y.no),removed:n([`submissions`,`removedField`],Y.removed),statuses:{new:n([`submissionStatus`,`new`],`New`),read:n([`submissionStatus`,`read`],`Read`),archived:n([`submissionStatus`,`archived`],`Archived`)}}}function Qe(e){return e.slice(0,16).replace(`T`,` `)}function $e(e,t){return e===void 0?``:typeof e==`boolean`?e?t.yes:t.no:e}const et=t({prefix:H,path:`export`,method:`GET`,permission:s,defaultRoles:[`admin`,`editor`],description:`Download a form’s submissions as CSV: ?form=<id> plus the screen’s filters.`,descriptionKey:`Forms.permissions.routeExport`,tokens:`accept`,handler:async(e,t)=>{let n=new URL(e.url),r=n.searchParams.get(`form`)??``;if(!W.test(r))return U({error:`not_found`},404);let i=await J(t.app,r);if(i===null)return U({error:`not_found`},404);let a={...fe(e=>n.searchParams.get(e)),until:new Date},o=n.searchParams.get(`uiLocale`)??t.locale,s=v(i.body),c=t.app,l=e=>D((e,t)=>le(c,r,a,{limit:t,offset:e}),{pageSize:m,...e!==void 0&&{onTruncated:e}});return O(async()=>{t.audit?.({action:`forms.submissions.export`,entityType:`form`,entityId:r,metadata:{filtered:_e(a).size>0}});let e=await X(o),n=new Map;for await(let e of l())h(s,[e],n);let u=he(s,n);return{filename:E(i.name),delimiter:T(o),header:[e.received,e.page,e.status,...u.map(t=>t.retired?`${t.label} (${e.removed})`:t.label)],rows:(async function*(){let t=l(e=>{c.logger.warn({formId:r,written:e},`forms: a submissions export reached the row cap — the file holds the newest rows only`)});for await(let n of t)yield[Qe(n.createdAt),n.pagePath??``,e.statuses[n.status]??n.status,...u.map(t=>$e(n.data[t.key],e))]})()}})}}),tt=t({prefix:H,path:`summary`,method:`GET`,permission:s,defaultRoles:[`admin`,`editor`],description:`How many submissions a form holds and how many are new: ?form=<id>.`,descriptionKey:`Forms.permissions.routeSummary`,tokens:`accept`,handler:Je}),nt=t({prefix:H,path:``,method:`PATCH`,permission:o,defaultRoles:[`admin`,`editor`],description:`Mark submissions of a form new, read or archived: { form, ids, status }.`,descriptionKey:`Forms.permissions.routeStatus`,tokens:`accept`,handler:Ye}),rt=t({prefix:H,path:``,method:`DELETE`,permission:o,defaultRoles:[`admin`,`editor`],description:`Delete submissions of a form for good: { form, ids }.`,descriptionKey:`Forms.permissions.routeDelete`,tokens:`accept`,confirm:!0,handler:Xe});function it(){return e([et,tt,nt,rt])}const Z=`${l.name}:view`,at=async e=>(await import(`@murumets-ee/forms-ui/pages`)).FormsListPage(e),ot=async e=>(await import(`@murumets-ee/forms-ui/pages`)).FormCanvasPage(e),st=async e=>(await import(`@murumets-ee/forms-ui/pages`)).SubmissionsPage(e),ct=async e=>(await import(`@murumets-ee/forms-ui/pages`)).PrivacyRequestsPage(e),Q=`forms:retention`,$=`23 3 * * *`;function lt(){return n({name:`@murumets-ee/forms`,requires:[`@murumets-ee/content`,`@murumets-ee/settings`,`@murumets-ee/notifications`,`@murumets-ee/queue`],server:{entities:[l,u],tables:{...oe},routes:[...S(),...it(),...Ke(),...je()],init:async e=>{if(!e.plugins.has(`@murumets-ee/queue`))return;let{defineJob:t,registerJob:n}=await import(`@murumets-ee/queue/client`);n(t({name:Q,schedule:$}),async()=>{let{runAsCli:t,runInEverySite:n}=await import(`@murumets-ee/core`),{runFormsRetention:r}=await import(`@murumets-ee/forms`),i=await t(()=>n(()=>r(e),{app:e,label:Q})),a=i.reduce((e,t)=>e+t.removed,0);e.logger.info({sites:i.length,removed:a,unfinished:i.filter(e=>e.unfinished).length,backfilled:i.reduce((e,t)=>e+t.backfilled,0)},`forms: daily clean-up of expired submissions finished`)});let{runAsCli:r}=await import(`@murumets-ee/core`),{runFormEmailJob:i,sendFormEmailJob:a}=await import(`@murumets-ee/forms/email-jobs`);n(a,async t=>{await r(async()=>{let n=`en`;try{let{getNotificationsConfig:e}=await import(`@murumets-ee/notifications/plugin`);n=e().defaultLocale}catch{e.logger.warn({outcome:`no_default_locale`},`forms: notifications default locale unavailable — using en`)}await i(t.payload,{app:e,logger:e.logger,defaultLocale:n})})})}},contributes:{"blocks.blocks":[...Se,ye],"blocks.environmentBases":[be],"blocks.environments":xe},shared:{features:x,notifications:[...ie],settings:[ae],messages:w},adminUi:{canvasHeaderActions:{[l.name]:Ce},pages:{FormsListPage:at,FormCanvasPage:ot,SubmissionsPage:st,PrivacyRequestsPage:ct},sidebar:[{id:`forms`,group:`Content`,label:`Forms`,href:`/admin/forms`,iconName:`clipboard-list`,requires:Z}],defaultRoutes:[{path:`forms`,factory:`FormsListPage`,page:{requires:Z,defaultRoles:[`admin`,`editor`],description:`List this site’s forms and create new ones.`,descriptionKey:`Forms.permissions.list`,tokens:`accept`}},{path:`forms/privacy`,factory:`PrivacyRequestsPage`,page:{requires:s,defaultRoles:[`admin`,`editor`],description:`Find one person’s submissions by email address, to export or erase them.`,descriptionKey:`Forms.permissions.privacy`,tokens:`accept`}},{path:`forms/[id]`,factory:`FormCanvasPage`,page:{requires:i,defaultRoles:[`admin`,`editor`],description:`Edit a form on its canvas.`,descriptionKey:`Forms.permissions.canvas`,tokens:`accept`}},{path:`forms/[id]/submissions`,factory:`SubmissionsPage`,page:{requires:s,defaultRoles:[`admin`,`editor`],description:`Read and export what visitors sent through a form.`,descriptionKey:`Forms.permissions.submissions`,tokens:`accept`}},{path:`form`,factory:`FormsListPage`},{path:`form/[id]`,factory:`FormCanvasPage`}]}})}export{Q as FORMS_RETENTION_JOB,$ as FORMS_RETENTION_SCHEDULE,lt as formsPlugin};
2
+ //# sourceMappingURL=plugin.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.mjs","names":["MAX_BODY_BYTES","UUID_RE","json","isRecord","readBody","loadForm","MAX_BODY_BYTES","json","isRecord","message","exportCopy","csvTime","exportRoute"],"sources":["../src/email-routes.ts","../src/privacy-routes.ts","../src/submission-routes.ts","../src/plugin.ts"],"sourcesContent":["/**\n * A form's email settings and delivery Retry — the admin API behind the form\n * panel's Emails tab and the Submissions screen (forms D006). Prefix\n * `form-emails`:\n *\n * - `PATCH form-emails` `{ form, recipients, autoReplyEnabled, autoReplyField }`\n * — who is told of each answer, and whether the visitor is thanked.\n * - `POST form-emails/retry` `{ form, submission, kind }` — queue a failed (or\n * skipped) email again.\n *\n * **Authorization.** `PATCH` is `forms:manage` (building the form), `retry` is\n * `form_submissions:manage` (tending its submissions). The tables are reached\n * through `@murumets-ee/forms`' functions with the app's own database handle,\n * after the route has authorized and the form has been shown to belong to THIS\n * site — another site's id reads as \"not found\" (C2).\n *\n * **Validation.** Strict bodies (any other key is malformed); every recipient is\n * a plain `local@domain.tld` ({@link parseRecipients}), at most ten; the\n * auto-reply field must be an email field of the form AS IT IS NOW.\n *\n * **Why recipients are not in the form.** A form's published tree is delivered to\n * every visitor, so an address in a block prop would be published (F017). They\n * live in `forms_email_settings`, which no public route reads.\n *\n * **Agents.** An agent whose role holds the permission may call them. Changing\n * who receives every answer is the one call here that could redirect visitors'\n * data, so it is `confirm: true`: an agent under a human-approves policy has it\n * parked for a person. A Retry only re-sends to recipients already set.\n *\n * **Personal data (C5).** An audit row names the form and counts. Never an\n * address.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n requireRequestSite,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport {\n compileForm,\n enqueueFormEmail,\n FORM_SUBMISSION_ENTITY,\n FORM_SUBMISSIONS_MANAGE,\n FORMS_MANAGE,\n FORMS_SETTINGS_NAMESPACE,\n Form,\n FormSubmission,\n isEmailKind,\n parseRecipients,\n readCappedBody,\n readDeliveries,\n writeEmailSettings,\n} from '@murumets-ee/forms'\n\nexport const EMAIL_ROUTES_PREFIX = 'form-emails'\n\nconst MAX_BODY_BYTES = 8192\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },\n })\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nasync function readBody(req: Request): Promise<{ value: unknown } | { refusal: Response }> {\n const body = await readCappedBody(req, MAX_BODY_BYTES)\n if ('tooLarge' in body) return { refusal: json({ error: 'body_too_large' }, 413) }\n try {\n return { value: JSON.parse(body.text) as unknown }\n } catch {\n return { refusal: json({ error: 'invalid_json' }, 400) }\n }\n}\n\n/** Exactly these keys, or `null`. */\nfunction strict(value: unknown, keys: readonly string[]): Record<string, unknown> | null {\n if (!isRecord(value)) return null\n const allowed = new Set(keys)\n return Object.keys(value).every((key) => allowed.has(key)) ? value : null\n}\n\n/** This site's form `id` (system read, confined by the site), or `null`. */\nasync function loadForm(app: ToolkitApp, id: string): Promise<{ body: unknown } | null> {\n const row: Record<string, unknown> | null | undefined = await app\n .getSystemClient(Form)\n .findById(id)\n return row === null || row === undefined ? null : { body: row.body }\n}\n\nexport const handleSaveEmails: AdminRouteHandler = async (req, ctx) => {\n const body = await readBody(req)\n if ('refusal' in body) return body.refusal\n const parsed = strict(body.value, ['form', 'recipients', 'autoReplyEnabled', 'autoReplyField'])\n const { form, recipients, autoReplyEnabled, autoReplyField } = parsed ?? {}\n if (\n parsed === null ||\n typeof form !== 'string' ||\n !UUID_RE.test(form) ||\n typeof autoReplyEnabled !== 'boolean' ||\n !(autoReplyField === null || typeof autoReplyField === 'string')\n ) {\n return json(\n {\n error:\n 'Expected { form: uuid, recipients: string[], autoReplyEnabled: boolean, autoReplyField: string|null }',\n },\n 400,\n )\n }\n const verdict = parseRecipients(recipients)\n if (!verdict.ok) {\n return json(\n { error: 'invalid_recipients', invalid: verdict.invalid, tooMany: verdict.tooMany },\n 400,\n )\n }\n const loaded = await loadForm(ctx.app, form)\n if (loaded === null) return json({ error: 'not_found' }, 404)\n\n // The auto-reply goes to ONE email field of the form as it is now.\n const fields = compileForm(loaded.body)?.fields ?? []\n const emailKeys = fields.filter((field) => field.kind === 'email').map((field) => field.key)\n if (autoReplyField !== null && !emailKeys.includes(autoReplyField)) {\n return json({ error: 'invalid_auto_reply_field' }, 400)\n }\n if (autoReplyEnabled && autoReplyField === null) {\n return json({ error: 'auto_reply_needs_field' }, 400)\n }\n\n await writeEmailSettings(ctx.app, form, {\n recipients: verdict.recipients,\n autoReplyEnabled,\n autoReplyField,\n })\n ctx.audit?.({\n action: 'forms.emails.update',\n entityType: 'form',\n entityId: form,\n metadata: { recipients: verdict.recipients.length, autoReply: autoReplyEnabled },\n })\n return json({ recipients: verdict.recipients, autoReplyEnabled, autoReplyField })\n}\n\n/** A queued email this old has lost its job (worker down, process died). */\nconst STUCK_AFTER_MS = 5 * 60 * 1000\n/** How long a timed-out auto-reply holds its slot — the guard's window. */\nconst GUARD_WINDOW_MS = 60 * 60 * 1000\n/** Skips a changed setting can cure; the others (hourly limit, bad address, gone) would be refused again. */\nconst RETRYABLE_SKIPS: readonly string[] = ['no_recipients', 'disabled']\n\n/** Where a Retry can change the answer — the screen's `canRetry` and this route agree. */\nfunction isRetryable(\n row: { kind: string; status: string; errorClass: string | null; updatedAt: string },\n now: number,\n): boolean {\n if (row.status === 'failed') {\n // A timed-out auto-reply holds its hourly slot (the mail may still arrive):\n // a Retry inside that hour would only be refused.\n const held =\n row.kind === 'autoReply' &&\n row.errorClass === 'timeout' &&\n now - new Date(row.updatedAt).getTime() < GUARD_WINDOW_MS\n return !held\n }\n if (row.status === 'queued') return now - new Date(row.updatedAt).getTime() > STUCK_AFTER_MS\n return (\n row.status === 'skipped' && row.errorClass !== null && RETRYABLE_SKIPS.includes(row.errorClass)\n )\n}\n\nexport const handleRetry: AdminRouteHandler = async (req, ctx) => {\n const body = await readBody(req)\n if ('refusal' in body) return body.refusal\n const parsed = strict(body.value, ['form', 'submission', 'kind'])\n const { form, submission, kind } = parsed ?? {}\n if (\n parsed === null ||\n typeof form !== 'string' ||\n !UUID_RE.test(form) ||\n typeof submission !== 'string' ||\n !UUID_RE.test(submission) ||\n !isEmailKind(kind)\n ) {\n return json(\n { error: 'Expected { form: uuid, submission: uuid, kind: notification|autoReply }' },\n 400,\n )\n }\n if ((await loadForm(ctx.app, form)) === null) return json({ error: 'not_found' }, 404)\n // The submission must be this form's — and this site's, which the system\n // client's site binding already guarantees.\n const row: Record<string, unknown> | null | undefined = await ctx.app\n .getSystemClient(FormSubmission)\n .findById(submission)\n if (row === null || row === undefined || String(row.form) !== form) {\n return json({ error: 'not_found' }, 404)\n }\n // The server holds the same line the screen draws: an email that went is not\n // sent again by a replayed request (every call would mail every recipient the\n // full set of answers), and a fresh queued one is still on its way.\n const current = (await readDeliveries(ctx.app, form, [submission])).get(submission) ?? []\n const delivery = current.find((entry) => entry.kind === kind)\n if (delivery !== undefined && !isRetryable(delivery, Date.now())) {\n return json({ error: 'not_retryable', status: delivery.status }, 409)\n }\n const site = await requireRequestSite(FORMS_SETTINGS_NAMESPACE, ctx.app)\n\n const queued = await enqueueFormEmail(ctx.app, {\n site,\n formId: form,\n submissionId: submission,\n kind,\n })\n ctx.audit?.({\n action: 'forms.emails.retry',\n entityType: FORM_SUBMISSION_ENTITY,\n entityId: submission,\n metadata: { kind, queued },\n })\n // `readEmailSettings` is not consulted: a Retry sends to whoever is set NOW,\n // and the job says \"skipped: no_recipients\" if nobody is.\n return json({ queued })\n}\n\nconst saveRoute = defineAdminRoute({\n prefix: EMAIL_ROUTES_PREFIX,\n path: '',\n method: 'PATCH',\n permission: FORMS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Set a form’s email recipients and auto-reply: { form, recipients, … }.',\n descriptionKey: 'Forms.permissions.routeEmails',\n tokens: 'accept',\n // Redirecting every answer to a new address is the one call here that could\n // move visitors' data; an agent under a human-approves policy waits for a person.\n confirm: true,\n handler: handleSaveEmails,\n})\n\nconst retryRoute = defineAdminRoute({\n prefix: EMAIL_ROUTES_PREFIX,\n path: 'retry',\n method: 'POST',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Queue a failed form email again: { form, submission, kind }.',\n descriptionKey: 'Forms.permissions.routeEmailRetry',\n tokens: 'accept',\n handler: handleRetry,\n})\n\n/** The form-email admin routes (`server.routes`). */\nexport function emailRoutes(): AdminRoute[] {\n return combineAdminRoutes([saveRoute, retryRoute])\n}\n","/**\n * The Privacy requests screen's admin API (forms D007, W5) — prefix\n * `form-privacy`:\n *\n * - `POST form-privacy/search` `{ email }` — which of this site's submissions\n * carry that address: form, time and page of each, and the total.\n * - `POST form-privacy/export` `{ email, format, uiLocale? }` — all of them,\n * with their answers under the labels they had, as CSV or JSON.\n * - `DELETE form-privacy` `{ email }` — erase all of them, for good.\n *\n * **The address travels in the request BODY, never the URL** — which is why\n * the two reads are POSTs. A query string is written to access logs, proxy\n * logs and browser history; an address there would be personal data in a log\n * (C5), about the very person asking to be forgotten.\n *\n * **Authorization.** Each method declares its permission, checked by\n * `defineAdminRoute` before the handler runs: finding and exporting is\n * `form_submissions:view`, erasing is `form_submissions:manage` (forms D011).\n * The rows are then reached through `@murumets-ee/forms`' privacy functions,\n * which use an elevated client — sanctioned because the route HAS authorized,\n * on a different resource, and nothing from the body is forwarded into a\n * write: the one input is an address, parsed to a whole address and used as a\n * bound parameter of a lookup. The site is the request's, so another site's\n * submissions are never found (C2).\n *\n * **Erasing names the address again.** The erase takes `{ email }`, not ids\n * from the browser, so what is erased is what the server finds for that\n * address at that moment — a submission that arrived since the search goes\n * too, and a browser cannot name rows of its own choosing.\n *\n * **Agents.** An agent whose role holds the permission may FIND and EXPORT\n * (`tokens: 'accept'`), as it may read a form's Submissions. ERASING is closed\n * to every token-derived door (no `tokens` choice, which is deny): an erase a\n * person has to approve is PARKED with its request body, and that body is the\n * address — stored in the approvals table, and shown on the Approvals page,\n * about the very person asking to be forgotten. A person erases; an agent can\n * prepare the request by finding and exporting.\n *\n * **What a search cannot see.** A submission stored before the email lookup\n * existed is not findable until the nightly clean-up has filled its lookup\n * in. Search and erase answer `unindexed` — how many of this site's\n * submissions that is — and the screen says so, so \"nothing found\" is never\n * mistaken for \"nothing stored\".\n *\n * **Audit (C5).** A row names the action, a count and the form ids. Never the\n * address, never an answer.\n */\n\nimport {\n type CsvExport,\n csvDelimiterFor,\n csvFilename,\n pagedExportRows,\n runCsvExport,\n} from '@murumets-ee/admin-ui/csv-export'\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport {\n countSubmissionsByEmail,\n countUnindexedSubmissions,\n eraseSubmissionsByEmail,\n FORM_SUBMISSIONS_MANAGE,\n FORM_SUBMISSIONS_VIEW,\n findSubmissionsByEmail,\n formNames,\n type PersonSubmission,\n PRIVACY_EXPORT_MAX,\n PRIVACY_LIST_MAX,\n parsePrivacyEmail,\n readCappedBody,\n SUBMISSIONS_READ_MAX,\n type SubmissionAnswer,\n} from '@murumets-ee/forms'\nimport { getFormsMessages } from '@murumets-ee/forms/i18n'\nimport type { PrivacyMatch } from './privacy-match.js'\n\nexport const PRIVACY_ROUTES_PREFIX = 'form-privacy'\n\n/** Says whether an export stopped at its row cap — read by the screen. */\nexport const TRUNCATED_HEADER = 'X-Lumi-Export-Truncated'\n\n/** Exports being READ into memory at once in this process; a third is told to wait. */\nconst MAX_EXPORT_READS = 2\nlet reading = 0\n\n/** For tests: how many privacy exports are being read right now. */\nexport function _readingPrivacyExports(): number {\n return reading\n}\n\nfunction busy(): Response {\n return new Response(JSON.stringify({ error: 'export_busy', code: 'export_busy' }), {\n status: 503,\n headers: {\n 'Content-Type': 'application/json',\n 'Cache-Control': 'no-store',\n 'Retry-After': '10',\n },\n })\n}\n\n/** A body is an address and two short words. */\nconst MAX_BODY_BYTES = 2048\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },\n })\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * The body's address, with exactly the keys named beside it — strict, so a\n * body carrying anything else is malformed rather than half-read.\n */\nasync function readRequest(\n req: Request,\n extraKeys: readonly string[],\n): Promise<{ email: string; rest: Record<string, unknown> } | { refusal: Response }> {\n // Capped as the bytes arrive — a header can lie or be absent.\n const body = await readCappedBody(req, MAX_BODY_BYTES)\n if ('tooLarge' in body) return { refusal: json({ error: 'body_too_large' }, 413) }\n let value: unknown\n try {\n value = JSON.parse(body.text)\n } catch {\n return { refusal: json({ error: 'invalid_json' }, 400) }\n }\n const allowed = new Set(['email', ...extraKeys])\n if (!isRecord(value) || Object.keys(value).some((key) => !allowed.has(key))) {\n return { refusal: json({ error: 'invalid_body' }, 400) }\n }\n const email = parsePrivacyEmail(value.email)\n // The answer never echoes what was sent.\n if (email === null)\n return { refusal: json({ error: 'invalid_email', code: 'invalid_email' }, 400) }\n return { email, rest: value }\n}\n\n// ---------------------------------------------------------------------------\n// POST search — what the screen lists\n// ---------------------------------------------------------------------------\n\nexport const handlePrivacySearch: AdminRouteHandler = async (req, ctx) => {\n const request = await readRequest(req, [])\n if ('refusal' in request) return request.refusal\n const { email } = request\n const [total, rows, unindexed] = await Promise.all([\n countSubmissionsByEmail(ctx.app, email),\n findSubmissionsByEmail(ctx.app, email, { limit: PRIVACY_LIST_MAX, offset: 0 }),\n countUnindexedSubmissions(ctx.app),\n ])\n const names = await formNames(\n ctx.app,\n rows.map((row) => row.formId),\n )\n const items: PrivacyMatch[] = rows.map((row) => ({\n id: row.id,\n formId: row.formId,\n formName: names.get(row.formId) ?? null,\n createdAt: row.createdAt,\n pagePath: row.pagePath,\n }))\n ctx.audit?.({\n action: 'forms.privacy.search',\n entityType: 'form_submission',\n metadata: { count: total, forms: [...new Set(rows.map((row) => row.formId))], unindexed },\n })\n return json({ total, items, unindexed, exportMax: PRIVACY_EXPORT_MAX })\n}\n\n// ---------------------------------------------------------------------------\n// POST export — everything the person sent, with labels\n// ---------------------------------------------------------------------------\n\nconst FORMATS = ['csv', 'json'] as const\ntype ExportFormat = (typeof FORMATS)[number]\n\ninterface ExportCopy {\n form: string\n received: string\n page: string\n status: string\n yes: string\n no: string\n deletedForm: string\n statuses: Record<string, string>\n}\n\n/** `Forms.<...path>` in a catalogue, when it is a non-empty string. */\nfunction message(catalogue: Record<string, unknown>, path: readonly string[]): string | undefined {\n let node: unknown = catalogue.Forms\n for (const key of path) {\n if (!isRecord(node)) return undefined\n node = node[key]\n }\n return typeof node === 'string' && node !== '' ? node : undefined\n}\n\nasync function exportCopy(locale: string | undefined): Promise<ExportCopy> {\n // The loader answers English for a locale it does not ship, so an unknown\n // `uiLocale` cannot choose a file to import.\n const catalogue = await getFormsMessages(locale ?? 'en')\n const pick = (path: readonly string[], fallback: string) => message(catalogue, path) ?? fallback\n return {\n form: pick(['privacy', 'csv', 'form'], 'Form'),\n received: pick(['privacy', 'csv', 'received'], 'Received (UTC)'),\n page: pick(['privacy', 'csv', 'page'], 'Page'),\n status: pick(['privacy', 'csv', 'status'], 'Status'),\n yes: pick(['submissions', 'yes'], 'yes'),\n no: pick(['submissions', 'no'], 'no'),\n deletedForm: pick(['privacy', 'deletedForm'], 'deleted'),\n statuses: {\n new: pick(['submissionStatus', 'new'], 'New'),\n read: pick(['submissionStatus', 'read'], 'Read'),\n archived: pick(['submissionStatus', 'archived'], 'Archived'),\n },\n }\n}\n\n/** The label an answer had when it was sent; its key when the snapshot has none. */\nfunction labelOf(row: PersonSubmission, key: string): string {\n const label = row.fields[key]?.label\n return label !== undefined && label !== '' ? label : key\n}\n\n/** `{ label: answers }` of one submission — two fields that shared a label share its cell. */\nfunction answersByLabel(row: PersonSubmission): Map<string, SubmissionAnswer[]> {\n const byLabel = new Map<string, SubmissionAnswer[]>()\n for (const [key, value] of Object.entries(row.data)) {\n const label = labelOf(row, key)\n byLabel.set(label, [...(byLabel.get(label) ?? []), value])\n }\n return byLabel\n}\n\nfunction cell(values: readonly SubmissionAnswer[] | undefined, copy: ExportCopy): string | number {\n if (values === undefined || values.length === 0) return ''\n const words = values.map((value) =>\n typeof value === 'boolean' ? (value ? copy.yes : copy.no) : value,\n )\n const only = words[0]\n return words.length === 1 && only !== undefined ? only : words.join(' | ')\n}\n\n/** `2026-10-06 14:02` — a form a spreadsheet reads as a date, in UTC as the header says. */\nfunction csvTime(iso: string): string {\n return iso.slice(0, 16).replace('T', ' ')\n}\n\n/** The person's submissions, page by page, fixed at `until`, at most the export cap. */\nfunction personRows(\n app: ToolkitApp,\n email: string,\n until: Date,\n onTruncated?: (written: number) => void,\n): AsyncGenerator<PersonSubmission, void, undefined> {\n return pagedExportRows<PersonSubmission>(\n (offset, limit) => findSubmissionsByEmail(app, email, { limit, offset, until }),\n {\n pageSize: SUBMISSIONS_READ_MAX,\n maxRows: PRIVACY_EXPORT_MAX,\n ...(onTruncated !== undefined && { onTruncated }),\n },\n )\n}\n\nexport const handlePrivacyExport: AdminRouteHandler = async (req, ctx) => {\n const request = await readRequest(req, ['format', 'uiLocale'])\n if ('refusal' in request) return request.refusal\n const { email, rest } = request\n const format: ExportFormat | undefined = FORMATS.find((name) => name === rest.format)\n if (format === undefined) return json({ error: 'invalid_format' }, 400)\n const uiLocale =\n typeof rest.uiLocale === 'string' && rest.uiLocale.length <= 35 ? rest.uiLocale : ctx.locale\n const app = ctx.app\n // The whole set is read ONCE, into memory, before a byte is written: the CSV\n // header needs every label, and a second pass over a set that an erase or\n // the clean-up is changing could meet a row the first pass never saw. So the\n // read is bounded twice — PRIVACY_EXPORT_MAX rows, and MAX_EXPORT_READS\n // reads at a time in this process. (The rows then live until the download\n // ends: what is bounded is the querying, and each file's size.)\n if (reading >= MAX_EXPORT_READS) return busy()\n reading += 1\n const until = new Date()\n const rows: PersonSubmission[] = []\n let truncated = false\n let names: Map<string, string>\n try {\n for await (const row of personRows(app, email, until, () => {\n truncated = true\n })) {\n rows.push(row)\n }\n names = await formNames(app, [...new Set(rows.map((row) => row.formId))])\n } finally {\n reading -= 1\n }\n if (truncated) {\n // A count — nothing a visitor sent, and not the address.\n app.logger.warn(\n { written: rows.length },\n 'forms: a privacy export reached the row cap — the file holds the newest rows only',\n )\n }\n // Written once the file is actually on its way — a refused export (every CSV\n // slot taken) must not be recorded as one somebody received.\n const audit = (): void => {\n ctx.audit?.({\n action: 'forms.privacy.export',\n entityType: 'form_submission',\n metadata: {\n count: rows.length,\n forms: [...new Set(rows.map((row) => row.formId))],\n format,\n truncated,\n },\n })\n }\n\n if (format === 'json') {\n const body = JSON.stringify(\n {\n exportedAt: until.toISOString(),\n truncated,\n submissions: rows.map((row) => ({\n form: names.get(row.formId) ?? null,\n receivedAt: row.createdAt,\n page: row.pagePath,\n language: row.locale,\n status: row.status,\n answers: Object.entries(row.data).map(([key, value]) => ({\n field: labelOf(row, key),\n key,\n answer: value,\n })),\n consent: Object.values(row.consent),\n })),\n },\n null,\n 2,\n )\n audit()\n return new Response(body, {\n status: 200,\n headers: {\n 'Content-Type': 'application/json; charset=utf-8',\n 'Content-Disposition': `attachment; filename=\"${csvFilename('privacy-request').replace(/\\.csv$/, '.json')}\"`,\n 'Cache-Control': 'private, no-store',\n 'X-Content-Type-Options': 'nosniff',\n [TRUNCATED_HEADER]: String(truncated),\n },\n })\n }\n\n const copy = await exportCopy(uiLocale)\n const labels: string[] = []\n const seen = new Set<string>()\n for (const row of rows) {\n for (const key of Object.keys(row.data)) {\n const label = labelOf(row, key)\n if (seen.has(label)) continue\n seen.add(label)\n labels.push(label)\n }\n }\n const res = await runCsvExport(\n (): CsvExport => ({\n filename: csvFilename('privacy-request'),\n delimiter: csvDelimiterFor(uiLocale),\n header: [copy.form, copy.received, copy.page, copy.status, ...labels],\n rows: rows.map((row) => {\n const answers = answersByLabel(row)\n return [\n names.get(row.formId) ?? copy.deletedForm,\n csvTime(row.createdAt),\n row.pagePath ?? '',\n copy.statuses[row.status] ?? row.status,\n ...labels.map((label) => cell(answers.get(label), copy)),\n ]\n }),\n }),\n )\n if (res.ok) {\n audit()\n res.headers.set(TRUNCATED_HEADER, String(truncated))\n }\n return res\n}\n\n// ---------------------------------------------------------------------------\n// DELETE — erase, for good\n// ---------------------------------------------------------------------------\n\nexport const handlePrivacyErase: AdminRouteHandler = async (req, ctx) => {\n const request = await readRequest(req, [])\n if ('refusal' in request) return request.refusal\n // Never throws part-way: what went before a failed batch is reported, so it\n // reaches the audit row below either way.\n const result = await eraseSubmissionsByEmail(ctx.app, request.email)\n const unindexed = await countUnindexedSubmissions(ctx.app).catch(() => null)\n ctx.audit?.({\n action: 'forms.privacy.erase',\n entityType: 'form_submission',\n // How many, from which forms — never whose (D007).\n metadata: {\n count: result.erased,\n forms: [...result.formIds],\n unfinished: result.more,\n failed: result.failed,\n unindexed,\n },\n })\n return json(\n {\n erased: result.erased,\n forms: result.formIds.length,\n more: result.more,\n failed: result.failed,\n unindexed,\n },\n result.failed ? 500 : 200,\n )\n}\n\n// ---------------------------------------------------------------------------\n// Declarations\n// ---------------------------------------------------------------------------\n\nconst searchRoute = defineAdminRoute({\n prefix: PRIVACY_ROUTES_PREFIX,\n path: 'search',\n method: 'POST',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n // The request and the answer are in this file's header.\n description: 'Find this site’s submissions by one whole email address: { email }.',\n descriptionKey: 'Forms.permissions.routePrivacySearch',\n tokens: 'accept',\n handler: handlePrivacySearch,\n})\n\nconst exportRoute = defineAdminRoute({\n prefix: PRIVACY_ROUTES_PREFIX,\n path: 'export',\n method: 'POST',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n // The request and the answer are in this file's header.\n description: 'Download one person’s submissions as CSV or JSON: { email, format }.',\n descriptionKey: 'Forms.permissions.routePrivacyExport',\n tokens: 'accept',\n handler: handlePrivacyExport,\n})\n\nconst eraseRoute = defineAdminRoute({\n prefix: PRIVACY_ROUTES_PREFIX,\n path: '',\n method: 'DELETE',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n // The request and the answer are in this file's header.\n description: 'Erase one person’s submissions for good: { email }. People only, no agents.',\n descriptionKey: 'Forms.permissions.routePrivacyErase',\n // Closed to every token-derived door, and said so (agent-access PR14). A\n // parked erase would store its body — the address — in the approvals table\n // (see the header). A person erases, from their own session.\n tokens: { refuse: 'personal-data' },\n handler: handlePrivacyErase,\n})\n\n/** The Privacy requests screen's admin routes (`server.routes`). */\nexport function privacyRoutes(): AdminRoute[] {\n return combineAdminRoutes([searchRoute, exportRoute, eraseRoute])\n}\n","/**\n * The Submissions screen's admin API (forms D005, W4) — prefix\n * `form-submissions`:\n *\n * - `GET form-submissions/export?form=<id>&…filters` — the filtered set as a CSV.\n * - `GET form-submissions/summary?form=<id>` — `{ total, fresh }`, for the\n * \"Submissions (3 new)\" link on a Form block's panel (W3).\n * - `PATCH form-submissions` `{ form, ids, status }` — mark new / read / archived.\n * - `DELETE form-submissions` `{ form, ids }` — delete for good.\n *\n * (The screen's own rows are read on the server by the page; there is no list\n * route to keep in step with it.)\n *\n * **Authorization.** Each method declares its permission, checked by\n * `defineAdminRoute` before the handler runs: reading and exporting is\n * `form_submissions:view`, changing and deleting is `form_submissions:manage`.\n * The rows are then reached through `@murumets-ee/forms`' submission functions,\n * which use an elevated client — sanctioned here because the route HAS\n * authorized, on a different resource, and because nothing from the request\n * body is forwarded into a write: a status is one of three literals and ids\n * are UUIDs, both validated below. The site is the request's, and every query\n * names the form, so another site's form id reads as \"not found\" (C2).\n *\n * **Agents.** An agent whose role holds the permission may use these routes\n * (`tokens: 'accept'`), as it may the support inbox (agent-access D021);\n * deleting is `confirm: true`, so an agent under a human-approves policy has\n * its delete parked for a person.\n *\n * **Personal data (C5).** An audit row names the form, the action and a count.\n * Never an answer.\n *\n * Lives in forms-ui, not `@murumets-ee/forms`, because the export answers\n * through admin-ui's CSV writer and the forms package does not depend on\n * admin-ui. Reached from the jiti-loaded plugin entry, so nothing here imports\n * a `server-only` module.\n */\n\nimport {\n type CsvExport,\n csvDelimiterFor,\n csvFilename,\n pagedExportRows,\n runCsvExport,\n} from '@murumets-ee/admin-ui/csv-export'\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n type ToolkitApp,\n} from '@murumets-ee/core'\nimport {\n collectRetiredColumns,\n currentFormFields,\n deleteSubmissions,\n FORM_SUBMISSIONS_MANAGE,\n FORM_SUBMISSIONS_VIEW,\n Form,\n isSubmissionStatus,\n listSubmissionRows,\n MAX_SUBMISSION_BULK,\n parseSubmissionFilters,\n readCappedBody,\n SUBMISSIONS_READ_MAX,\n type SubmissionAnswer,\n type SubmissionRow,\n setSubmissionStatus,\n submissionColumns,\n submissionCounts,\n submissionFilterParams,\n} from '@murumets-ee/forms'\nimport { getFormsMessages } from '@murumets-ee/forms/i18n'\n\nexport const SUBMISSION_ROUTES_PREFIX = 'form-submissions'\n\n/** A body is a form id, a status and at most a hundred ids. */\nconst MAX_BODY_BYTES = 8192\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },\n })\n}\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/** `Array.isArray` on `unknown` narrows to `any[]`; this keeps the elements `unknown`. */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * `{ form, ids, ...extra }` with exactly the keys named — strict, so a body\n * carrying anything else is malformed rather than half-read. `null` otherwise.\n */\nfunction parseTarget(\n value: unknown,\n extraKeys: readonly string[],\n): { form: string; ids: string[]; rest: Record<string, unknown> } | null {\n if (!isRecord(value)) return null\n const allowed = new Set(['form', 'ids', ...extraKeys])\n if (Object.keys(value).some((key) => !allowed.has(key))) return null\n const { form, ids } = value\n if (typeof form !== 'string' || !UUID_RE.test(form)) return null\n if (!isUnknownArray(ids) || ids.length === 0 || ids.length > MAX_SUBMISSION_BULK) return null\n const clean = ids.filter((id): id is string => typeof id === 'string' && UUID_RE.test(id))\n if (clean.length !== ids.length) return null\n return { form, ids: clean, rest: value }\n}\n\n/** The request body as JSON, or the refusal to answer with. */\nasync function readBody(req: Request): Promise<{ value: unknown } | { refusal: Response }> {\n // Capped as the bytes arrive — a header can lie or be absent.\n const body = await readCappedBody(req, MAX_BODY_BYTES)\n if ('tooLarge' in body) return { refusal: json({ error: 'body_too_large' }, 413) }\n const raw = body.text\n try {\n const value: unknown = JSON.parse(raw)\n return { value }\n } catch {\n return { refusal: json({ error: 'invalid_json' }, 400) }\n }\n}\n\n/**\n * This site's form `id`, or `null`. Read with an elevated client for the same\n * reason the submissions are: the route's own permission is what authorized\n * seeing this form's name and fields, and the site still confines the read —\n * another site's id is not found.\n */\nasync function loadForm(\n app: ToolkitApp,\n id: string,\n): Promise<{ name: string; body: unknown } | null> {\n const row: Record<string, unknown> | null | undefined = await app\n .getSystemClient(Form)\n .findById(id)\n if (row === null || row === undefined) return null\n return { name: typeof row.name === 'string' ? row.name : 'form', body: row.body }\n}\n\n// ---------------------------------------------------------------------------\n// GET summary — two numbers, for the link on a Form block's panel\n// ---------------------------------------------------------------------------\n\nexport const handleSummary: AdminRouteHandler = async (req, ctx) => {\n const formId = new URL(req.url).searchParams.get('form') ?? ''\n if (!UUID_RE.test(formId)) return json({ error: 'not_found' }, 404)\n if ((await loadForm(ctx.app, formId)) === null) return json({ error: 'not_found' }, 404)\n const counts = (await submissionCounts(ctx.app, [formId])).get(formId)\n return json({ total: counts?.total ?? 0, fresh: counts?.fresh ?? 0 })\n}\n\n// ---------------------------------------------------------------------------\n// PATCH — mark new / read / archived\n// ---------------------------------------------------------------------------\n\nexport const handleSetStatus: AdminRouteHandler = async (req, ctx) => {\n const body = await readBody(req)\n if ('refusal' in body) return body.refusal\n const parsed = parseTarget(body.value, ['status'])\n const status = parsed?.rest.status\n if (parsed === null || !isSubmissionStatus(status)) {\n return json({ error: 'Expected { form: uuid, ids: uuid[], status: new|read|archived }' }, 400)\n }\n const { form } = parsed\n if ((await loadForm(ctx.app, form)) === null) return json({ error: 'not_found' }, 404)\n\n const changed = await setSubmissionStatus(ctx.app, form, parsed.ids, status)\n ctx.audit?.({\n action: 'forms.submissions.status',\n entityType: 'form',\n entityId: form,\n metadata: { status, count: changed },\n })\n return json({ changed })\n}\n\n// ---------------------------------------------------------------------------\n// DELETE — for good\n// ---------------------------------------------------------------------------\n\nexport const handleDelete: AdminRouteHandler = async (req, ctx) => {\n const body = await readBody(req)\n if ('refusal' in body) return body.refusal\n const parsed = parseTarget(body.value, [])\n if (parsed === null) return json({ error: 'Expected { form: uuid, ids: uuid[] }' }, 400)\n const { form } = parsed\n if ((await loadForm(ctx.app, form)) === null) return json({ error: 'not_found' }, 404)\n\n const deleted = await deleteSubmissions(ctx.app, form, parsed.ids)\n ctx.audit?.({\n action: 'forms.submissions.delete',\n entityType: 'form',\n entityId: form,\n metadata: { count: deleted },\n })\n return json({ deleted })\n}\n\n// ---------------------------------------------------------------------------\n// GET export — the filtered set as a CSV\n// ---------------------------------------------------------------------------\n\n/** The words a CSV needs, in the reader's interface language (English when the catalogue lacks one). */\ninterface ExportCopy {\n received: string\n page: string\n status: string\n yes: string\n no: string\n removed: string\n statuses: Record<string, string>\n}\n\nconst ENGLISH_COPY: ExportCopy = {\n received: 'Received (UTC)',\n page: 'Page',\n status: 'Status',\n yes: 'yes',\n no: 'no',\n removed: 'removed field',\n statuses: { new: 'New', read: 'Read', archived: 'Archived' },\n}\n\n/** `Forms.<...path>` in a catalogue, when it is a non-empty string. */\nfunction message(catalogue: Record<string, unknown>, path: readonly string[]): string | undefined {\n let node: unknown = catalogue.Forms\n for (const key of path) {\n if (!isRecord(node)) return undefined\n node = node[key]\n }\n return typeof node === 'string' && node !== '' ? node : undefined\n}\n\nasync function exportCopy(locale: string | undefined): Promise<ExportCopy> {\n // The loader answers English for a locale it does not ship, so a hostile or\n // unknown `uiLocale` cannot choose a file to import.\n const catalogue = await getFormsMessages(locale ?? 'en')\n const pick = (path: readonly string[], fallback: string) => message(catalogue, path) ?? fallback\n return {\n received: pick(['submissions', 'csv', 'received'], ENGLISH_COPY.received),\n page: pick(['submissions', 'csv', 'page'], ENGLISH_COPY.page),\n status: pick(['submissions', 'csv', 'status'], ENGLISH_COPY.status),\n yes: pick(['submissions', 'yes'], ENGLISH_COPY.yes),\n no: pick(['submissions', 'no'], ENGLISH_COPY.no),\n removed: pick(['submissions', 'removedField'], ENGLISH_COPY.removed),\n statuses: {\n new: pick(['submissionStatus', 'new'], 'New'),\n read: pick(['submissionStatus', 'read'], 'Read'),\n archived: pick(['submissionStatus', 'archived'], 'Archived'),\n },\n }\n}\n\n/** `2026-10-06 14:02` — a form a spreadsheet reads as a date, in UTC as the header says. */\nfunction csvTime(iso: string): string {\n return iso.slice(0, 16).replace('T', ' ')\n}\n\nfunction csvAnswer(value: SubmissionAnswer | undefined, copy: ExportCopy): string | number {\n if (value === undefined) return ''\n if (typeof value === 'boolean') return value ? copy.yes : copy.no\n return value\n}\n\nexport const handleExport: AdminRouteHandler = async (req, ctx) => {\n const url = new URL(req.url)\n const formId = url.searchParams.get('form') ?? ''\n if (!UUID_RE.test(formId)) return json({ error: 'not_found' }, 404)\n const form = await loadForm(ctx.app, formId)\n if (form === null) return json({ error: 'not_found' }, 404)\n\n // `until`: the set is read twice (below), so it is fixed at the moment the\n // export starts — a submission arriving mid-export is simply in the next one.\n // (A row stamped a moment before this instant and committed a moment after\n // the first pass can still shift one row across a page boundary: the window\n // is the length of an insert, and the cost is one repeated row.)\n const filters = {\n ...parseSubmissionFilters((name) => url.searchParams.get(name)),\n until: new Date(),\n }\n const uiLocale = url.searchParams.get('uiLocale') ?? ctx.locale\n const current = currentFormFields(form.body)\n const app = ctx.app\n const pages = (onTruncated?: (written: number) => void) =>\n pagedExportRows<SubmissionRow>(\n (offset, limit) => listSubmissionRows(app, formId, filters, { limit, offset }),\n // Exactly the reader's own page size: asking for more would be answered\n // with fewer, which reads as \"the last page\".\n { pageSize: SUBMISSIONS_READ_MAX, ...(onTruncated !== undefined && { onTruncated }) },\n )\n\n return runCsvExport(async (): Promise<CsvExport> => {\n ctx.audit?.({\n action: 'forms.submissions.export',\n entityType: 'form',\n entityId: formId,\n metadata: { filtered: submissionFilterParams(filters).size > 0 },\n })\n const copy = await exportCopy(uiLocale)\n // The header has to be written before the first row, and a retired field\n // may first appear on the last page — so the set is read once for its\n // keys, then again for its rows. Both reads are paged and capped.\n const retired = new Map<string, string>()\n for await (const row of pages()) collectRetiredColumns(current, [row], retired)\n const columns = submissionColumns(current, retired)\n\n return {\n filename: csvFilename(form.name),\n delimiter: csvDelimiterFor(uiLocale),\n header: [\n copy.received,\n copy.page,\n copy.status,\n ...columns.map((column) =>\n column.retired ? `${column.label} (${copy.removed})` : column.label,\n ),\n ],\n rows: (async function* () {\n const rows = pages((written) => {\n // The form and a count — nothing a visitor sent.\n app.logger.warn(\n { formId, written },\n 'forms: a submissions export reached the row cap — the file holds the newest rows only',\n )\n })\n for await (const row of rows) {\n yield [\n csvTime(row.createdAt),\n row.pagePath ?? '',\n copy.statuses[row.status] ?? row.status,\n ...columns.map((column) => csvAnswer(row.data[column.key], copy)),\n ]\n }\n })(),\n }\n })\n}\n\n// ---------------------------------------------------------------------------\n// Declarations\n// ---------------------------------------------------------------------------\n\nconst exportRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: 'export',\n method: 'GET',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'Download a form’s submissions as CSV: ?form=<id> plus the screen’s filters.',\n descriptionKey: 'Forms.permissions.routeExport',\n tokens: 'accept',\n handler: handleExport,\n})\n\nconst summaryRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: 'summary',\n method: 'GET',\n permission: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'How many submissions a form holds and how many are new: ?form=<id>.',\n descriptionKey: 'Forms.permissions.routeSummary',\n tokens: 'accept',\n handler: handleSummary,\n})\n\nconst statusRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: '',\n method: 'PATCH',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Mark submissions of a form new, read or archived: { form, ids, status }.',\n descriptionKey: 'Forms.permissions.routeStatus',\n tokens: 'accept',\n handler: handleSetStatus,\n})\n\nconst deleteRoute = defineAdminRoute({\n prefix: SUBMISSION_ROUTES_PREFIX,\n path: '',\n method: 'DELETE',\n permission: FORM_SUBMISSIONS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Delete submissions of a form for good: { form, ids }.',\n descriptionKey: 'Forms.permissions.routeDelete',\n tokens: 'accept',\n // Destroys visitors' answers, up to a hundred at a time.\n confirm: true,\n handler: handleDelete,\n})\n\n/** The Submissions screen's admin routes (`server.routes`). */\nexport function submissionRoutes(): AdminRoute[] {\n return combineAdminRoutes([exportRoute, summaryRoute, statusRoute, deleteRoute])\n}\n","/**\n * `formsPlugin()` — everything `@murumets-ee/forms` contributes, declared as\n * data on one Plugin object (CLAUDE.md \"all plugin contributions are\n * declarative\"). Drop it into `plugins:` and the framework wires:\n *\n * - the `form` entity (site-bound canvas document), the `form_submission`\n * entity and the per-form spam count table;\n * - the per-site `forms` settings namespace (CAPTCHA site keys);\n * - the block vocabulary to the SERVER — `blocks.blocks` (so the tree walk\n * that judges every write knows `ancestor` / `uniqueKey`), the `form`\n * environment base (so the canvas resolves its palette), and the Form block\n * in the site's `page` environment (D002 — a saved form on any page);\n * - the admin routes: \"Detach a copy\", \"delete a form with its submissions\",\n * the Submissions screen's (status, delete, CSV export, summary) and\n * Privacy requests' (find by email, export, erase);\n * - the permissions — `forms:manage`, the `form` entity grants and the\n * `form_submissions` row (view / manage) — to the Permission Matrix, seeded\n * for `editor`;\n * - the catalogue (`shared.messages`);\n * - the Forms list, the form canvas, a form's Submissions and Privacy requests\n * as pages, and the sidebar link;\n * - the daily clean-up job that deletes submissions older than their form's\n * period (D007), and the table that says when it last ran.\n *\n * The browser half — renders and the panel's field editors — is\n * `<FormBlocksRegistration />` from `@murumets-ee/forms-ui`, mounted by the\n * admin layout (the standing client-registry exception in CLAUDE.md).\n *\n * jiti-safe: this module is reached from `lumi.config.ts`, so the pages load\n * lazily and nothing here imports JSX or the blocks root.\n */\n\n// The `blocks.*` contribution slots are declared by module augmentation in\n// `@murumets-ee/blocks`; the type-only import is what brings them into scope.\nimport type {} from '@murumets-ee/blocks'\nimport {\n definePlugin,\n type PageFactory,\n type PluginNotificationTypeDefinition,\n} from '@murumets-ee/core'\nimport {\n FORM_SUBMISSIONS_VIEW,\n FORMS_MANAGE,\n Form,\n FormSubmission,\n formEmailTypes,\n formsFeatures,\n formsRoutes,\n formsSettings,\n formsTables,\n} from '@murumets-ee/forms'\nimport { getFormsMessages } from '@murumets-ee/forms/i18n'\nimport {\n FormEmbedBlock,\n formEnvironment,\n formPageEnvironmentExtension,\n formSchemaBlocks,\n} from '@murumets-ee/forms/schema'\nimport { FormSubmissionsHeaderAction } from '@murumets-ee/forms-ui/canvas-header-action'\nimport { emailRoutes } from './email-routes.js'\nimport { privacyRoutes } from './privacy-routes.js'\nimport { submissionRoutes } from './submission-routes.js'\n\n/** Seeing the Forms list is seeing this site's forms — the `form` entity's own `view`. */\nconst FORM_LIST_GATE = `${Form.name}:view` as const\n\nconst FormsListPage: PageFactory<{ locale: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.FormsListPage(props)\n}\nconst FormCanvasPage: PageFactory<{ locale: string; id: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.FormCanvasPage(props)\n}\nconst SubmissionsPage: PageFactory<{ locale: string; id: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.SubmissionsPage(props)\n}\n\nconst PrivacyRequestsPage: PageFactory<{ locale: string }> = async (props) => {\n const mod = await import('@murumets-ee/forms-ui/pages')\n return mod.PrivacyRequestsPage(props)\n}\n\n/** The daily clean-up of expired submissions (forms D007) — see `runFormsRetention`. */\nexport const FORMS_RETENTION_JOB = 'forms:retention'\n\n/** Nightly, off the hour so it does not start with every other 03:00 job. */\nexport const FORMS_RETENTION_SCHEDULE = '23 3 * * *'\n\nexport function formsPlugin() {\n return definePlugin({\n name: '@murumets-ee/forms',\n // `publishable()` keeps its drafts in tables the content plugin owns; the\n // per-site Forms settings (CAPTCHA site keys) live in the settings plugin.\n // The emails are `message_template` rows sent by a queue job: the template\n // editor and the type registry are notifications', the job is the queue's.\n requires: [\n '@murumets-ee/content',\n '@murumets-ee/settings',\n '@murumets-ee/notifications',\n '@murumets-ee/queue',\n ],\n server: {\n // `form_submission` is written only by the public submission path\n // (`@murumets-ee/forms/server`, mounted on the content API) and read by\n // the submissions screen (PR04).\n entities: [Form, FormSubmission],\n // The per-form \"dropped as spam\" counts (D004) — a number per form.\n // …and, per site, when the daily clean-up last ran and what it removed (D007).\n // One list (`formsTables`), so the erasure test walks exactly these.\n tables: { ...formsTables },\n // `POST /api/admin/form-copy` — the Form block's \"Detach a copy\" —,\n // `DELETE /api/admin/form-remove`, the Submissions screen's routes\n // (`/api/admin/form-submissions`) and Privacy requests'\n // (`/api/admin/form-privacy`).\n routes: [...formsRoutes(), ...submissionRoutes(), ...privacyRoutes(), ...emailRoutes()],\n // The daily clean-up. There is no `Plugin.server.jobs` slot to declare\n // into — a job is registered from `init`, as every plugin's is (auth's\n // approvals sweep, the queue's own trash purge); the queue materialises\n // its schedule when a worker starts. Without the queue plugin there is\n // no worker and nothing to register with: the Forms list then says the\n // clean-up has never run, rather than implying a promise is being kept.\n init: async (app) => {\n if (!app.plugins.has('@murumets-ee/queue')) return\n const { defineJob, registerJob } = await import('@murumets-ee/queue/client')\n registerJob(\n // No `description`: the queue catalogue has no translation-key slot,\n // so one would be English on every locale. What it does is above.\n defineJob({ name: FORMS_RETENTION_JOB, schedule: FORMS_RETENTION_SCHEDULE }),\n async () => {\n const { runAsCli, runInEverySite } = await import('@murumets-ee/core')\n const { runFormsRetention } = await import('@murumets-ee/forms')\n // As the synthetic `cli` admin — a job has no signed-in user — and\n // once per site: each run reads and deletes within ONE site. A\n // failure is rethrown so the queue retries and the dead-letter\n // alert fires; a promise to delete must not fail silently.\n const runs = await runAsCli(() =>\n runInEverySite(() => runFormsRetention(app), { app, label: FORMS_RETENTION_JOB }),\n )\n const removed = runs.reduce((sum, run) => sum + run.removed, 0)\n // Counts only — never what was removed (C5).\n app.logger.info(\n {\n sites: runs.length,\n removed,\n unfinished: runs.filter((run) => run.unfinished).length,\n backfilled: runs.reduce((sum, run) => sum + run.backfilled, 0),\n },\n 'forms: daily clean-up of expired submissions finished',\n )\n },\n )\n\n // forms PR05 — the email job: `forms:send_email` (see `@murumets-ee/forms/email-jobs`).\n const { runAsCli: runAsCliEmail } = await import('@murumets-ee/core')\n const { runFormEmailJob, sendFormEmailJob } = await import('@murumets-ee/forms/email-jobs')\n registerJob(sendFormEmailJob, async (job) => {\n await runAsCliEmail(async () => {\n let defaultLocale = 'en'\n try {\n const { getNotificationsConfig } = await import('@murumets-ee/notifications/plugin')\n defaultLocale = getNotificationsConfig().defaultLocale\n } catch {\n app.logger.warn(\n { outcome: 'no_default_locale' },\n 'forms: notifications default locale unavailable — using en',\n )\n }\n await runFormEmailJob(job.payload, { app, logger: app.logger, defaultLocale })\n })\n })\n },\n },\n contributes: {\n // The form document's blocks, and the page's Form block (D002).\n 'blocks.blocks': [...formSchemaBlocks, FormEmbedBlock],\n 'blocks.environmentBases': [formEnvironment],\n // The site's pages may hold a Form block, and the page environment\n // holds the form's blocks too, because a page DRAWS an embedded form\n // with the site theme (see `formPageEnvironmentExtension`).\n 'blocks.environments': formPageEnvironmentExtension,\n },\n shared: {\n features: formsFeatures,\n // The notification and the auto-reply — `notifications()` walks this and\n // fills the type registry (and so the template editor).\n notifications: [...formEmailTypes] as unknown as PluginNotificationTypeDefinition[],\n settings: [formsSettings],\n messages: getFormsMessages,\n },\n adminUi: {\n // \"Submissions (N new)\" beside Publish on a form's canvas (PR08).\n canvasHeaderActions: { [Form.name]: FormSubmissionsHeaderAction },\n pages: {\n FormsListPage,\n FormCanvasPage,\n SubmissionsPage,\n PrivacyRequestsPage,\n },\n sidebar: [\n {\n id: 'forms',\n group: 'Content',\n label: 'Forms',\n href: '/admin/forms',\n iconName: 'clipboard-list',\n // Derived from the list page's gate in any case: whoever may see\n // this site's forms gets the link.\n requires: FORM_LIST_GATE,\n },\n ],\n defaultRoutes: [\n {\n // Owns the `form` entity's list URL — the generic table never shows.\n path: 'forms',\n factory: 'FormsListPage',\n page: {\n // `form:view`, not the build job: the list is also the way in for\n // a person who only reads submissions (D005). What they can do\n // from it is decided inside the page.\n requires: FORM_LIST_GATE,\n defaultRoles: ['admin', 'editor'],\n description: 'List this site’s forms and create new ones.',\n descriptionKey: 'Forms.permissions.list',\n // An agent's browser session may open it (agent-access D020) —\n // the same reach its key already has to `form` through the API,\n // still behind its role's `form:view`.\n tokens: 'accept',\n },\n },\n {\n // Privacy requests (W5) — reached from the Forms list's header;\n // never a menu entry. A literal path: the page router matches\n // literals before `[param]` paths, so it is never read as a form id.\n path: 'forms/privacy',\n factory: 'PrivacyRequestsPage',\n page: {\n // The permission that reads submissions (forms D011): what this\n // screen finds and exports is the same answers. Erasing asks for\n // `form_submissions:manage` on its route.\n requires: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'Find one person’s submissions by email address, to export or erase them.',\n descriptionKey: 'Forms.permissions.privacy',\n // Not a page about who holds authority: an agent whose role holds\n // the permission may open it to find and export, as it may a\n // form's Submissions. ERASING is closed to every agent door on\n // its route, and the page does not draw the button for one.\n tokens: 'accept',\n },\n },\n {\n // Owns the `form` entity's edit URL — the canvas, gated by the job.\n path: 'forms/[id]',\n factory: 'FormCanvasPage',\n page: {\n requires: FORMS_MANAGE,\n defaultRoles: ['admin', 'editor'],\n description: 'Edit a form on its canvas.',\n descriptionKey: 'Forms.permissions.canvas',\n tokens: 'accept',\n },\n },\n {\n // Submissions (W4) — reached from the Forms list, the form's canvas\n // and a Form block's panel; never a menu entry of its own.\n path: 'forms/[id]/submissions',\n factory: 'SubmissionsPage',\n page: {\n requires: FORM_SUBMISSIONS_VIEW,\n defaultRoles: ['admin', 'editor'],\n description: 'Read and export what visitors sent through a form.',\n descriptionKey: 'Forms.permissions.submissions',\n // An agent whose role holds the permission may open it, as it may\n // the support inbox (agent-access D021).\n tokens: 'accept',\n },\n },\n // The SINGULAR slugs resolve to the same entity (`/admin/form/<id>`),\n // so they are owned too — otherwise the generic editor would answer\n // them behind `form:*` alone, and a form would have two addresses.\n // No `page` spec: the gate wraps the FACTORY once (above), so every\n // path that renders it is gated the same way.\n { path: 'form', factory: 'FormsListPage' },\n { path: 'form/[id]', factory: 'FormCanvasPage' },\n ],\n },\n })\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@murumets-ee/forms-ui",
3
+ "version": "0.0.0-preview-20261014110621",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "description": "Admin half of @murumets-ee/forms: the form canvas registration, the Forms list, the New form dialog and the plugin.",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.mts",
10
+ "import": "./dist/index.mjs"
11
+ },
12
+ "./canvas-header-action": {
13
+ "types": "./dist/canvas-header-action.d.mts",
14
+ "import": "./dist/canvas-header-action.mjs"
15
+ },
16
+ "./pages": {
17
+ "types": "./dist/pages.d.mts",
18
+ "import": "./dist/pages.mjs"
19
+ },
20
+ "./plugin": {
21
+ "types": "./dist/plugin.d.mts",
22
+ "import": "./dist/plugin.mjs"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "dependencies": {
29
+ "@murumets-ee/blocks": "0.0.0-preview-20261014110621",
30
+ "@murumets-ee/admin-ui": "0.0.0-preview-20261014110621",
31
+ "@murumets-ee/core": "0.0.0-preview-20261014110621",
32
+ "@murumets-ee/content": "0.0.0-preview-20261014110621",
33
+ "@murumets-ee/notifications": "0.0.0-preview-20261014110621",
34
+ "@murumets-ee/forms": "0.0.0-preview-20261014110621",
35
+ "@murumets-ee/queue": "0.0.0-preview-20261014110621",
36
+ "@murumets-ee/settings": "0.0.0-preview-20261014110621",
37
+ "@murumets-ee/ui": "0.0.0-preview-20261014110621"
38
+ },
39
+ "peerDependencies": {
40
+ "next": ">=16.0.0 <17",
41
+ "next-intl": ">=4.0.0 <5",
42
+ "react": ">=19.0.0 <20",
43
+ "react-dom": ">=19.0.0 <20"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "next": {
47
+ "optional": true
48
+ },
49
+ "next-intl": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "devDependencies": {
54
+ "@testing-library/react": "^16.3.2",
55
+ "@types/react": "^19",
56
+ "@types/react-dom": "^19",
57
+ "happy-dom": "^20.10.1",
58
+ "next": "16.3.8",
59
+ "next-intl": "^4.13.0",
60
+ "react": "19.2.7",
61
+ "react-dom": "19.2.7",
62
+ "tsdown": "^0.22.2",
63
+ "typescript": "^5.7.3",
64
+ "vitest": "^2.1.8"
65
+ },
66
+ "typeCoverage": {
67
+ "atLeast": 100
68
+ },
69
+ "bundleBudget": {
70
+ "dist/index.mjs": 20480,
71
+ "dist/canvas-header-action.mjs": 2048,
72
+ "dist/pages.mjs": 14336,
73
+ "dist/plugin.mjs": 20480
74
+ },
75
+ "scripts": {
76
+ "build": "tsdown",
77
+ "dev": "tsdown --watch",
78
+ "test": "vitest run",
79
+ "test:watch": "vitest"
80
+ }
81
+ }