@murumets-ee/forms-ui 0.99.0 → 0.101.0

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.
@@ -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,CAAA;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"}
package/dist/index.d.mts CHANGED
@@ -1,6 +1,25 @@
1
1
 
2
2
  import { ReactElement } from "react";
3
+ import { SubmissionColumn, SubmissionRow } from "@murumets-ee/forms";
3
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
4
23
  //#region src/new-form-dialog.d.ts
5
24
  interface NewFormDialogProps {
6
25
  readonly apiBasePath: string;
@@ -12,6 +31,23 @@ declare function NewFormDialog({
12
31
  formsBasePath
13
32
  }: NewFormDialogProps): ReactElement;
14
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
15
51
  //#region src/register.d.ts
16
52
  declare function registerFormBlocks(): void;
17
53
  /**
@@ -21,5 +57,35 @@ declare function registerFormBlocks(): void;
21
57
  */
22
58
  declare function FormBlocksRegistration(): null;
23
59
  //#endregion
24
- export { FormBlocksRegistration, NewFormDialog, type NewFormDialogProps, registerFormBlocks };
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
+ readonly apiBasePath: string;
68
+ /** What to say when no submission is listed — the page knows whether a filter is why. */
69
+ readonly emptyText: string;
70
+ }
71
+ declare function SubmissionsView({
72
+ formId,
73
+ rows,
74
+ columns,
75
+ canManage,
76
+ apiBasePath,
77
+ emptyText
78
+ }: SubmissionsViewProps): ReactElement;
79
+ /**
80
+ * "Export CSV" — a plain download link to the export of the filters on screen.
81
+ * A link cannot carry the site header the admin's own requests send, so the
82
+ * selected site is added as `_site` when it is clicked, from the page URL.
83
+ */
84
+ declare function SubmissionsExportLink({
85
+ href
86
+ }: {
87
+ readonly href: string;
88
+ }): ReactElement;
89
+ //#endregion
90
+ export { DeleteFormButton, type DeleteFormButtonProps, FormBlocksRegistration, NewFormDialog, type NewFormDialogProps, PrivacyRequestsView, type PrivacyRequestsViewProps, SubmissionsExportLink, SubmissionsView, type SubmissionsViewProps, registerFormBlocks };
25
91
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/new-form-dialog.tsx","../src/register.tsx"],"mappings":";;;;UA8BiB,kBAAA;EAAA,SACN,WAAA;EADM;EAAA,SAGN,aAAa;AAAA;AAAA,iBAGR,aAAA,CAAA;EAAgB,WAAA;EAAa;AAAA,GAAiB,kBAAA,GAAqB,YAAA;;;iBCLnE,kBAAA,CAAA;;;ADDhB;;;iBCkCgB,sBAAA,CAAA"}
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,CAAA;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,CAAA;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,CAAA;EACd,WAAA;EACA,aAAA;EACA,MAAA;EACA;AAAA,GACC,wBAAA,GAA2B,YAAA;;;iBCtDd,kBAAA,CAAA;;;;AHPhB;;iBGwCgB,sBAAA,CAAA;;;UCvBC,oBAAA;EAAA,SACN,MAAA;EAAA,SACA,IAAA,WAAe,aAAA;EAAA,SACf,OAAA,WAAkB,gBAAgB;;WAElC,SAAA;EAAA,SACA,WAAA;EJrBA;EAAA,SIuBA,SAAA;AAAA;AAAA,iBA2BK,eAAA,CAAA;EACd,MAAA;EACA,IAAA;EACA,OAAA;EACA,SAAA;EACA,WAAA;EACA;AAAA,GACC,oBAAA,GAAuB,YAAA;;AJpDV;AAGhB;;;iBIuZgB,qBAAA,CAAA;EAAwB;AAAA;EAAA,SAAmB,IAAA;AAAA,IAAiB,YAAY"}
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  "use client";
2
- import{FORMS_SOURCE as e,FORM_ENVIRONMENT as t,FORM_NAME_MAX as n,PAGE_ENVIRONMENT as r,formEnvironmentBlocks as i,formEnvironmentTraits as a,formSiteBlocks as o}from"@murumets-ee/forms/schema";import{Button as s,Dialog as c,DialogContent as l,DialogHeader as u,DialogTitle as d,Input as f,Label as p}from"@murumets-ee/ui";import{useRouter as m}from"next/navigation";import{useTranslations as h}from"next-intl";import{useState as g}from"react";import{Fragment as _,jsx as v,jsxs as y}from"react/jsx-runtime";import{registerBlockEnvironment as b,registerBlockEnvironmentBase as x,registerBlockTheme as S,widenFieldType as C}from"@murumets-ee/blocks/core";import{formFieldKeyType as w,formPickerType as T,formSettingsTabsType as E}from"@murumets-ee/forms/editor";import{FORMS_THEME as D,formsTheme as O}from"@murumets-ee/forms/render";function k({apiBasePath:e,formsBasePath:t}){let r=h(`Forms.newDialog`),i=m(),[a,o]=g(!1),[b,x]=g(``),[S,C]=g(!1),[w,T]=g(null),E=b.trim(),D=E!==``&&E.length<=n&&!S;return y(_,{children:[v(s,{type:`button`,size:`sm`,onClick:()=>o(!0),children:r(`title`)}),v(c,{open:a,onOpenChange:o,children:y(l,{className:`max-w-md`,children:[v(u,{children:v(d,{children:r(`title`)})}),y(`form`,{onSubmit:async n=>{if(n.preventDefault(),D){C(!0),T(null);try{let n=await fetch(`${e}/form`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({name:E})});if(!n.ok)throw Error(r(`failed`,{status:n.status}));let a=await n.json();if(typeof a.id!=`string`)throw Error(r(`noId`));o(!1),C(!1),i.push(`${t}/${a.id}`)}catch(e){T(e instanceof Error?e.message:r(`failed`,{status:0})),C(!1)}}},className:`flex flex-col gap-3`,children:[y(`div`,{className:`flex flex-col gap-1.5`,children:[v(p,{htmlFor:`new-form-name`,children:r(`name`)}),v(f,{id:`new-form-name`,value:b,maxLength:n,onChange:e=>x(e.target.value),placeholder:r(`namePlaceholder`),"aria-describedby":`new-form-name-help`}),v(`p`,{id:`new-form-name-help`,className:`text-xs text-muted-foreground`,children:r(`help`)})]}),w!==null&&v(`p`,{role:`alert`,className:`text-sm text-destructive`,children:w}),y(`div`,{className:`flex justify-end gap-2 pt-1`,children:[v(s,{type:`button`,variant:`outline`,onClick:()=>o(!1),children:r(`cancel`)}),v(s,{type:`submit`,disabled:!D,loading:S,children:r(`create`)})]})]})]})})]})}function A(){S(O),x({name:t,blocks:i,traits:a,fieldTypes:[C(w),C(E)],defaultTheme:D}),b({source:e,name:r,blocks:o,fieldTypes:[C(T),C(w),C(E)]})}function j(){return A(),null}export{j as FormBlocksRegistration,k as NewFormDialog,A as registerFormBlocks};
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 s,Input as c,Label as l,Table as u,TableBody as d,TableCell as f,TableHead as p,TableHeader as m,TableRow as h}from"@murumets-ee/ui";import{useRouter as g}from"next/navigation";import{useFormatter as _,useTranslations as v}from"next-intl";import{useEffect as y,useId as b,useRef as x,useState as S,useTransition as C}from"react";import{Fragment as w,jsx as T,jsxs as E}from"react/jsx-runtime";import{FORMS_SOURCE as D,FORM_ENVIRONMENT as O,FORM_NAME_MAX as k,PAGE_ENVIRONMENT as A,formEnvironmentBlocks as j,formEnvironmentTraits as M,formSiteBlocks as N}from"@murumets-ee/forms/schema";import{registerBlockEnvironment as ee,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:c,submissions:l,pages:u}){let d=v(`Forms.remove`),f=v(`Forms.list`),p=g(),[,m]=C(),[h,_]=S(!1),[y,b]=S(!1),[x,D]=S(null),[O,k]=S(!1),A=u!==null&&u>0,j=()=>{_(!1),O&&m(()=>p.refresh())},M=async()=>{b(!0),D(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){D(d(`awaitingApproval`));return}if(t.status===409){D(d(`stillInUse`));return}if(t.status===403){D(d(`forbidden`));return}let r=await t.json().catch(()=>null),i=typeof r?.submissions==`number`?r.submissions:0;if(t.status===500&&r?.deleted===!0){k(!0),D(d(`lateFailed`));return}if(m(()=>p.refresh()),t.status===500&&r?.more===!0){D(d(`partial`,{count:i}));return}if(!t.ok){D(d(`failed`,{status:t.status}));return}if(r?.deleted!==!0){D(d(`more`,{count:i}));return}_(!1)}catch{D(d(`failed`,{status:0}))}finally{b(!1)}};return E(w,{children:[T(t,{type:`button`,variant:`ghost`,size:`sm`,className:`text-destructive hover:text-destructive`,"aria-label":f(`deleteForm`,{form:c}),onClick:()=>{D(null),_(!0)},children:f(`delete`)}),T(r,{open:h,onOpenChange:e=>e?_(!0):j(),children:E(i,{className:`max-w-md`,children:[T(o,{children:T(s,{children:d(`title`,{form:c})})}),T(a,{asChild:!0,children:T(`div`,{className:`space-y-2 text-sm text-muted-foreground`,children:A?T(`p`,{"data-forms-remove-in-use":``,children:d(`inUse`,{count:u})}):E(w,{children:[T(`p`,{"data-forms-remove-count":l??``,children:l===null?d(`submissionsUnknown`):d(`submissions`,{count:l})}),T(`p`,{children:d(`trash`)})]})})}),x!==null&&T(`p`,{role:`alert`,className:`text-sm text-destructive`,children:x}),E(`div`,{className:`flex justify-end gap-2 pt-2`,children:[T(t,{type:`button`,variant:`outline`,onClick:j,children:d(`cancel`)}),!A&&!O&&T(t,{variant:`destructive`,disabled:y,onClick:()=>void M(),children:d(l===0?`confirmEmpty`:`confirm`)})]})]})})]})}function W({apiBasePath:e,formsBasePath:n}){let a=v(`Forms.newDialog`),u=g(),[d,f]=S(!1),[p,m]=S(``),[h,_]=S(!1),[y,b]=S(null),x=p.trim(),C=x!==``&&x.length<=k&&!h;return E(w,{children:[T(t,{type:`button`,size:`sm`,onClick:()=>f(!0),children:a(`title`)}),T(r,{open:d,onOpenChange:f,children:E(i,{className:`max-w-md`,children:[T(o,{children:T(s,{children:a(`title`)})}),E(`form`,{onSubmit:async t=>{if(t.preventDefault(),C){_(!0),b(null);try{let t=await fetch(`${e}/form`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({name:x})});if(!t.ok)throw Error(a(`failed`,{status:t.status}));let r=await t.json();if(typeof r.id!=`string`)throw Error(a(`noId`));f(!1),_(!1),u.push(`${n}/${r.id}`)}catch(e){b(e instanceof Error?e.message:a(`failed`,{status:0})),_(!1)}}},className:`flex flex-col gap-3`,children:[E(`div`,{className:`flex flex-col gap-1.5`,children:[T(l,{htmlFor:`new-form-name`,children:a(`name`)}),T(c,{id:`new-form-name`,value:p,maxLength:k,onChange:e=>m(e.target.value),placeholder:a(`namePlaceholder`),"aria-describedby":`new-form-name-help`}),T(`p`,{id:`new-form-name-help`,className:`text-xs text-muted-foreground`,children:a(`help`)})]}),y!==null&&T(`p`,{role:`alert`,className:`text-sm text-destructive`,children:y}),E(`div`,{className:`flex justify-end gap-2 pt-1`,children:[T(t,{type:`button`,variant:`outline`,onClick:()=>f(!1),children:a(`cancel`)}),T(t,{type:`submit`,disabled:!C,loading:h,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 q(e,t){return/filename="([^"]+)"/.exec(e.headers.get(`Content-Disposition`)??``)?.[1]??t}function J({apiBasePath:e,formsBasePath:n,locale:g,canErase:y}){let x=v(`Forms.privacy`),C=_(),D=b(),[O,k]=S(``),[A,j]=S(null),[M,N]=S(!1),[ee,P]=S(!1),[F,I]=S(null),[L,R]=S(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(x(`invalid`)):e.status===403?I(x(`forbidden`)):e.status===503?I(x(`busy`)):I(x(`failed`,{status:e.status})),!1),H=async e=>{N(!0),I(null);try{let t=await B(`/search`,`POST`,{email:e});if(!V(t)){j(null);return}let n=await t.json().catch(()=>null);if(!G(n)||typeof n.total!=`number`||!Array.isArray(n.items)){I(x(`failed`,{status:t.status})),j(null);return}let r=n.items;j({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(x(`failed`,{status:0})),j(null)}finally{N(!1)}},U=e=>{e.preventDefault();let t=O.trim();if(R(null),t===``){I(x(`invalid`)),j(null);return}H(t)},W=async e=>{if(A!==null){N(!0),I(null),R(null);try{let t=await B(`/export`,`POST`,{email:A.email,format:e,uiLocale:g});if(!V(t))return;let n=URL.createObjectURL(await t.blob()),r=document.createElement(`a`);r.href=n,r.download=q(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(x(`exportCapped`,{max:A.exportMax}))}catch{I(x(`failed`,{status:0}))}finally{N(!1)}}},J=async()=>{if(A!==null){P(!1),N(!0),I(null),R(null);try{let e=await B(``,`DELETE`,{email:A.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(A.email),I(x(`eraseFailed`,{count:n}));return}if(!V(e))return;if(n===null){I(x(`failed`,{status:e.status}));return}let r=G(t)&&t.more===!0;await H(A.email),R(x(r?`erasedMore`:`erased`,{count:n}))}catch{I(x(`failed`,{status:0}))}finally{N(!1)}}},Y=e=>C.dateTime(new Date(e),{dateStyle:`short`,timeStyle:`short`});return E(`div`,{className:`space-y-4`,children:[E(`form`,{onSubmit:U,className:`flex flex-wrap items-end gap-3 rounded-md border p-3`,autoComplete:`off`,children:[E(`div`,{className:`flex min-w-[16rem] flex-1 flex-col gap-1.5`,children:[T(l,{htmlFor:D,children:x(`email`)}),T(c,{id:D,type:`email`,inputMode:`email`,autoComplete:`off`,spellCheck:!1,maxLength:320,value:O,onChange:e=>k(e.target.value),placeholder:x(`placeholder`)})]}),T(t,{type:`submit`,disabled:M,children:x(M&&A===null?`finding`:`find`)})]}),F!==null&&T(`p`,{role:`alert`,className:`rounded-md border border-destructive/40 p-3 text-sm`,children:F}),L!==null&&T(`p`,{role:`status`,className:`rounded-md border p-3 text-sm`,"data-forms-privacy-notice":``,children:L}),A!==null&&A.unindexed>0&&T(`p`,{role:`status`,className:`rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm`,"data-forms-privacy-unindexed":A.unindexed,children:x(`unindexed`,{count:A.unindexed})}),A!==null&&A.total===0&&E(`div`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:[T(`p`,{children:x(`none`)}),T(`p`,{className:`mt-1`,children:x(`noneHint`)})]}),A!==null&&A.total>0&&E(w,{children:[T(`div`,{className:`overflow-x-auto rounded-md border`,children:E(u,{children:[T(m,{children:E(h,{children:[T(p,{children:x(`form`)}),T(p,{children:x(`received`)}),T(p,{children:x(`page`)})]})}),T(d,{children:A.items.map(e=>E(h,{"data-forms-privacy-row":e.id,children:[T(f,{className:`font-medium`,children:e.formName===null?T(`span`,{className:`text-muted-foreground`,children:x(`deletedForm`)}):T(`a`,{href:`${n}/${e.formId}/submissions`,className:`text-primary hover:underline`,children:e.formName})}),T(f,{className:`whitespace-nowrap tabular-nums`,children:Y(e.createdAt)}),T(f,{className:`text-muted-foreground`,children:e.pagePath??`—`})]},e.id))})]})}),E(`div`,{className:`flex flex-wrap items-center gap-2 text-sm`,children:[T(`span`,{"data-forms-privacy-total":A.total,children:x(`summary`,{count:A.total})}),T(t,{variant:`outline`,size:`sm`,disabled:M,onClick:()=>void W(`csv`),children:x(`exportCsv`)}),T(t,{variant:`outline`,size:`sm`,disabled:M,onClick:()=>void W(`json`),children:x(`exportJson`)}),y&&T(t,{variant:`outline`,size:`sm`,className:`border-destructive/50 text-destructive hover:bg-destructive/10`,disabled:M,onClick:()=>P(!0),children:x(`eraseAll`,{count:A.total})})]}),A.total>A.items.length&&T(`p`,{className:`text-xs text-muted-foreground`,children:x(`listCapped`,{shown:A.items.length,max:A.exportMax})}),T(`p`,{className:`text-xs text-muted-foreground`,children:x(`eraseNote`)})]}),T(r,{open:ee,onOpenChange:P,children:E(i,{className:`max-w-md`,children:[T(o,{children:T(s,{children:x(`confirmTitle`,{count:Math.max(A?.total??1,1)})})}),T(a,{children:x(`confirmBody`)}),E(`div`,{className:`flex justify-end gap-2 pt-2`,children:[T(t,{variant:`outline`,onClick:()=>P(!1),children:x(`cancel`)}),T(t,{variant:`destructive`,disabled:M,onClick:()=>void J(),children:x(`confirmErase`)})]})]})})]})}function Y(){F(V),P({name:O,blocks:j,traits:M,fieldTypes:[I(L),I(z)],defaultTheme:B}),ee({source:D,name:A,blocks:N,fieldTypes:[I(R),I(L),I(z)]})}function X(){return Y(),null}const te={new:`default`,read:`secondary`,archived:`outline`};function Z(e,t){return Object.hasOwn(e,t)?e[t]:void 0}function ne(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 re({formId:c,rows:l,columns:b,canManage:w,apiBasePath:D,emptyText:O}){let k=v(`Forms.submissions`),A=v(`Forms.submissionStatus`),j=_(),M=g(),[N,ee]=C(),[P,F]=S(null),I=P?.id??null,L=x(null),R=x(new Set),[z,B]=S(new Set),[V,H]=S(null),[U,W]=S(!1),[G,K]=S(null),q=l.find(e=>e.id===I)??P,J=l.filter(e=>z.has(e.id)).map(e=>e.id),Y=l.length>0&&J.length===l.length,X=e=>j.dateTime(new Date(e),{dateStyle:`short`,timeStyle:`short`}),re=e=>e===void 0||e===``?`—`:typeof e==`boolean`?k(e?`yes`:`no`):String(e),ie=async(e,t)=>{W(!0),K(null);try{let n=await fetch(`${D}/form-submissions`,{method:e,headers:{"Content-Type":`application/json`},body:JSON.stringify({form:c,...t})});if(n.status===202)return K(k(`awaitingApproval`)),!1;if(!n.ok)return K(k(`failed`,{status:n.status})),!1;let r=await n.json().catch(()=>null),i=e===`DELETE`?r?.deleted:r?.changed;if(ee(()=>M.refresh()),typeof i!=`number`||i<=0){K(k(`nothingChanged`));let e=t.ids;return Array.isArray(e)&&F(t=>t&&e.includes(t.id)?null:t),!1}return!0}catch{return K(k(`failed`,{status:0})),!1}finally{W(!1)}},Q=async(e,t)=>{let n=await ie(`PATCH`,{ids:e,status:t});if(!n||t===`new`)for(let t of e)R.current.delete(t);return n&&F(n=>n&&e.includes(n.id)?{...n,status:t}:n),n},ae=e=>{F(e),w&&e.status===`new`&&!R.current.has(e.id)&&(R.current.add(e.id),Q([e.id],`read`))};y(()=>{I!==null&&L.current?.focus()},[I]);let oe=async()=>{if(V===null)return;let e=V;H(null),await ie(`DELETE`,{ids:e})&&(B(t=>new Set([...t].filter(t=>!e.includes(t)))),F(t=>t&&e.includes(t.id)?null:t))},se=(e,t)=>B(n=>{let r=new Set(n);return t?r.add(e):r.delete(e),r}),$=U||N;return E(`div`,{className:`space-y-3`,children:[G!==null&&T(`p`,{role:`alert`,className:`rounded-md border border-destructive/40 p-3 text-sm`,children:G}),E(`div`,{className:`flex flex-col gap-4 lg:flex-row lg:items-start`,children:[E(`div`,{className:`min-w-0 flex-1 space-y-3`,children:[l.length===0&&T(`p`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:O}),T(`div`,{className:`overflow-x-auto rounded-md border`,hidden:l.length===0,children:E(u,{children:[T(m,{children:E(h,{children:[w&&T(p,{className:`w-10`,children:T(n,{"aria-label":k(`selectAll`),checked:Y,onCheckedChange:e=>B(e===!0?new Set(l.map(e=>e.id)):new Set)})}),T(p,{className:`whitespace-nowrap`,children:k(`received`)}),T(p,{className:`whitespace-nowrap`,children:k(`status`)}),T(p,{className:`whitespace-nowrap`,children:k(`page`)}),b.map(e=>E(p,{"data-forms-column":e.key,title:e.label,className:`max-w-[14rem] truncate whitespace-nowrap`,children:[e.label,e.retired&&E(`span`,{className:`ml-1 text-xs font-normal text-muted-foreground`,children:[`(`,k(`removedField`),`)`]})]},e.key))]})}),T(d,{children:l.map(t=>E(h,{"data-state":t.id===I?`selected`:void 0,className:t.status===`new`?`font-medium`:void 0,children:[w&&T(f,{children:T(n,{"aria-label":k(`selectRow`,{when:X(t.createdAt)}),checked:z.has(t.id),onCheckedChange:e=>se(t.id,e===!0)})}),T(f,{className:`whitespace-nowrap`,children:T(`button`,{type:`button`,className:`text-primary hover:underline`,"aria-expanded":t.id===I,onClick:()=>ae(t),children:X(t.createdAt)})}),T(f,{children:T(e,{variant:te[t.status],children:A(t.status)})}),T(f,{className:`text-muted-foreground`,children:t.pagePath??`—`}),b.map(e=>{let n=re(Z(t.data,e.key));return T(f,{className:`max-w-[16rem] truncate`,children:n.length>60?`${n.slice(0,60)}…`:n},e.key)})]},t.id))})]})}),w&&l.length>0&&T(t,{variant:`outline`,size:`sm`,disabled:$||J.length===0,onClick:()=>H(J),children:k(`deleteSelected`,{count:J.length})})]}),q!==null&&E(`aside`,{ref:L,tabIndex:-1,"aria-label":k(`detail`),className:`w-full shrink-0 space-y-4 rounded-md border p-4 lg:w-96`,children:[E(`div`,{className:`flex items-start justify-between gap-2`,children:[E(`div`,{children:[T(`p`,{className:`font-medium`,children:X(q.createdAt)}),T(`p`,{className:`text-sm text-muted-foreground`,children:q.pagePath===null?k(`noPage`):k(`fromPage`,{page:q.pagePath})})]}),T(t,{variant:`ghost`,size:`sm`,onClick:()=>F(null),children:k(`close`)})]}),T(`dl`,{className:`space-y-3 text-sm`,children:ne(q,b).map(e=>{let t=Z(q.data,e),n=Z(q.consent,e);return E(`div`,{children:[T(`dt`,{className:`text-xs text-muted-foreground`,children:Z(q.fields,e)?.label??e}),T(`dd`,{className:`whitespace-pre-wrap break-words`,children:re(t)}),n!==void 0&&T(`dd`,{className:`mt-1 text-xs text-muted-foreground`,children:k(`consentWording`,{wording:n})})]},e)})}),w&&E(`div`,{className:`flex flex-wrap gap-2 border-t pt-3`,children:[q.status===`new`?T(t,{variant:`outline`,size:`sm`,disabled:$,onClick:()=>void Q([q.id],`read`),children:k(`markRead`)}):T(t,{variant:`outline`,size:`sm`,disabled:$,onClick:()=>void Q([q.id],`new`),children:k(`markNew`)}),q.status===`archived`?T(t,{variant:`outline`,size:`sm`,disabled:$,onClick:()=>void Q([q.id],`read`),children:k(`unarchive`)}):T(t,{variant:`outline`,size:`sm`,disabled:$,onClick:()=>void Q([q.id],`archived`),children:k(`archive`)}),T(t,{variant:`destructive`,size:`sm`,disabled:$,onClick:()=>H([q.id]),children:k(`delete`)})]})]})]}),T(r,{open:V!==null,onOpenChange:e=>!e&&H(null),children:E(i,{children:[T(o,{children:T(s,{children:k(`confirmDeleteTitle`,{count:V?.length??0})})}),T(a,{children:k(`confirmDeleteBody`)}),E(`div`,{className:`flex justify-end gap-2`,children:[T(t,{variant:`outline`,onClick:()=>H(null),children:k(`cancel`)}),T(t,{variant:`destructive`,disabled:U,onClick:()=>void oe(),children:k(`confirmDelete`)})]})]})})]})}function ie({href:e}){return T(t,{variant:`outline`,size:`sm`,asChild:!0,children:T(`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:v(`Forms.submissions`)(`exportCsv`)})})}export{U as DeleteFormButton,X as FormBlocksRegistration,W as NewFormDialog,J as PrivacyRequestsView,ie as SubmissionsExportLink,re as SubmissionsView,Y as registerFormBlocks};
3
3
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/new-form-dialog.tsx","../src/register.tsx"],"sourcesContent":["'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 * 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"],"mappings":"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"}
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 { SubmissionColumn, SubmissionRow, SubmissionStatus } 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 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/** `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 apiBasePath,\n emptyText,\n}: SubmissionsViewProps): ReactElement {\n const t = useTranslations('Forms.submissions')\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\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 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\n return (\n <div className=\"space-y-3\">\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\">{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 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 {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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"}
package/dist/pages.d.mts CHANGED
@@ -17,5 +17,21 @@ declare function FormsListPage({
17
17
  locale: string;
18
18
  }>): Promise<ReactNode>;
19
19
  //#endregion
20
- export { FormCanvasPage, FormsListPage };
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 };
21
37
  //# sourceMappingURL=pages.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pages.d.mts","names":[],"sources":["../src/pages/form-canvas-page.tsx","../src/pages/forms-list-page.tsx"],"mappings":";;;;iBAuBsB,cAAA,CAAA;EACpB,MAAA;EACA;AAAA,GACC,SAAA;EAAY,MAAA;EAAgB,EAAA;AAAA,KAAgB,OAAA,CAAQ,SAAA;;;iBCQjC,aAAA,CAAA;EAAgB;AAAA,GAAU,SAAA;EAAY,MAAA;AAAA,KAAoB,OAAA,CAAQ,SAAA"}
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,CAAA;EACpB,MAAA;EACA;AAAA,GACC,SAAA;EAAY,MAAA;EAAgB,EAAA;AAAA,KAAgB,OAAA,CAAQ,SAAA;;;iBCgCjC,aAAA,CAAA;EAAgB;AAAA,GAAU,SAAA;EAAY,MAAA;AAAA,KAAoB,OAAA,CAAQ,SAAA;;;iBCnClE,mBAAA,CAAA;EACpB;AAAA,GACC,SAAA;EAAY,MAAA;AAAA,KAAoB,OAAA,CAAQ,SAAA;;;iBCsBrB,eAAA,CAAA;EACpB,MAAA;EACA;AAAA,GACC,SAAA;EAAY,MAAA;EAAgB,EAAA;AAAA,KAAgB,OAAA,CAAQ,SAAA"}
package/dist/pages.mjs CHANGED
@@ -1,2 +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{FORMS_SETTINGS_NAMESPACE as i,FORM_ENTITY as a,Form as o,formSpamCounts as s,formsSettings as c}from"@murumets-ee/forms";import{FormCanvasFactsProvider as l}from"@murumets-ee/forms/editor";import{notFound as u}from"next/navigation";import{getApp as d,requireRequestSite as f}from"@murumets-ee/core";import{challengeSecretFor as p}from"@murumets-ee/forms/server";import{SettingsClient as m}from"@murumets-ee/settings";import{jsx as h,jsxs as g}from"react/jsx-runtime";import{fetchEntityList as _}from"@murumets-ee/admin-ui/server";import{WorkflowStatusBadge as v}from"@murumets-ee/admin-ui/workflow";import{countEmbedUsages as y}from"@murumets-ee/blocks/server";import{NewFormDialog as b}from"@murumets-ee/forms-ui";import{Table as x,TableBody as S,TableCell as C,TableHead as w,TableHeader as T,TableRow as E}from"@murumets-ee/ui";import D from"next/link";import{getTranslations as O,setRequestLocale as k}from"next-intl/server";async function A(e){let t=new Map;if(e.length===0)return t;let n=await s.makeClient(d().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 j(){let e=d(),t=await f(i,e),n=new m(c,{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)&&p(`turnstile`,t,process.env)!==void 0,recaptcha:o(a)&&p(`recaptcha`,t,process.env)!==void 0}}const M=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;async function N({params:t,searchParams:n}){let{id:a,locale:s}=await t;M.test(a)||u();let c=e(),d=await r(o,c,{params:t,searchParams:n}),f=await c.withAdminContext(s,async()=>{let[e,t]=await Promise.all([A([a]).catch(()=>(console.warn(`forms: the blocked count for form ${a} could not be read`),null)),j()]);return{discarded:e===null?null:e.get(a)??0,ready:t,settingsHref:`${c.adminBasePath.replace(`:locale`,s)}/settings/${i}`}}).catch(()=>(console.warn(`forms: the spam facts for form ${a} could not be read`),null));return f===null?d:h(l,{facts:f,children:d})}async function P({params:r}){let i=e(),{locale:s}=await r;k(s);let c=await O(`Forms.list`),l=`${i.adminBasePath.replace(`:locale`,s)}/forms`;return i.withAdminContext(s,async()=>{await t(o,`view`,i)||u();let[{items:e,total:r},s]=await Promise.all([_(o,{sortField:`name`,sortDirection:`asc`,limit:200}),n(`form`,`create`,i)]),d=await y(a,e.map(e=>e.id)).catch(()=>null),f=await A(e.map(e=>String(e.id))).catch(()=>(console.warn(`forms: the blocked counts for the Forms list could not be read`),null));return g(`div`,{className:`space-y-4 p-6`,children:[g(`div`,{className:`flex items-start justify-between gap-4`,children:[g(`div`,{children:[h(`h1`,{className:`text-2xl font-bold`,children:c(`title`)}),h(`p`,{className:`text-sm text-muted-foreground`,children:c(`description`)})]}),s&&h(b,{apiBasePath:i.apiBasePath??`/api/admin`,formsBasePath:l})]}),e.length===0?h(`p`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:c(`empty`)}):g(x,{children:[h(T,{children:g(E,{children:[h(w,{children:c(`name`)}),h(w,{children:c(`usedOn`)}),h(w,{className:`text-right`,children:c(`submissions`)}),h(w,{className:`text-right`,children:c(`new`)}),h(w,{className:`text-right`,children:c(`blocked`)}),h(w,{children:c(`status`)})]})}),h(S,{children:e.map(e=>g(E,{children:[h(C,{className:`font-medium`,children:h(D,{href:`${l}/${e.id}`,className:`text-primary hover:underline`,children:e.name})}),h(C,{className:`text-muted-foreground`,"data-forms-used-on":e.id,children:d===null?c(`notYet`):c(`usedOnPages`,{count:d.get(e.id)??0})}),h(C,{className:`text-right text-muted-foreground`,children:c(`notYet`)}),h(C,{className:`text-right text-muted-foreground`,children:c(`notYet`)}),h(C,{className:`text-right tabular-nums`,children:f===null?c(`notYet`):f.get(String(e.id))??0}),h(C,{children:h(v,{status:e.status===`published`?`published`:`draft`})})]},e.id))})]}),r>e.length&&h(`p`,{className:`text-xs text-muted-foreground`,children:c(`truncated`,{count:e.length})})]})})}export{N as FormCanvasPage,P as FormsListPage};
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_RESOURCE as c,FORMS_SETTINGS_NAMESPACE as l,FORM_ENTITY as u,FORM_SUBMISSIONS_RESOURCE as d,Form as f,STATUS_FILTERS as p,SUBMISSIONS_PAGE_SIZE as m,collectRetiredColumns as h,currentFormFields as g,formSpamCounts as _,formsSettings as v,lastRetentionRun as y,listSubmissions as b,parseSubmissionFilters as x,submissionColumns as S,submissionCounts as C,submissionFilterParams as w,submissionPages as ee}from"@murumets-ee/forms";import{FormCanvasFactsProvider as T}from"@murumets-ee/forms/editor";import{notFound as E,redirect as D}from"next/navigation";import{challengeSecretFor as O}from"@murumets-ee/forms/server";import{SettingsClient as k}from"@murumets-ee/settings";import{jsx as A,jsxs as j}from"react/jsx-runtime";import{fetchEntityList as M}from"@murumets-ee/admin-ui/server";import{WorkflowStatusBadge as N}from"@murumets-ee/admin-ui/workflow";import{countEmbedUsages as P}from"@murumets-ee/blocks/server";import{DeleteFormButton as F,NewFormDialog as I,PrivacyRequestsView as L,SubmissionsExportLink as R,SubmissionsView as z}from"@murumets-ee/forms-ui";import{Button as B,Table as V,TableBody as H,TableCell as U,TableHead as W,TableHeader as G,TableRow as K}from"@murumets-ee/ui";import q from"next/link";import{getFormatter as J,getTimeZone as Y,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 _.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 te(){let e=i(),t=await s(l,e),n=new k(v,{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)&&O(`turnstile`,t,process.env)!==void 0,recaptcha:o(a)&&O(`recaptcha`,t,process.env)!==void 0}}const ne=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;async function re({params:t,searchParams:a}){let{id:o,locale:s}=await t;ne.test(o)||E();let c=e(),u=await r(f,c,{params:t,searchParams:a}),p=await c.withAdminContext(s,async()=>{let e=c.adminBasePath.replace(`:locale`,s),[t,r,a]=await Promise.all([Q([o]).catch(()=>(console.warn(`forms: the blocked count for form ${o} could not be read`),null)),te(),n(d,`view`,c).then(async e=>e?(await C(i(),[o])).get(o)?.fresh??0:null).catch(()=>(console.warn(`forms: the new-submissions count for form ${o} could not be read`),null))]);return{discarded:t===null?null:t.get(o)??0,ready:r,settingsHref:`${e}/settings/${l}`,...a!==null&&{submissions:{href:`${e}/forms/${o}/submissions`,fresh:a}}}}).catch(()=>(console.warn(`forms: the spam facts for form ${o} could not be read`),null));return p===null?u:A(T,{facts:p,children:u})}async function ie({params:r}){let a=e(),{locale:o}=await r;Z(o);let s=await X(`Forms.list`),l=`${a.adminBasePath.replace(`:locale`,o)}/forms`;return a.withAdminContext(o,async()=>{await t(f,`view`,a)||E();let[{items:e,total:r},o,p,m,h]=await Promise.all([M(f,{sortField:`name`,sortDirection:`asc`,limit:200}),n(`form`,`create`,a),n(`form`,`delete`,a),n(c,`manage`,a),n(d,`view`,a)]),g=a.apiBasePath??`/api/admin`,_=await P(u,e.map(e=>e.id)).catch(()=>null),v=m||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,b=h?await C(i(),e.map(e=>String(e.id))).catch(()=>(console.warn(`forms: the submission counts for the Forms list could not be read`),null)):null,x=e=>`${l}/${String(e)}/submissions`,S=h?await y(i()).catch(()=>{console.warn(`forms: the last clean-up run for the Forms list could not be read`)}):void 0,w=await J();return j(`div`,{className:`space-y-4 p-6`,children:[j(`div`,{className:`flex items-start justify-between gap-4`,children:[j(`div`,{children:[A(`h1`,{className:`text-2xl font-bold`,children:s(`title`)}),A(`p`,{className:`text-sm text-muted-foreground`,children:s(`description`)})]}),j(`div`,{className:`flex items-center gap-2`,children:[h&&A(B,{variant:`outline`,size:`sm`,asChild:!0,children:A(q,{href:`${l}/privacy`,"data-forms-privacy-link":``,children:s(`privacy`)})}),o&&m&&A(I,{apiBasePath:g,formsBasePath:l})]})]}),e.length===0?A(`p`,{className:`rounded-md border border-dashed p-6 text-sm text-muted-foreground`,children:s(`empty`)}):j(V,{children:[A(G,{children:j(K,{children:[A(W,{children:s(`name`)}),A(W,{children:s(`usedOn`)}),A(W,{className:`text-right`,children:s(`submissions`)}),A(W,{className:`text-right`,children:s(`new`)}),A(W,{className:`text-right`,children:s(`blocked`)}),A(W,{children:s(`status`)}),p&&m&&A(W,{className:`w-0`})]})}),A(H,{children:e.map(e=>j(K,{children:[A(U,{className:`font-medium`,children:m||h?A(q,{href:m?`${l}/${e.id}`:x(e.id),className:`text-primary hover:underline`,children:e.name}):e.name}),A(U,{className:`text-muted-foreground`,"data-forms-used-on":e.id,children:_===null?s(`notYet`):s(`usedOnPages`,{count:_.get(e.id)??0})}),A(U,{className:`text-right tabular-nums`,"data-forms-submissions":e.id,children:b===null?A(`span`,{className:`text-muted-foreground`,children:s(`notYet`)}):A(q,{href:x(e.id),className:`text-primary hover:underline`,"aria-label":s(`submissionsOf`,{form:String(e.name),count:b.get(String(e.id))?.total??0}),children:b.get(String(e.id))?.total??0})}),A(U,{className:`text-right tabular-nums`,"data-forms-new":e.id,children:b===null?A(`span`,{className:`text-muted-foreground`,children:s(`notYet`)}):b.get(String(e.id))?.fresh??0}),A(U,{className:`text-right tabular-nums`,children:v===null?s(`notYet`):v.get(String(e.id))??0}),A(U,{children:A(N,{status:e.status===`published`?`published`:`draft`})}),p&&m&&A(U,{className:`text-right`,children:A(F,{apiBasePath:g,formId:String(e.id),formName:String(e.name),submissions:b===null?null:b.get(String(e.id))?.total??0,pages:_===null?null:_.get(e.id)??0})})]},e.id))})]}),r>e.length&&A(`p`,{className:`text-xs text-muted-foreground`,children:s(`truncated`,{count:e.length})}),S!==void 0&&A(`p`,{className:`text-xs text-muted-foreground`,"data-forms-retention-run":``,children:S===null?s(`retentionNever`):`${s(`retentionRun`,{when:w.dateTime(new Date(S.lastRunAt),{dateStyle:`medium`,timeStyle:`short`}),count:S.removed})}${S.unfinished?` ${s(`retentionUnfinished`)}`:``}`})]})})}async function ae({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(d,`view`,r)||E();let e=!o(a()?.door)&&await n(d,`manage`,r);return j(`div`,{className:`space-y-4 p-6`,children:[j(`div`,{children:[A(`p`,{className:`text-sm text-muted-foreground`,children:j(q,{href:c,className:`hover:underline`,children:[`← `,s(`backToForms`)]})}),A(`h1`,{className:`text-2xl font-bold`,children:s(`title`)}),A(`p`,{className:`max-w-prose text-sm text-muted-foreground`,children:s(`intro`)})]}),A(L,{apiBasePath:r.apiBasePath??`/api/admin`,formsBasePath:c,locale:i,canErase:e})]})})}const oe=/^[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 se({params:t,searchParams:r}){let{id:a,locale:o}=await t;oe.test(a)||E();let s=await r,l=e();Z(o);let u=await X(`Forms.submissions`),_=`${l.adminBasePath.replace(`:locale`,o)}/forms`,v=l.apiBasePath??`/api/admin`;return l.withAdminContext(o,async()=>{await n(d,`view`,l)||E();let e=i(),t=await e.getSystemClient(f).findById(a);t??E();let r=x(e=>$(s[e]),{timeZone:await Y()}),y=Number($(s.p)),C=Number.isFinite(y)?Math.min(Math.max(Math.trunc(y),1),1e5):1,[{items:T,total:O},k,M,N,P]=await Promise.all([b(e,a,r,{limit:m,offset:(C-1)*m}),ee(e,a),n(d,`manage`,l),n(c,`manage`,l),Q([a]).catch(()=>(console.warn(`forms: the blocked count for form ${a} could not be read`),null))]),F=g(t.body),I=S(F,h(F,T)),L=w(r),V=new URLSearchParams(L);V.set(`form`,a),V.set(`uiLocale`,o),V.set(`tz`,r.timeZone);let H=e=>{let t=new URLSearchParams(L);e>1&&t.set(`p`,String(e));let n=t.toString();return`${_}/${a}/submissions${n===``?``:`?${n}`}`},U=Math.max(Math.ceil(O/m),1);T.length===0&&O>0&&C>U&&D(H(U));let W=L.toString()!==``,G=typeof t.name==`string`?t.name:``,K=P===null?null:P.get(a)??0,J=r.page!==void 0&&!k.includes(r.page)?[r.page,...k]:k;return j(`div`,{className:`space-y-4 p-6`,children:[j(`div`,{className:`flex flex-wrap items-start justify-between gap-4`,children:[j(`div`,{children:[A(`p`,{className:`text-sm text-muted-foreground`,children:j(q,{href:_,className:`hover:underline`,children:[`← `,u(`backToForms`)]})}),A(`h1`,{className:`text-2xl font-bold`,children:u(`title`,{form:G})})]}),j(`div`,{className:`flex items-center gap-2`,children:[N&&A(B,{variant:`outline`,size:`sm`,asChild:!0,children:A(q,{href:`${_}/${a}`,children:u(`openForm`)})}),A(R,{href:`${v}/form-submissions/export?${V.toString()}`})]})]}),j(`form`,{method:`get`,action:`${_}/${a}/submissions`,className:`flex flex-wrap items-end gap-3 rounded-md border p-3 text-sm`,children:[j(`label`,{className:`flex flex-col gap-1`,children:[A(`span`,{className:`text-xs text-muted-foreground`,children:u(`filters.from`)}),A(`input`,{type:`date`,name:`from`,defaultValue:r.from??``,className:`h-8 rounded-md border bg-background px-2`})]}),j(`label`,{className:`flex flex-col gap-1`,children:[A(`span`,{className:`text-xs text-muted-foreground`,children:u(`filters.to`)}),A(`input`,{type:`date`,name:`to`,defaultValue:r.to??``,className:`h-8 rounded-md border bg-background px-2`})]}),j(`label`,{className:`flex flex-col gap-1`,children:[A(`span`,{className:`text-xs text-muted-foreground`,children:u(`filters.page`)}),j(`select`,{name:`page`,defaultValue:r.page??``,className:`h-8 rounded-md border bg-background px-2`,children:[A(`option`,{value:``,children:u(`filters.allPages`)}),J.map(e=>A(`option`,{value:e,children:e},e))]})]}),j(`label`,{className:`flex flex-col gap-1`,children:[A(`span`,{className:`text-xs text-muted-foreground`,children:u(`filters.status`)}),A(`select`,{name:`status`,defaultValue:r.status,className:`h-8 rounded-md border bg-background px-2`,children:p.map(e=>A(`option`,{value:e,children:u(`filters.statuses.${e}`)},e))})]}),A(B,{type:`submit`,size:`sm`,variant:`secondary`,children:u(`filters.apply`)}),W&&A(B,{size:`sm`,variant:`ghost`,asChild:!0,children:A(q,{href:`${_}/${a}/submissions`,children:u(`filters.clear`)})}),K!==null&&K>0&&A(`span`,{className:`ml-auto text-xs text-muted-foreground`,"data-forms-blocked":!0,children:u(`blocked`,{count:K})})]}),A(z,{formId:a,rows:T,columns:I,canManage:M,apiBasePath:v,emptyText:u(W?`emptyFiltered`:`empty`)},`${L.toString()}#${C}`),j(`div`,{className:`flex items-center justify-between text-sm text-muted-foreground`,children:[A(`span`,{children:u(`showing`,{shown:T.length,total:O})}),U>1&&j(`span`,{className:`flex items-center gap-2`,children:[C>1&&j(q,{href:H(C-1),className:`hover:underline`,children:[`← `,u(`previous`)]}),A(`span`,{children:u(`pageOf`,{page:Math.min(C,U),pages:U})}),C<U&&j(q,{href:H(C+1),className:`hover:underline`,children:[u(`next`),` →`]})]})]})]})})}export{re as FormCanvasPage,ie as FormsListPage,ae as PrivacyRequestsPage,se as SubmissionsPage};
2
2
  //# sourceMappingURL=pages.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"pages.mjs","names":[],"sources":["../src/pages/form-facts.ts","../src/pages/form-canvas-page.tsx","../src/pages/forms-list-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 { FORMS_SETTINGS_NAMESPACE, formSpamCounts, formsSettings } from '@murumets-ee/forms'\nimport { challengeSecretFor } from '@murumets-ee/forms/server'\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 * 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 { renderEntityEditFallback } from '@murumets-ee/admin-ui/pages'\nimport type { PageProps } from '@murumets-ee/core'\nimport { FORMS_SETTINGS_NAMESPACE, Form } from '@murumets-ee/forms'\nimport { FormCanvasFactsProvider } from '@murumets-ee/forms/editor'\nimport { notFound } from 'next/navigation'\nimport type { ReactNode } from 'react'\nimport { challengeReadiness, discardedCounts } 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 [counts, ready] = 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 ])\n return {\n discarded: counts === null ? null : (counts.get(id) ?? 0),\n ready,\n settingsHref: `${config.adminBasePath.replace(':locale', locale)}/settings/${FORMS_SETTINGS_NAMESPACE}`,\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 *\n * Gated by `forms:manage` on its route (the plugin's `defaultRoutes`), which\n * is also what the sidebar link inherits. Lists THIS site's forms only: `form`\n * is site-bound, so the entity read is scoped by the request's site like\n * 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 (PR03, D004); \"Submissions\" and \"New\" fill in with the\n * submissions screen (PR04). Until then those two show a dash rather than a\n * zero — a zero would claim a count that nothing has taken yet.\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 type { PageProps } from '@murumets-ee/core'\nimport { FORM_ENTITY, Form } from '@murumets-ee/forms'\nimport { NewFormDialog } from '@murumets-ee/forms-ui'\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@murumets-ee/ui'\nimport Link from 'next/link'\nimport { notFound } from 'next/navigation'\nimport { 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 the job (`forms:manage`); reading the rows is the\n // entity's own `form:view`. A role holding the first without the second\n // gets the same no-leak 404 as the gate, not a failed read.\n if (!(await hasEntityPermission(Form, 'view', config))) notFound()\n const [{ items, total }, canCreate] = await Promise.all([\n fetchEntityList(Form, { sortField: 'name', sortDirection: 'asc', limit: MAX_FORMS }),\n hasResourcePermission('form', 'create', config),\n ])\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 const blocked = 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 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 {canCreate && (\n <NewFormDialog\n apiBasePath={config.apiBasePath ?? '/api/admin'}\n formsBasePath={formsBasePath}\n />\n )}\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 </TableRow>\n </TableHeader>\n <TableBody>\n {items.map((form) => (\n <TableRow key={form.id}>\n <TableCell className=\"font-medium\">\n <Link\n href={`${formsBasePath}/${form.id}`}\n className=\"text-primary hover:underline\"\n >\n {form.name}\n </Link>\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 text-muted-foreground\">{t('notYet')}</TableCell>\n <TableCell className=\"text-right text-muted-foreground\">{t('notYet')}</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 </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 </div>\n )\n })\n}\n"],"mappings":"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"}
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 { FORMS_SETTINGS_NAMESPACE, formSpamCounts, formsSettings } from '@murumets-ee/forms'\nimport { challengeSecretFor } from '@murumets-ee/forms/server'\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 * 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 } 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] = 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 ])\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 }\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 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 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 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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"}
package/dist/plugin.d.mts CHANGED
@@ -172,6 +172,10 @@ interface DefineFeatureSpec {
172
172
  */
173
173
  //#endregion
174
174
  //#region src/plugin.d.ts
175
+ /** The daily clean-up of expired submissions (forms D007) — see `runFormsRetention`. */
176
+ declare const FORMS_RETENTION_JOB = "forms:retention";
177
+ /** Nightly, off the hour so it does not start with every other 03:00 job. */
178
+ declare const FORMS_RETENTION_SCHEDULE = "23 3 * * *";
175
179
  declare function formsPlugin(): Pick<import("@murumets-ee/core").Plugin, "server" | "adminUi" | "postInstall" | "requires" | "contributes"> & {
176
180
  readonly name: "@murumets-ee/forms";
177
181
  readonly shared?: {
@@ -196,5 +200,5 @@ declare function formsPlugin(): Pick<import("@murumets-ee/core").Plugin, "server
196
200
  };
197
201
  };
198
202
  //#endregion
199
- export { formsPlugin };
203
+ export { FORMS_RETENTION_JOB, FORMS_RETENTION_SCHEDULE, formsPlugin };
200
204
  //# sourceMappingURL=plugin.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.mts","names":["PermissionString","AdminRouteMethod","SegmentPath","P","DefineAdminRouteSpec","TApp","AdminRouteHandler","AdminRouteMcpSpec","prefix","path","method","permission","defaultRoles","description","descriptionKey","handler","matchAnyPath","tokens","mcp","confirm","JsonValue","key","McpInputSchema","type","properties","required","additionalProperties","inputSchema","ENTRY_GATED","AdminRouteEntry","resource","action","guardedHandler","acceptsTokens","requiresConfirmation","PermissionCatalogEntry","source","registerPermission","entry","getPermissionCatalog","Map","_resetPermissionCatalog","clearFeaturePermissions","safeAudit","Parameters","NonNullable","ctx","safeAuditMany","Promise","entries","PermissionDenialContext","Record","extraMetadata","emitPermissionDenied","spec","permissionDeniedResponse","Response","role","defineAdminRoute","DefineFeatureSpec","actions","defineFeature","combineAdminRoutes","AdminRoute","AuthDoor","TOKEN_DERIVED_DOORS","ReadonlySet","isTokenDerivedDoor","door","CredentialNarrowing","Readonly","CallerCredential","id","narrowing","rateLimitPerMinute","CREDENTIAL_RATE_BUDGET_MAX","parseCredentialRateBudget","raw","AdminApiCaller","U","ConfirmationPolicy","name","email","user","credential","confirmation","narrowCheck","base","GATED","AuthUser","AuditLogFn","entityType","entityId","userId","userName","changes","metadata","AuditLogManyFn","PermissionChecker","Request","req","segments","locale","defaultLocale","audit","auditMany","checkPermission","app","TokenPolicyEntry","handlers","GET","POST","PATCH","DELETE","tokenPolicy","matchTokenPolicyEntry","Pick","route","routeRequiresConfirmation","routeAcceptsTokens","isGatedRoute","value","AGENT_APPROVALS_RESOURCE","APPROVAL_STATUSES","ApprovalStatus","isApprovalStatus","DEFAULT_APPROVAL_TTL_MS","MAX_PARKED_BODY_BYTES","MAX_PENDING_PER_PRINCIPAL","APPROVAL_ID_PATTERN","RegExp","ApprovalPreview","kind","entity","ids","snapshot","ParkedOperation","Date","principalId","credentialId","query","body","preview","status","requestedAt","expiresAt","decidedBy","decidedAt","resultStatus","result","ParkInput","ParkOutcome","ok","reason","AgentApprovalQueue","park","input","now","get","list","limit","offset","options","items","total","countPending","claim","reject","finish","outcome","resumeCaller","op","sweep","staleExecutingMs","expired","interrupted","CONFIRMATION_REQUIRED_STATUS","CONFIRMATION_REQUIRED_CODE","BuiltInEntityAction","suffix","behavior","requires","needsContentClient","BUILT_IN_ENTITY_ACTIONS","ADMIN_SITE_HEADER","ADMIN_SITE_QUERY_PARAM","ADMIN_SITE_SEGMENT_PREFIX","isAdminSiteHandleShape","parseAdminSiteSegment","segment","isAdminSiteSegment","adminSiteSegment","site","siteFromAdminPath","pathname","withAdminSite","href","stripAdminSite"],"sources":["../../admin-route/dist/index.d.mts","../src/plugin.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;UAqvBU2D,iBAAAA;;;;;;EAMR7B,QAAAA;;;;;;;;EAQA8B,OAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0CAhD,YAAAA;;;;;;;EAOAC,WAAAA;AAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBC5vBc,WAAA,CAAA,GAAW,IAAA,6BAAA,MAAA;EAAA;;gCAAA,iBAAA;IAAA"}
1
+ {"version":3,"file":"plugin.d.mts","names":["PermissionString","AdminRouteMethod","SegmentPath","P","DefineAdminRouteSpec","TApp","AdminRouteHandler","RouteTokenChoice","AdminRouteMcpSpec","prefix","path","method","permission","defaultRoles","description","descriptionKey","handler","matchAnyPath","tokens","mcp","confirm","JsonValue","key","McpInputSchema","type","properties","required","additionalProperties","inputSchema","ENTRY_GATED","AdminRouteEntry","RouteTokenRefusalReason","resource","action","guardedHandler","acceptsTokens","tokenRefusal","requiresConfirmation","PermissionCatalogEntry","source","registerPermission","entry","getPermissionCatalog","Map","_resetPermissionCatalog","clearFeaturePermissions","safeAudit","Parameters","NonNullable","ctx","safeAuditMany","Promise","entries","PermissionDenialContext","Record","extraMetadata","emitPermissionDenied","spec","permissionDeniedResponse","Response","role","defineAdminRoute","DefineFeatureSpec","actions","defineFeature","combineAdminRoutes","AdminRoute","AuthDoor","TOKEN_DERIVED_DOORS","ReadonlySet","isTokenDerivedDoor","door","CredentialNarrowing","Readonly","CallerCredential","id","narrowing","rateLimitPerMinute","CREDENTIAL_RATE_BUDGET_MAX","parseCredentialRateBudget","raw","AdminApiCaller","U","ConfirmationPolicy","name","email","user","credential","confirmation","narrowCheck","base","GATED","AuthUser","AuditLogFn","entityType","entityId","userId","userName","changes","metadata","AuditLogManyFn","PermissionChecker","Request","req","segments","locale","defaultLocale","audit","auditMany","checkPermission","app","TokenPolicyEntry","handlers","GET","POST","PATCH","DELETE","tokenPolicy","ROUTE_TOKEN_REFUSAL_REASONS","refuse","RouteTokenDecision","refusal","matchTokenPolicyEntry","Pick","route","routeRequiresConfirmation","routeAcceptsTokens","AdminRouteTokenDecision","decision","RouteTokenDecisionSource","listAdminRouteTokenDecisions","routes","findUndecidedAdminRoutes","isGatedRoute","value","AGENT_APPROVALS_RESOURCE","APPROVAL_STATUSES","ApprovalStatus","isApprovalStatus","DEFAULT_APPROVAL_TTL_MS","MAX_PARKED_BODY_BYTES","MAX_PENDING_PER_PRINCIPAL","APPROVAL_ID_PATTERN","RegExp","ApprovalPreview","kind","entity","ids","snapshot","ParkedOperation","Date","principalId","credentialId","query","body","preview","status","requestedAt","expiresAt","decidedBy","decidedAt","resultStatus","result","ParkInput","ParkOutcome","ok","reason","AgentApprovalQueue","park","input","now","get","list","limit","offset","options","items","total","countPending","claim","reject","finish","outcome","resumeCaller","op","sweep","staleExecutingMs","expired","interrupted","CONFIRMATION_REQUIRED_STATUS","CONFIRMATION_REQUIRED_CODE","BuiltInEntityAction","suffix","behavior","requires","needsContentClient","BUILT_IN_ENTITY_ACTIONS","ADMIN_SITE_HEADER","ADMIN_SITE_QUERY_PARAM","ADMIN_SITE_SEGMENT_PREFIX","isAdminSiteHandleShape","parseAdminSiteSegment","segment","isAdminSiteSegment","adminSiteSegment","site","siteFromAdminPath","pathname","withAdminSite","href","stripAdminSite"],"sources":["../../admin-route/dist/index.d.mts","../src/plugin.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;UAmwBU8D,iBAAAA;;;;;;EAMR9B,QAAAA;;;;;;;;EAQA+B,OAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0CAlD,YAAAA;;;;;;;EAOAC,WAAAA;AAAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cCnvBW,mBAAA;;cAGA,wBAAA;AAAA,iBAEG,WAAA,CAAA,GAAW,IAAA,6BAAA,MAAA;EAAA;;gCAAA,iBAAA;IAAA"}
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{definePlugin as e}from"@murumets-ee/core";import{FORMS_MANAGE as t,Form as n,FormSubmission as r,formSpamCounts as i,formsFeatures as a,formsRoutes as o,formsSettings as s}from"@murumets-ee/forms";import{getFormsMessages as c}from"@murumets-ee/forms/i18n";import{FormEmbedBlock as l,formEnvironment as u,formPageEnvironmentExtension as d,formSchemaBlocks as f}from"@murumets-ee/forms/schema";const p=async e=>(await import(`@murumets-ee/forms-ui/pages`)).FormsListPage(e),m=async e=>(await import(`@murumets-ee/forms-ui/pages`)).FormCanvasPage(e);function h(){return e({name:`@murumets-ee/forms`,requires:[`@murumets-ee/content`,`@murumets-ee/settings`],server:{entities:[n,r],tables:{forms_spam_counts:i.table},routes:[...o()]},contributes:{"blocks.blocks":[...f,l],"blocks.environmentBases":[u],"blocks.environments":d},shared:{features:a,settings:[s],messages:c},adminUi:{pages:{FormsListPage:p,FormCanvasPage:m},sidebar:[{id:`forms`,group:`Content`,label:`Forms`,href:`/admin/forms`,iconName:`clipboard-list`,requires:t}],defaultRoutes:[{path:`forms`,factory:`FormsListPage`,page:{requires:t,defaultRoles:[`admin`,`editor`],description:`List this site’s forms and create new ones.`,descriptionKey:`Forms.permissions.list`,tokens:`accept`}},{path:`forms/[id]`,factory:`FormCanvasPage`,page:{requires:t,defaultRoles:[`admin`,`editor`],description:`Edit a form on its canvas.`,descriptionKey:`Forms.permissions.canvas`,tokens:`accept`}},{path:`form`,factory:`FormsListPage`},{path:`form/[id]`,factory:`FormCanvasPage`}]}})}export{h as formsPlugin};
1
+ import{combineAdminRoutes as e,defineAdminRoute as t,definePlugin as n}from"@murumets-ee/core";import{FORMS_MANAGE as r,FORM_SUBMISSIONS_MANAGE as i,FORM_SUBMISSIONS_VIEW as a,Form as o,FormSubmission as s,MAX_SUBMISSION_BULK as c,PRIVACY_EXPORT_MAX as l,PRIVACY_LIST_MAX as u,SUBMISSIONS_READ_MAX as d,collectRetiredColumns as f,countSubmissionsByEmail as p,countUnindexedSubmissions as m,currentFormFields as h,deleteSubmissions as g,eraseSubmissionsByEmail as ee,findSubmissionsByEmail as _,formNames as v,formsFeatures as te,formsRoutes as y,formsSettings as b,formsTables as x,isSubmissionStatus as S,listSubmissionRows as ne,parsePrivacyEmail as re,parseSubmissionFilters as ie,readCappedBody as C,setSubmissionStatus as ae,submissionColumns as oe,submissionCounts as se,submissionFilterParams as ce}from"@murumets-ee/forms";import{getFormsMessages as w}from"@murumets-ee/forms/i18n";import{FormEmbedBlock as le,formEnvironment as ue,formPageEnvironmentExtension as de,formSchemaBlocks as fe}from"@murumets-ee/forms/schema";import{FormSubmissionsHeaderAction as T}from"@murumets-ee/forms-ui/canvas-header-action";import{csvDelimiterFor as E,csvFilename as D,pagedExportRows as O,runCsvExport as k}from"@murumets-ee/admin-ui/csv-export";const A=`form-privacy`,j=`X-Lumi-Export-Truncated`;let M=0;function N(){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 P(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`}})}function F(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}async function I(e,t){let n=await C(e,2048);if(`tooLarge`in n)return{refusal:P({error:`body_too_large`},413)};let r;try{r=JSON.parse(n.text)}catch{return{refusal:P({error:`invalid_json`},400)}}let i=new Set([`email`,...t]);if(!F(r)||Object.keys(r).some(e=>!i.has(e)))return{refusal:P({error:`invalid_body`},400)};let a=re(r.email);return a===null?{refusal:P({error:`invalid_email`,code:`invalid_email`},400)}:{email:a,rest:r}}const L=async(e,t)=>{let n=await I(e,[]);if(`refusal`in n)return n.refusal;let{email:r}=n,[i,a,o]=await Promise.all([p(t.app,r),_(t.app,r,{limit:u,offset:0}),m(t.app)]),s=await v(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}}),P({total:i,items:c,unindexed:o,exportMax:l})},R=[`csv`,`json`];function z(e,t){let n=e.Forms;for(let e of t){if(!F(n))return;n=n[e]}return typeof n==`string`&&n!==``?n:void 0}async function B(e){let t=await w(e??`en`),n=(e,n)=>z(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 pe(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 me(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 he(e){return e.slice(0,16).replace(`T`,` `)}function ge(e,t,n,r){return O((r,i)=>_(e,t,{limit:i,offset:r,until:n}),{pageSize:d,maxRows:l,...r!==void 0&&{onTruncated:r}})}const H=async(e,t)=>{let n=await I(e,[`format`,`uiLocale`]);if(`refusal`in n)return n.refusal;let{email:r,rest:i}=n,a=R.find(e=>e===i.format);if(a===void 0)return P({error:`invalid_format`},400);let o=typeof i.uiLocale==`string`&&i.uiLocale.length<=35?i.uiLocale:t.locale,s=t.app;if(M>=2)return N();M+=1;let c=new Date,l=[],u=!1,d;try{for await(let e of ge(s,r,c,()=>{u=!0}))l.push(e);d=await v(s,[...new Set(l.map(e=>e.formId))])}finally{--M}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="${D(`privacy-request`).replace(/\.csv$/,`.json`)}"`,"Cache-Control":`private, no-store`,"X-Content-Type-Options":`nosniff`,[j]:String(u)}})}let p=await B(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 k(()=>({filename:D(`privacy-request`),delimiter:E(o),header:[p.form,p.received,p.page,p.status,...m],rows:l.map(e=>{let t=pe(e);return[d.get(e.formId)??p.deletedForm,he(e.createdAt),e.pagePath??``,p.statuses[e.status]??e.status,...m.map(e=>me(t.get(e),p))]})}));return g.ok&&(f(),g.headers.set(j,String(u))),g},_e=async(e,t)=>{let n=await I(e,[]);if(`refusal`in n)return n.refusal;let r=await ee(t.app,n.email),i=await m(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}}),P({erased:r.erased,forms:r.formIds.length,more:r.more,failed:r.failed,unindexed:i},r.failed?500:200)},ve=t({prefix:A,path:`search`,method:`POST`,permission:a,defaultRoles:[`admin`,`editor`],description:`Find this site’s submissions by one whole email address: { email }.`,descriptionKey:`Forms.permissions.routePrivacySearch`,tokens:`accept`,handler:L}),ye=t({prefix:A,path:`export`,method:`POST`,permission:a,defaultRoles:[`admin`,`editor`],description:`Download one person’s submissions as CSV or JSON: { email, format }.`,descriptionKey:`Forms.permissions.routePrivacyExport`,tokens:`accept`,handler:H}),be=t({prefix:A,path:``,method:`DELETE`,permission:i,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:_e});function xe(){return e([ve,ye,be])}const U=`form-submissions`;function W(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`,"Cache-Control":`no-store`}})}const G=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function K(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function Se(e){return Array.isArray(e)}function q(e,t){if(!K(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`||!G.test(r)||!Se(i)||i.length===0||i.length>c)return null;let a=i.filter(e=>typeof e==`string`&&G.test(e));return a.length===i.length?{form:r,ids:a,rest:e}:null}async function J(e){let t=await C(e,8192);if(`tooLarge`in t)return{refusal:W({error:`body_too_large`},413)};let n=t.text;try{return{value:JSON.parse(n)}}catch{return{refusal:W({error:`invalid_json`},400)}}}async function Y(e,t){let n=await e.getSystemClient(o).findById(t);return n==null?null:{name:typeof n.name==`string`?n.name:`form`,body:n.body}}const Ce=async(e,t)=>{let n=new URL(e.url).searchParams.get(`form`)??``;if(!G.test(n)||await Y(t.app,n)===null)return W({error:`not_found`},404);let r=(await se(t.app,[n])).get(n);return W({total:r?.total??0,fresh:r?.fresh??0})},we=async(e,t)=>{let n=await J(e);if(`refusal`in n)return n.refusal;let r=q(n.value,[`status`]),i=r?.rest.status;if(r===null||!S(i))return W({error:`Expected { form: uuid, ids: uuid[], status: new|read|archived }`},400);let{form:a}=r;if(await Y(t.app,a)===null)return W({error:`not_found`},404);let o=await ae(t.app,a,r.ids,i);return t.audit?.({action:`forms.submissions.status`,entityType:`form`,entityId:a,metadata:{status:i,count:o}}),W({changed:o})},Te=async(e,t)=>{let n=await J(e);if(`refusal`in n)return n.refusal;let r=q(n.value,[]);if(r===null)return W({error:`Expected { form: uuid, ids: uuid[] }`},400);let{form:i}=r;if(await Y(t.app,i)===null)return W({error:`not_found`},404);let a=await g(t.app,i,r.ids);return t.audit?.({action:`forms.submissions.delete`,entityType:`form`,entityId:i,metadata:{count:a}}),W({deleted:a})},X={received:`Received (UTC)`,page:`Page`,status:`Status`,yes:`yes`,no:`no`,removed:`removed field`,statuses:{new:`New`,read:`Read`,archived:`Archived`}};function Ee(e,t){let n=e.Forms;for(let e of t){if(!K(n))return;n=n[e]}return typeof n==`string`&&n!==``?n:void 0}async function De(e){let t=await w(e??`en`),n=(e,n)=>Ee(t,e)??n;return{received:n([`submissions`,`csv`,`received`],X.received),page:n([`submissions`,`csv`,`page`],X.page),status:n([`submissions`,`csv`,`status`],X.status),yes:n([`submissions`,`yes`],X.yes),no:n([`submissions`,`no`],X.no),removed:n([`submissions`,`removedField`],X.removed),statuses:{new:n([`submissionStatus`,`new`],`New`),read:n([`submissionStatus`,`read`],`Read`),archived:n([`submissionStatus`,`archived`],`Archived`)}}}function Oe(e){return e.slice(0,16).replace(`T`,` `)}function ke(e,t){return e===void 0?``:typeof e==`boolean`?e?t.yes:t.no:e}const Ae=t({prefix:U,path:`export`,method:`GET`,permission:a,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(!G.test(r))return W({error:`not_found`},404);let i=await Y(t.app,r);if(i===null)return W({error:`not_found`},404);let a={...ie(e=>n.searchParams.get(e)),until:new Date},o=n.searchParams.get(`uiLocale`)??t.locale,s=h(i.body),c=t.app,l=e=>O((e,t)=>ne(c,r,a,{limit:t,offset:e}),{pageSize:d,...e!==void 0&&{onTruncated:e}});return k(async()=>{t.audit?.({action:`forms.submissions.export`,entityType:`form`,entityId:r,metadata:{filtered:ce(a).size>0}});let e=await De(o),n=new Map;for await(let e of l())f(s,[e],n);let u=oe(s,n);return{filename:D(i.name),delimiter:E(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[Oe(n.createdAt),n.pagePath??``,e.statuses[n.status]??n.status,...u.map(t=>ke(n.data[t.key],e))]})()}})}}),je=t({prefix:U,path:`summary`,method:`GET`,permission:a,defaultRoles:[`admin`,`editor`],description:`How many submissions a form holds and how many are new: ?form=<id>.`,descriptionKey:`Forms.permissions.routeSummary`,tokens:`accept`,handler:Ce}),Me=t({prefix:U,path:``,method:`PATCH`,permission:i,defaultRoles:[`admin`,`editor`],description:`Mark submissions of a form new, read or archived: { form, ids, status }.`,descriptionKey:`Forms.permissions.routeStatus`,tokens:`accept`,handler:we}),Ne=t({prefix:U,path:``,method:`DELETE`,permission:i,defaultRoles:[`admin`,`editor`],description:`Delete submissions of a form for good: { form, ids }.`,descriptionKey:`Forms.permissions.routeDelete`,tokens:`accept`,confirm:!0,handler:Te});function Pe(){return e([Ae,je,Me,Ne])}const Z=`${o.name}:view`,Fe=async e=>(await import(`@murumets-ee/forms-ui/pages`)).FormsListPage(e),Ie=async e=>(await import(`@murumets-ee/forms-ui/pages`)).FormCanvasPage(e),Le=async e=>(await import(`@murumets-ee/forms-ui/pages`)).SubmissionsPage(e),Re=async e=>(await import(`@murumets-ee/forms-ui/pages`)).PrivacyRequestsPage(e),Q=`forms:retention`,$=`23 3 * * *`;function ze(){return n({name:`@murumets-ee/forms`,requires:[`@murumets-ee/content`,`@murumets-ee/settings`],server:{entities:[o,s],tables:{...x},routes:[...y(),...Pe(),...xe()],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`)})}},contributes:{"blocks.blocks":[...fe,le],"blocks.environmentBases":[ue],"blocks.environments":de},shared:{features:te,settings:[b],messages:w},adminUi:{canvasHeaderActions:{[o.name]:T},pages:{FormsListPage:Fe,FormCanvasPage:Ie,SubmissionsPage:Le,PrivacyRequestsPage:Re},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:a,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:r,defaultRoles:[`admin`,`editor`],description:`Edit a form on its canvas.`,descriptionKey:`Forms.permissions.canvas`,tokens:`accept`}},{path:`forms/[id]/submissions`,factory:`SubmissionsPage`,page:{requires:a,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,ze as formsPlugin};
2
2
  //# sourceMappingURL=plugin.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["/**\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 route behind \"Detach a copy\";\n * - the permissions — `forms:manage` and the `form` entity grants — to the\n * Permission Matrix, seeded for `editor`;\n * - the catalogue (`shared.messages`);\n * - the Forms list and the form canvas as pages, and the sidebar link.\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 { definePlugin, type PageFactory } from '@murumets-ee/core'\nimport {\n FORMS_MANAGE,\n Form,\n FormSubmission,\n formSpamCounts,\n formsFeatures,\n formsRoutes,\n formsSettings,\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'\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}\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 requires: ['@murumets-ee/content', '@murumets-ee/settings'],\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 tables: { forms_spam_counts: formSpamCounts.table },\n // `POST /api/admin/form-copy` — the Form block's \"Detach a copy\".\n routes: [...formsRoutes()],\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 settings: [formsSettings],\n messages: getFormsMessages,\n },\n adminUi: {\n pages: {\n FormsListPage,\n FormCanvasPage,\n },\n sidebar: [\n {\n id: 'forms',\n group: 'Content',\n label: 'Forms',\n href: '/admin/forms',\n iconName: 'clipboard-list',\n requires: FORMS_MANAGE,\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 requires: FORMS_MANAGE,\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 `forms:manage`.\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 // 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":"+YA+CA,MAAM,EAAiD,KAAO,KAErD,MADW,OAAO,+BAAA,CACd,cAAc,CAAK,EAE1B,EAA8D,KAAO,KAElE,MADW,OAAO,+BAAA,CACd,eAAe,CAAK,EAGjC,SAAgB,GAAc,CAC5B,OAAO,EAAa,CAClB,KAAM,qBAGN,SAAU,CAAC,uBAAwB,uBAAuB,EAC1D,OAAQ,CAIN,SAAU,CAAC,EAAM,CAAc,EAE/B,OAAQ,CAAE,kBAAmB,EAAe,KAAM,EAElD,OAAQ,CAAC,GAAG,EAAY,CAAC,CAC3B,EACA,YAAa,CAEX,gBAAiB,CAAC,GAAG,EAAkB,CAAc,EACrD,0BAA2B,CAAC,CAAe,EAI3C,sBAAuB,CACzB,EACA,OAAQ,CACN,SAAU,EACV,SAAU,CAAC,CAAa,EACxB,SAAU,CACZ,EACA,QAAS,CACP,MAAO,CACL,gBACA,gBACF,EACA,QAAS,CACP,CACE,GAAI,QACJ,MAAO,UACP,MAAO,QACP,KAAM,eACN,SAAU,iBACV,SAAU,CACZ,CACF,EACA,cAAe,CACb,CAEE,KAAM,QACN,QAAS,gBACT,KAAM,CACJ,SAAU,EACV,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,8CACb,eAAgB,yBAIhB,OAAQ,QACV,CACF,EACA,CAEE,KAAM,aACN,QAAS,iBACT,KAAM,CACJ,SAAU,EACV,aAAc,CAAC,QAAS,QAAQ,EAChC,YAAa,6BACb,eAAgB,2BAChB,OAAQ,QACV,CACF,EAMA,CAAE,KAAM,OAAQ,QAAS,eAAgB,EACzC,CAAE,KAAM,YAAa,QAAS,gBAAiB,CACjD,CACF,CACF,CAAC,CACH"}
1
+ {"version":3,"file":"plugin.mjs","names":["MAX_BODY_BYTES","json","isRecord","message","exportCopy","csvTime","exportRoute"],"sources":["../src/privacy-routes.ts","../src/submission-routes.ts","../src/plugin.ts"],"sourcesContent":["/**\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 { definePlugin, type PageFactory } from '@murumets-ee/core'\nimport {\n FORM_SUBMISSIONS_VIEW,\n FORMS_MANAGE,\n Form,\n FormSubmission,\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 { 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 requires: ['@murumets-ee/content', '@murumets-ee/settings'],\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()],\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 },\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 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/forms-ui",
3
- "version": "0.99.0",
3
+ "version": "0.101.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "description": "Admin half of @murumets-ee/forms: the form canvas registration, the Forms list, the New form dialog and the plugin.",
@@ -9,6 +9,10 @@
9
9
  "types": "./dist/index.d.mts",
10
10
  "import": "./dist/index.mjs"
11
11
  },
12
+ "./canvas-header-action": {
13
+ "types": "./dist/canvas-header-action.d.mts",
14
+ "import": "./dist/canvas-header-action.mjs"
15
+ },
12
16
  "./pages": {
13
17
  "types": "./dist/pages.d.mts",
14
18
  "import": "./dist/pages.mjs"
@@ -22,13 +26,14 @@
22
26
  "dist"
23
27
  ],
24
28
  "dependencies": {
25
- "@murumets-ee/admin-ui": "0.99.0",
26
- "@murumets-ee/content": "0.99.0",
27
- "@murumets-ee/blocks": "0.99.0",
28
- "@murumets-ee/core": "0.99.0",
29
- "@murumets-ee/forms": "0.99.0",
30
- "@murumets-ee/settings": "0.99.0",
31
- "@murumets-ee/ui": "0.99.0"
29
+ "@murumets-ee/admin-ui": "0.101.0",
30
+ "@murumets-ee/blocks": "0.101.0",
31
+ "@murumets-ee/content": "0.101.0",
32
+ "@murumets-ee/core": "0.101.0",
33
+ "@murumets-ee/forms": "0.101.0",
34
+ "@murumets-ee/queue": "0.101.0",
35
+ "@murumets-ee/ui": "0.101.0",
36
+ "@murumets-ee/settings": "0.101.0"
32
37
  },
33
38
  "peerDependencies": {
34
39
  "next": ">=16.0.0 <17",
@@ -61,9 +66,10 @@
61
66
  "atLeast": 100
62
67
  },
63
68
  "bundleBudget": {
64
- "dist/index.mjs": 16384,
65
- "dist/pages.mjs": 8192,
66
- "dist/plugin.mjs": 5120
69
+ "dist/index.mjs": 20480,
70
+ "dist/canvas-header-action.mjs": 2048,
71
+ "dist/pages.mjs": 12288,
72
+ "dist/plugin.mjs": 16384
67
73
  },
68
74
  "scripts": {
69
75
  "build": "tsdown",